소스 검색

feat: add admin post management page (Step 22)

yangyi 3 일 전
부모
커밋
9fa7576834
4개의 변경된 파일247개의 추가작업 그리고 18개의 파일을 삭제
  1. 0 0
      openapi.json
  2. 41 13
      src/api/post-controller.ts
  3. 6 5
      src/router/index.ts
  4. 200 0
      src/view/PostManageView.vue

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
openapi.json


+ 41 - 13
src/api/post-controller.ts

@@ -19,6 +19,35 @@ import type {
 import { customAxiosInstance } from "../util/axios-instance";
 
 export const getPostController = () => {
+  /**
+   * @summary 获取帖子详情
+   */
+  const getPostDetail = (id: number) => {
+    return customAxiosInstance<ResponsePostVo>({
+      url: `/api/posts/${id}`,
+      method: "GET",
+    });
+  };
+  /**
+   * @summary 更新帖子(管理员)
+   */
+  const updatePost = (id: number, postDto: PostDto) => {
+    return customAxiosInstance<ResponseVoid>({
+      url: `/api/posts/${id}`,
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      data: postDto,
+    });
+  };
+  /**
+   * @summary 删除帖子(管理员,逻辑删除)
+   */
+  const deletePost = (id: number) => {
+    return customAxiosInstance<ResponseVoid>({
+      url: `/api/posts/${id}`,
+      method: "DELETE",
+    });
+  };
   /**
    * @summary 修改查看人数(管理员)
    */
@@ -68,15 +97,6 @@ export const getPostController = () => {
       data: postDto,
     });
   };
-  /**
-   * @summary 获取帖子详情
-   */
-  const getPostDetail = (id: number) => {
-    return customAxiosInstance<ResponsePostVo>({
-      url: `/api/posts/${id}`,
-      method: "GET",
-    });
-  };
   /**
    * @summary 获取专家往期帖子
    */
@@ -91,14 +111,25 @@ export const getPostController = () => {
     });
   };
   return {
+    getPostDetail,
+    updatePost,
+    deletePost,
     updateViewCount,
     updateHitStatus,
     listPosts,
     createPost,
-    getPostDetail,
     listPreviousPosts,
   };
 };
+export type GetPostDetailResult = NonNullable<
+  Awaited<ReturnType<ReturnType<typeof getPostController>["getPostDetail"]>>
+>;
+export type UpdatePostResult = NonNullable<
+  Awaited<ReturnType<ReturnType<typeof getPostController>["updatePost"]>>
+>;
+export type DeletePostResult = NonNullable<
+  Awaited<ReturnType<ReturnType<typeof getPostController>["deletePost"]>>
+>;
 export type UpdateViewCountResult = NonNullable<
   Awaited<ReturnType<ReturnType<typeof getPostController>["updateViewCount"]>>
 >;
@@ -111,9 +142,6 @@ export type ListPostsResult = NonNullable<
 export type CreatePostResult = NonNullable<
   Awaited<ReturnType<ReturnType<typeof getPostController>["createPost"]>>
 >;
-export type GetPostDetailResult = NonNullable<
-  Awaited<ReturnType<ReturnType<typeof getPostController>["getPostDetail"]>>
->;
 export type ListPreviousPostsResult = NonNullable<
   Awaited<ReturnType<ReturnType<typeof getPostController>["listPreviousPosts"]>>
 >;

+ 6 - 5
src/router/index.ts

@@ -13,13 +13,14 @@ import UserLayout from '../layout/UserLayout.vue'
 import AdminLayout from "../layout/AdminLayout.vue"
 import UserView from "../view/UserView.vue"
 import SiteSettingsView from "../view/SiteSettingsView.vue"
+import PostManageView from "../view/PostManageView.vue"
 import {
   HomeFilled,
-  Menu,
   User,
   ShoppingCart,
   Bell,
   Setting,
+  Document,
 } from '@element-plus/icons-vue'
 import { useLoginUserStore } from "../store"
 import { ElMessage } from "element-plus"
@@ -95,10 +96,10 @@ export const adminRoutes: RouteRecordRaw[] = [
         meta: { title: '用户管理', icon: User, requiresAdmin: true }
       },
       {
-        path: '/admin/other',
-        name: 'other',
-        component: AdminLayout,
-        meta: { title: '其他', icon: Menu, requiresAdmin: true }
+        path: '/admin/posts',
+        name: 'adminPosts',
+        component: PostManageView,
+        meta: { title: '帖子管理', icon: Document, requiresAdmin: true }
       },
       {
         path: '/admin/settings',

+ 200 - 0
src/view/PostManageView.vue

@@ -0,0 +1,200 @@
+<template>
+  <div class="post-manage">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>帖子管理</span>
+        </div>
+      </template>
+
+      <el-form :model="searchForm" inline>
+        <el-form-item label="标题">
+          <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-option label="全部" value="all" />
+            <el-option label="在售" value="on_sale" />
+            <el-option label="公开" value="public" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">搜索</el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table :data="tableData" v-loading="loading" stripe style="width: 100%">
+        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="expertName" label="专家" width="120" />
+        <el-table-column label="价格" width="80">
+          <template #default="{ row }">¥{{ row.price }}</template>
+        </el-table-column>
+        <el-table-column label="命中状态" width="110">
+          <template #default="{ row }">
+            <el-tag v-if="row.hitStatus === 'hit'" type="success" size="small">命中</el-tag>
+            <el-tag v-else-if="row.hitStatus === 'miss'" type="info" size="small">未命中</el-tag>
+            <el-tag v-else type="warning" size="small">待确认</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="查看人数" width="90">
+          <template #default="{ row }">
+            <el-input-number v-model="row._viewCount" :min="0" size="small" controls-position="right"
+              @change="saveViewCount(row)" style="width: 100px" />
+          </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="200" fixed="right">
+          <template #default="{ row }">
+            <el-button size="small" @click="editHitStatus(row)">设命中</el-button>
+            <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination-wrap">
+        <el-pagination
+          v-model:current-page="pagination.pageNum"
+          v-model:page-size="pagination.pageSize"
+          :total="pagination.total"
+          layout="total, prev, pager, next"
+          @change="fetchData"
+        />
+      </div>
+    </el-card>
+
+    <el-dialog v-model="hitDialog.visible" title="设置命中状态" width="400px">
+      <el-radio-group v-model="hitDialog.value">
+        <el-radio value="pending">待确认</el-radio>
+        <el-radio value="hit">命中</el-radio>
+        <el-radio value="miss">未命中</el-radio>
+      </el-radio-group>
+      <template #footer>
+        <el-button @click="hitDialog.visible = false">取消</el-button>
+        <el-button type="primary" :loading="hitDialog.loading" @click="confirmHitStatus">确认</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getPostController } from '../api/post-controller'
+import type { PostVo, ListPostsParams } from '../api/models'
+
+const api = getPostController()
+const tableData = ref<(PostVo & { _viewCount?: number })[]>([])
+const loading = ref(false)
+
+const searchForm = reactive({ keyword: '', status: 'all' })
+
+const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 })
+
+const hitDialog = reactive({
+  visible: false,
+  postId: 0,
+  value: 'pending',
+  loading: false,
+})
+
+async function fetchData() {
+  loading.value = true
+  try {
+    const params: ListPostsParams = {
+      keyword: searchForm.keyword,
+      pageNum: pagination.pageNum,
+      pageSize: pagination.pageSize,
+    }
+    if (searchForm.status && searchForm.status !== 'all') {
+      params.status = searchForm.status
+    }
+    const res = await api.listPosts(params)
+    if (res.code === 200 && res.data) {
+      tableData.value = (res.data.data ?? []).map((p) => ({ ...p, _viewCount: p.viewCount ?? 0 }))
+      pagination.total = res.data.total ?? 0
+    }
+  } catch {
+    tableData.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  pagination.pageNum = 1
+  fetchData()
+}
+
+function handleReset() {
+  searchForm.keyword = ''
+  searchForm.status = 'all'
+  handleSearch()
+}
+
+async function saveViewCount(row: PostVo & { _viewCount?: number }) {
+  try {
+    await api.updateViewCount(Number(row.id), { viewCount: row._viewCount ?? 0 })
+  } catch {
+    ElMessage.error('更新查看人数失败')
+  }
+}
+
+function editHitStatus(row: PostVo) {
+  hitDialog.postId = Number(row.id)
+  hitDialog.value = row.hitStatus ?? 'pending'
+  hitDialog.visible = true
+}
+
+async function confirmHitStatus() {
+  hitDialog.loading = true
+  try {
+    await api.updateHitStatus(hitDialog.postId, { hitStatus: hitDialog.value })
+    ElMessage.success('命中状态已更新')
+    hitDialog.visible = false
+    await fetchData()
+  } catch {
+    ElMessage.error('更新失败')
+  } finally {
+    hitDialog.loading = false
+  }
+}
+
+function handleDelete(row: PostVo) {
+  ElMessageBox.confirm(`确定删除帖子"${row.title}"?`, '确认删除', {
+    confirmButtonText: '删除',
+    cancelButtonText: '取消',
+    type: 'warning',
+  }).then(async () => {
+    try {
+      await api.deletePost(Number(row.id))
+      ElMessage.success('删除成功')
+      await fetchData()
+    } catch {
+      ElMessage.error('删除失败')
+    }
+  }).catch(() => {})
+}
+
+onMounted(() => fetchData())
+</script>
+
+<style scoped>
+.post-manage {
+  padding: 16px;
+}
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 16px;
+  font-weight: 600;
+}
+.pagination-wrap {
+  margin-top: 16px;
+  display: flex;
+  justify-content: flex-end;
+}
+</style>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.