init
This commit is contained in:
30
AI-CHANGELOG.md
Normal file
30
AI-CHANGELOG.md
Normal 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`)
|
||||
- 结果:构建通过。
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -463,6 +463,10 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.category-filters {
|
||||
grid-template-columns: 1.2fr auto auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
69
client/src/admin/components/AdminCategoriesSection.vue
Normal file
69
client/src/admin/components/AdminCategoriesSection.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
75
client/src/admin/components/CategoryFormDialog.vue
Normal file
75
client/src/admin/components/CategoryFormDialog.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
47
client/src/admin/pages/AdminAuditLogsPage.vue
Normal file
47
client/src/admin/pages/AdminAuditLogsPage.vue
Normal 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>
|
||||
33
client/src/admin/pages/AdminCategoriesPage.vue
Normal file
33
client/src/admin/pages/AdminCategoriesPage.vue
Normal 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>
|
||||
52
client/src/admin/pages/AdminOverviewPage.vue
Normal file
52
client/src/admin/pages/AdminOverviewPage.vue
Normal 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>
|
||||
94
client/src/admin/pages/AdminToolsPage.vue
Normal file
94
client/src/admin/pages/AdminToolsPage.vue
Normal 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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
266
docs/2026-03-27-12-09-设计-下载大文件功能.md
Normal file
266
docs/2026-03-27-12-09-设计-下载大文件功能.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# 设计:下载大文件功能(v1)
|
||||
|
||||
- 文档类别:设计(系统设计)
|
||||
- 创建时间:2026-03-27 12:09 (Asia/Shanghai)
|
||||
- 适用项目:ToolsShow(NestJS + 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
223
docs/TOOLSHOW_ER.drawio
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
207
server/src/modules/admin-categories/admin-categories.service.ts
Normal file
207
server/src/modules/admin-categories/admin-categories.service.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateCategoryDto } from './create-category.dto';
|
||||
|
||||
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
|
||||
29
server/src/modules/admin-tags/admin-tags.controller.ts
Normal file
29
server/src/modules/admin-tags/admin-tags.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
server/src/modules/admin-tags/admin-tags.module.ts
Normal file
9
server/src/modules/admin-tags/admin-tags.module.ts
Normal 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 {}
|
||||
65
server/src/modules/admin-tags/admin-tags.service.ts
Normal file
65
server/src/modules/admin-tags/admin-tags.service.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
12
server/src/modules/admin-tags/dto/create-tag.dto.ts
Normal file
12
server/src/modules/admin-tags/dto/create-tag.dto.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user