Просмотр исходного кода

feat: expert tags in post card + detail, format dates, view count button

yangyi 2 дней назад
Родитель
Сommit
8077403cb5

+ 9 - 0
AGENTS.md

@@ -32,3 +32,12 @@ Format only with `npx prettier --write .` (no config file, uses Prettier default
 - Views: `LoginView` (auth), `UserView` (admin CRUD), `HomeView`, `AboutView`.
 - Layouts: `UserLayout` (public, horizontal nav), `AdminLayout` (sidebar + header).
 - No unit/e2e tests exist — no test dependencies, no test runner config.
+
+## Git remote
+
+| Remote | URL | Push allowed? |
+|--------|-----|--------------|
+| `gogs` | `git@git.anyi.space:gdit/lt_ui.git` | **Yes** — team repo, always push here |
+| `origin` | `git@git.anyi.space:yangyi/ui_template.git` | **No** — personal fork, never push |
+
+**Always use `git push gogs`.** Never push to `origin`.

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
openapi.json


+ 8 - 7
src/api/meta-controller.ts

@@ -6,28 +6,29 @@
  * OpenAPI spec version: 0.0.1-SNAPSHOT
  */
 import type {
-  OssConfigDto,
+  OssConfigMeta,
   ResponseObject,
+  ResponseOssConfigMeta,
   ResponseVoid,
-  WebsiteMetaDto,
+  WebsiteMeta,
 } from "./models";
 import { customAxiosInstance } from "../util/axios-instance";
 
 export const getMetaController = () => {
-  const updateWebsiteMeta = (websiteMetaDto: WebsiteMetaDto) => {
+  const updateWebsiteMeta = (websiteMeta: WebsiteMeta) => {
     return customAxiosInstance<ResponseVoid>({
       url: `/meta/updateWebsiteMeta`,
       method: "POST",
       headers: { "Content-Type": "application/json" },
-      data: websiteMetaDto,
+      data: websiteMeta,
     });
   };
-  const updateOssConfig = (ossConfigDto: OssConfigDto) => {
+  const updateOssConfig = (ossConfigMeta: OssConfigMeta) => {
     return customAxiosInstance<ResponseVoid>({
       url: `/meta/updateOssConfig`,
       method: "POST",
       headers: { "Content-Type": "application/json" },
-      data: ossConfigDto,
+      data: ossConfigMeta,
     });
   };
   const getWebsiteMeta = () => {
@@ -37,7 +38,7 @@ export const getMetaController = () => {
     });
   };
   const getOssConfig = () => {
-    return customAxiosInstance<ResponseObject>({
+    return customAxiosInstance<ResponseOssConfigMeta>({
       url: `/meta/getOssConfig`,
       method: "GET",
     });

+ 3 - 2
src/api/models/index.ts

@@ -44,7 +44,7 @@ export * from "./loginDto";
 export * from "./markRead400";
 export * from "./notificationVo";
 export * from "./orderTipVo";
-export * from "./ossConfigDto";
+export * from "./ossConfigMeta";
 export * from "./pageVoListOrderTipVo";
 export * from "./pageVoListPostVo";
 export * from "./pageVoListUserVo";
@@ -71,6 +71,7 @@ export * from "./responseListNotificationVo";
 export * from "./responseListRealnameAuthVo";
 export * from "./responseListWalletTransactionVo";
 export * from "./responseObject";
+export * from "./responseOssConfigMeta";
 export * from "./responsePageVoListOrderTipVo";
 export * from "./responsePageVoListPostVo";
 export * from "./responsePageVoListUserVo";
@@ -111,7 +112,7 @@ export * from "./userDto";
 export * from "./userVo";
 export * from "./walletTransactionVo";
 export * from "./walletVo";
-export * from "./websiteMetaDto";
+export * from "./websiteMeta";
 export * from "./withdrawApplyVo";
 export * from "./withdrawDto";
 export * from "./withdrawReviewDto";

+ 1 - 1
src/api/models/ossConfigDto.ts → src/api/models/ossConfigMeta.ts

@@ -9,7 +9,7 @@
 /**
  * OSS配置DTO,用于更新OSS存储配置信息
  */
-export interface OssConfigDto {
+export interface OssConfigMeta {
   /** Access Key */
   accessKey?: string;
   /** Bucket */

+ 3 - 0
src/api/models/postVo.ts

@@ -13,7 +13,10 @@ export interface PostVo {
   contentIntro?: string;
   contentPaid?: string;
   expertAvatar?: string;
+  expertBrief?: string;
   expertId?: string;
+  expertIsRealname?: boolean;
+  expertLevel?: string;
   expertName?: string;
   expireTime?: string;
   hitStatus?: string;

+ 20 - 0
src/api/models/responseOssConfigMeta.ts

@@ -0,0 +1,20 @@
+/**
+ * Generated by orval v7.0.1 🍺
+ * Do not edit manually.
+ * Serve API
+ * Serve应用接口文档
+ * OpenAPI spec version: 0.0.1-SNAPSHOT
+ */
+import type { OssConfigMeta } from "./ossConfigMeta";
+
+/**
+ * 后端统一的响应实体
+ */
+export interface ResponseOssConfigMeta {
+  /** 状态码;200:成功 */
+  code?: number;
+  /** 响应的具体数据 */
+  data?: OssConfigMeta;
+  /** 响应附加信息 */
+  message?: string;
+}

+ 1 - 1
src/api/models/websiteMetaDto.ts → src/api/models/websiteMeta.ts

@@ -9,7 +9,7 @@
 /**
  * 网站元数据DTO,用于更新网站配置信息
  */
-export interface WebsiteMetaDto {
+export interface WebsiteMeta {
   /**
    * 网站公告
    * @minLength 0

+ 13 - 6
src/components/ExpertInfoCard.vue

@@ -6,7 +6,13 @@
     <div class="expert-info">
       <div class="expert-name-row">
         <span class="expert-name">{{ expert.name }}</span>
+        <el-tag v-if="expert.level === 'master'" type="danger" size="small" effect="dark">大师</el-tag>
+        <el-tag v-else-if="expert.level === 'diamond'" type="primary" size="small">钻石</el-tag>
+        <el-tag v-else-if="expert.level === 'gold'" type="warning" size="small" effect="plain">黄金</el-tag>
+        <el-tag v-if="expert.isRealname" type="success" size="small" effect="plain">已认证</el-tag>
+        <el-tag v-for="tag in expert.tags" :key="tag" size="small">{{ tag }}</el-tag>
       </div>
+      <div v-if="expert.brief" class="expert-brief">{{ expert.brief }}</div>
     </div>
   </div>
 </template>
@@ -16,6 +22,10 @@ defineProps<{
   expert: {
     name: string
     avatar: string
+    level?: string
+    isRealname?: boolean
+    tags?: string[]
+    brief?: string
   }
 }>()
 </script>
@@ -36,21 +46,18 @@ defineProps<{
 .expert-name-row {
   display: flex;
   align-items: center;
-  gap: 8px;
+  flex-wrap: wrap;
+  gap: 6px;
   margin-bottom: 4px;
 }
 .expert-name {
   font-size: 16px;
   font-weight: 600;
 }
-.expert-level {
-  font-size: 12px;
-  color: var(--color-text-secondary, #999);
-  margin-bottom: 4px;
-}
 .expert-brief {
   font-size: 13px;
   color: var(--color-text, #333);
   line-height: 1.5;
+  margin-top: 6px;
 }
 </style>

+ 36 - 27
src/components/PostCard.vue

@@ -1,19 +1,29 @@
 <template>
   <div class="post-card" @click="router.push(`/post/${post.id}`)">
+    <div class="author-row">
+      <el-avatar :size="32" :src="post.expertAvatar">
+        {{ post.expertName[0] }}
+      </el-avatar>
+      <div class="author-info">
+        <span class="expert-name">{{ post.expertName }}</span>
+        <div v-if="post.tags.length" class="tag-row">
+          <el-tag v-for="tag in post.tags" :key="tag" size="small" class="tag">{{ tag }}</el-tag>
+        </div>
+      </div>
+    </div>
     <div class="card-header">
       <h3 class="card-title">{{ post.title }}</h3>
       <el-tag v-if="post.isTodayNew" size="small" type="danger" effect="dark">最新</el-tag>
     </div>
     <div class="card-meta">
-      <span class="expert-name">{{ post.expertName }}</span>
       <span class="view-count">
         <el-icon><View /></el-icon>
         {{ post.viewCount }}
       </span>
+      <span class="time">{{ relativeTime(post.publishTime) }}</span>
     </div>
     <div class="card-footer">
       <span class="price">{{ post.price ? `¥${post.price}` : '免费' }}</span>
-      <span class="time">{{ relativeTime(post.publishTime) }}</span>
     </div>
   </div>
 </template>
@@ -22,21 +32,10 @@
 import type { PostItem } from '../type'
 import { View } from '@element-plus/icons-vue'
 import { useRouter } from 'vue-router'
+import { relativeTime } from '../util/format'
 
 defineProps<{ post: PostItem }>()
 const router = useRouter()
-
-function relativeTime(t: string): string {
-  const diff = Date.now() - new Date(t).getTime()
-  const minutes = Math.floor(diff / 60000)
-  if (minutes < 1) return '刚刚'
-  if (minutes < 60) return `${minutes}分钟前`
-  const hours = Math.floor(minutes / 60)
-  if (hours < 24) return `${hours}小时前`
-  const days = Math.floor(hours / 24)
-  if (days < 30) return `${days}天前`
-  return new Date(t).toLocaleDateString()
-}
 </script>
 
 <style scoped>
@@ -52,6 +51,29 @@ function relativeTime(t: string): string {
 .post-card:active {
   box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
 }
+.author-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+.author-info {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.expert-name {
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--color-primary, #db2777);
+}
+.tag-row {
+  display: flex;
+  gap: 4px;
+}
+.tag {
+  font-size: 11px;
+}
 .card-header {
   display: flex;
   align-items: flex-start;
@@ -77,27 +99,14 @@ function relativeTime(t: string): string {
   color: var(--color-text-secondary, #999);
   margin-bottom: 6px;
 }
-.expert-name {
-  color: var(--color-primary, #db2777);
-}
 .view-count {
   display: flex;
   align-items: center;
   gap: 2px;
 }
-.card-tags {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 4px;
-  margin-bottom: 8px;
-}
-.tag {
-  font-size: 11px;
-}
 .card-footer {
   display: flex;
   align-items: center;
-  justify-content: space-between;
   font-size: 13px;
 }
 .price {

+ 8 - 0
src/store/post.ts

@@ -11,11 +11,16 @@ function mapPostVo(v: PostVo): PostItem {
     pub.getFullYear() === now.getFullYear() &&
     pub.getMonth() === now.getMonth() &&
     pub.getDate() === now.getDate()
+  const tags: string[] = []
+  if (v.expertIsRealname) tags.push('已认证')
   return {
     id: v.id ?? '',
     title: v.title ?? '',
     expertName: v.expertName ?? '',
     expertAvatar: v.expertAvatar ?? '',
+    expertLevel: v.expertLevel ?? 'gold',
+    expertIsRealname: v.expertIsRealname ?? false,
+    tags,
     price: v.price ?? 0,
     publishTime: v.publishTime ?? '',
     viewCount: v.viewCount ?? 0,
@@ -76,6 +81,9 @@ export const usePostStore = defineStore('post', () => {
           expert: {
             name: v.expertName ?? '',
             avatar: v.expertAvatar ?? '',
+            level: v.expertLevel ?? 'gold',
+            isRealname: v.expertIsRealname ?? false,
+            tags: v.expertIsRealname ? ['已认证'] : [],
           },
           previousPosts: [],
         }

+ 7 - 0
src/type/index.ts

@@ -27,6 +27,9 @@ export interface PostItem {
   title: string
   expertName: string
   expertAvatar: string
+  expertLevel: string
+  expertIsRealname: boolean
+  tags: string[]
   price: number
   publishTime: string
   viewCount: number
@@ -50,6 +53,10 @@ export interface PostDetail {
   expert: {
     name: string
     avatar: string
+    level: string
+    isRealname: boolean
+    tags: string[]
+    brief?: string
   }
   previousPosts: Array<{
     id: string

+ 24 - 0
src/util/format.ts

@@ -0,0 +1,24 @@
+export function formatDateTime(t: string): string {
+  if (!t) return ''
+  const d = new Date(t)
+  if (isNaN(d.getTime())) return t
+  const y = d.getFullYear()
+  const mo = String(d.getMonth() + 1).padStart(2, '0')
+  const dd = String(d.getDate()).padStart(2, '0')
+  const h = String(d.getHours()).padStart(2, '0')
+  const mi = String(d.getMinutes()).padStart(2, '0')
+  return `${y}-${mo}-${dd} ${h}:${mi}`
+}
+
+export function relativeTime(t: string): string {
+  if (!t) return ''
+  const diff = Date.now() - new Date(t).getTime()
+  const minutes = Math.floor(diff / 60000)
+  if (minutes < 1) return '刚刚'
+  if (minutes < 60) return `${minutes}分钟前`
+  const hours = Math.floor(minutes / 60)
+  if (hours < 24) return `${hours}小时前`
+  const days = Math.floor(hours / 24)
+  if (days < 30) return `${days}天前`
+  return formatDateTime(t)
+}

+ 39 - 8
src/view/PostManageView.vue

@@ -13,7 +13,7 @@
           <el-input v-model="searchForm.keyword" placeholder="搜索帖子标题" clearable @keyup.enter="handleSearch" />
         </el-form-item>
         <el-form-item label="状态">
-          <el-select v-model="searchForm.status" clearable placeholder="全部">
+          <el-select v-model="searchForm.status" clearable placeholder="全部" style="width: 130px">
             <el-option label="全部" value="all" />
             <el-option label="在售" value="on_sale" />
             <el-option label="公开" value="public" />
@@ -39,14 +39,17 @@
             <el-tag v-else type="warning" size="small">待确认</el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="查看人数" width="90">
+        <el-table-column label="查看人数" width="110">
           <template #default="{ row }">
-            <el-input-number v-model="row._viewCount" :min="0" size="small" controls-position="right"
-              @change="saveViewCount(row)" style="width: 100px" />
+            <el-button size="small" @click="openViewCountEditor(row)">{{ row._viewCount ?? 0 }}</el-button>
           </template>
         </el-table-column>
-        <el-table-column prop="publishTime" label="发布时间" width="160" />
-        <el-table-column prop="expireTime" label="过期时间" width="160" />
+        <el-table-column label="发布时间" width="150">
+          <template #default="{ row }">{{ formatDateTime(row.publishTime) }}</template>
+        </el-table-column>
+        <el-table-column label="过期时间" width="150">
+          <template #default="{ row }">{{ formatDateTime(row.expireTime) }}</template>
+        </el-table-column>
         <el-table-column label="操作" width="280" fixed="right">
           <template #default="{ row }">
             <el-button v-if="canEdit(row)" size="small" @click="router.push('/admin/posts/' + row.id + '/edit')">编辑</el-button>
@@ -67,6 +70,14 @@
       </div>
     </el-card>
 
+    <el-dialog v-model="vcDialog.visible" title="修改查看人数" width="360px">
+      <el-input-number v-model="vcDialog.value" :min="0" />
+      <template #footer>
+        <el-button @click="vcDialog.visible = false">取消</el-button>
+        <el-button type="primary" :loading="vcDialog.loading" @click="confirmViewCount">确认</el-button>
+      </template>
+    </el-dialog>
+
     <el-dialog v-model="hitDialog.visible" title="设置命中状态" width="400px">
       <el-radio-group v-model="hitDialog.value">
         <el-radio value="pending">待确认</el-radio>
@@ -87,6 +98,7 @@ import { useRouter } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { getPostController } from '../api/post-controller'
 import { useLoginUserStore } from '../store'
+import { formatDateTime } from '../util/format'
 import type { PostVo, ListPostsParams } from '../api/models'
 
 const router = useRouter()
@@ -106,6 +118,13 @@ const searchForm = reactive({ keyword: '', status: 'all' })
 
 const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 })
 
+const vcDialog = reactive({
+  visible: false,
+  postId: 0,
+  value: 0,
+  loading: false,
+})
+
 const hitDialog = reactive({
   visible: false,
   postId: 0,
@@ -147,11 +166,23 @@ function handleReset() {
   handleSearch()
 }
 
-async function saveViewCount(row: PostVo & { _viewCount?: number }) {
+function openViewCountEditor(row: PostVo & { _viewCount?: number }) {
+  vcDialog.postId = Number(row.id)
+  vcDialog.value = row._viewCount ?? row.viewCount ?? 0
+  vcDialog.visible = true
+}
+
+async function confirmViewCount() {
+  vcDialog.loading = true
   try {
-    await api.updateViewCount(Number(row.id), { viewCount: row._viewCount ?? 0 })
+    await api.updateViewCount(vcDialog.postId, { viewCount: vcDialog.value })
+    ElMessage.success('查看人数已更新')
+    vcDialog.visible = false
+    await fetchData()
   } catch {
     ElMessage.error('更新查看人数失败')
+  } finally {
+    vcDialog.loading = false
   }
 }
 

Некоторые файлы не были показаны из-за большого количества измененных файлов