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

30
AI-CHANGELOG.md Normal file
View File

@@ -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`
- 结果:构建通过。

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;
},

View File

@@ -0,0 +1,266 @@
# 设计下载大文件功能v1
- 文档类别:设计(系统设计)
- 创建时间2026-03-27 12:09 (Asia/Shanghai)
- 适用项目ToolsShowNestJS + Prisma + GitLab Generic Package
- 关联模块:`access``downloads``gitlab-storage`
## 1. 背景与问题
当前下载链路(`POST /tools/:id/launch` -> `GET /downloads/:ticket`)可以完成普通文件下载,但在大文件场景存在明显短板:
1. 现有 ticket 为“一次性消费”(`consumedAt` 写入后不可重用),网络中断后无法原 ticket 续传。
2. `GET /downloads/:ticket` 仅做整文件流式透传,没有 `Range` / `206 Partial Content`,无法断点续传。
3. 对下载过程缺少阶段化记录(开始、部分成功、失败重试),难以统计真实下载质量。
4. 大文件下载失败时用户体验较差(必须回到前端重新触发 launch
## 2. 目标与非目标
### 2.1 目标
1. 支持标准 HTTP 断点续传(`Range``Accept-Ranges``Content-Range``206`)。
2. 网络中断后允许在有效期内继续下载,不要求重新 launch。
3. 在不改变“统一 launch 入口”前提下完成兼容升级。
4. 增加大文件下载可观测性(失败率、平均耗时、重试次数、完成率)。
5. 兼容两种存储后端GitLab 远端包与本地文件回退存储。
### 2.2 非目标(本期不做)
1. P2P/BT 分发。
2. CDN 回源策略编排。
3. 客户端多线程分片加速协议(先支持标准浏览器与下载器)。
## 3. 设计原则
1. **兼容优先**:旧接口可保留短期兼容,前端可灰度切换。
2. **安全优先**:令牌短期有效、可撤销、与工具/制品强绑定。
3. **可恢复优先**:会话级下载权限 > 一次性 ticket。
4. **可运维优先**:必须可追踪失败原因与瓶颈位置(应用层/存储层/网络层)。
## 4. 总体方案
采用“**下载会话Download Session+ Range 流式传输**”替代“一次性 ticket + 全量下载”。
### 4.1 核心变化
1. 下载模式 launch 不再返回一次性 ticket而是返回可续传会话 token。
2. 新下载接口支持 `HEAD``GET + Range`
3. 会话在有效期内可多次请求同一文件不同字节区间。
4. 下载记录改为“会话聚合 + 分段记录”,用于分析中断与重试。
### 4.2 兼容策略
1. 保留 `GET /downloads/:ticket`1-2 个版本周期。
2. 新增 `GET /downloads/sessions/:token/file`(新)。
3. 前端先读 launch 返回字段,若存在 `sessionToken` 则走新链路,否则走旧链路。
## 5. 数据模型设计
> 以下为设计层面的 Prisma 结构草案,具体字段可在实现阶段微调。
### 5.1 新增表:`download_sessions`
- `id`String (UUID)
- `sessionToken`String (Unique)
- `toolId`String
- `artifactId`String
- `channel`String?
- `clientVersion`String?
- `requestIp`String?
- `userAgent`String?
- `expiresAt`DateTime
- `lastAccessAt`DateTime
- `completedAt`DateTime?
- `revokedAt`DateTime?
- `createdAt`DateTime
索引建议:
- `(sessionToken unique)`
- `(expiresAt)`
- `(artifactId, expiresAt)`
### 5.2 新增表:`download_session_chunks`(可选但建议)
- `id`Int (auto increment)
- `sessionId`String
- `rangeStart`BigInt
- `rangeEnd`BigInt
- `bytesSent`BigInt
- `status``success | failed | cancelled`
- `errorMessage`String?
- `durationMs`Int?
- `createdAt`DateTime
说明:用于分析大文件下载过程中的断点位置、失败区间、重试质量。
## 6. API 设计
Base path: `/api/v1`
### 6.1 Launch下载模式响应升级
`POST /tools/:id/launch`
下载模式响应示例:
```json
{
"mode": "download",
"sessionToken": "dl_sess_xxx",
"expiresInSec": 3600,
"actionUrl": "/api/v1/downloads/sessions/dl_sess_xxx/file",
"resumeSupported": true,
"file": {
"name": "tool-v2.1.0.zip",
"size": 2147483648,
"sha256": "..."
}
}
```
### 6.2 文件元信息探测
`HEAD /downloads/sessions/:token/file`
返回:
- `Accept-Ranges: bytes`
- `Content-Length`
- `ETag`(建议使用 artifact sha256
- `Content-Disposition`
### 6.3 下载文件(支持 Range
`GET /downloads/sessions/:token/file`
请求头:
- 可选 `Range: bytes=0-1048575`
响应:
- 无 Range`200` + 全量流
- 有效 Range`206` + 指定区间流
- 非法 Range`416`
关键响应头:
- `Accept-Ranges: bytes`
- `Content-Range`
- `Content-Length`
- `Content-Type`
- `Content-Disposition`
- `ETag`
### 6.4 会话失效
- 过期或撤销:`410 Gone`
- 无效 token`404``401`(按安全策略统一)
## 7. 服务端实现设计
## 7.1 AccessService 改造
文件:`server/src/modules/access/access.service.ts`
1. 下载模式不再创建一次性 `downloadTicket`,改为创建 `downloadSession`
2. 默认会话 TTL 建议 1 小时(可配置 `DOWNLOAD_SESSION_TTL_SEC`)。
3. 返回 `sessionToken + actionUrl + file meta`
## 7.2 DownloadsController 改造
文件:`server/src/modules/downloads/downloads.controller.ts`
新增路由:
- `HEAD /downloads/sessions/:token/file`
- `GET /downloads/sessions/:token/file`
保留旧路由:
- `GET /downloads/:ticket`(兼容期)
## 7.3 DownloadsService 改造
文件:`server/src/modules/downloads/downloads.service.ts`
新增能力:
1. 解析并校验 Range。
2. 校验会话有效性、工具状态、制品状态。
3. 根据 Range 组装响应头并返回 `200/206/416`
4. 在响应关闭/中断时记录 chunk 结果。
5. 当客户端拿到完整文件后标记 `completedAt`(可通过全量下载成功或累计字节判定)。
## 7.4 GitlabStorageService 改造
文件:`server/src/modules/gitlab-storage/gitlab-storage.service.ts`
新增方法建议:
- `getArtifactStream(artifact, range?)`
- `headArtifact(artifact)`
实现要点:
1. 远端 GitLab 下载请求透传 `Range` 头。
2. 若 GitLab 返回 `206`,直接桥接状态码与头。
3. 若远端不支持 Range则回退为 `200` 全量(并在响应中标记 `resumeSupported=false`)。
4. 本地文件场景使用 `createReadStream(path, { start, end })`
## 8. 安全与风控
1. `sessionToken` 使用高熵随机串;数据库只保存 hash推荐
2. 会话与 `toolId/artifactId` 强绑定,防止跨资源复用。
3. 限制并发分段请求数(例如单会话最多 4 并发)。
4. 单 IP / 单工具限流,防止恶意刷取带宽。
5. 所有下载响应增加 `X-Content-Type-Options: nosniff`
## 9. 观测指标
1. `download_session_started_total`
2. `download_chunk_success_total`
3. `download_chunk_failed_total`
4. `download_session_completed_total`
5. `download_resume_ratio`(续传请求占比)
6. `download_5xx_ratio`
7. `p95_chunk_duration_ms`
日志字段建议:`traceId``sessionId``artifactId``rangeStart``rangeEnd``bytesSent``status``errorCode`
## 10. 迁移与发布计划
### Phase 1后端可用
1. 落库 `download_sessions`
2. 新增会话下载接口 + Range 支持。
3. Access 返回新字段。
4. 保留旧 ticket 接口。
### Phase 2前端切换
1. 前端优先走 `sessionToken` 新链路。
2. 引入失败重试与断点续传提示。
3. 观察 1 周核心指标。
### Phase 3收敛
1. 宣布下线旧 ticket 下载接口。
2. 清理旧表和兼容逻辑(按版本策略执行)。
## 11. 风险与应对
1. **GitLab Range 兼容性不一致**先做能力探测HEAD/小范围 GET不支持时降级。
2. **高并发导致服务端带宽占满**:增加会话并发限制 + 网关限流。
3. **大文件长连接导致 Node 资源占用**:严格使用流式处理,避免读入内存;设置连接超时与中断清理。
4. **统计口径变化**:区分“会话完成”与“分段成功”,避免误解下载成功率。
## 12. 验收标准
1. 2GB 文件可在中断后 5 分钟内基于同一 `sessionToken` 成功续传。
2. `Range` 请求返回符合 RFC 7233 的响应码与头部。
3. 下载中断、失败、成功均有可追踪日志。
4. 旧版前端不改动时仍可通过旧 ticket 接口下载。
## 13. 对应当前代码的最小改造清单
1. `access.service.ts`:创建并返回 `downloadSession`
2. `downloads.controller.ts`:新增 `HEAD/GET /downloads/sessions/:token/file`
3. `downloads.service.ts`实现会话校验、Range 解析、206/416 响应、chunk 记录。
4. `gitlab-storage.service.ts`:支持 Range 透传与本地分段读取。
5. `prisma/schema.prisma`:新增会话与分段记录模型。
---
本设计文档用于“下载大文件功能”研发基线,可在进入实现阶段前补充更细的 DTO、错误码扩展和数据库 migration 细节。

223
docs/TOOLSHOW_ER.drawio Normal file
View File

@@ -0,0 +1,223 @@
<mxfile host="app.diagrams.net" modified="2026-03-27T00:00:00.000Z" agent="Codex" version="24.7.17">
<diagram id="toolsshow-er" name="ToolsShow-ER">
<mxGraphModel dx="1800" dy="980" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="4200" pageHeight="2200" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="e_categories" value="categories" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="80" y="220" width="150" height="56" as="geometry"/>
</mxCell>
<mxCell id="e_tools" value="tools" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="360" y="220" width="170" height="56" as="geometry"/>
</mxCell>
<mxCell id="e_artifacts" value="tool_artifacts" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="700" y="220" width="190" height="56" as="geometry"/>
</mxCell>
<mxCell id="e_admin_users" value="admin_users" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="1040" y="220" width="170" height="56" as="geometry"/>
</mxCell>
<mxCell id="e_hot_keywords" value="hot_keywords" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="80" y="520" width="170" height="56" as="geometry"/>
</mxCell>
<mxCell id="e_tags" value="tags" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="360" y="520" width="150" height="56" as="geometry"/>
</mxCell>
<mxCell id="e_tool_tags" value="tool_tags" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="560" y="520" width="170" height="56" as="geometry"/>
</mxCell>
<mxCell id="e_tickets" value="download_tickets" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="760" y="520" width="210" height="56" as="geometry"/>
</mxCell>
<mxCell id="e_records" value="download_records" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="1010" y="520" width="210" height="56" as="geometry"/>
</mxCell>
<mxCell id="e_features" value="tool_features" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="360" y="820" width="170" height="56" as="geometry"/>
</mxCell>
<mxCell id="e_open_records" value="open_records" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="700" y="820" width="190" height="56" as="geometry"/>
</mxCell>
<mxCell id="e_audit_logs" value="admin_audit_logs" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="1020" y="820" width="220" height="56" as="geometry"/>
</mxCell>
<mxCell id="r_cat_tools" value="belongs_to_category" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="260" y="226" width="80" height="44" as="geometry"/>
</mxCell>
<mxCell id="r_tools_artifacts" value="has_artifact" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="580" y="226" width="90" height="44" as="geometry"/>
</mxCell>
<mxCell id="r_tools_latest" value="latest_artifact_ref" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="560" y="120" width="120" height="50" as="geometry"/>
</mxCell>
<mxCell id="r_admin_artifacts" value="uploaded_by" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="930" y="226" width="90" height="44" as="geometry"/>
</mxCell>
<mxCell id="r_tools_tooltags" value="tool_ref" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="460" y="370" width="80" height="44" as="geometry"/>
</mxCell>
<mxCell id="r_tags_tooltags" value="tag_ref" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="520" y="526" width="80" height="44" as="geometry"/>
</mxCell>
<mxCell id="r_tools_features" value="has_feature" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="400" y="710" width="90" height="44" as="geometry"/>
</mxCell>
<mxCell id="r_tools_tickets" value="creates_ticket" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="620" y="406" width="100" height="44" as="geometry"/>
</mxCell>
<mxCell id="r_artifacts_tickets" value="targets_artifact" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="800" y="406" width="110" height="44" as="geometry"/>
</mxCell>
<mxCell id="r_tools_records" value="download_of_tool" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="820" y="620" width="110" height="44" as="geometry"/>
</mxCell>
<mxCell id="r_artifacts_records" value="download_of_artifact" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="950" y="406" width="130" height="44" as="geometry"/>
</mxCell>
<mxCell id="r_tools_open" value="open_event" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="620" y="716" width="90" height="44" as="geometry"/>
</mxCell>
<mxCell id="r_admin_audit" value="operates" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="1080" y="680" width="90" height="44" as="geometry"/>
</mxCell>
<mxCell id="a_categories_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="70" y="130" width="90" height="36" as="geometry"/>
</mxCell>
<mxCell id="a_tools_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="360" y="130" width="90" height="36" as="geometry"/>
</mxCell>
<mxCell id="a_artifacts_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="700" y="130" width="90" height="36" as="geometry"/>
</mxCell>
<mxCell id="a_admin_users_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="1040" y="130" width="90" height="36" as="geometry"/>
</mxCell>
<mxCell id="a_hot_keywords_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="80" y="600" width="90" height="36" as="geometry"/>
</mxCell>
<mxCell id="a_tags_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="320" y="600" width="90" height="36" as="geometry"/>
</mxCell>
<mxCell id="a_tool_tags_pk" value="tool_id + tag_id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="560" y="600" width="150" height="36" as="geometry"/>
</mxCell>
<mxCell id="a_tickets_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="760" y="600" width="90" height="36" as="geometry"/>
</mxCell>
<mxCell id="a_records_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="1010" y="600" width="90" height="36" as="geometry"/>
</mxCell>
<mxCell id="a_features_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="360" y="900" width="90" height="36" as="geometry"/>
</mxCell>
<mxCell id="a_open_records_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="700" y="900" width="90" height="36" as="geometry"/>
</mxCell>
<mxCell id="a_audit_logs_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
<mxGeometry x="1020" y="900" width="90" height="36" as="geometry"/>
</mxCell>
<mxCell id="l1" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_categories" target="r_cat_tools"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l2" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_cat_tools" target="e_tools"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l3" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_artifacts"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l4" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_artifacts" target="e_artifacts"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l5" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_latest"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l6" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_latest" target="e_artifacts"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l7" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_admin_users" target="r_admin_artifacts"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l8" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_admin_artifacts" target="e_artifacts"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l9" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_tooltags"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l10" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_tooltags" target="e_tool_tags"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l11" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tags" target="r_tags_tooltags"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l12" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tags_tooltags" target="e_tool_tags"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l13" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_features"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l14" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_features" target="e_features"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l15" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_tickets"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l16" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_tickets" target="e_tickets"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l17" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_artifacts" target="r_artifacts_tickets"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l18" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_artifacts_tickets" target="e_tickets"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l19" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_records"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l20" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_records" target="e_records"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l21" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_artifacts" target="r_artifacts_records"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l22" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_artifacts_records" target="e_records"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l23" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_open"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l24" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_open" target="e_open_records"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l25" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_admin_users" target="r_admin_audit"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="l26" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_admin_audit" target="e_audit_logs"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la1" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_categories" target="a_categories_id"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la2" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_tools" target="a_tools_id"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la3" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_artifacts" target="a_artifacts_id"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la4" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_admin_users" target="a_admin_users_id"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la5" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_hot_keywords" target="a_hot_keywords_id"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la6" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_tags" target="a_tags_id"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la7" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_tool_tags" target="a_tool_tags_pk"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la8" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_tickets" target="a_tickets_id"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la9" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_records" target="a_records_id"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la10" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_features" target="a_features_id"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la11" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_open_records" target="a_open_records_id"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="la12" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_audit_logs" target="a_audit_logs_id"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="c1" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="232" y="238" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c2" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="345" y="238" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c3" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="535" y="238" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c4" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="675" y="238" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c5" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="545" y="148" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c6" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="683" y="148" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c7" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="1018" y="238" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c8" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="915" y="238" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c9" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="448" y="350" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c10" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="544" y="448" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c11" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="510" y="538" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c12" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="606" y="538" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c13" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="428" y="690" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c14" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="428" y="760" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c15" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="598" y="404" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c16" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="736" y="468" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c17" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="776" y="392" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c18" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="898" y="468" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c19" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="808" y="608" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c20" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="940" y="608" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c21" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="938" y="392" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c22" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="1086" y="468" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c23" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="598" y="704" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c24" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="728" y="774" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c25" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="1080" y="664" width="20" height="20" as="geometry"/></mxCell>
<mxCell id="c26" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="1160" y="772" width="20" height="20" as="geometry"/></mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@@ -5,6 +5,8 @@ import { AccessModule } from './modules/access/access.module';
import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.module';
import { AdminAuditModule } from './modules/admin-audit/admin-audit.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { AdminCategoriesModule } from './modules/admin-categories/admin-categories.module';
import { AdminTagsModule } from './modules/admin-tags/admin-tags.module';
import { AdminToolsModule } from './modules/admin-tools/admin-tools.module';
import { CategoriesModule } from './modules/categories/categories.module';
import { DownloadsModule } from './modules/downloads/downloads.module';
@@ -30,6 +32,8 @@ import { ToolsModule } from './modules/tools/tools.module';
GitlabStorageModule,
DownloadsModule,
AdminAuthModule,
AdminCategoriesModule,
AdminTagsModule,
AdminToolsModule,
AdminArtifactsModule,
AdminAuditModule,

View File

@@ -0,0 +1,45 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards, UseInterceptors } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Audit } from '../../common/decorators/audit.decorator';
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
import { AdminCategoriesService } from './admin-categories.service';
import { AdminCategoriesQueryDto } from './dto/admin-categories-query.dto';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
@ApiTags('admin-categories')
@ApiBearerAuth('admin-access-token')
@UseGuards(AdminJwtGuard)
@UseInterceptors(AdminAuditInterceptor)
@Controller('admin/categories')
export class AdminCategoriesController {
constructor(private readonly adminCategoriesService: AdminCategoriesService) {}
@Get()
@ApiOperation({ summary: 'Admin list categories' })
getCategories(@Query() query: AdminCategoriesQueryDto) {
return this.adminCategoriesService.getCategories(query);
}
@Post()
@Audit({ action: 'category.create', resourceType: 'category' })
@ApiOperation({ summary: 'Admin create category' })
createCategory(@Body() body: CreateCategoryDto) {
return this.adminCategoriesService.createCategory(body);
}
@Patch(':id')
@Audit({ action: 'category.update', resourceType: 'category' })
@ApiOperation({ summary: 'Admin update category' })
updateCategory(@Param('id') id: string, @Body() body: UpdateCategoryDto) {
return this.adminCategoriesService.updateCategory(id, body);
}
@Delete(':id')
@Audit({ action: 'category.delete', resourceType: 'category' })
@ApiOperation({ summary: 'Admin delete category' })
deleteCategory(@Param('id') id: string) {
return this.adminCategoriesService.deleteCategory(id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AdminCategoriesController } from './admin-categories.controller';
import { AdminCategoriesService } from './admin-categories.service';
@Module({
controllers: [AdminCategoriesController],
providers: [AdminCategoriesService],
})
export class AdminCategoriesModule {}

View File

@@ -0,0 +1,207 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
import { PrismaService } from '../../prisma/prisma.service';
import { AdminCategoriesQueryDto } from './dto/admin-categories-query.dto';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
@Injectable()
export class AdminCategoriesService {
constructor(private readonly prisma: PrismaService) {}
async getCategories(query: AdminCategoriesQueryDto) {
const keyword = query.query?.trim();
const categories = await this.prisma.category.findMany({
where: {
isDeleted: false,
...(keyword
? {
name: {
contains: keyword,
},
}
: {}),
},
include: {
tools: {
where: {
isDeleted: false,
},
select: {
id: true,
},
},
},
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
});
return categories.map((item) => ({
id: item.id,
name: item.name,
sortOrder: item.sortOrder,
toolCount: item.tools.length,
}));
}
async createCategory(body: CreateCategoryDto) {
const name = body.name.trim();
const existing = await this.prisma.category.findUnique({
where: { name },
include: {
tools: {
where: {
isDeleted: false,
},
select: {
id: true,
},
},
},
});
if (existing) {
if (!existing.isDeleted) {
throw new AppException(
ERROR_CODES.CONFLICT,
'category name already exists',
HttpStatus.CONFLICT,
);
}
const restored = await this.prisma.category.update({
where: { id: existing.id },
data: {
isDeleted: false,
sortOrder: body.sortOrder ?? existing.sortOrder,
},
include: {
tools: {
where: {
isDeleted: false,
},
select: {
id: true,
},
},
},
});
return {
id: restored.id,
name: restored.name,
sortOrder: restored.sortOrder,
toolCount: restored.tools.length,
};
}
const category = await this.prisma.category.create({
data: {
id: this.generateBusinessId('cat'),
name,
sortOrder: body.sortOrder ?? 100,
},
});
return {
id: category.id,
name: category.name,
sortOrder: category.sortOrder,
toolCount: 0,
};
}
async updateCategory(id: string, body: UpdateCategoryDto) {
const existing = await this.getCategoryEntity(id);
const name =
body.name !== undefined
? body.name.trim()
: existing.name;
await this.assertNameAvailable(name, id);
const updated = await this.prisma.category.update({
where: { id },
data: {
name: body.name !== undefined ? name : undefined,
sortOrder: body.sortOrder,
},
include: {
tools: {
where: {
isDeleted: false,
},
select: {
id: true,
},
},
},
});
return {
id: updated.id,
name: updated.name,
sortOrder: updated.sortOrder,
toolCount: updated.tools.length,
};
}
async deleteCategory(id: string) {
await this.getCategoryEntity(id);
const usedCount = await this.prisma.tool.count({
where: {
categoryId: id,
isDeleted: false,
},
});
if (usedCount > 0) {
throw new AppException(
ERROR_CODES.CONFLICT,
'category is still used by tools',
HttpStatus.CONFLICT,
);
}
await this.prisma.category.update({
where: { id },
data: {
isDeleted: true,
},
});
return {
success: true,
id,
};
}
private async getCategoryEntity(id: string) {
const category = await this.prisma.category.findFirst({
where: {
id,
isDeleted: false,
},
});
if (!category) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'category not found', HttpStatus.NOT_FOUND);
}
return category;
}
private async assertNameAvailable(name: string, excludeId?: string) {
const conflict = await this.prisma.category.findUnique({
where: { name },
select: {
id: true,
},
});
if (conflict && conflict.id !== excludeId) {
throw new AppException(ERROR_CODES.CONFLICT, 'category name already exists', HttpStatus.CONFLICT);
}
}
private generateBusinessId(prefix: string): string {
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
}
}

View File

@@ -0,0 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class AdminCategoriesQueryDto {
@ApiPropertyOptional({ description: 'Category name query' })
@IsOptional()
@IsString()
@MaxLength(80)
query?: string;
}

View File

@@ -0,0 +1,20 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
export class CreateCategoryDto {
@ApiProperty({ description: 'Category name' })
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(80)
name!: string;
@ApiPropertyOptional({ description: 'Sort order', default: 100, minimum: 0, maximum: 9999 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(9999)
sortOrder?: number;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCategoryDto } from './create-category.dto';
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}

View File

@@ -0,0 +1,29 @@
import { Body, Controller, Get, Post, UseGuards, UseInterceptors } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Audit } from '../../common/decorators/audit.decorator';
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
import { AdminTagsService } from './admin-tags.service';
import { CreateTagDto } from './dto/create-tag.dto';
@ApiTags('admin-tags')
@ApiBearerAuth('admin-access-token')
@UseGuards(AdminJwtGuard)
@UseInterceptors(AdminAuditInterceptor)
@Controller('admin/tags')
export class AdminTagsController {
constructor(private readonly adminTagsService: AdminTagsService) {}
@Get()
@ApiOperation({ summary: 'Admin list tags' })
getTags() {
return this.adminTagsService.getTags();
}
@Post()
@Audit({ action: 'tag.create', resourceType: 'tag' })
@ApiOperation({ summary: 'Admin create tag' })
createTag(@Body() body: CreateTagDto) {
return this.adminTagsService.createTag(body);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AdminTagsController } from './admin-tags.controller';
import { AdminTagsService } from './admin-tags.service';
@Module({
controllers: [AdminTagsController],
providers: [AdminTagsService],
})
export class AdminTagsModule {}

View File

@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateTagDto } from './dto/create-tag.dto';
@Injectable()
export class AdminTagsService {
constructor(private readonly prisma: PrismaService) {}
async getTags() {
const tags = await this.prisma.tag.findMany({
where: {
isDeleted: false,
},
orderBy: {
name: 'asc',
},
});
return tags.map((tag) => ({
id: tag.id,
name: tag.name,
}));
}
async createTag(body: CreateTagDto) {
const name = body.name.trim();
const existing = await this.prisma.tag.findUnique({
where: { name },
});
if (existing) {
if (existing.isDeleted) {
const restored = await this.prisma.tag.update({
where: { id: existing.id },
data: { isDeleted: false },
});
return {
id: restored.id,
name: restored.name,
};
}
return {
id: existing.id,
name: existing.name,
};
}
const created = await this.prisma.tag.create({
data: {
id: this.generateBusinessId('tag'),
name,
},
});
return {
id: created.id,
name: created.name,
};
}
private generateBusinessId(prefix: string): string {
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
}
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsString, MaxLength, MinLength } from 'class-validator';
export class CreateTagDto {
@ApiProperty({ description: 'Tag display name' })
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(50)
name!: string;
}