This commit is contained in:
dlandy
2026-03-30 09:36:36 +08:00
parent 40be11adbf
commit b627f8c020
29 changed files with 1881 additions and 95 deletions

View File

@@ -2,20 +2,19 @@
<div>
<header class="header-wrap" :class="{ 'is-scrolled': isScrolled }">
<div class="container header">
<a class="brand" href="#" aria-label="ToolsShow 首页" @click.prevent>
<a class="brand" href="#" aria-label="Tools工具" @click.prevent>
<span class="brand-mark">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M4 6.5C4 5.67 4.67 5 5.5 5H18.5C19.33 5 20 5.67 20 6.5V17.5C20 18.33 19.33 19 18.5 19H5.5C4.67 19 4 18.33 4 17.5V6.5Z" stroke="currentColor" stroke-width="1.8" />
<path d="M8 9H16M8 12H16M8 15H13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</span>
<span>ToolsShow</span>
<span>资源导航</span>
</a>
<nav class="nav" aria-label="主导航">
<a href="#tools">工具列表</a>
<a href="#tools">分类浏览</a>
<a href="#tools">工具中心</a>
<a href="/admin">管理端</a>
<button
type="button"
class="nav-btn"

View File

@@ -65,7 +65,7 @@
@logout="handleLogout"
/>
<main class="dashboard-main" :class="{ 'with-kpi': activeMenu === 'overview' }">
<main class="dashboard-main" :class="{ 'with-kpi': isOverviewRoute }">
<AdminTopbar
v-model:search="topSearch"
:section-title="sectionTitle"
@@ -73,63 +73,13 @@
@refresh="refreshCurrentSection"
@open-overview="openOverviewSection"
@open-tools="openToolsSection"
@open-categories="openCategoriesSection"
@open-audit="openAuditSection"
/>
<AdminKpiRow
v-if="activeMenu === 'overview'"
:kpi-cards="kpiCards"
:format-number="formatNumber"
/>
<AdminOverviewSection
v-show="activeMenu === 'overview'"
v-model:trend-range="trendRange"
:trend-polyline="trendPolyline"
:trend-markers="trendMarkers"
:device-traffic="deviceTraffic"
:location-traffic="locationTraffic"
/>
<AdminToolsSection
v-show="activeMenu === 'tools'"
:tool-filters="consoleStore.toolFilters"
:category-loading="consoleStore.categoryLoading"
:categories="consoleStore.categories"
:status-options="statusOptions"
:access-mode-options="accessModeOptions"
:tool-loading="consoleStore.toolLoading"
:tools="consoleStore.tools"
:tool-pagination="consoleStore.toolPagination"
:status-tag-type="statusTagType"
:access-mode-tag-type="accessModeTagType"
:format-number="formatNumber"
:format-date="formatDate"
@search="searchTools"
@reset="resetToolFilters"
@create="openCreateToolDialog"
@edit="openEditToolDialog"
@artifact="openArtifactDialog"
@status="openStatusDialog"
@mode="openModeDialog"
@delete="deleteTool"
@page-change="handleToolPageChange"
@size-change="handleToolSizeChange"
/>
<AdminAuditSection
v-show="activeMenu === 'audit'"
:audit-filters="consoleStore.auditFilters"
:audit-loading="consoleStore.auditLoading"
:audit-logs="consoleStore.auditLogs"
:audit-pagination="consoleStore.auditPagination"
:format-date-time="formatDateTime"
:stringify-body="stringifyBody"
@search="searchAuditLogs"
@reset="resetAuditFilters"
@page-change="handleAuditPageChange"
@size-change="handleAuditSizeChange"
/>
<router-view v-slot="{ Component }">
<component :is="Component" v-bind="currentPageProps" v-on="currentPageEvents" />
</router-view>
</main>
</div>
@@ -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"
/>
<CategoryFormDialog
ref="categoryDialogFormRef"
v-model:visible="categoryDialog.visible"
:mode="categoryDialog.mode"
:category-form="categoryForm"
:category-form-rules="categoryFormRules"
:submitting="categoryDialog.submitting"
@submit="submitCategoryForm"
/>
<ArtifactDialog
v-model:visible="artifactDialog.visible"
:artifact-dialog="artifactDialog"
@@ -182,26 +144,34 @@
<script setup>
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) => {

View File

@@ -463,6 +463,10 @@
gap: 10px;
}
.category-filters {
grid-template-columns: 1.2fr auto auto;
}
.data-table {
margin-top: 12px;
}

View File

@@ -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);
}

View File

@@ -0,0 +1,69 @@
<template>
<section class="dashboard-section">
<article class="panel data-panel">
<header class="data-head">
<div>
<h3>Category Management</h3>
<p>支持工具分类新增编辑与删除维护</p>
</div>
<div class="data-head-actions">
<el-button type="primary" @click="emit('create')">新增分类</el-button>
</div>
</header>
<div class="data-filters category-filters">
<el-input
v-model="categoryFilters.query"
placeholder="搜索分类名称"
clearable
@keyup.enter="emit('search')"
/>
<el-button type="primary" :loading="categoryLoading" @click="emit('search')">查询</el-button>
<el-button @click="emit('reset')">重置</el-button>
</div>
<el-table
:data="categories"
border
stripe
v-loading="categoryLoading"
class="data-table"
>
<el-table-column prop="name" label="分类名称" min-width="220" />
<el-table-column prop="sortOrder" label="排序值" width="120" />
<el-table-column label="工具数" width="120">
<template #default="{ row }">
{{ Number(row.toolCount || 0) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-space>
<el-button size="small" type="primary" plain @click="emit('edit', row)">编辑</el-button>
<el-button size="small" type="danger" plain @click="emit('delete', row)">删除</el-button>
</el-space>
</template>
</el-table-column>
</el-table>
</article>
</section>
</template>
<script setup>
defineProps({
categoryFilters: {
type: Object,
required: true,
},
categoryLoading: {
type: Boolean,
required: true,
},
categories: {
type: Array,
required: true,
},
});
const emit = defineEmits(['search', 'reset', 'create', 'edit', 'delete']);
</script>

View File

@@ -2,7 +2,7 @@
<aside class="dashboard-sidebar">
<div class="sidebar-brand">
<span class="brand-mark">*</span>
<span class="brand-text">snowui</span>
<span class="brand-text">管理端</span>
</div>
<div class="sidebar-menu">
@@ -31,6 +31,7 @@
<script setup>
import {
ChatDotRound,
CollectionTag,
DataAnalysis,
Document,
Management,
@@ -50,11 +51,12 @@ const emit = defineEmits(['menu-change', 'go-public', 'logout']);
const menuItems = [
{ key: 'overview', label: 'Overview', icon: DataAnalysis },
{ key: 'tools', label: 'Tool Management', icon: ShoppingBag },
{ key: 'audit', label: 'Audit Logs', icon: Document },
{ key: 'projects', label: 'Projects', icon: Management },
{ key: 'profile', label: 'User Profile', icon: User },
{ key: 'account', label: 'Account', icon: Setting },
{ key: 'social', label: 'Social', icon: ChatDotRound },
{ key: 'tools', label: '工具管理', icon: ShoppingBag },
{ key: 'categories', label: '分类管理', icon: CollectionTag },
{ key: 'audit', label: '审计日志', icon: Document },
// { key: 'projects', label: 'Projects', icon: Management },
// { key: 'profile', label: 'User Profile', icon: User },
// { key: 'account', label: 'Account', icon: Setting },
// { key: 'social', label: 'Social', icon: ChatDotRound },
];
</script>

View File

@@ -28,6 +28,9 @@
<el-button class="icon-btn" circle @click="emit('open-tools')">
<el-icon><Grid /></el-icon>
</el-button>
<el-button class="icon-btn" circle @click="emit('open-categories')">
<el-icon><CollectionTag /></el-icon>
</el-button>
<el-button class="icon-btn" circle @click="emit('open-audit')">
<el-icon><Document /></el-icon>
</el-button>
@@ -36,7 +39,7 @@
</template>
<script setup>
import { DataAnalysis, Document, Grid, Refresh, Search } from '@element-plus/icons-vue';
import { CollectionTag, DataAnalysis, Document, Grid, Refresh, Search } from '@element-plus/icons-vue';
defineProps({
sectionTitle: {
@@ -50,5 +53,12 @@ const search = defineModel('search', {
default: '',
});
const emit = defineEmits(['apply-search', 'refresh', 'open-overview', 'open-tools', 'open-audit']);
const emit = defineEmits([
'apply-search',
'refresh',
'open-overview',
'open-tools',
'open-categories',
'open-audit',
]);
</script>

View File

@@ -0,0 +1,75 @@
<template>
<el-dialog
v-model="visible"
:title="mode === 'create' ? '新增分类' : '编辑分类'"
width="520px"
destroy-on-close
>
<el-form
ref="categoryFormRef"
:model="categoryForm"
:rules="categoryFormRules"
label-width="88px"
>
<el-form-item label="分类名称" prop="name">
<el-input
v-model="categoryForm.name"
placeholder="请输入分类名称"
maxlength="80"
show-word-limit
/>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-input-number
v-model="categoryForm.sortOrder"
:min="0"
:max="9999"
:step="10"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="emit('submit')">
{{ mode === 'create' ? '创建分类' : '保存修改' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue';
const visible = defineModel('visible', {
type: Boolean,
default: false,
});
defineProps({
mode: {
type: String,
required: true,
},
categoryForm: {
type: Object,
required: true,
},
categoryFormRules: {
type: Object,
required: true,
},
submitting: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['submit']);
const categoryFormRef = ref(null);
defineExpose({
validate: () => categoryFormRef.value?.validate?.(),
clearValidate: () => categoryFormRef.value?.clearValidate?.(),
});
</script>

View File

@@ -30,6 +30,26 @@
/>
</el-select>
</el-form-item>
<el-form-item label="标签" prop="tagIds">
<el-select
v-model="toolForm.tagIds"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
placeholder="可多选,也可直接输入新标签"
style="width: 100%"
:loading="tagLoading"
>
<el-option
v-for="item in tags"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="工具简介" prop="description">
<el-input
v-model="toolForm.description"
@@ -116,6 +136,14 @@ defineProps({
type: Boolean,
required: true,
},
tags: {
type: Array,
required: true,
},
tagLoading: {
type: Boolean,
required: true,
},
accessModeOptions: {
type: Array,
required: true,

View File

@@ -0,0 +1,47 @@
<template>
<AdminAuditSection
:audit-filters="auditFilters"
:audit-loading="auditLoading"
:audit-logs="auditLogs"
:audit-pagination="auditPagination"
:format-date-time="formatDateTime"
:stringify-body="stringifyBody"
@search="emit('search')"
@reset="emit('reset')"
@page-change="emit('page-change', $event)"
@size-change="emit('size-change', $event)"
/>
</template>
<script setup>
import AdminAuditSection from '../components/AdminAuditSection.vue';
defineProps({
auditFilters: {
type: Object,
required: true,
},
auditLoading: {
type: Boolean,
required: true,
},
auditLogs: {
type: Array,
required: true,
},
auditPagination: {
type: Object,
required: true,
},
formatDateTime: {
type: Function,
required: true,
},
stringifyBody: {
type: Function,
required: true,
},
});
const emit = defineEmits(['search', 'reset', 'page-change', 'size-change']);
</script>

View File

@@ -0,0 +1,33 @@
<template>
<AdminCategoriesSection
:category-filters="categoryFilters"
:category-loading="categoryLoading"
:categories="categories"
@search="emit('search')"
@reset="emit('reset')"
@create="emit('create')"
@edit="emit('edit', $event)"
@delete="emit('delete', $event)"
/>
</template>
<script setup>
import AdminCategoriesSection from '../components/AdminCategoriesSection.vue';
defineProps({
categoryFilters: {
type: Object,
required: true,
},
categoryLoading: {
type: Boolean,
required: true,
},
categories: {
type: Array,
required: true,
},
});
const emit = defineEmits(['search', 'reset', 'create', 'edit', 'delete']);
</script>

View File

@@ -0,0 +1,52 @@
<template>
<section class="admin-page-overview">
<AdminKpiRow :kpi-cards="kpiCards" :format-number="formatNumber" />
<AdminOverviewSection
:trend-range="trendRange"
:trend-polyline="trendPolyline"
:trend-markers="trendMarkers"
:device-traffic="deviceTraffic"
:location-traffic="locationTraffic"
@update:trend-range="emit('update:trend-range', $event)"
/>
</section>
</template>
<script setup>
import AdminKpiRow from '../components/AdminKpiRow.vue';
import AdminOverviewSection from '../components/AdminOverviewSection.vue';
defineProps({
kpiCards: {
type: Array,
required: true,
},
trendRange: {
type: String,
required: true,
},
formatNumber: {
type: Function,
required: true,
},
trendPolyline: {
type: String,
required: true,
},
trendMarkers: {
type: Array,
required: true,
},
deviceTraffic: {
type: Array,
required: true,
},
locationTraffic: {
type: Array,
required: true,
},
});
const emit = defineEmits(['update:trend-range']);
</script>

View File

@@ -0,0 +1,94 @@
<template>
<AdminToolsSection
:tool-filters="toolFilters"
:category-loading="categoryLoading"
:categories="categories"
:status-options="statusOptions"
:access-mode-options="accessModeOptions"
:tool-loading="toolLoading"
:tools="tools"
:tool-pagination="toolPagination"
:status-tag-type="statusTagType"
:access-mode-tag-type="accessModeTagType"
:format-number="formatNumber"
:format-date="formatDate"
@search="emit('search')"
@reset="emit('reset')"
@create="emit('create')"
@edit="emit('edit', $event)"
@artifact="emit('artifact', $event)"
@status="emit('status', $event)"
@mode="emit('mode', $event)"
@delete="emit('delete', $event)"
@page-change="emit('page-change', $event)"
@size-change="emit('size-change', $event)"
/>
</template>
<script setup>
import AdminToolsSection from '../components/AdminToolsSection.vue';
defineProps({
toolFilters: {
type: Object,
required: true,
},
categoryLoading: {
type: Boolean,
required: true,
},
categories: {
type: Array,
required: true,
},
statusOptions: {
type: Array,
required: true,
},
accessModeOptions: {
type: Array,
required: true,
},
toolLoading: {
type: Boolean,
required: true,
},
tools: {
type: Array,
required: true,
},
toolPagination: {
type: Object,
required: true,
},
statusTagType: {
type: Function,
required: true,
},
accessModeTagType: {
type: Function,
required: true,
},
formatNumber: {
type: Function,
required: true,
},
formatDate: {
type: Function,
required: true,
},
});
const emit = defineEmits([
'search',
'reset',
'create',
'edit',
'artifact',
'status',
'mode',
'delete',
'page-change',
'size-change',
]);
</script>

View File

@@ -1,6 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router';
import PublicApp from '../App.vue';
import AdminApp from './AdminApp.vue';
import AdminAuditLogsPage from './pages/AdminAuditLogsPage.vue';
import AdminCategoriesPage from './pages/AdminCategoriesPage.vue';
import AdminOverviewPage from './pages/AdminOverviewPage.vue';
import AdminToolsPage from './pages/AdminToolsPage.vue';
const routes = [
{
@@ -10,8 +14,50 @@ const routes = [
},
{
path: '/admin',
name: 'admin-home',
component: AdminApp,
redirect: '/admin/tools',
children: [
{
path: 'overview',
name: 'admin-overview',
component: AdminOverviewPage,
meta: {
menuKey: 'overview',
sectionTitle: 'Overview',
withKpi: true,
},
},
{
path: 'tools',
name: 'admin-tools',
component: AdminToolsPage,
meta: {
menuKey: 'tools',
sectionTitle: 'Tools',
withKpi: false,
},
},
{
path: 'categories',
name: 'admin-categories',
component: AdminCategoriesPage,
meta: {
menuKey: 'categories',
sectionTitle: 'Categories',
withKpi: false,
},
},
{
path: 'auditlogs',
name: 'admin-auditlogs',
component: AdminAuditLogsPage,
meta: {
menuKey: 'audit',
sectionTitle: 'Audit Logs',
withKpi: false,
},
},
],
},
];

View File

@@ -1,14 +1,19 @@
import { defineStore } from 'pinia';
import {
adminCreateCategory,
adminCreateTag,
adminCreateTool,
adminDeleteCategory,
adminDeleteArtifact,
adminDeleteTool,
adminGetArtifacts,
adminGetAuditLogs,
adminGetCategories,
adminGetTags,
adminGetTools,
adminSetLatestArtifact,
adminUpdateArtifactStatus,
adminUpdateCategory,
adminUpdateTool,
adminUpdateAccessMode,
adminUpdateToolStatus,
@@ -19,6 +24,11 @@ export const useAdminConsoleStore = defineStore('admin-console', {
state: () => ({
categories: [],
categoryLoading: false,
categoryFilters: {
query: '',
},
tags: [],
tagLoading: false,
toolFilters: {
query: '',
@@ -54,15 +64,57 @@ export const useAdminConsoleStore = defineStore('admin-console', {
},
}),
actions: {
async loadCategories() {
async loadCategories(token) {
this.categoryLoading = true;
try {
const data = await adminGetCategories();
const data = await adminGetCategories(
{
query: this.categoryFilters.query || undefined,
},
token,
);
this.categories = Array.isArray(data) ? data : [];
} finally {
this.categoryLoading = false;
}
},
resetCategoryFilters() {
this.categoryFilters.query = '';
},
async createCategory(payload, token) {
return adminCreateCategory(payload, token);
},
async updateCategory(id, payload, token) {
return adminUpdateCategory(id, payload, token);
},
async deleteCategory(id, token) {
return adminDeleteCategory(id, token);
},
async loadTags(token) {
this.tagLoading = true;
try {
const data = await adminGetTags(token);
this.tags = Array.isArray(data) ? data : [];
} finally {
this.tagLoading = false;
}
},
async createTag(payload, token) {
const created = await adminCreateTag(payload, token);
const createdId = created?.id;
if (!createdId) {
return created;
}
const existingIndex = this.tags.findIndex((item) => item.id === createdId);
if (existingIndex >= 0) {
this.tags.splice(existingIndex, 1, created);
} else {
this.tags.push(created);
}
this.tags.sort((a, b) => String(a?.name ?? '').localeCompare(String(b?.name ?? '')));
return created;
},
setToolPage(page) {
this.toolFilters.page = page;
},