This commit is contained in:
dlandy
2026-04-08 17:56:12 +08:00
parent 5a6328561f
commit e6c2d76238
41 changed files with 1361 additions and 335 deletions

View File

@@ -28,7 +28,9 @@ COPY --from=server-builder /build/server/dist ./dist
COPY --from=server-builder /build/server/prisma ./prisma COPY --from=server-builder /build/server/prisma ./prisma
RUN npx prisma generate RUN npx prisma generate
COPY --from=client-builder /build/client/dist ./public COPY --from=client-builder /build/client/dist ./public
COPY docker/runtime-entrypoint.sh /usr/local/bin/toolsshow-entrypoint
RUN chmod +x /usr/local/bin/toolsshow-entrypoint
EXPOSE 3000 EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/src/main.js"] CMD ["toolsshow-entrypoint"]

View File

@@ -0,0 +1,2 @@
VITE_API_BASE=/api/v1
VITE_API_PROXY_TARGET=http://localhost:3000

View File

@@ -3,16 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ToolsShow - Vue3 客户端</title> <title>资源导航站点</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="icon" type="image/png" sizes="256x256" href="/favicon.png" />
<link <link rel="shortcut icon" href="/favicon.ico" />
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Sora:wght@500;600;700;800&display=swap" <link rel="stylesheet" href="/local-fonts.css" />
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -9,7 +9,9 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"axios": "^1.13.1", "axios": "^1.13.1",
"dompurify": "^3.3.3",
"element-plus": "^2.11.7", "element-plus": "^2.11.7",
"marked": "^17.0.5",
"pinia": "^2.3.1", "pinia": "^2.3.1",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
@@ -1257,6 +1259,13 @@
"@types/lodash": "*" "@types/lodash": "*"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/web-bluetooth": { "node_modules/@types/web-bluetooth": {
"version": "0.0.20", "version": "0.0.20",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
@@ -1518,6 +1527,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1893,6 +1911,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/marked": {
"version": "17.0.5",
"resolved": "https://registry.npmmirror.com/marked/-/marked-17.0.5.tgz",
"integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -10,7 +10,9 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.1", "axios": "^1.13.1",
"dompurify": "^3.3.3",
"element-plus": "^2.11.7", "element-plus": "^2.11.7",
"marked": "^17.0.5",
"pinia": "^2.3.1", "pinia": "^2.3.1",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
client/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

40
client/public/favicon.svg Normal file
View File

@@ -0,0 +1,40 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="tile" x1="16" y1="18" x2="48" y2="50" gradientUnits="userSpaceOnUse">
<stop stop-color="#E8FBFF"/>
<stop offset="1" stop-color="#BCEFFA"/>
</linearGradient>
<linearGradient id="needle" x1="34" y1="17" x2="47" y2="31" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF8D9"/>
<stop offset="1" stop-color="#F4FCFF"/>
</linearGradient>
</defs>
<g filter="url(#shadow)">
<rect x="15" y="17" width="14" height="12" rx="4" fill="url(#tile)" stroke="#0A8FB5" stroke-width="2"/>
<rect x="15" y="33" width="14" height="16" rx="4" fill="url(#tile)" stroke="#0A8FB5" stroke-width="2"/>
<rect x="33" y="35" width="15" height="14" rx="4" fill="url(#tile)" fill-opacity="0.92" stroke="#0A8FB5" stroke-width="2"/>
</g>
<path d="M35.2 17.4L49.2 22.4L38.2 33.3L35.6 27.2L29.6 24.6L35.2 17.4Z" fill="url(#needle)"/>
<path d="M35.2 17.4L40.3 27.1L49.2 22.4" stroke="#74DFF2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.5 22.2H25.5" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
<path d="M18.5 37.8H25.5" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
<path d="M18.5 42.8H24" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
<path d="M36.5 40.8H44.5" stroke="#0C7D9E" stroke-opacity="0.64" stroke-width="2" stroke-linecap="round"/>
<path d="M36.5 45.6H42.5" stroke="#0C7D9E" stroke-opacity="0.64" stroke-width="2" stroke-linecap="round"/>
<defs>
<filter id="shadow" x="11" y="14" width="41" height="39" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1.5"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.016 0 0 0 0 0.282 0 0 0 0 0.38 0 0 0 0.14 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_1" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,6 @@
:root {
--font-sans: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-display: "Avenir Next", "Trebuchet MS", "Segoe UI", "PingFang SC", sans-serif;
--font-admin-sans: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-admin-mono: "Cascadia Code", "Consolas", "SFMono-Regular", monospace;
}

View File

@@ -3,18 +3,14 @@
<header class="header-wrap" :class="{ 'is-scrolled': isScrolled }"> <header class="header-wrap" :class="{ 'is-scrolled': isScrolled }">
<div class="container header"> <div class="container header">
<a class="brand" href="#" aria-label="Tools工具" @click.prevent> <a class="brand" href="#" aria-label="Tools工具" @click.prevent>
<span class="brand-mark"> <span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> <img src="/favicon.svg" alt="" width="32" height="32" />
<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>
<span>资源导航</span> <span>资源导航</span>
</a> </a>
<nav class="nav" aria-label="主导航"> <nav class="nav" aria-label="主导航">
<a href="#tools">工具列表</a> <a href="#tools">工具列表</a>
<a href="#tools">分类浏览</a> <a href="#tools">分类浏览</a>
<a href="#tools">工具中心</a>
<button <button
type="button" type="button"
class="nav-btn" class="nav-btn"
@@ -101,6 +97,7 @@
<p class="result-tip">{{ resultTip }}</p> <p class="result-tip">{{ resultTip }}</p>
<label class="sr-only" for="sortSelect">排序方式</label> <label class="sr-only" for="sortSelect">排序方式</label>
<select id="sortSelect" v-model="filters.sortBy" class="select" @change="onSortChange"> <select id="sortSelect" v-model="filters.sortBy" class="select" @change="onSortChange">
<option value="created">按创建时间排序</option>
<option value="latest">按更新时间排序</option> <option value="latest">按更新时间排序</option>
<option value="popular">按下载量排序</option> <option value="popular">按下载量排序</option>
<option value="rating">按评分排序</option> <option value="rating">按评分排序</option>
@@ -137,7 +134,7 @@
</div> </div>
<h3>{{ tool.name }}</h3> <h3>{{ tool.name }}</h3>
<p class="desc">{{ tool.description }}</p> <div class="desc markdown markdown-inline" v-html="renderInlineMarkdown(tool.description)"></div>
<div class="tags"> <div class="tags">
<span v-for="tag in tool.tags" :key="`${tool.id}-${tag}`" class="tag">{{ tag }}</span> <span v-for="tag in tool.tags" :key="`${tool.id}-${tag}`" class="tag">{{ tag }}</span>
@@ -210,7 +207,7 @@
<p class="modal-error">{{ detailError }}</p> <p class="modal-error">{{ detailError }}</p>
</template> </template>
<template v-else-if="detail"> <template v-else-if="detail">
<p>{{ detail.description }}</p> <div class="markdown markdown-detail" v-html="renderMarkdown(detail.description)"></div>
<ul class="meta-list"> <ul class="meta-list">
<li>分类<strong>{{ detail.category?.name || '-' }}</strong></li> <li>分类<strong>{{ detail.category?.name || '-' }}</strong></li>
<li>评分<strong>{{ Number(detail.rating || 0).toFixed(1) }}</strong></li> <li>评分<strong>{{ Number(detail.rating || 0).toFixed(1) }}</strong></li>
@@ -224,9 +221,13 @@
<li v-if="detail.accessMode === 'download'"> <li v-if="detail.accessMode === 'download'">
最新版本<strong>{{ detail.latestVersion || '暂无版本' }}</strong> 最新版本<strong>{{ detail.latestVersion || '暂无版本' }}</strong>
</li> </li>
<li v-if="detail.accessMode === 'download'"> <li v-if="detail.accessMode === 'download' && detail.fileSize !== null">
文件大小<strong>{{ formatFileSize(detail.fileSize) }}</strong> 文件大小<strong>{{ formatFileSize(detail.fileSize) }}</strong>
</li> </li>
<li v-if="detail.accessMode === 'download' && detail.openUrl">
下载地址
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
</li>
<li v-if="detail.accessMode === 'web' && detail.openUrl"> <li v-if="detail.accessMode === 'web' && detail.openUrl">
打开地址 打开地址
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a> <a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
@@ -235,7 +236,9 @@
</ul> </ul>
<h3>核心能力</h3> <h3>核心能力</h3>
<ul v-if="detail.features?.length" class="feature-list"> <ul v-if="detail.features?.length" class="feature-list">
<li v-for="feature in detail.features" :key="`detail-${feature}`">{{ feature }}</li> <li v-for="(feature, featureIndex) in detail.features" :key="`detail-${featureIndex}`">
<div class="markdown markdown-inline" v-html="renderInlineMarkdown(feature)"></div>
</li>
</ul> </ul>
<p v-else class="modal-muted">暂无能力描述</p> <p v-else class="modal-muted">暂无能力描述</p>
</template> </template>
@@ -284,8 +287,10 @@ import {
fetchTools, fetchTools,
getApiErrorMessage, getApiErrorMessage,
launchTool, launchTool,
notifyToolInteraction,
resolveActionUrl, resolveActionUrl,
} from './api'; } from './api';
import { renderInlineMarkdown, renderMarkdown } from './utils/markdown';
const CLIENT_VERSION = 'web-1.0.0'; const CLIENT_VERSION = 'web-1.0.0';
const QUERY_DEBOUNCE_MS = 320; const QUERY_DEBOUNCE_MS = 320;
@@ -293,7 +298,7 @@ const QUERY_DEBOUNCE_MS = 320;
const filters = reactive({ const filters = reactive({
query: '', query: '',
category: 'all', category: 'all',
sortBy: 'latest', sortBy: 'created',
page: 1, page: 1,
pageSize: 6, pageSize: 6,
}); });
@@ -523,7 +528,7 @@ function applyHotKeyword(keyword) {
function resetFilters() { function resetFilters() {
filters.query = ''; filters.query = '';
filters.category = 'all'; filters.category = 'all';
filters.sortBy = 'latest'; filters.sortBy = 'created';
filters.page = 1; filters.page = 1;
clearTimeout(queryTimer); clearTimeout(queryTimer);
loadTools(); loadTools();
@@ -571,7 +576,7 @@ function isLaunchDisabled(tool) {
if (launchingId.value === tool.id) { if (launchingId.value === tool.id) {
return true; return true;
} }
return tool.accessMode === 'download' && !tool.hasArtifact; return tool.accessMode === 'download' && !tool.downloadReady;
} }
function launchButtonText(tool) { function launchButtonText(tool) {
@@ -581,8 +586,8 @@ function launchButtonText(tool) {
if (tool.accessMode === 'web') { if (tool.accessMode === 'web') {
return '打开网页'; return '打开网页';
} }
if (!tool.hasArtifact) { if (!tool.downloadReady) {
return '暂无可下载'; return '暂无可下载资源';
} }
return '下载'; return '下载';
} }
@@ -606,14 +611,26 @@ async function triggerLaunch(tool) {
channel: 'official', channel: 'official',
clientVersion: CLIENT_VERSION, clientVersion: CLIENT_VERSION,
}); });
const isWebLaunch = result?.mode === 'web';
const isDownloadLaunch = result?.mode === 'download';
if (isWebLaunch || isDownloadLaunch) {
notifyToolInteraction(tool.id, {
action: isWebLaunch ? 'open' : 'download',
channel: 'official',
clientVersion: CLIENT_VERSION,
});
}
const actionUrl = resolveActionUrl(result?.actionUrl); const actionUrl = resolveActionUrl(result?.actionUrl);
if (result?.mode === 'web') { if (isWebLaunch || isDownloadLaunch) {
if (result.openIn === 'same_tab') { if (result.openIn === 'same_tab') {
window.location.assign(actionUrl); window.location.assign(actionUrl);
return; return;
} }
}
if (isWebLaunch) {
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer'); const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
if (!page) { if (!page) {
showToast('浏览器阻止了新窗口,请允许弹窗后重试'); showToast('浏览器阻止了新窗口,请允许弹窗后重试');
@@ -626,7 +643,7 @@ async function triggerLaunch(tool) {
window.location.assign(actionUrl); window.location.assign(actionUrl);
return; return;
} }
showToast(`${tool.name} 下载任务已创建`); showToast(`${tool.name} 已开始下载`);
} }
await Promise.all([loadTools(), refreshOverview()]); await Promise.all([loadTools(), refreshOverview()]);

View File

@@ -173,28 +173,6 @@ const activeMenu = computed(() => {
return typeof menuKey === 'string' && menuKey ? menuKey : 'tools'; return typeof menuKey === 'string' && menuKey ? menuKey : 'tools';
}); });
const topSearch = ref(''); const topSearch = ref('');
const trendRange = ref('week');
const trendValues = [63, 66, 55, 58, 59, 67, 66, 65, 74, 62, 65, 60, 64, 65];
const trendMarkers = computed(() => [2, 4, 6, 8, 10, 12].map((idx) => calcTrendPoint(idx)));
const deviceTraffic = [
{ name: 'Linux', value: 46, active: false },
{ name: 'Mac', value: 72, active: false },
{ name: 'iOS', value: 54, active: false },
{ name: 'Windows', value: 80, active: false },
{ name: 'Android', value: 60, active: true },
{ name: 'Other', value: 34, active: false },
];
const locationTraffic = [
{ name: 'US', value: 52, active: false },
{ name: 'Canada', value: 74, active: false },
{ name: 'Mexico', value: 60, active: false },
{ name: 'China', value: 35, active: false },
{ name: 'Japan', value: 80, active: true },
{ name: 'Australia', value: 45, active: false },
];
const sectionTitle = computed(() => { const sectionTitle = computed(() => {
const routeTitle = route.meta?.sectionTitle; const routeTitle = route.meta?.sectionTitle;
@@ -202,46 +180,37 @@ const sectionTitle = computed(() => {
}); });
const isOverviewRoute = computed(() => route.meta?.withKpi === true); const isOverviewRoute = computed(() => route.meta?.withKpi === true);
const overviewSummary = computed(() => consoleStore.overview.summary || {});
const kpiCards = computed(() => { const kpiCards = computed(() => {
const toolTotal = consoleStore.toolPagination.total; const summary = overviewSummary.value;
const openTotal = consoleStore.tools.reduce((sum, item) => sum + Number(item.openCount || 0), 0); const publishRate = formatPercent(summary.publishedTotal, summary.toolTotal);
const downloadTotal = consoleStore.tools.reduce((sum, item) => sum + Number(item.downloadCount || 0), 0); const downloadReadyRate = formatPercent(summary.downloadReadyToolTotal, summary.downloadToolTotal);
const publishedCount = consoleStore.tools.filter((item) => item.status === 'published').length;
const auditTotal = consoleStore.auditPagination.total;
return [ return [
{ key: 'views', label: 'Views', value: toolTotal, delta: 11.01, theme: 'blue' }, { key: 'tool-total', label: '工具总数', value: summary.toolTotal, note: `已发布率 ${publishRate}`, theme: 'blue' },
{ key: 'visits', label: 'Visits', value: openTotal, delta: -0.03, theme: 'dark' }, { key: 'category-total', label: '分类总数', value: summary.categoryTotal, note: `标签 ${formatNumber(summary.tagTotal)}`, theme: 'dark' },
{ key: 'new-users', label: 'New Users', value: publishedCount, delta: 15.03, theme: 'blue' }, { key: 'open-total', label: '累计访问', value: summary.openTotal, note: `交互总量 ${formatNumber(summary.interactionTotal)}`, theme: 'blue' },
{ key: 'download-total', label: '累计下载', value: summary.downloadTotal, note: `下载模式 ${formatNumber(summary.downloadToolTotal)}`, theme: 'dark' },
{ {
key: 'active-users', key: 'download-ready',
label: 'Active Users', label: '下载就绪工具',
value: downloadTotal + auditTotal, value: summary.downloadReadyToolTotal,
delta: 6.08, note: `就绪率 ${downloadReadyRate}`,
theme: 'dark', theme: 'blue',
}, },
{ key: 'audit-total', label: '审计日志总量', value: summary.auditLogTotal, note: `活跃版本 ${formatNumber(summary.activeArtifactTotal)}`, theme: 'dark' },
]; ];
}); });
const trendPolyline = computed(() =>
trendValues
.map((_, idx) => {
const point = calcTrendPoint(idx);
return `${point.x},${point.y}`;
})
.join(' '),
);
const currentPageProps = computed(() => { const currentPageProps = computed(() => {
if (activeMenu.value === 'overview') { if (activeMenu.value === 'overview') {
return { return {
kpiCards: kpiCards.value, kpiCards: kpiCards.value,
trendRange: trendRange.value, loadingOverview: consoleStore.overviewLoading,
overview: consoleStore.overview,
formatNumber, formatNumber,
trendPolyline: trendPolyline.value, formatDate,
trendMarkers: trendMarkers.value,
deviceTraffic,
locationTraffic,
}; };
} }
@@ -282,9 +251,7 @@ const currentPageProps = computed(() => {
const currentPageEvents = computed(() => { const currentPageEvents = computed(() => {
if (activeMenu.value === 'overview') { if (activeMenu.value === 'overview') {
return { return {};
'update:trend-range': updateTrendRange,
};
} }
if (activeMenu.value === 'categories') { if (activeMenu.value === 'categories') {
@@ -394,23 +361,24 @@ const toolFormRules = {
openUrl: [ openUrl: [
{ {
validator: (_rule, value, callback) => { validator: (_rule, value, callback) => {
if (toolForm.accessMode !== 'web') { const normalized = String(value || '').trim();
if (toolForm.accessMode !== 'web' && !normalized) {
callback(); callback();
return; return;
} }
if (!value || !String(value).trim()) { if (!normalized) {
callback(new Error('网页模式必须填写 Open URL')); callback(new Error('网页模式必须填写 Open URL'));
return; return;
} }
try { try {
const parsed = new URL(String(value).trim()); const parsed = new URL(normalized);
if (!['http:', 'https:'].includes(parsed.protocol)) { if (!['http:', 'https:'].includes(parsed.protocol)) {
callback(new Error('Open URL 必须是 http/https 地址')); callback(new Error('地址必须是 http/https 链接'));
return; return;
} }
callback(); callback();
} catch { } catch {
callback(new Error('Open URL 格式不正确')); callback(new Error('地址格式不正确'));
} }
}, },
trigger: 'blur', trigger: 'blur',
@@ -501,28 +469,20 @@ const modeDialog = reactive({
const currentToken = computed(() => authStore.accessToken); const currentToken = computed(() => authStore.accessToken);
function calcTrendPoint(index) {
const width = 760;
const height = 220;
const xPadding = 16;
const yPadding = 18;
const min = 50;
const max = 80;
const x = xPadding + (index * (width - xPadding * 2)) / (trendValues.length - 1);
const normalized = (trendValues[index] - min) / (max - min);
const y = height - yPadding - normalized * (height - yPadding * 2);
return { x, y };
}
function updateTrendRange(nextRange) {
trendRange.value = nextRange;
}
function formatNumber(value) { function formatNumber(value) {
const numeric = Number(value); const numeric = Number(value);
return new Intl.NumberFormat('zh-CN').format(Number.isFinite(numeric) ? numeric : 0); return new Intl.NumberFormat('zh-CN').format(Number.isFinite(numeric) ? numeric : 0);
} }
function formatPercent(value, total) {
const numerator = Number(value || 0);
const denominator = Number(total || 0);
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
return '0.0%';
}
return `${((numerator / denominator) * 100).toFixed(1)}%`;
}
function formatDate(dateText) { function formatDate(dateText) {
if (!dateText) { if (!dateText) {
return '-'; return '-';
@@ -635,6 +595,15 @@ function normalizeTagName(value) {
return String(value || '').trim().replace(/\s+/g, ' '); return String(value || '').trim().replace(/\s+/g, ' ');
} }
function isValidHttpUrl(value) {
try {
const parsed = new URL(String(value || '').trim());
return ['http:', 'https:'].includes(parsed.protocol);
} catch {
return false;
}
}
function splitTagSelections(values) { function splitTagSelections(values) {
const selected = Array.isArray(values) ? values : []; const selected = Array.isArray(values) ? values : [];
const sourceTags = Array.isArray(consoleStore.tags) ? consoleStore.tags : []; const sourceTags = Array.isArray(consoleStore.tags) ? consoleStore.tags : [];
@@ -734,6 +703,7 @@ async function runWithAuth(fn) {
async function initializeAdminData() { async function initializeAdminData() {
try { try {
await Promise.all([ await Promise.all([
runWithAuth((token) => consoleStore.loadOverview(token)),
runWithAuth((token) => consoleStore.loadCategories(token)), runWithAuth((token) => consoleStore.loadCategories(token)),
runWithAuth((token) => consoleStore.loadTags(token)), runWithAuth((token) => consoleStore.loadTags(token)),
runWithAuth((token) => consoleStore.loadTools(token)), runWithAuth((token) => consoleStore.loadTools(token)),
@@ -806,6 +776,10 @@ function openAuditSection() {
} }
async function refreshCurrentSection() { async function refreshCurrentSection() {
if (activeMenu.value === 'overview') {
await loadOverview();
return;
}
if (activeMenu.value === 'tools') { if (activeMenu.value === 'tools') {
await loadTools(); await loadTools();
return; return;
@@ -818,7 +792,7 @@ async function refreshCurrentSection() {
await loadAuditLogs(); await loadAuditLogs();
return; return;
} }
await Promise.all([loadCategories(), loadTools(), loadAuditLogs()]); await Promise.all([loadOverview(), loadCategories(), loadTools(), loadAuditLogs()]);
} }
function applyTopSearch() { function applyTopSearch() {
@@ -889,6 +863,20 @@ async function loadCategories() {
} }
} }
async function loadOverview() {
try {
await runWithAuth((token) => consoleStore.loadOverview(token));
} catch (error) {
if (isUnauthorized(error)) {
await authStore.logout();
consoleStore.$reset();
ElMessage.error('登录已过期,请重新登录');
return;
}
ElMessage.error(getApiErrorMessage(error));
}
}
async function loadTools() { async function loadTools() {
try { try {
await runWithAuth((token) => consoleStore.loadTools(token)); await runWithAuth((token) => consoleStore.loadTools(token));
@@ -1004,6 +992,7 @@ function openEditToolDialog(row) {
} }
function buildToolPayload(tagIds) { function buildToolPayload(tagIds) {
const openUrl = toolForm.openUrl.trim();
const payload = { const payload = {
name: toolForm.name.trim(), name: toolForm.name.trim(),
categoryId: toolForm.categoryId, categoryId: toolForm.categoryId,
@@ -1014,14 +1003,9 @@ function buildToolPayload(tagIds) {
accessMode: toolForm.accessMode, accessMode: toolForm.accessMode,
openInNewTab: toolForm.openInNewTab, openInNewTab: toolForm.openInNewTab,
status: toolForm.status, status: toolForm.status,
openUrl: openUrl || null,
}; };
if (toolForm.accessMode === 'web') {
payload.openUrl = toolForm.openUrl.trim();
} else if (toolDialog.mode === 'edit') {
payload.openUrl = null;
}
return payload; return payload;
} }
@@ -1237,10 +1221,15 @@ function openModeDialog(row) {
} }
async function submitAccessModeUpdate() { async function submitAccessModeUpdate() {
if (modeDialog.accessMode === 'web' && !modeDialog.openUrl.trim()) { const openUrl = modeDialog.openUrl.trim();
if (modeDialog.accessMode === 'web' && !openUrl) {
ElMessage.warning('网页模式必须填写 Open URL'); ElMessage.warning('网页模式必须填写 Open URL');
return; return;
} }
if (openUrl && !isValidHttpUrl(openUrl)) {
ElMessage.warning('请输入有效的 http/https 地址');
return;
}
modeDialog.submitting = true; modeDialog.submitting = true;
try { try {
@@ -1249,7 +1238,7 @@ async function submitAccessModeUpdate() {
modeDialog.id, modeDialog.id,
{ {
accessMode: modeDialog.accessMode, accessMode: modeDialog.accessMode,
openUrl: modeDialog.accessMode === 'web' ? modeDialog.openUrl.trim() : undefined, openUrl: openUrl || null,
openInNewTab: modeDialog.openInNewTab, openInNewTab: modeDialog.openInNewTab,
}, },
token, token,
@@ -1302,6 +1291,9 @@ async function loadAuditLogs() {
} }
watch(activeMenu, async (nextMenu) => { watch(activeMenu, async (nextMenu) => {
if (nextMenu === 'overview' && !consoleStore.overview.generatedAt) {
await loadOverview();
}
if (nextMenu === 'categories' && !consoleStore.categories.length) { if (nextMenu === 'categories' && !consoleStore.categories.length) {
await loadCategories(); await loadCategories();
} }
@@ -1313,15 +1305,6 @@ watch(activeMenu, async (nextMenu) => {
} }
}); });
watch(
() => toolForm.accessMode,
(mode) => {
if (mode === 'download') {
toolForm.openUrl = '';
}
},
);
watch( watch(
() => toolDialog.visible, () => toolDialog.visible,
(visible) => { (visible) => {

View File

@@ -1,9 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600;700&family=Fira+Sans:wght@400;500;600;700&display=swap');
.admin-ref { .admin-ref {
min-height: 100vh; min-height: 100vh;
background: #e7eaee; background: #e7eaee;
font-family: "Fira Sans", "PingFang SC", "Microsoft YaHei", sans-serif; font-family: var(--font-admin-sans);
color: #1d2430; color: #1d2430;
} }
@@ -36,14 +34,14 @@
place-items: center; place-items: center;
background: linear-gradient(135deg, #2f83ed, #379cff); background: linear-gradient(135deg, #2f83ed, #379cff);
color: #fff; color: #fff;
font-family: "Fira Code", monospace; font-family: var(--font-admin-mono);
font-weight: 700; font-weight: 700;
} }
.admin-login-title h2 { .admin-login-title h2 {
margin: 0; margin: 0;
font-size: 22px; font-size: 22px;
font-family: "Fira Code", monospace; font-family: var(--font-admin-mono);
} }
.admin-login-title span { .admin-login-title span {
@@ -102,7 +100,7 @@
} }
.brand-text { .brand-text {
font-family: "Fira Code", monospace; font-family: var(--font-admin-mono);
font-size: 24px; font-size: 24px;
color: #566579; color: #566579;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -168,7 +166,7 @@
} }
.dashboard-main.with-kpi { .dashboard-main.with-kpi {
grid-template-rows: auto auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
} }
.dashboard-topbar { .dashboard-topbar {
@@ -226,7 +224,7 @@
.kpi-row { .kpi-row {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px; gap: 12px;
} }
@@ -235,7 +233,7 @@
border-radius: 0; border-radius: 0;
padding: 16px 18px; padding: 16px 18px;
color: #fff; color: #fff;
min-height: 104px; min-height: 112px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.26); border: 1px solid rgba(255, 255, 255, 0.26);
} }
@@ -256,26 +254,18 @@
.kpi-value { .kpi-value {
margin-top: 10px; margin-top: 10px;
font-size: 36px; font-size: 32px;
line-height: 1; line-height: 1;
font-family: "Fira Code", monospace; font-family: var(--font-admin-mono);
font-weight: 700; font-weight: 700;
} }
.kpi-delta { .kpi-note {
position: absolute; position: absolute;
right: 16px; left: 18px;
bottom: 16px; bottom: 16px;
font-size: 16px; font-size: 13px;
font-weight: 600; opacity: 0.9;
}
.kpi-delta.up {
color: rgba(236, 255, 246, 0.96);
}
.kpi-delta.down {
color: rgba(255, 226, 226, 0.96);
} }
.dashboard-section { .dashboard-section {
@@ -283,6 +273,78 @@
gap: 12px; gap: 12px;
} }
.overview-dashboard {
align-content: start;
}
.overview-grid {
padding: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.overview-block {
border: 1px solid rgba(144, 157, 177, 0.24);
background: rgba(255, 255, 255, 0.94);
padding: 12px;
}
.overview-block-head h3 {
margin: 0;
font-size: 16px;
color: #253244;
}
.metric-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: grid;
gap: 10px;
}
.metric-item {
display: grid;
gap: 6px;
}
.metric-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: #4f5e73;
}
.metric-head strong {
color: #1f2a3a;
font-family: var(--font-admin-mono);
}
.metric-bar {
height: 8px;
background: rgba(145, 161, 186, 0.2);
overflow: hidden;
}
.metric-bar > span {
display: block;
height: 100%;
background: linear-gradient(90deg, #3d8dff, #2268de);
}
.overview-bottom {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.overview-loading {
color: #5f6878;
font-size: 13px;
}
.panel { .panel {
border-radius: 0; border-radius: 0;
border: 1px solid rgba(144, 157, 177, 0.28); border: 1px solid rgba(144, 157, 177, 0.28);
@@ -314,7 +376,7 @@
padding: 6px 2px; padding: 6px 2px;
font-size: 19px; font-size: 19px;
cursor: pointer; cursor: pointer;
font-family: "Fira Sans", sans-serif; font-family: var(--font-admin-sans);
} }
.tab.active { .tab.active {
@@ -377,7 +439,7 @@
margin: 0; margin: 0;
font-size: 26px; font-size: 26px;
color: #1f4fb8; color: #1f4fb8;
font-family: "Fira Code", monospace; font-family: var(--font-admin-mono);
} }
.mini-panel:nth-child(2) .mini-head h3 { .mini-panel:nth-child(2) .mini-head h3 {
@@ -442,7 +504,7 @@
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
color: #2b3647; color: #2b3647;
font-family: "Fira Code", monospace; font-family: var(--font-admin-mono);
} }
.data-head p { .data-head p {
@@ -546,6 +608,10 @@
.data-filters { .data-filters {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.overview-grid {
grid-template-columns: 1fr;
}
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
@@ -575,6 +641,10 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.overview-bottom {
grid-template-columns: 1fr;
}
.mini-panels { .mini-panels {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -607,6 +677,10 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.overview-grid {
padding: 10px;
}
.data-filters { .data-filters {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -54,6 +54,11 @@ export async function adminGetTools(params, token) {
return unwrap(response.data); return unwrap(response.data);
} }
export async function adminGetOverview(token) {
const response = await http.get('/admin/overview', withToken(token));
return unwrap(response.data);
}
export async function adminCreateTool(payload, token) { export async function adminCreateTool(payload, token) {
const response = await http.post('/admin/tools', payload, withToken(token)); const response = await http.post('/admin/tools', payload, withToken(token));
return unwrap(response.data); return unwrap(response.data);

View File

@@ -14,8 +14,14 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="modeDialog.accessMode === 'web'" label="Open URL"> <el-form-item
<el-input v-model="modeDialog.openUrl" placeholder="https://example.com" /> v-if="modeDialog.accessMode === 'web' || modeDialog.accessMode === 'download'"
:label="modeDialog.accessMode === 'web' ? 'Open URL' : '下载地址'"
>
<el-input
v-model="modeDialog.openUrl"
:placeholder="modeDialog.accessMode === 'web' ? 'https://example.com' : 'https://gitlab.example.com/...' "
/>
</el-form-item> </el-form-item>
<el-form-item label="新标签页"> <el-form-item label="新标签页">
<el-switch v-model="modeDialog.openInNewTab" /> <el-switch v-model="modeDialog.openInNewTab" />

View File

@@ -8,9 +8,7 @@
> >
<p>{{ card.label }}</p> <p>{{ card.label }}</p>
<div class="kpi-value">{{ formatNumber(card.value) }}</div> <div class="kpi-value">{{ formatNumber(card.value) }}</div>
<span class="kpi-delta" :class="card.delta >= 0 ? 'up' : 'down'"> <span v-if="card.note" class="kpi-note">{{ card.note }}</span>
{{ card.delta >= 0 ? '+' : '' }}{{ card.delta.toFixed(2) }}%
</span>
</article> </article>
</section> </section>
</template> </template>

View File

@@ -1,101 +1,230 @@
<template> <template>
<section class="dashboard-section"> <section class="dashboard-section overview-dashboard">
<section class="panel trend-panel"> <section class="panel overview-grid">
<header class="panel-head"> <article class="overview-block">
<div class="panel-tabs"> <header class="overview-block-head">
<button type="button" class="tab active">Users</button> <h3>工具状态分布</h3>
<button type="button" class="tab">Projects</button> </header>
<button type="button" class="tab">Operating Status</button> <ul class="metric-list">
</div> <li v-for="item in statusStats" :key="item.key" class="metric-item">
<div class="panel-controls"> <div class="metric-head">
<el-select v-model="trendRange" size="small" style="width: 108px"> <span>{{ item.label }}</span>
<el-option label="Week" value="week" /> <strong>{{ formatNumber(item.value) }}</strong>
<el-option label="Month" value="month" /> </div>
<el-option label="Quarter" value="quarter" /> <div class="metric-bar">
</el-select> <span :style="{ width: `${item.ratio}%` }"></span>
</div> </div>
</header> </li>
</ul>
</article>
<div class="line-chart-wrap" aria-hidden="true"> <article class="overview-block">
<svg viewBox="0 0 760 220" preserveAspectRatio="none"> <header class="overview-block-head">
<polyline class="line-main" :points="trendPolyline" /> <h3>访问模式分布</h3>
<circle </header>
v-for="point in trendMarkers" <ul class="metric-list">
:key="`${point.x}-${point.y}`" <li v-for="item in accessModeStats" :key="item.key" class="metric-item">
class="line-dot" <div class="metric-head">
:cx="point.x" <span>{{ item.label }}</span>
:cy="point.y" <strong>{{ formatNumber(item.value) }}</strong>
r="5.5" </div>
/> <div class="metric-bar">
</svg> <span :style="{ width: `${item.ratio}%` }"></span>
<div class="line-months"> </div>
<span v-for="month in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']" :key="month">{{ month }}</span> </li>
</div> </ul>
</div> </article>
</section> </section>
<section class="mini-panels"> <section class="panel data-panel">
<article class="panel mini-panel"> <header class="data-head">
<header class="mini-head"> <div>
<h3>Device Traffic</h3> <h3> 7 天访问行为</h3>
<button type="button" class="more-btn">···</button> <p>每日打开下载审计日志动作统计</p>
</header>
<div class="bar-grid">
<div
v-for="item in deviceTraffic"
:key="item.name"
class="bar-item"
:class="{ active: item.active }"
>
<div class="bar" :style="{ height: `${item.value}%` }"></div>
<span>{{ item.name }}</span>
</div>
</div> </div>
</article> </header>
<el-table :data="activityRows" size="small" stripe class="data-table">
<el-table-column prop="date" label="日期" min-width="120" />
<el-table-column label="打开次数" min-width="120">
<template #default="{ row }">
{{ formatNumber(row.openCount) }}
</template>
</el-table-column>
<el-table-column label="下载次数" min-width="120">
<template #default="{ row }">
{{ formatNumber(row.downloadCount) }}
</template>
</el-table-column>
<el-table-column label="交互总量" min-width="120">
<template #default="{ row }">
{{ formatNumber(row.interactionTotal) }}
</template>
</el-table-column>
<el-table-column label="审计日志" min-width="120">
<template #default="{ row }">
{{ formatNumber(row.auditCount) }}
</template>
</el-table-column>
</el-table>
</section>
<article class="panel mini-panel"> <section class="overview-bottom">
<header class="mini-head"> <section class="panel data-panel">
<h3>Location Traffic</h3> <header class="data-head">
<button type="button" class="more-btn">···</button> <div>
</header> <h3>分类贡献 Top 8</h3>
<div class="bar-grid"> <p>按交互总量排序</p>
<div
v-for="item in locationTraffic"
:key="item.name"
class="bar-item"
:class="{ active: item.active }"
>
<div class="bar" :style="{ height: `${item.value}%` }"></div>
<span>{{ item.name }}</span>
</div> </div>
</div> </header>
</article> <el-table :data="topCategoriesRows" size="small" stripe class="data-table">
<el-table-column prop="categoryName" label="分类" min-width="140" />
<el-table-column label="工具数" min-width="90">
<template #default="{ row }">
{{ formatNumber(row.toolTotal) }}
</template>
</el-table-column>
<el-table-column label="访问量" min-width="100">
<template #default="{ row }">
{{ formatNumber(row.openTotal) }}
</template>
</el-table-column>
<el-table-column label="下载量" min-width="100">
<template #default="{ row }">
{{ formatNumber(row.downloadTotal) }}
</template>
</el-table-column>
<el-table-column label="交互总量" min-width="110">
<template #default="{ row }">
{{ formatNumber(row.interactionTotal) }}
</template>
</el-table-column>
</el-table>
</section>
<section class="panel data-panel">
<header class="data-head">
<div>
<h3>工具价值 Top 8</h3>
<p>按交互总量排序</p>
</div>
</header>
<el-table :data="topToolsRows" size="small" stripe class="data-table">
<el-table-column prop="name" label="工具" min-width="160" />
<el-table-column prop="categoryName" label="分类" min-width="120" />
<el-table-column label="模式" min-width="80">
<template #default="{ row }">
{{ row.accessMode === 'web' ? '网页' : '下载' }}
</template>
</el-table-column>
<el-table-column label="交互总量" min-width="110">
<template #default="{ row }">
{{ formatNumber(row.interactionTotal) }}
</template>
</el-table-column>
<el-table-column label="最近更新" min-width="120">
<template #default="{ row }">
{{ formatDate(row.updatedAt) }}
</template>
</el-table-column>
</el-table>
</section>
</section>
<section v-if="loading" class="panel data-panel overview-loading">
正在加载业务概览数据...
</section> </section>
</section> </section>
</template> </template>
<script setup> <script setup>
defineProps({ import { computed } from 'vue';
trendPolyline: {
type: String, const props = defineProps({
loading: {
type: Boolean,
default: false,
},
overview: {
type: Object,
required: true, required: true,
}, },
trendMarkers: { formatNumber: {
type: Array, type: Function,
required: true, required: true,
}, },
deviceTraffic: { formatDate: {
type: Array, type: Function,
required: true,
},
locationTraffic: {
type: Array,
required: true, required: true,
}, },
}); });
const trendRange = defineModel('trendRange', { const summary = computed(() => props.overview?.summary || {});
type: String,
default: 'week', function toPercent(value, total) {
const numerator = Number(value || 0);
const denominator = Number(total || 0);
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
return 0;
}
const ratio = (numerator / denominator) * 100;
return Math.max(0, Math.min(100, Number(ratio.toFixed(1))));
}
const statusStats = computed(() => {
const total = Number(summary.value.toolTotal || 0);
return [
{
key: 'published',
label: '已发布',
value: summary.value.publishedTotal || 0,
ratio: toPercent(summary.value.publishedTotal, total),
},
{
key: 'draft',
label: '草稿',
value: summary.value.draftTotal || 0,
ratio: toPercent(summary.value.draftTotal, total),
},
{
key: 'archived',
label: '已归档',
value: summary.value.archivedTotal || 0,
ratio: toPercent(summary.value.archivedTotal, total),
},
];
}); });
const accessModeStats = computed(() => {
const total = Number(summary.value.toolTotal || 0);
return [
{
key: 'download',
label: '下载模式',
value: summary.value.downloadToolTotal || 0,
ratio: toPercent(summary.value.downloadToolTotal, total),
},
{
key: 'web',
label: '网页模式',
value: summary.value.webToolTotal || 0,
ratio: toPercent(summary.value.webToolTotal, total),
},
{
key: 'ready',
label: '下载就绪',
value: summary.value.downloadReadyToolTotal || 0,
ratio: toPercent(summary.value.downloadReadyToolTotal, summary.value.downloadToolTotal || 0),
},
];
});
const activityRows = computed(() =>
Array.isArray(props.overview?.dailyActivity) ? props.overview.dailyActivity : [],
);
const topCategoriesRows = computed(() =>
Array.isArray(props.overview?.topCategories) ? props.overview.topCategories : [],
);
const topToolsRows = computed(() =>
Array.isArray(props.overview?.topTools) ? props.overview.topTools : [],
);
</script> </script>

View File

@@ -4,7 +4,7 @@
<header class="data-head"> <header class="data-head">
<div> <div>
<h3>Tools Management</h3> <h3>Tools Management</h3>
<p>支持工具信息维护包上传与版本管理</p> <p>支持工具信息维护包上传 GitLab 下载地址配置</p>
</div> </div>
<div class="data-head-actions"> <div class="data-head-actions">
<el-button type="primary" @click="emit('create')">新增工具</el-button> <el-button type="primary" @click="emit('create')">新增工具</el-button>
@@ -97,7 +97,7 @@
<template #default="{ row }"> <template #default="{ row }">
<el-space> <el-space>
<el-button size="small" type="primary" plain @click="emit('edit', row)">编辑</el-button> <el-button size="small" type="primary" plain @click="emit('edit', row)">编辑</el-button>
<el-button size="small" type="success" plain @click="emit('artifact', row)">上传</el-button> <el-button size="small" type="success" plain @click="emit('artifact', row)">管理</el-button>
<el-button size="small" @click="emit('status', row)">改状态</el-button> <el-button size="small" @click="emit('status', row)">改状态</el-button>
<el-button size="small" type="primary" plain @click="emit('mode', row)">改访问方式</el-button> <el-button size="small" type="primary" plain @click="emit('mode', row)">改访问方式</el-button>
<el-button size="small" type="danger" plain @click="emit('delete', row)">删除</el-button> <el-button size="small" type="danger" plain @click="emit('delete', row)">删除</el-button>

View File

@@ -57,8 +57,9 @@
:rows="4" :rows="4"
maxlength="2000" maxlength="2000"
show-word-limit show-word-limit
placeholder="请描述工具用途、适用场景与优势" placeholder="支持 Markdown例如## 用途&#10;支持 **加粗**、`代码`、[链接](https://example.com)"
/> />
<div class="el-form-item__description">简介支持 Markdown 渲染</div>
</el-form-item> </el-form-item>
<el-form-item label="评分" prop="rating"> <el-form-item label="评分" prop="rating">
<el-input-number v-model="toolForm.rating" :min="0" :max="5" :step="0.1" :precision="1" /> <el-input-number v-model="toolForm.rating" :min="0" :max="5" :step="0.1" :precision="1" />
@@ -68,8 +69,9 @@
v-model="toolForm.featuresText" v-model="toolForm.featuresText"
type="textarea" type="textarea"
:rows="4" :rows="4"
placeholder="每行一个功能点,例如&#10;支持离线模式&#10;支持自动更新" placeholder="每行一个功能点,支持 Markdown例如&#10;支持 **离线模式**&#10;支持 [自动更新](https://example.com)"
/> />
<div class="el-form-item__description">每行作为一个功能点并按 Markdown 展示</div>
</el-form-item> </el-form-item>
<el-form-item label="访问方式" prop="accessMode"> <el-form-item label="访问方式" prop="accessMode">
<el-select v-model="toolForm.accessMode" style="width: 100%"> <el-select v-model="toolForm.accessMode" style="width: 100%">
@@ -81,8 +83,18 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="toolForm.accessMode === 'web'" label="Open URL" prop="openUrl"> <el-form-item
<el-input v-model="toolForm.openUrl" placeholder="https://example.com" /> v-if="toolForm.accessMode === 'web' || toolForm.accessMode === 'download'"
:label="toolForm.accessMode === 'web' ? 'Open URL' : '下载地址'"
prop="openUrl"
>
<el-input
v-model="toolForm.openUrl"
:placeholder="toolForm.accessMode === 'web' ? 'https://example.com' : 'https://gitlab.example.com/...' "
/>
<div class="el-form-item__description">
{{ toolForm.accessMode === 'web' ? '网页模式下用于打开页面。' : '下载模式下可直接填写 GitLab 下载地址,不上传文件也可使用。' }}
</div>
</el-form-item> </el-form-item>
<el-form-item label="新标签页"> <el-form-item label="新标签页">
<el-switch v-model="toolForm.openInNewTab" /> <el-switch v-model="toolForm.openInNewTab" />

View File

@@ -3,12 +3,10 @@
<AdminKpiRow :kpi-cards="kpiCards" :format-number="formatNumber" /> <AdminKpiRow :kpi-cards="kpiCards" :format-number="formatNumber" />
<AdminOverviewSection <AdminOverviewSection
:trend-range="trendRange" :loading="loadingOverview"
:trend-polyline="trendPolyline" :overview="overview"
:trend-markers="trendMarkers" :format-number="formatNumber"
:device-traffic="deviceTraffic" :format-date="formatDate"
:location-traffic="locationTraffic"
@update:trend-range="emit('update:trend-range', $event)"
/> />
</section> </section>
</template> </template>
@@ -22,31 +20,21 @@ defineProps({
type: Array, type: Array,
required: true, required: true,
}, },
trendRange: { loadingOverview: {
type: String, type: Boolean,
default: false,
},
overview: {
type: Object,
required: true, required: true,
}, },
formatNumber: { formatNumber: {
type: Function, type: Function,
required: true, required: true,
}, },
trendPolyline: { formatDate: {
type: String, type: Function,
required: true,
},
trendMarkers: {
type: Array,
required: true,
},
deviceTraffic: {
type: Array,
required: true,
},
locationTraffic: {
type: Array,
required: true, required: true,
}, },
}); });
const emit = defineEmits(['update:trend-range']);
</script> </script>

View File

@@ -15,7 +15,7 @@ const routes = [
{ {
path: '/admin', path: '/admin',
component: AdminApp, component: AdminApp,
redirect: '/admin/tools', redirect: '/admin/overview',
children: [ children: [
{ {
path: 'overview', path: 'overview',

View File

@@ -5,6 +5,7 @@ import {
adminCreateTool, adminCreateTool,
adminDeleteCategory, adminDeleteCategory,
adminDeleteArtifact, adminDeleteArtifact,
adminGetOverview,
adminDeleteTool, adminDeleteTool,
adminGetArtifacts, adminGetArtifacts,
adminGetAuditLogs, adminGetAuditLogs,
@@ -20,6 +21,32 @@ import {
adminUploadArtifact, adminUploadArtifact,
} from '../api'; } from '../api';
function createEmptyOverviewState() {
return {
generatedAt: '',
summary: {
toolTotal: 0,
draftTotal: 0,
publishedTotal: 0,
archivedTotal: 0,
categoryTotal: 0,
tagTotal: 0,
webToolTotal: 0,
downloadToolTotal: 0,
downloadReadyToolTotal: 0,
openTotal: 0,
downloadTotal: 0,
interactionTotal: 0,
artifactTotal: 0,
activeArtifactTotal: 0,
auditLogTotal: 0,
},
dailyActivity: [],
topCategories: [],
topTools: [],
};
}
export const useAdminConsoleStore = defineStore('admin-console', { export const useAdminConsoleStore = defineStore('admin-console', {
state: () => ({ state: () => ({
categories: [], categories: [],
@@ -46,6 +73,8 @@ export const useAdminConsoleStore = defineStore('admin-console', {
total: 0, total: 0,
totalPages: 1, totalPages: 1,
}, },
overviewLoading: false,
overview: createEmptyOverviewState(),
auditFilters: { auditFilters: {
action: '', action: '',
@@ -150,6 +179,28 @@ export const useAdminConsoleStore = defineStore('admin-console', {
this.toolLoading = false; this.toolLoading = false;
} }
}, },
async loadOverview(token) {
this.overviewLoading = true;
try {
const data = await adminGetOverview(token);
const defaults = createEmptyOverviewState();
const payload = data && typeof data === 'object' ? data : {};
this.overview = {
...defaults,
...payload,
summary: {
...defaults.summary,
...(payload.summary && typeof payload.summary === 'object' ? payload.summary : {}),
},
dailyActivity: Array.isArray(payload.dailyActivity) ? payload.dailyActivity : [],
topCategories: Array.isArray(payload.topCategories) ? payload.topCategories : [],
topTools: Array.isArray(payload.topTools) ? payload.topTools : [],
};
} finally {
this.overviewLoading = false;
}
},
async updateToolStatus(id, status, token) { async updateToolStatus(id, status, token) {
await adminUpdateToolStatus(id, status, token); await adminUpdateToolStatus(id, status, token);
}, },

View File

@@ -64,6 +64,53 @@ export function resolveActionUrl(actionUrl) {
return new URL(actionUrl, apiOrigin).toString(); return new URL(actionUrl, apiOrigin).toString();
} }
function resolveApiUrl(path) {
if (/^https?:\/\//.test(path)) {
return path;
}
return new URL(path, apiOrigin).toString();
}
export function notifyToolInteraction(toolId, payload) {
const pathBase = baseURL.replace(/\/$/, '');
const endpoint = `${pathBase}/tools/${toolId}/interaction`;
const url = resolveApiUrl(endpoint);
const body = JSON.stringify(payload || {});
try {
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
const queued = navigator.sendBeacon(
url,
new Blob([body], { type: 'application/json' }),
);
if (queued) {
return;
}
}
} catch {
// Ignore tracking errors to avoid interrupting user flow.
}
if (typeof fetch === 'function') {
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
keepalive: true,
credentials: 'same-origin',
}).catch(() => {
// Ignore tracking errors to avoid interrupting user flow.
});
return;
}
http.post(`/tools/${toolId}/interaction`, payload).catch(() => {
// Ignore tracking errors to avoid interrupting user flow.
});
}
export function getApiErrorMessage(error) { export function getApiErrorMessage(error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const data = error.response?.data; const data = error.response?.data;

View File

@@ -35,7 +35,7 @@ body {
padding: 0; padding: 0;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: "Manrope", "PingFang SC", "Microsoft YaHei", sans-serif; font-family: var(--font-sans);
line-height: 1.5; line-height: 1.5;
} }
@@ -100,7 +100,7 @@ select {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
font-family: "Sora", sans-serif; font-family: var(--font-display);
font-weight: 700; font-weight: 700;
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
@@ -108,11 +108,13 @@ select {
.brand-mark { .brand-mark {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 10px; display: block;
background: var(--primary); }
color: #fff;
display: grid; .brand-mark img {
place-items: center; width: 100%;
height: 100%;
display: block;
} }
.nav { .nav {
@@ -173,7 +175,7 @@ h1,
h2, h2,
h3 { h3 {
margin: 0; margin: 0;
font-family: "Sora", sans-serif; font-family: var(--font-display);
} }
.search-row { .search-row {
@@ -408,11 +410,9 @@ h3 {
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
min-height: 280px; min-height: 280px;
opacity: 0; opacity: 1;
transform: translateY(8px); transform: translateY(0);
will-change: transform, opacity; will-change: transform, opacity;
animation: card-enter 380ms var(--ease-standard) both;
animation-delay: var(--stagger, 0ms);
transition: transition:
border-color var(--duration-normal) var(--ease-standard), border-color var(--duration-normal) var(--ease-standard),
background-color var(--duration-normal) var(--ease-standard), background-color var(--duration-normal) var(--ease-standard),
@@ -486,6 +486,66 @@ h3 {
min-height: 42px; min-height: 42px;
} }
.card .desc.markdown-inline {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.markdown {
color: var(--muted);
overflow-wrap: anywhere;
}
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre) {
margin: 0 0 8px;
}
.markdown :where(h1, h2, h3, h4, h5, h6) {
color: var(--text);
font-size: 16px;
}
.markdown :where(p, li) {
line-height: 1.6;
}
.markdown :where(ul, ol) {
padding-left: 18px;
}
.markdown :where(p, ul, ol, blockquote, pre):last-child {
margin-bottom: 0;
}
.markdown a {
color: var(--primary-strong);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
background: rgba(15, 47, 61, 0.08);
padding: 1px 4px;
border-radius: 4px;
}
.markdown pre {
background: rgba(15, 47, 61, 0.08);
border: 1px solid rgba(18, 117, 150, 0.16);
border-radius: var(--radius-sm);
padding: 10px 12px;
overflow: auto;
}
.markdown pre code {
background: transparent;
padding: 0;
}
.tags { .tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -650,6 +710,10 @@ h3 {
color: var(--muted); color: var(--muted);
} }
.markdown-detail {
margin: 10px 0 12px;
}
.modal-muted { .modal-muted {
color: var(--muted); color: var(--muted);
} }
@@ -679,18 +743,31 @@ h3 {
gap: 6px; gap: 6px;
} }
.feature-list li {
color: var(--muted);
}
.icon-btn { .icon-btn {
width: 36px; width: 36px;
height: 36px; height: 36px;
padding: 0;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
cursor: pointer; cursor: pointer;
transition: transition:
background-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard); transform var(--duration-fast) var(--ease-standard);
} }
.icon-btn svg {
display: block;
}
.icon-btn:hover { .icon-btn:hover {
background: rgba(240, 251, 255, 0.95); background: rgba(240, 251, 255, 0.95);
border-color: var(--line-strong); border-color: var(--line-strong);
@@ -778,6 +855,15 @@ select:focus-visible {
} }
} }
@media (prefers-reduced-motion: no-preference) {
.card {
opacity: 0;
transform: translateY(8px);
animation: card-enter 380ms var(--ease-standard) both;
animation-delay: var(--stagger, 0ms);
}
}
.btn:active, .btn:active,
.btn-small:active, .btn-small:active,
.icon-btn:active, .icon-btn:active,

View File

@@ -0,0 +1,72 @@
import DOMPurify from 'dompurify';
import { marked } from 'marked';
marked.setOptions({
gfm: true,
breaks: true,
});
const markdownCache = new Map();
const inlineCache = new Map();
function sanitizeHtml(html) {
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
}
function toSafeString(value) {
if (typeof value === 'string') {
return value.trim();
}
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
function parseMarkdown(source) {
const parsed = marked.parse(source);
if (typeof parsed === 'string') {
return parsed;
}
return '';
}
function parseInlineMarkdown(source) {
const parsed = marked.parseInline(source);
if (typeof parsed === 'string') {
return parsed;
}
return '';
}
export function renderMarkdown(value) {
const source = toSafeString(value);
if (!source) {
return '';
}
if (markdownCache.has(source)) {
return markdownCache.get(source);
}
const html = sanitizeHtml(parseMarkdown(source));
markdownCache.set(source, html);
return html;
}
export function renderInlineMarkdown(value) {
const source = toSafeString(value);
if (!source) {
return '';
}
if (inlineCache.has(source)) {
return inlineCache.get(source);
}
const html = sanitizeHtml(parseInlineMarkdown(source));
inlineCache.set(source, html);
return html;
}

View File

@@ -1,15 +1,20 @@
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [vue()], const env = loadEnv(mode, process.cwd(), '');
server: { const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:3000';
port: 5173,
proxy: { return {
'/api': { plugins: [vue()],
target: 'http://localhost:3000', server: {
changeOrigin: true, port: 5173,
proxy: {
'/api': {
target: proxyTarget,
changeOrigin: true,
},
}, },
}, },
}, };
}); });

View File

@@ -0,0 +1,35 @@
#!/bin/sh
set -eu
normalize_wrapped_env() {
var_name="$1"
if ! eval "[ \"\${$var_name+x}\" = x ]"; then
return 0
fi
eval "current_value=\${$var_name}"
case "$current_value" in
\"*\")
normalized_value=${current_value#\"}
normalized_value=${normalized_value%\"}
;;
\'*\')
normalized_value=${current_value#\'}
normalized_value=${normalized_value%\'}
;;
*)
normalized_value=$current_value
;;
esac
export "$var_name=$normalized_value"
}
# Docker --env-file keeps surrounding quotes as literal characters.
# Prisma expects an unquoted SQLite URL such as file:./dev.db.
normalize_wrapped_env DATABASE_URL
npx prisma migrate deploy
exec node dist/src/main.js

View File

@@ -31,7 +31,7 @@ Copy-Item server/.env.example server/.env
```env ```env
PORT=3000 PORT=3000
DATABASE_URL="file:./dev.db" DATABASE_URL=file:./dev.db
JWT_ACCESS_SECRET=change_this_access_secret JWT_ACCESS_SECRET=change_this_access_secret
JWT_REFRESH_SECRET=change_this_refresh_secret JWT_REFRESH_SECRET=change_this_refresh_secret
DEFAULT_ADMIN_USERNAME=admin DEFAULT_ADMIN_USERNAME=admin
@@ -41,7 +41,8 @@ DEFAULT_ADMIN_PASSWORD=admin123456
说明: 说明:
- 当前项目 Prisma 使用 `SQLite``server/prisma/schema.prisma`)。 - 当前项目 Prisma 使用 `SQLite``server/prisma/schema.prisma`)。
- `DATABASE_URL="file:./dev.db"` 对应数据库文件在容器内路径 `/app/server/prisma/dev.db` - `DATABASE_URL=file:./dev.db` 对应数据库文件在容器内路径 `/app/server/prisma/dev.db`
- `docker run --env-file` 会把环境变量中的外层引号当作实际内容保留,所以这里不要写成 `DATABASE_URL="file:./dev.db"`
- 应用启动后会自动检查管理员账号;若 `DEFAULT_ADMIN_USERNAME` 不存在,则自动创建该账号。 - 应用启动后会自动检查管理员账号;若 `DEFAULT_ADMIN_USERNAME` 不存在,则自动创建该账号。
## 3. 构建镜像 ## 3. 构建镜像
@@ -77,6 +78,13 @@ docker run -d \
toolsshow:latest toolsshow:latest
``` ```
docker run -d `
--name toolsshow-app `
-p 3000:3000 `
--env-file .\server\.env `
-v "${PWD}\server\prisma:/app/server/prisma" `
toolsshow:latest
说明: 说明:
- 容器启动命令已在 `Dockerfile` 中定义:`npx prisma migrate deploy && node dist/src/main.js`。 - 容器启动命令已在 `Dockerfile` 中定义:`npx prisma migrate deploy && node dist/src/main.js`。

View File

@@ -1,5 +1,5 @@
PORT=3000 PORT=3000
DATABASE_URL="file:./dev.db" DATABASE_URL=file:./dev.db
DOWNLOAD_TICKET_TTL_SEC=120 DOWNLOAD_TICKET_TTL_SEC=120
JWT_ACCESS_SECRET=change_this_access_secret JWT_ACCESS_SECRET=change_this_access_secret

View File

@@ -6,6 +6,7 @@ import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.
import { AdminAuditModule } from './modules/admin-audit/admin-audit.module'; import { AdminAuditModule } from './modules/admin-audit/admin-audit.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module'; import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { AdminCategoriesModule } from './modules/admin-categories/admin-categories.module'; import { AdminCategoriesModule } from './modules/admin-categories/admin-categories.module';
import { AdminOverviewModule } from './modules/admin-overview/admin-overview.module';
import { AdminTagsModule } from './modules/admin-tags/admin-tags.module'; import { AdminTagsModule } from './modules/admin-tags/admin-tags.module';
import { AdminToolsModule } from './modules/admin-tools/admin-tools.module'; import { AdminToolsModule } from './modules/admin-tools/admin-tools.module';
import { CategoriesModule } from './modules/categories/categories.module'; import { CategoriesModule } from './modules/categories/categories.module';
@@ -32,6 +33,7 @@ import { ToolsModule } from './modules/tools/tools.module';
GitlabStorageModule, GitlabStorageModule,
DownloadsModule, DownloadsModule,
AdminAuthModule, AdminAuthModule,
AdminOverviewModule,
AdminCategoriesModule, AdminCategoriesModule,
AdminTagsModule, AdminTagsModule,
AdminToolsModule, AdminToolsModule,

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Param, Post, Req } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface'; import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { LaunchToolDto } from './dto/launch-tool.dto'; import { LaunchToolDto } from './dto/launch-tool.dto';
import { TrackToolInteractionDto } from './dto/track-tool-interaction.dto';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
@ApiTags('public-launch') @ApiTags('public-launch')
@@ -18,4 +19,14 @@ export class AccessController {
) { ) {
return this.accessService.launchTool(id, body, request); return this.accessService.launchTool(id, body, request);
} }
@Post(':id/interaction')
@ApiOperation({ summary: 'Track tool interaction (open/download) asynchronously' })
trackInteraction(
@Param('id') id: string,
@Body() body: TrackToolInteractionDto,
@Req() request: RequestWithContext,
) {
return this.accessService.trackInteraction(id, body, request);
}
} }

View File

@@ -7,6 +7,7 @@ import { AppException } from '../../common/exceptions/app.exception';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface'; import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { LaunchToolDto } from './dto/launch-tool.dto'; import { LaunchToolDto } from './dto/launch-tool.dto';
import { ToolInteractionAction, TrackToolInteractionDto } from './dto/track-tool-interaction.dto';
@Injectable() @Injectable()
export class AccessService { export class AccessService {
@@ -40,23 +41,6 @@ export class AccessService {
); );
} }
await this.prisma.$transaction([
this.prisma.openRecord.create({
data: {
toolId: tool.id,
channel: body.channel,
clientVersion: body.clientVersion,
clientIp: this.extractIp(request),
userAgent: request.headers['user-agent'],
referer: this.extractHeader(request, 'referer'),
},
}),
this.prisma.tool.update({
where: { id: tool.id },
data: { openCount: { increment: 1 } },
}),
]);
return { return {
mode: 'web' as const, mode: 'web' as const,
actionUrl: tool.openUrl, actionUrl: tool.openUrl,
@@ -64,6 +48,14 @@ export class AccessService {
}; };
} }
if (tool.openUrl) {
return {
mode: 'download' as const,
actionUrl: tool.openUrl,
openIn: tool.openInNewTab ? 'new_tab' : 'same_tab',
};
}
if (!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active) { if (!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active) {
throw new AppException( throw new AppException(
ERROR_CODES.ARTIFACT_NOT_AVAILABLE, ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
@@ -93,9 +85,63 @@ export class AccessService {
ticket, ticket,
expiresInSec: ttlSec, expiresInSec: ttlSec,
actionUrl: `/api/v1/downloads/${ticket}`, actionUrl: `/api/v1/downloads/${ticket}`,
openIn: 'new_tab' as const,
}; };
} }
async trackInteraction(
toolId: string,
body: TrackToolInteractionDto,
request: RequestWithContext,
) {
const tool = await this.prisma.tool.findFirst({
where: {
id: toolId,
isDeleted: false,
status: ToolStatus.published,
},
select: {
id: true,
},
});
if (!tool) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
}
if (body.action === ToolInteractionAction.open) {
await this.prisma.$transaction([
this.prisma.openRecord.create({
data: {
toolId: tool.id,
channel: body.channel,
clientVersion: body.clientVersion,
clientIp: this.extractIp(request),
userAgent: request.headers['user-agent'],
referer: this.extractHeader(request, 'referer'),
},
}),
this.prisma.tool.update({
where: { id: tool.id },
data: { openCount: { increment: 1 } },
}),
]);
return { success: true };
}
await this.prisma.tool.update({
where: { id: tool.id },
data: {
downloadCount: {
increment: 1,
},
},
});
return { success: true };
}
private extractIp(request: RequestWithContext): string | undefined { private extractIp(request: RequestWithContext): string | undefined {
const forwarded = request.headers['x-forwarded-for']; const forwarded = request.headers['x-forwarded-for'];
if (Array.isArray(forwarded) && forwarded.length > 0) { if (Array.isArray(forwarded) && forwarded.length > 0) {

View File

@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
export enum ToolInteractionAction {
open = 'open',
download = 'download',
}
export class TrackToolInteractionDto {
@ApiProperty({ enum: ToolInteractionAction })
@IsEnum(ToolInteractionAction)
action!: ToolInteractionAction;
@ApiPropertyOptional({ example: 'official' })
@IsOptional()
@IsString()
@MaxLength(32)
channel?: string;
@ApiPropertyOptional({ example: 'web-1.0.0' })
@IsOptional()
@IsString()
@MaxLength(64)
clientVersion?: string;
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
import { AdminOverviewService } from './admin-overview.service';
@ApiTags('admin-overview')
@ApiBearerAuth('admin-access-token')
@UseGuards(AdminJwtGuard)
@Controller('admin/overview')
export class AdminOverviewController {
constructor(private readonly adminOverviewService: AdminOverviewService) {}
@Get()
@ApiOperation({ summary: 'Get admin dashboard overview metrics' })
getOverview() {
return this.adminOverviewService.getOverview();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AdminOverviewController } from './admin-overview.controller';
import { AdminOverviewService } from './admin-overview.service';
@Module({
controllers: [AdminOverviewController],
providers: [AdminOverviewService],
})
export class AdminOverviewModule {}

View File

@@ -0,0 +1,300 @@
import { Injectable } from '@nestjs/common';
import { AccessMode, ArtifactStatus, DownloadRecordStatus, ToolStatus } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
type DailyRange = {
date: string;
start: Date;
end: Date;
};
@Injectable()
export class AdminOverviewService {
constructor(private readonly prisma: PrismaService) {}
async getOverview() {
const [
categoryTotal,
tagTotal,
artifactTotal,
activeArtifactTotal,
auditLogTotal,
downloadReadyToolTotal,
toolStatusBuckets,
accessModeBuckets,
publishedTraffic,
publishedTools,
dailyActivity,
] = await Promise.all([
this.prisma.category.count({
where: {
isDeleted: false,
},
}),
this.prisma.tag.count({
where: {
isDeleted: false,
},
}),
this.prisma.toolArtifact.count({
where: {
status: {
not: ArtifactStatus.deleted,
},
},
}),
this.prisma.toolArtifact.count({
where: {
status: ArtifactStatus.active,
},
}),
this.prisma.adminAuditLog.count(),
this.prisma.tool.count({
where: {
isDeleted: false,
accessMode: AccessMode.download,
OR: [
{
AND: [
{
openUrl: {
not: null,
},
},
{
openUrl: {
not: '',
},
},
],
},
{
latestArtifact: {
is: {
status: ArtifactStatus.active,
},
},
},
],
},
}),
this.prisma.tool.groupBy({
by: ['status'],
where: {
isDeleted: false,
},
_count: {
_all: true,
},
}),
this.prisma.tool.groupBy({
by: ['accessMode'],
where: {
isDeleted: false,
},
_count: {
_all: true,
},
}),
this.prisma.tool.aggregate({
where: {
isDeleted: false,
status: ToolStatus.published,
},
_sum: {
openCount: true,
downloadCount: true,
},
}),
this.prisma.tool.findMany({
where: {
isDeleted: false,
status: ToolStatus.published,
},
select: {
id: true,
name: true,
categoryId: true,
category: {
select: {
name: true,
},
},
accessMode: true,
openCount: true,
downloadCount: true,
modifiedAt: true,
},
}),
this.getDailyActivity(7),
]);
const statusStats = {
draft: 0,
published: 0,
archived: 0,
};
toolStatusBuckets.forEach((item) => {
statusStats[item.status] = item._count._all;
});
const modeStats = {
web: 0,
download: 0,
};
accessModeBuckets.forEach((item) => {
modeStats[item.accessMode] = item._count._all;
});
const openTotal = publishedTraffic._sum.openCount ?? 0;
const downloadTotal = publishedTraffic._sum.downloadCount ?? 0;
const interactionTotal = openTotal + downloadTotal;
const topTools = publishedTools
.map((tool) => ({
id: tool.id,
name: tool.name,
categoryName: tool.category.name,
accessMode: tool.accessMode,
openCount: tool.openCount,
downloadCount: tool.downloadCount,
interactionTotal: tool.openCount + tool.downloadCount,
updatedAt: tool.modifiedAt.toISOString(),
}))
.sort((a, b) => {
if (b.interactionTotal !== a.interactionTotal) {
return b.interactionTotal - a.interactionTotal;
}
return a.name.localeCompare(b.name);
})
.slice(0, 8);
const categoryStatMap = new Map<
string,
{
categoryId: string;
categoryName: string;
toolTotal: number;
openTotal: number;
downloadTotal: number;
}
>();
publishedTools.forEach((tool) => {
const existing = categoryStatMap.get(tool.categoryId) ?? {
categoryId: tool.categoryId,
categoryName: tool.category.name,
toolTotal: 0,
openTotal: 0,
downloadTotal: 0,
};
existing.toolTotal += 1;
existing.openTotal += tool.openCount;
existing.downloadTotal += tool.downloadCount;
categoryStatMap.set(tool.categoryId, existing);
});
const topCategories = Array.from(categoryStatMap.values())
.map((item) => ({
...item,
interactionTotal: item.openTotal + item.downloadTotal,
}))
.sort((a, b) => {
if (b.interactionTotal !== a.interactionTotal) {
return b.interactionTotal - a.interactionTotal;
}
return b.toolTotal - a.toolTotal;
})
.slice(0, 8);
const toolTotal = statusStats.draft + statusStats.published + statusStats.archived;
return {
generatedAt: new Date().toISOString(),
summary: {
toolTotal,
draftTotal: statusStats.draft,
publishedTotal: statusStats.published,
archivedTotal: statusStats.archived,
categoryTotal,
tagTotal,
webToolTotal: modeStats.web,
downloadToolTotal: modeStats.download,
downloadReadyToolTotal,
openTotal,
downloadTotal,
interactionTotal,
artifactTotal,
activeArtifactTotal,
auditLogTotal,
},
dailyActivity,
topCategories,
topTools,
};
}
private async getDailyActivity(days: number) {
const ranges = this.buildDailyRanges(days);
const queries = ranges.flatMap((range) => [
this.prisma.openRecord.count({
where: {
openedAt: {
gte: range.start,
lt: range.end,
},
},
}),
this.prisma.downloadRecord.count({
where: {
downloadedAt: {
gte: range.start,
lt: range.end,
},
status: DownloadRecordStatus.success,
},
}),
this.prisma.adminAuditLog.count({
where: {
createdAt: {
gte: range.start,
lt: range.end,
},
},
}),
]);
const counts = await this.prisma.$transaction(queries);
return ranges.map((range, index) => {
const openCount = counts[index * 3] ?? 0;
const downloadCount = counts[index * 3 + 1] ?? 0;
const auditCount = counts[index * 3 + 2] ?? 0;
return {
date: range.date,
openCount,
downloadCount,
interactionTotal: openCount + downloadCount,
auditCount,
};
});
}
private buildDailyRanges(days: number): DailyRange[] {
const safeDays = Math.max(1, days);
const now = new Date();
const todayStart = new Date(now);
todayStart.setHours(0, 0, 0, 0);
return Array.from({ length: safeDays }, (_, index) => {
const offset = safeDays - index - 1;
const start = new Date(todayStart);
start.setDate(todayStart.getDate() - offset);
const end = new Date(start);
end.setDate(start.getDate() + 1);
return {
date: start.toISOString().slice(0, 10),
start,
end,
};
});
}
}

View File

@@ -77,9 +77,10 @@ export class AdminToolsService {
async createTool(body: CreateToolDto) { async createTool(body: CreateToolDto) {
await this.assertCategoryExists(body.categoryId); await this.assertCategoryExists(body.categoryId);
await this.assertTagsExist(body.tags ?? []); await this.assertTagsExist(body.tags ?? []);
const openUrl = this.normalizeOptionalUrl(body.openUrl);
if ((body.status ?? ToolStatus.draft) === ToolStatus.published) { if ((body.status ?? ToolStatus.draft) === ToolStatus.published) {
this.assertPublishInput(body.accessMode, body.openUrl, undefined); this.assertPublishInput(body.accessMode, openUrl, undefined);
} }
const toolId = this.generateBusinessId('tool'); const toolId = this.generateBusinessId('tool');
@@ -95,7 +96,7 @@ export class AdminToolsService {
description: body.description.trim(), description: body.description.trim(),
rating: body.rating ?? 0, rating: body.rating ?? 0,
accessMode: body.accessMode, accessMode: body.accessMode,
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null, openUrl,
openInNewTab: body.openInNewTab ?? true, openInNewTab: body.openInNewTab ?? true,
status: body.status ?? ToolStatus.draft, status: body.status ?? ToolStatus.draft,
updatedAt, updatedAt,
@@ -156,14 +157,12 @@ export class AdminToolsService {
async updateTool(id: string, body: UpdateToolDto) { async updateTool(id: string, body: UpdateToolDto) {
const existingTool = await this.getToolEntity(id); const existingTool = await this.getToolEntity(id);
const normalizedOpenUrl =
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : undefined;
const nextAccessMode = body.accessMode ?? existingTool.accessMode; const nextAccessMode = body.accessMode ?? existingTool.accessMode;
const nextOpenUrl = const nextOpenUrl =
body.openUrl !== undefined normalizedOpenUrl !== undefined ? normalizedOpenUrl : existingTool.openUrl ?? null;
? body.openUrl
: nextAccessMode === AccessMode.web
? existingTool.openUrl
: null;
const nextStatus = body.status ?? existingTool.status; const nextStatus = body.status ?? existingTool.status;
if (body.categoryId) { if (body.categoryId) {
@@ -189,7 +188,7 @@ export class AdminToolsService {
description: body.description?.trim(), description: body.description?.trim(),
rating: body.rating, rating: body.rating,
accessMode: body.accessMode, accessMode: body.accessMode,
openUrl: body.openUrl, openUrl: normalizedOpenUrl,
openInNewTab: body.openInNewTab, openInNewTab: body.openInNewTab,
status: body.status, status: body.status,
updatedAt, updatedAt,
@@ -245,10 +244,12 @@ export class AdminToolsService {
async updateAccessMode(id: string, body: UpdateAccessModeDto) { async updateAccessMode(id: string, body: UpdateAccessModeDto) {
const tool = await this.getToolEntity(id); const tool = await this.getToolEntity(id);
const nextOpenUrl =
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : tool.openUrl ?? null;
this.assertModeSwitchConstraint( this.assertModeSwitchConstraint(
tool.status, tool.status,
body.accessMode, body.accessMode,
body.openUrl, nextOpenUrl,
tool, tool,
tool.accessMode !== body.accessMode, tool.accessMode !== body.accessMode,
); );
@@ -257,7 +258,7 @@ export class AdminToolsService {
where: { id }, where: { id },
data: { data: {
accessMode: body.accessMode, accessMode: body.accessMode,
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null, openUrl: nextOpenUrl,
openInNewTab: body.openInNewTab ?? tool.openInNewTab, openInNewTab: body.openInNewTab ?? tool.openInNewTab,
updatedAt: this.getDateOnlyString(), updatedAt: this.getDateOnlyString(),
}, },
@@ -354,7 +355,7 @@ export class AdminToolsService {
private assertPublishInput( private assertPublishInput(
accessMode: AccessMode, accessMode: AccessMode,
openUrl?: string, openUrl?: string | null,
latestArtifact?: { status: ArtifactStatus } | null, latestArtifact?: { status: ArtifactStatus } | null,
) { ) {
if (accessMode === AccessMode.web) { if (accessMode === AccessMode.web) {
@@ -368,10 +369,14 @@ export class AdminToolsService {
return; return;
} }
if (openUrl) {
return;
}
if (!latestArtifact || latestArtifact.status !== ArtifactStatus.active) { if (!latestArtifact || latestArtifact.status !== ArtifactStatus.active) {
throw new AppException( throw new AppException(
ERROR_CODES.ARTIFACT_NOT_AVAILABLE, ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
'download mode tool requires one active latest artifact before publish', 'download mode tool requires one active latest artifact or one download URL before publish',
HttpStatus.CONFLICT, HttpStatus.CONFLICT,
); );
} }
@@ -396,11 +401,12 @@ export class AdminToolsService {
isSwitching && isSwitching &&
targetMode === AccessMode.download && targetMode === AccessMode.download &&
currentStatus === ToolStatus.published && currentStatus === ToolStatus.published &&
!openUrl &&
(!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active) (!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active)
) { ) {
throw new AppException( throw new AppException(
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH, ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
'published tool cannot switch to download mode without active artifact', 'published tool cannot switch to download mode without active artifact or download URL',
HttpStatus.CONFLICT, HttpStatus.CONFLICT,
); );
} }
@@ -481,4 +487,13 @@ export class AdminToolsService {
return slug; return slug;
} }
private normalizeOptionalUrl(value: string | null | undefined): string | null | undefined {
if (value === undefined) {
return undefined;
}
const trimmed = String(value ?? '').trim();
return trimmed ? trimmed : null;
}
} }

View File

@@ -15,7 +15,6 @@ import {
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
ValidateIf,
} from 'class-validator'; } from 'class-validator';
export class CreateToolDto { export class CreateToolDto {
@@ -62,8 +61,10 @@ export class CreateToolDto {
@IsEnum(AccessMode) @IsEnum(AccessMode)
accessMode!: AccessMode; accessMode!: AccessMode;
@ApiPropertyOptional({ description: 'Required when accessMode=web' }) @ApiPropertyOptional({
@ValidateIf((obj: CreateToolDto) => obj.accessMode === AccessMode.web) description: 'Required when accessMode=web; optional external download URL when accessMode=download',
})
@IsOptional()
@IsString() @IsString()
@IsUrl({ @IsUrl({
require_protocol: true, require_protocol: true,

View File

@@ -1,15 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AccessMode } from '@prisma/client'; import { AccessMode } from '@prisma/client';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsOptional, IsString, IsUrl, ValidateIf } from 'class-validator'; import { IsBoolean, IsEnum, IsOptional, IsString, IsUrl } from 'class-validator';
export class UpdateAccessModeDto { export class UpdateAccessModeDto {
@ApiProperty({ enum: AccessMode }) @ApiProperty({ enum: AccessMode })
@IsEnum(AccessMode) @IsEnum(AccessMode)
accessMode!: AccessMode; accessMode!: AccessMode;
@ApiPropertyOptional({ description: 'Required when accessMode=web' }) @ApiPropertyOptional({
@ValidateIf((obj: UpdateAccessModeDto) => obj.accessMode === AccessMode.web) description: 'Required when accessMode=web; optional external download URL when accessMode=download',
})
@IsOptional()
@IsString() @IsString()
@IsUrl({ @IsUrl({
require_protocol: true, require_protocol: true,

View File

@@ -107,15 +107,6 @@ export class DownloadsService {
status: DownloadRecordStatus.success, status: DownloadRecordStatus.success,
}, },
}); });
await tx.tool.update({
where: { id: ticketEntity.toolId },
data: {
downloadCount: {
increment: 1,
},
},
});
}); });
response.setHeader( response.setHeader(

View File

@@ -3,6 +3,7 @@ import { Transform, Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export enum ToolSortBy { export enum ToolSortBy {
created = 'created',
popular = 'popular', popular = 'popular',
latest = 'latest', latest = 'latest',
rating = 'rating', rating = 'rating',
@@ -21,7 +22,7 @@ export class GetToolsQueryDto {
@IsString() @IsString()
category?: string; category?: string;
@ApiPropertyOptional({ enum: ToolSortBy, default: ToolSortBy.latest }) @ApiPropertyOptional({ enum: ToolSortBy, default: ToolSortBy.created })
@IsOptional() @IsOptional()
@IsEnum(ToolSortBy) @IsEnum(ToolSortBy)
sortBy?: ToolSortBy; sortBy?: ToolSortBy;

View File

@@ -12,7 +12,7 @@ export class ToolsService {
async getTools(query: GetToolsQueryDto) { async getTools(query: GetToolsQueryDto) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = Math.min(query.pageSize ?? 6, 50); const pageSize = Math.min(query.pageSize ?? 6, 50);
const sortBy = query.sortBy ?? ToolSortBy.latest; const sortBy = query.sortBy ?? ToolSortBy.created;
const where: Prisma.ToolWhereInput = { const where: Prisma.ToolWhereInput = {
isDeleted: false, isDeleted: false,
@@ -59,6 +59,8 @@ export class ToolsService {
const hasArtifact = Boolean( const hasArtifact = Boolean(
tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active, tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active,
); );
const hasExternalDownloadUrl =
tool.accessMode === 'download' && Boolean(tool.openUrl);
return { return {
id: tool.id, id: tool.id,
@@ -75,8 +77,10 @@ export class ToolsService {
latestVersion: latestVersion:
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null, tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
accessMode: tool.accessMode, accessMode: tool.accessMode,
openUrl: tool.accessMode === 'web' ? tool.openUrl : null, openUrl: tool.openUrl,
hasArtifact: tool.accessMode === 'download' ? hasArtifact : false, hasArtifact: tool.accessMode === 'download' ? hasArtifact : false,
downloadReady:
tool.accessMode === 'download' ? hasArtifact || hasExternalDownloadUrl : false,
tags: tool.tags.map((item) => item.tag.name), tags: tool.tags.map((item) => item.tag.name),
features: tool.features.map((item) => item.featureText), features: tool.features.map((item) => item.featureText),
updatedAt: tool.updatedAt, updatedAt: tool.updatedAt,
@@ -134,7 +138,7 @@ export class ToolsService {
tags: tool.tags.map((item) => item.tag.name), tags: tool.tags.map((item) => item.tag.name),
features: tool.features.map((item) => item.featureText), features: tool.features.map((item) => item.featureText),
updatedAt: tool.updatedAt, updatedAt: tool.updatedAt,
openUrl: tool.accessMode === 'web' ? tool.openUrl : null, openUrl: tool.openUrl,
latestVersion: latestVersion:
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null, tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
fileSize: fileSize:
@@ -143,7 +147,10 @@ export class ToolsService {
: null, : null,
downloadReady: downloadReady:
tool.accessMode === 'download' tool.accessMode === 'download'
? Boolean(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active) ? Boolean(
tool.openUrl ||
(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active),
)
: false, : false,
}; };
@@ -152,6 +159,8 @@ export class ToolsService {
private buildOrderBy(sortBy: ToolSortBy): Prisma.ToolOrderByWithRelationInput[] { private buildOrderBy(sortBy: ToolSortBy): Prisma.ToolOrderByWithRelationInput[] {
switch (sortBy) { switch (sortBy) {
case ToolSortBy.created:
return [{ createdAt: 'desc' }, { modifiedAt: 'desc' }];
case ToolSortBy.popular: case ToolSortBy.popular:
return [{ downloadCount: 'desc' }, { openCount: 'desc' }, { modifiedAt: 'desc' }]; return [{ downloadCount: 'desc' }, { openCount: 'desc' }, { modifiedAt: 'desc' }];
case ToolSortBy.rating: case ToolSortBy.rating: