update
This commit is contained in:
@@ -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"]
|
||||||
|
|||||||
2
client/.env.development.example
Normal file
2
client/.env.development.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE=/api/v1
|
||||||
|
VITE_API_PROXY_TARGET=http://localhost:3000
|
||||||
@@ -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>
|
||||||
|
|||||||
30
client/package-lock.json
generated
30
client/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
client/public/favicon.png
Normal file
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
40
client/public/favicon.svg
Normal 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 |
6
client/public/local-fonts.css
Normal file
6
client/public/local-fonts.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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()]);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -57,8 +57,9 @@
|
|||||||
:rows="4"
|
:rows="4"
|
||||||
maxlength="2000"
|
maxlength="2000"
|
||||||
show-word-limit
|
show-word-limit
|
||||||
placeholder="请描述工具用途、适用场景与优势"
|
placeholder="支持 Markdown,例如:## 用途 支持 **加粗**、`代码`、[链接](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="每行一个功能点,例如 支持离线模式 支持自动更新"
|
placeholder="每行一个功能点,支持 Markdown,例如 支持 **离线模式** 支持 [自动更新](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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
72
client/src/utils/markdown.js
Normal file
72
client/src/utils/markdown.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
35
docker/runtime-entrypoint.sh
Normal file
35
docker/runtime-entrypoint.sh
Normal 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
|
||||||
@@ -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`。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
25
server/src/modules/access/dto/track-tool-interaction.dto.ts
Normal file
25
server/src/modules/access/dto/track-tool-interaction.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
300
server/src/modules/admin-overview/admin-overview.service.ts
Normal file
300
server/src/modules/admin-overview/admin-overview.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user