From b627f8c020d8c65399752a42f2342b010e5b4610 Mon Sep 17 00:00:00 2001 From: dlandy Date: Mon, 30 Mar 2026 09:36:36 +0800 Subject: [PATCH] init --- AI-CHANGELOG.md | 30 ++ client/src/App.vue | 5 +- client/src/admin/AdminApp.vue | 473 +++++++++++++++--- client/src/admin/admin.scss | 4 + client/src/admin/api.js | 29 +- .../components/AdminCategoriesSection.vue | 69 +++ client/src/admin/components/AdminSidebar.vue | 16 +- client/src/admin/components/AdminTopbar.vue | 14 +- .../admin/components/CategoryFormDialog.vue | 75 +++ .../src/admin/components/ToolFormDialog.vue | 28 ++ client/src/admin/pages/AdminAuditLogsPage.vue | 47 ++ .../src/admin/pages/AdminCategoriesPage.vue | 33 ++ client/src/admin/pages/AdminOverviewPage.vue | 52 ++ client/src/admin/pages/AdminToolsPage.vue | 94 ++++ client/src/admin/router.js | 48 +- client/src/admin/stores/console.js | 56 ++- docs/2026-03-27-12-09-设计-下载大文件功能.md | 266 ++++++++++ docs/TOOLSHOW_ER.drawio | 223 +++++++++ server/src/app.module.ts | 4 + .../admin-categories.controller.ts | 45 ++ .../admin-categories.module.ts | 9 + .../admin-categories.service.ts | 207 ++++++++ .../dto/admin-categories-query.dto.ts | 10 + .../dto/create-category.dto.ts | 20 + .../dto/update-category.dto.ts | 4 + .../admin-tags/admin-tags.controller.ts | 29 ++ .../modules/admin-tags/admin-tags.module.ts | 9 + .../modules/admin-tags/admin-tags.service.ts | 65 +++ .../modules/admin-tags/dto/create-tag.dto.ts | 12 + 29 files changed, 1881 insertions(+), 95 deletions(-) create mode 100644 AI-CHANGELOG.md create mode 100644 client/src/admin/components/AdminCategoriesSection.vue create mode 100644 client/src/admin/components/CategoryFormDialog.vue create mode 100644 client/src/admin/pages/AdminAuditLogsPage.vue create mode 100644 client/src/admin/pages/AdminCategoriesPage.vue create mode 100644 client/src/admin/pages/AdminOverviewPage.vue create mode 100644 client/src/admin/pages/AdminToolsPage.vue create mode 100644 docs/2026-03-27-12-09-设计-下载大文件功能.md create mode 100644 docs/TOOLSHOW_ER.drawio create mode 100644 server/src/modules/admin-categories/admin-categories.controller.ts create mode 100644 server/src/modules/admin-categories/admin-categories.module.ts create mode 100644 server/src/modules/admin-categories/admin-categories.service.ts create mode 100644 server/src/modules/admin-categories/dto/admin-categories-query.dto.ts create mode 100644 server/src/modules/admin-categories/dto/create-category.dto.ts create mode 100644 server/src/modules/admin-categories/dto/update-category.dto.ts create mode 100644 server/src/modules/admin-tags/admin-tags.controller.ts create mode 100644 server/src/modules/admin-tags/admin-tags.module.ts create mode 100644 server/src/modules/admin-tags/admin-tags.service.ts create mode 100644 server/src/modules/admin-tags/dto/create-tag.dto.ts diff --git a/AI-CHANGELOG.md b/AI-CHANGELOG.md new file mode 100644 index 0000000..888582d --- /dev/null +++ b/AI-CHANGELOG.md @@ -0,0 +1,30 @@ +# AI Change Log + +## 2026-03-27 11:25:24 +08:00 + +### 目标 +将管理端从“单一 `AdminApp.vue` 内按路由显示区块”的结构,拆分为 `admin/pages` 下的独立路由页面组件,并通过 `vue-router` 实现页面切换。 + +### 实现结果 +- 管理端路由改为嵌套路由:`/admin` 作为布局壳,子路由分别对应 `overview/tools/categories/auditlogs`。 +- 管理端页面拆分到 `client/src/admin/pages`: + - `AdminOverviewPage.vue` + - `AdminToolsPage.vue` + - `AdminCategoriesPage.vue` + - `AdminAuditLogsPage.vue` +- `AdminApp.vue` 不再使用 `v-show` 在同文件切换四个区块,改为 `router-view` 渲染当前路由页面。 +- `AdminApp.vue` 保留登录态、侧边栏、顶栏、数据加载与通用弹窗逻辑,通过 `currentPageProps` 和 `currentPageEvents` 向不同路由页面分发数据与事件。 +- 路由元信息 `meta`(`menuKey/sectionTitle/withKpi`)用于驱动菜单高亮、标题和布局样式,不再使用路径字符串硬编码判断。 + +### 变更文件 +- `client/src/admin/router.js` +- `client/src/admin/AdminApp.vue` +- `client/src/admin/pages/AdminOverviewPage.vue` +- `client/src/admin/pages/AdminToolsPage.vue` +- `client/src/admin/pages/AdminCategoriesPage.vue` +- `client/src/admin/pages/AdminAuditLogsPage.vue` +- `AI-CHANGELOG.md` + +### 验证 +- 执行:`npm run build`(`client`) +- 结果:构建通过。 diff --git a/client/src/App.vue b/client/src/App.vue index cbad836..e7542a8 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -2,20 +2,19 @@
@@ -141,12 +91,24 @@ :tool-form-rules="toolFormRules" :categories="consoleStore.categories" :category-loading="consoleStore.categoryLoading" + :tags="consoleStore.tags" + :tag-loading="consoleStore.tagLoading" :access-mode-options="accessModeOptions" :status-options="statusOptions" :submitting="toolDialog.submitting" @submit="submitToolForm" /> + + import { ElMessage, ElMessageBox } from 'element-plus'; import { computed, onMounted, reactive, ref, watch } from 'vue'; -import { useRouter } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import { getApiErrorMessage } from '../api'; -import AdminKpiRow from './components/AdminKpiRow.vue'; import AdminSidebar from './components/AdminSidebar.vue'; import AdminTopbar from './components/AdminTopbar.vue'; import AccessModeDialog from './components/AccessModeDialog.vue'; -import AdminAuditSection from './components/AdminAuditSection.vue'; -import AdminOverviewSection from './components/AdminOverviewSection.vue'; -import AdminToolsSection from './components/AdminToolsSection.vue'; import ArtifactDialog from './components/ArtifactDialog.vue'; +import CategoryFormDialog from './components/CategoryFormDialog.vue'; import StatusDialog from './components/StatusDialog.vue'; import ToolFormDialog from './components/ToolFormDialog.vue'; import { useAdminAuthStore } from './stores/auth'; import { useAdminConsoleStore } from './stores/console'; const router = useRouter(); +const route = useRoute(); const authStore = useAdminAuthStore(); const consoleStore = useAdminConsoleStore(); -const activeMenu = ref('overview'); +const ADMIN_SECTION_ROUTE_MAP = { + overview: '/admin/overview', + tools: '/admin/tools', + categories: '/admin/categories', + audit: '/admin/auditlogs', +}; + +const activeMenu = computed(() => { + const menuKey = route.meta?.menuKey; + return typeof menuKey === 'string' && menuKey ? menuKey : 'tools'; +}); const topSearch = ref(''); const trendRange = ref('week'); @@ -227,14 +197,10 @@ const locationTraffic = [ ]; const sectionTitle = computed(() => { - if (activeMenu.value === 'tools') { - return 'Tools'; - } - if (activeMenu.value === 'audit') { - return 'Audit Logs'; - } - return 'Overview'; + const routeTitle = route.meta?.sectionTitle; + return typeof routeTitle === 'string' && routeTitle ? routeTitle : 'Tools'; }); +const isOverviewRoute = computed(() => route.meta?.withKpi === true); const kpiCards = computed(() => { const toolTotal = consoleStore.toolPagination.total; @@ -266,6 +232,94 @@ const trendPolyline = computed(() => .join(' '), ); +const currentPageProps = computed(() => { + if (activeMenu.value === 'overview') { + return { + kpiCards: kpiCards.value, + trendRange: trendRange.value, + formatNumber, + trendPolyline: trendPolyline.value, + trendMarkers: trendMarkers.value, + deviceTraffic, + locationTraffic, + }; + } + + if (activeMenu.value === 'categories') { + return { + categoryFilters: consoleStore.categoryFilters, + categoryLoading: consoleStore.categoryLoading, + categories: consoleStore.categories, + }; + } + + if (activeMenu.value === 'audit') { + return { + auditFilters: consoleStore.auditFilters, + auditLoading: consoleStore.auditLoading, + auditLogs: consoleStore.auditLogs, + auditPagination: consoleStore.auditPagination, + formatDateTime, + stringifyBody, + }; + } + + return { + toolFilters: consoleStore.toolFilters, + categoryLoading: consoleStore.categoryLoading, + categories: consoleStore.categories, + statusOptions, + accessModeOptions, + toolLoading: consoleStore.toolLoading, + tools: consoleStore.tools, + toolPagination: consoleStore.toolPagination, + statusTagType, + accessModeTagType, + formatNumber, + formatDate, + }; +}); + +const currentPageEvents = computed(() => { + if (activeMenu.value === 'overview') { + return { + 'update:trend-range': updateTrendRange, + }; + } + + if (activeMenu.value === 'categories') { + return { + search: searchCategories, + reset: resetCategoryFilters, + create: openCreateCategoryDialog, + edit: openEditCategoryDialog, + delete: deleteCategory, + }; + } + + if (activeMenu.value === 'audit') { + return { + search: searchAuditLogs, + reset: resetAuditFilters, + 'page-change': handleAuditPageChange, + 'size-change': handleAuditSizeChange, + }; + } + + return { + search: searchTools, + reset: resetToolFilters, + create: openCreateToolDialog, + edit: openEditToolDialog, + artifact: openArtifactDialog, + status: openStatusDialog, + mode: openModeDialog, + delete: deleteTool, + 'page-change': handleToolPageChange, + 'size-change': handleToolSizeChange, + }; +}); + const loginFormRef = ref(null); const loginForm = reactive({ username: 'admin', @@ -295,11 +349,13 @@ const accessModeOptions = [ ]; const toolDialogFormRef = ref(null); +const categoryDialogFormRef = ref(null); function createEmptyToolForm() { return { name: '', categoryId: '', + tagIds: [], description: '', rating: 0, featuresText: '', @@ -318,6 +374,18 @@ const toolFormRules = { { min: 2, max: 120, message: '工具名称长度为 2-120 位', trigger: 'blur' }, ], categoryId: [{ required: true, message: '请选择工具分类', trigger: 'change' }], + tagIds: [ + { + validator: (_rule, value, callback) => { + if (Array.isArray(value) && value.length > 20) { + callback(new Error('最多选择 20 个标签')); + return; + } + callback(); + }, + trigger: 'change', + }, + ], description: [ { required: true, message: '请输入工具简介', trigger: 'blur' }, { min: 10, max: 2000, message: '工具简介长度为 10-2000 位', trigger: 'blur' }, @@ -358,6 +426,43 @@ const toolDialog = reactive({ submitting: false, }); +function createEmptyCategoryForm() { + return { + name: '', + sortOrder: 100, + }; +} + +const categoryForm = reactive(createEmptyCategoryForm()); + +const categoryFormRules = { + name: [ + { required: true, message: '请输入分类名称', trigger: 'blur' }, + { min: 1, max: 80, message: '分类名称长度为 1-80 位', trigger: 'blur' }, + ], + sortOrder: [ + { required: true, message: '请输入排序值', trigger: 'change' }, + { + validator: (_rule, value, callback) => { + const numeric = Number(value); + if (!Number.isInteger(numeric) || numeric < 0 || numeric > 9999) { + callback(new Error('排序值必须是 0-9999 的整数')); + return; + } + callback(); + }, + trigger: 'change', + }, + ], +}; + +const categoryDialog = reactive({ + visible: false, + mode: 'create', + id: '', + submitting: false, +}); + const artifactDialog = reactive({ visible: false, toolId: '', @@ -409,6 +514,10 @@ function calcTrendPoint(index) { return { x, y }; } +function updateTrendRange(nextRange) { + trendRange.value = nextRange; +} + function formatNumber(value) { const numeric = Number(value); return new Intl.NumberFormat('zh-CN').format(Number.isFinite(numeric) ? numeric : 0); @@ -522,10 +631,85 @@ function normalizeFeatureText(value) { .slice(0, 20); } +function normalizeTagName(value) { + return String(value || '').trim().replace(/\s+/g, ' '); +} + +function splitTagSelections(values) { + const selected = Array.isArray(values) ? values : []; + const sourceTags = Array.isArray(consoleStore.tags) ? consoleStore.tags : []; + const knownTagIdSet = new Set(sourceTags.map((item) => item.id)); + const knownTagNameToId = new Map( + sourceTags + .map((item) => [normalizeTagName(item.name).toLowerCase(), item.id]) + .filter(([name]) => Boolean(name)), + ); + + const existingTagIds = []; + const newTagNames = []; + const seenTagIds = new Set(); + const seenTagNames = new Set(); + + selected.forEach((item) => { + const raw = String(item ?? ''); + const normalized = normalizeTagName(raw); + if (!normalized) { + return; + } + + if (knownTagIdSet.has(raw)) { + if (!seenTagIds.has(raw)) { + existingTagIds.push(raw); + seenTagIds.add(raw); + } + return; + } + + const existingIdByName = knownTagNameToId.get(normalized.toLowerCase()); + if (existingIdByName) { + if (!seenTagIds.has(existingIdByName)) { + existingTagIds.push(existingIdByName); + seenTagIds.add(existingIdByName); + } + return; + } + + if (!seenTagNames.has(normalized.toLowerCase())) { + newTagNames.push(normalized); + seenTagNames.add(normalized.toLowerCase()); + } + }); + + return { + existingTagIds, + newTagNames, + }; +} + +async function resolveToolTagIds(token) { + const { existingTagIds, newTagNames } = splitTagSelections(toolForm.tagIds); + if (newTagNames.length === 0) { + return existingTagIds; + } + + const createdTags = await Promise.all( + newTagNames.map((name) => consoleStore.createTag({ name }, token)), + ); + const createdTagIds = createdTags + .map((item) => item?.id) + .filter((id) => typeof id === 'string' && id); + + return Array.from(new Set([...existingTagIds, ...createdTagIds])); +} + function resetToolForm() { Object.assign(toolForm, createEmptyToolForm()); } +function resetCategoryForm() { + Object.assign(categoryForm, createEmptyCategoryForm()); +} + function resetArtifactForm() { artifactForm.version = ''; artifactForm.releaseNotes = ''; @@ -550,7 +734,8 @@ async function runWithAuth(fn) { async function initializeAdminData() { try { await Promise.all([ - consoleStore.loadCategories(), + runWithAuth((token) => consoleStore.loadCategories(token)), + runWithAuth((token) => consoleStore.loadTags(token)), runWithAuth((token) => consoleStore.loadTools(token)), runWithAuth((token) => consoleStore.loadAuditLogs(token)), ]); @@ -594,23 +779,30 @@ function goPublic() { } function switchMenu(nextKey) { - if (!['overview', 'tools', 'audit'].includes(nextKey)) { + const targetPath = ADMIN_SECTION_ROUTE_MAP[nextKey]; + if (!targetPath) { ElMessage.info('该菜单为展示项,当前版本暂未开放'); return; } - activeMenu.value = nextKey; + if (route.path !== targetPath) { + router.push(targetPath); + } } function openOverviewSection() { - activeMenu.value = 'overview'; + switchMenu('overview'); } function openToolsSection() { - activeMenu.value = 'tools'; + switchMenu('tools'); +} + +function openCategoriesSection() { + switchMenu('categories'); } function openAuditSection() { - activeMenu.value = 'audit'; + switchMenu('audit'); } async function refreshCurrentSection() { @@ -618,11 +810,15 @@ async function refreshCurrentSection() { await loadTools(); return; } + if (activeMenu.value === 'categories') { + await loadCategories(); + return; + } if (activeMenu.value === 'audit') { await loadAuditLogs(); return; } - await Promise.all([loadTools(), loadAuditLogs()]); + await Promise.all([loadCategories(), loadTools(), loadAuditLogs()]); } function applyTopSearch() { @@ -637,10 +833,26 @@ function applyTopSearch() { return; } + if (activeMenu.value === 'categories') { + consoleStore.categoryFilters.query = value; + searchCategories(); + return; + } + consoleStore.toolFilters.query = value; searchTools(); } +async function searchCategories() { + await loadCategories(); +} + +async function resetCategoryFilters() { + consoleStore.resetCategoryFilters(); + topSearch.value = ''; + await loadCategories(); +} + async function searchTools() { consoleStore.toolFilters.page = 1; await loadTools(); @@ -663,6 +875,20 @@ async function handleToolSizeChange(size) { await loadTools(); } +async function loadCategories() { + try { + await runWithAuth((token) => consoleStore.loadCategories(token)); + } catch (error) { + if (isUnauthorized(error)) { + await authStore.logout(); + consoleStore.$reset(); + ElMessage.error('登录已过期,请重新登录'); + return; + } + ElMessage.error(getApiErrorMessage(error)); + } +} + async function loadTools() { try { await runWithAuth((token) => consoleStore.loadTools(token)); @@ -677,6 +903,81 @@ async function loadTools() { } } +function openCreateCategoryDialog() { + categoryDialog.mode = 'create'; + categoryDialog.id = ''; + resetCategoryForm(); + categoryDialog.visible = true; +} + +function openEditCategoryDialog(row) { + categoryDialog.mode = 'edit'; + categoryDialog.id = row.id; + Object.assign(categoryForm, { + name: row.name || '', + sortOrder: Number(row.sortOrder ?? 100), + }); + categoryDialog.visible = true; +} + +function buildCategoryPayload() { + return { + name: categoryForm.name.trim(), + sortOrder: Number(categoryForm.sortOrder ?? 100), + }; +} + +async function submitCategoryForm() { + const formRef = categoryDialogFormRef.value; + if (!formRef) { + return; + } + + try { + await formRef.validate(); + } catch { + return; + } + + categoryDialog.submitting = true; + try { + const payload = buildCategoryPayload(); + await runWithAuth((token) => { + if (categoryDialog.mode === 'create') { + return consoleStore.createCategory(payload, token); + } + return consoleStore.updateCategory(categoryDialog.id, payload, token); + }); + categoryDialog.visible = false; + ElMessage.success(categoryDialog.mode === 'create' ? '分类已创建' : '分类已更新'); + await Promise.all([loadCategories(), loadTools()]); + } catch (error) { + ElMessage.error(getApiErrorMessage(error)); + } finally { + categoryDialog.submitting = false; + } +} + +async function deleteCategory(row) { + try { + await ElMessageBox.confirm(`确认删除分类「${row.name}」吗?`, '删除确认', { + type: 'warning', + confirmButtonText: '确认删除', + cancelButtonText: '取消', + }); + } catch { + return; + } + + try { + await runWithAuth((token) => consoleStore.deleteCategory(row.id, token)); + ElMessage.success('分类已删除'); + await Promise.all([loadCategories(), loadTools()]); + } catch (error) { + ElMessage.error(getApiErrorMessage(error)); + } +} + function openCreateToolDialog() { toolDialog.mode = 'create'; toolDialog.id = ''; @@ -690,6 +991,7 @@ function openEditToolDialog(row) { Object.assign(toolForm, { name: row.name || '', categoryId: row.category?.id || '', + tagIds: Array.isArray(row.tags) ? row.tags.map((item) => item.id).filter(Boolean) : [], description: row.description || '', rating: Number(row.rating ?? 0), featuresText: Array.isArray(row.features) ? row.features.join('\n') : '', @@ -701,10 +1003,11 @@ function openEditToolDialog(row) { toolDialog.visible = true; } -function buildToolPayload() { +function buildToolPayload(tagIds) { const payload = { name: toolForm.name.trim(), categoryId: toolForm.categoryId, + tags: tagIds, description: toolForm.description.trim(), rating: Number(toolForm.rating ?? 0), features: normalizeFeatureText(toolForm.featuresText), @@ -736,8 +1039,9 @@ async function submitToolForm() { toolDialog.submitting = true; try { - const payload = buildToolPayload(); - await runWithAuth((token) => { + await runWithAuth(async (token) => { + const resolvedTagIds = await resolveToolTagIds(token); + const payload = buildToolPayload(resolvedTagIds); if (toolDialog.mode === 'create') { return consoleStore.createTool(payload, token); } @@ -998,6 +1302,9 @@ async function loadAuditLogs() { } watch(activeMenu, async (nextMenu) => { + if (nextMenu === 'categories' && !consoleStore.categories.length) { + await loadCategories(); + } if (nextMenu === 'tools' && !consoleStore.tools.length) { await loadTools(); } @@ -1025,6 +1332,16 @@ watch( }, ); +watch( + () => categoryDialog.visible, + (visible) => { + if (!visible) { + resetCategoryForm(); + categoryDialogFormRef.value?.clearValidate?.(); + } + }, +); + watch( () => artifactDialog.visible, (visible) => { diff --git a/client/src/admin/admin.scss b/client/src/admin/admin.scss index 5a8a356..7970896 100644 --- a/client/src/admin/admin.scss +++ b/client/src/admin/admin.scss @@ -463,6 +463,10 @@ gap: 10px; } +.category-filters { + grid-template-columns: 1.2fr auto auto; +} + .data-table { margin-top: 12px; } diff --git a/client/src/admin/api.js b/client/src/admin/api.js index 714014d..2c031f9 100644 --- a/client/src/admin/api.js +++ b/client/src/admin/api.js @@ -120,7 +120,32 @@ export async function adminDeleteArtifact(toolId, artifactId, token) { return unwrap(response.data); } -export async function adminGetCategories() { - const response = await http.get('/categories'); +export async function adminGetCategories(params, token) { + const response = await http.get('/admin/categories', withToken(token, { params })); + return unwrap(response.data); +} + +export async function adminCreateCategory(payload, token) { + const response = await http.post('/admin/categories', payload, withToken(token)); + return unwrap(response.data); +} + +export async function adminUpdateCategory(id, payload, token) { + const response = await http.patch(`/admin/categories/${id}`, payload, withToken(token)); + return unwrap(response.data); +} + +export async function adminDeleteCategory(id, token) { + const response = await http.delete(`/admin/categories/${id}`, withToken(token)); + return unwrap(response.data); +} + +export async function adminGetTags(token) { + const response = await http.get('/admin/tags', withToken(token)); + return unwrap(response.data); +} + +export async function adminCreateTag(payload, token) { + const response = await http.post('/admin/tags', payload, withToken(token)); return unwrap(response.data); } diff --git a/client/src/admin/components/AdminCategoriesSection.vue b/client/src/admin/components/AdminCategoriesSection.vue new file mode 100644 index 0000000..1c5102b --- /dev/null +++ b/client/src/admin/components/AdminCategoriesSection.vue @@ -0,0 +1,69 @@ + + + diff --git a/client/src/admin/components/AdminSidebar.vue b/client/src/admin/components/AdminSidebar.vue index a4d3757..c437b41 100644 --- a/client/src/admin/components/AdminSidebar.vue +++ b/client/src/admin/components/AdminSidebar.vue @@ -2,7 +2,7 @@