Compare commits

...

4 Commits

Author SHA1 Message Date
dlandy
e6c2d76238 update 2026-04-08 17:56:12 +08:00
dlandy
5a6328561f update 2026-03-30 10:36:17 +08:00
dlandy
19c27dd7f8 update 2026-03-30 10:08:59 +08:00
dlandy
b627f8c020 init 2026-03-30 09:36:36 +08:00
67 changed files with 3217 additions and 1817 deletions

View File

@@ -19,14 +19,18 @@ FROM ${NODE_IMAGE} AS runtime
WORKDIR /app/server WORKDIR /app/server
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV CLIENT_DIST_PATH=/app/server/public
COPY server/package*.json ./ COPY server/package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY --from=server-builder /build/server/dist ./dist 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
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/main"] CMD ["toolsshow-entrypoint"]

474
app.js
View File

@@ -1,474 +0,0 @@
const tools = [
{id: "tool_1", name: "ClipFlow", category: "开发效率", description: "轻量级代码片段管理工具,支持团队共享与快速插入。", tags: ["代码片段", "团队协作", "VSCode"], version: "2.4.1", size: "12.3 MB", downloads: 36850, updatedAt: "2026-03-12", features: ["多工作区同步", "标签归档", "快捷键模板插入"]},
{id: "tool_2", name: "TaskOrbit", category: "团队协作", description: "面向产品团队的任务看板与冲刺管理工具。", tags: ["看板", "项目管理", "甘特图"], version: "1.9.0", url: "https://example.com/tools/taskorbit", downloads: 29210, updatedAt: "2026-03-18", features: ["多视图任务管理", "冲刺模板", "成员工作负载分析"]},
{id: "tool_3", name: "PixelLint", category: "设计协作", description: "设计稿一致性检查工具,可自动扫描颜色与间距规范。", tags: ["设计规范", "Figma", "UI 质检"], version: "3.1.2", size: "7.8 MB", downloads: 21430, updatedAt: "2026-02-25", features: ["组件一致性对比", "批量标注问题", "导出质检报告"]},
{id: "tool_4", name: "DataSparrow", category: "数据分析", description: "可视化数据清洗与探索平台,适合中小团队快速上手。", tags: ["可视化", "数据清洗", "BI"], version: "4.0.0", url: "https://example.com/tools/datasparrow", downloads: 17680, updatedAt: "2026-03-21", features: ["拖拽式数据流", "字段质量检测", "图表模板库"]},
{id: "tool_5", name: "ShipMate", category: "自动化", description: "一键打包发布脚本管理器,统一多环境部署流程。", tags: ["CI/CD", "部署", "脚本"], version: "2.0.3", size: "10.1 MB", downloads: 25600, updatedAt: "2026-03-09", features: ["多环境变量模板", "发布回滚", "构建流水线监控"]},
{id: "tool_6", name: "InsightPanel", category: "数据分析", description: "业务指标仪表盘构建器,支持实时数据看板。", tags: ["仪表盘", "指标", "实时看板"], version: "5.2.1", url: "https://example.com/tools/insightpanel", downloads: 40980, updatedAt: "2026-03-20", features: ["实时刷新组件", "阈值告警", "多数据源连接"]},
{id: "tool_7", name: "CloudWatchdog", category: "运维监控", description: "基础设施健康监控工具,支持可视化告警规则。", tags: ["告警", "监控", "日志分析"], version: "1.6.8", size: "15.4 MB", downloads: 19870, updatedAt: "2026-03-15", features: ["阈值策略库", "异常聚合", "告警分级通知"]},
{id: "tool_8", name: "FormForge", category: "开发效率", description: "表单构建器,支持低代码生成校验规则与提交流程。", tags: ["低代码", "表单", "校验"], version: "3.7.5", size: "9.2 MB", downloads: 23200, updatedAt: "2026-03-11", features: ["可视化表单设计", "字段联动", "提交数据导出"]},
{id: "tool_9", name: "QuerySprint", category: "数据分析", description: "面向分析师的 SQL 协作平台,支持查询片段共享。", tags: ["SQL", "查询优化", "协作"], version: "2.8.4", url: "https://example.com/tools/querysprint", downloads: 27540, updatedAt: "2026-02-18", features: ["查询版本管理", "性能诊断建议", "团队模版库"]},
{id: "tool_10", name: "TestPilot", category: "自动化", description: "自动化测试流程编排工具,支持 API 与 UI 混合测试。", tags: ["自动化测试", "API", "回归测试"], version: "4.3.0", size: "22.4 MB", downloads: 31420, updatedAt: "2026-03-17", features: ["测试用例可视化", "失败重跑策略", "测试报告仪表盘"]},
{id: "tool_11", name: "BrandBoard", category: "设计协作", description: "品牌资产管理工具,统一素材规范与组件资产。", tags: ["品牌资产", "设计系统", "素材管理"], version: "1.4.2", url: "https://example.com/tools/brandboard", downloads: 16890, updatedAt: "2026-03-08", features: ["版本化素材库", "品牌规范手册", "跨团队共享链接"]},
{id: "tool_12", name: "DeployLens", category: "运维监控", description: "发布质量追踪平台,聚合版本、错误率与回滚记录。", tags: ["发布追踪", "SRE", "质量分析"], version: "2.2.6", url: "https://example.com/tools/deploylens", downloads: 22160, updatedAt: "2026-03-19", features: ["发布健康指标", "回滚影响分析", "问题根因视图"]}
];
const state = {query: "", category: "all", sortBy: "popular", page: 1, pageSize: 6};
const keywords = ["自动化", "设计系统", "仪表盘", "监控", "协作"];
let toastTimer = null;
const elements = {
headerWrap: document.querySelector(".header-wrap"),
overviewBtn: document.getElementById("overviewBtn"),
searchInput: document.getElementById("searchInput"),
categorySelect: document.getElementById("categorySelect"),
categorySidebarList: document.getElementById("categorySidebarList"),
sortSelect: document.getElementById("sortSelect"),
resetBtn: document.getElementById("resetBtn"),
hotKeywords: document.getElementById("hotKeywords"),
resultTip: document.getElementById("resultTip"),
toolGrid: document.getElementById("toolGrid"),
pagination: document.getElementById("pagination"),
prevBtn: document.getElementById("prevBtn"),
nextBtn: document.getElementById("nextBtn"),
pageText: document.getElementById("pageText"),
detailModal: document.getElementById("detailModal"),
closeModalBtn: document.getElementById("closeModalBtn"),
overviewModal: document.getElementById("overviewModal"),
closeOverviewModalBtn: document.getElementById("closeOverviewModalBtn"),
detailTitle: document.getElementById("detailTitle"),
detailDescription: document.getElementById("detailDescription"),
detailMeta: document.getElementById("detailMeta"),
detailFeatures: document.getElementById("detailFeatures"),
toast: document.getElementById("toast"),
kpiTotal: document.getElementById("kpiTotal"),
kpiCategories: document.getElementById("kpiCategories"),
kpiDownloads: document.getElementById("kpiDownloads"),
kpiFiltered: document.getElementById("kpiFiltered")
};
function escapeHtml(value) {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(value);
}
function formatDate(dateText) {
return new Intl.DateTimeFormat("zh-CN", {year: "numeric", month: "2-digit", day: "2-digit"})
.format(new Date(dateText));
}
function getCategories() {
return Array.from(new Set(tools.map((tool) => tool.category)));
}
function matchesQuery(tool, query) {
if (!query) {
return true;
}
const pool = [tool.name, tool.description, tool.category, ...tool.tags].join(" ").toLowerCase();
return pool.includes(query);
}
function buildOptions() {
const categories = getCategories();
categories.forEach((category) => {
const option = document.createElement("option");
option.value = category;
option.textContent = category;
elements.categorySelect.append(option);
});
keywords.forEach((keyword) => {
const button = document.createElement("button");
button.type = "button";
button.className = "chip";
button.dataset.keyword = keyword;
button.textContent = keyword;
elements.hotKeywords.append(button);
});
}
function renderCategorySidebar() {
if (!elements.categorySidebarList) {
return;
}
const query = state.query.trim().toLowerCase();
const queryMatchedTools = tools.filter((tool) => matchesQuery(tool, query));
const countMap = new Map();
queryMatchedTools.forEach((tool) => {
countMap.set(tool.category, (countMap.get(tool.category) || 0) + 1);
});
const items = [
{value: "all", label: "全部分类", count: queryMatchedTools.length},
...getCategories().map((category) => ({
value: category,
label: category,
count: countMap.get(category) || 0
}))
];
elements.categorySidebarList.textContent = "";
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.className = "category-side-btn";
button.dataset.category = item.value;
button.setAttribute("aria-pressed", item.value === state.category ? "true" : "false");
if (item.value === state.category) {
button.classList.add("active");
}
const label = document.createElement("span");
label.className = "label";
label.textContent = item.label;
const count = document.createElement("span");
count.className = "count";
count.textContent = formatNumber(item.count);
button.append(label, count);
fragment.append(button);
});
elements.categorySidebarList.append(fragment);
}
function filterTools() {
const query = state.query.trim().toLowerCase();
const filtered = tools.filter((tool) => {
if (state.category !== "all" && state.category !== tool.category) {
return false;
}
return matchesQuery(tool, query);
});
const sorted = [...filtered];
if (state.sortBy === "popular") {
sorted.sort((a, b) => b.downloads - a.downloads);
} else if (state.sortBy === "latest") {
sorted.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
} else {
sorted.sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN"));
}
return sorted;
}
function paginate(items) {
const totalPages = Math.max(1, Math.ceil(items.length / state.pageSize));
if (state.page > totalPages) {
state.page = totalPages;
}
const start = (state.page - 1) * state.pageSize;
return {items: items.slice(start, start + state.pageSize), totalPages, start};
}
function renderKpi(filteredCount) {
elements.kpiTotal.textContent = formatNumber(tools.length);
elements.kpiCategories.textContent = formatNumber(new Set(tools.map((tool) => tool.category)).size);
elements.kpiDownloads.textContent = formatNumber(tools.reduce((sum, tool) => sum + tool.downloads, 0));
elements.kpiFiltered.textContent = formatNumber(filteredCount);
}
function render() {
const filtered = filterTools();
const page = paginate(filtered);
const displayStart = filtered.length ? page.start + 1 : 0;
const displayEnd = Math.min(page.start + state.pageSize, filtered.length);
renderKpi(filtered.length);
renderCategorySidebar();
elements.resultTip.textContent = `共找到 ${formatNumber(filtered.length)} 个工具,当前显示 ${displayStart}-${displayEnd}`;
if (filtered.length === 0) {
elements.toolGrid.innerHTML = `
<div class="empty">
<p>没有匹配结果,请尝试更换关键词或分类。</p>
<button id="clearEmptyBtn" type="button" class="btn">清空筛选条件</button>
</div>
`;
elements.pagination.style.display = "none";
return;
}
elements.toolGrid.innerHTML = page.items.map((tool, index) => `
<article class="card" style="--stagger:${index * 45}ms;">
<div class="card-top">
<span class="category">${escapeHtml(tool.category)}</span>
</div>
<h3>${escapeHtml(tool.name)}</h3>
<p class="desc">${escapeHtml(tool.description)}</p>
<div class="tags">${tool.tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("")}</div>
<ul class="meta-list">
<li>版本:<strong>${escapeHtml(tool.version)}</strong></li>
${tool.size ? `<li>大小:<strong>${escapeHtml(tool.size)}</strong></li>` : ""}
<li>更新:<strong>${formatDate(tool.updatedAt)}</strong></li>
</ul>
<div class="card-foot">
<span class="download-num">${tool.url ? "访问" : "下载"} ${formatNumber(tool.downloads)}</span>
<div class="actions">
<button type="button" class="btn-small js-detail" data-id="${tool.id}">详情</button>
${tool.url
? `<button type="button" class="btn-small btn-open js-open" data-id="${tool.id}">打开网页</button>`
: `<button type="button" class="btn-small btn-download js-download" data-id="${tool.id}">下载</button>`}
</div>
</div>
</article>
`).join("");
elements.pagination.style.display = "flex";
elements.pageText.textContent = `${state.page} / ${page.totalPages}`;
elements.prevBtn.disabled = state.page === 1;
elements.nextBtn.disabled = state.page === page.totalPages;
}
function showToast(message) {
elements.toast.textContent = message;
elements.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => elements.toast.classList.remove("show"), 2200);
}
function downloadTool(tool) {
if (tool.url) {
openWebTool(tool);
return;
}
const payload = {
toolId: tool.id,
toolName: tool.name,
version: tool.version,
category: tool.category,
downloadedAt: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {type: "application/json;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tool.name.replace(/\s+/g, "-").toLowerCase()}-manifest.json`;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
tool.downloads += 1;
render();
showToast(`${tool.name} 下载已开始Mock`);
}
function openWebTool(tool) {
if (!tool.url) {
return;
}
const nextWindow = window.open(tool.url, "_blank", "noopener,noreferrer");
if (!nextWindow) {
showToast("浏览器阻止了新窗口,请允许弹窗后重试");
return;
}
tool.downloads += 1;
render();
showToast(`${tool.name} 已在新标签页打开`);
}
function openModal(tool) {
elements.detailTitle.textContent = tool.name;
elements.detailDescription.textContent = tool.description;
elements.detailMeta.innerHTML = `
<li>分类:<strong>${escapeHtml(tool.category)}</strong></li>
<li>版本:<strong>${escapeHtml(tool.version)}</strong></li>
${tool.size ? `<li>大小:<strong>${escapeHtml(tool.size)}</strong></li>` : ""}
<li>访问方式:<strong>${tool.url ? "网页打开" : "下载安装"}</strong></li>
<li>${tool.url ? "访问" : "下载"}<strong>${formatNumber(tool.downloads)}</strong></li>
<li>更新时间:<strong>${formatDate(tool.updatedAt)}</strong></li>
`;
elements.detailFeatures.innerHTML = tool.features.map((feature) => `<li>${escapeHtml(feature)}</li>`).join("");
elements.detailModal.classList.add("open");
}
function closeModal() {
elements.detailModal.classList.remove("open");
}
function openOverviewModal() {
elements.overviewBtn.classList.add("active");
elements.overviewBtn.setAttribute("aria-expanded", "true");
elements.overviewModal.classList.add("open");
}
function closeOverviewModal() {
elements.overviewBtn.classList.remove("active");
elements.overviewBtn.setAttribute("aria-expanded", "false");
elements.overviewModal.classList.remove("open");
}
function updateHeaderScrollState() {
if (!elements.headerWrap) {
return;
}
const isScrolled = window.scrollY > 8;
elements.headerWrap.classList.toggle("is-scrolled", isScrolled);
}
function clearChipActive() {
elements.hotKeywords.querySelectorAll(".chip").forEach((chip) => chip.classList.remove("active"));
}
function resetFilters() {
state.query = "";
state.category = "all";
state.page = 1;
elements.searchInput.value = "";
elements.categorySelect.value = "all";
clearChipActive();
render();
}
function bindEvents() {
elements.searchInput.addEventListener("input", (event) => {
state.query = event.target.value;
state.page = 1;
if (!state.query.trim()) {
clearChipActive();
}
render();
});
elements.categorySelect.addEventListener("change", (event) => {
state.category = event.target.value;
state.page = 1;
render();
});
elements.sortSelect.addEventListener("change", (event) => {
state.sortBy = event.target.value;
state.page = 1;
render();
});
elements.resetBtn.addEventListener("click", () => {
resetFilters();
});
elements.hotKeywords.addEventListener("click", (event) => {
const chip = event.target.closest(".chip");
if (!chip) {
return;
}
clearChipActive();
chip.classList.add("active");
state.query = chip.dataset.keyword || "";
state.page = 1;
elements.searchInput.value = state.query;
render();
});
if (elements.categorySidebarList) {
elements.categorySidebarList.addEventListener("click", (event) => {
const categoryButton = event.target.closest(".category-side-btn");
if (!categoryButton) {
return;
}
state.category = categoryButton.dataset.category || "all";
state.page = 1;
elements.categorySelect.value = state.category;
render();
});
}
elements.prevBtn.addEventListener("click", () => {
if (state.page > 1) {
state.page -= 1;
render();
}
});
elements.nextBtn.addEventListener("click", () => {
const totalPages = Math.max(1, Math.ceil(filterTools().length / state.pageSize));
if (state.page < totalPages) {
state.page += 1;
render();
}
});
elements.toolGrid.addEventListener("click", (event) => {
const detailBtn = event.target.closest(".js-detail");
if (detailBtn) {
const tool = tools.find((item) => item.id === detailBtn.dataset.id);
if (tool) {
openModal(tool);
}
return;
}
const downloadBtn = event.target.closest(".js-download");
if (downloadBtn) {
const tool = tools.find((item) => item.id === downloadBtn.dataset.id);
if (tool) {
downloadTool(tool);
}
return;
}
const openBtn = event.target.closest(".js-open");
if (openBtn) {
const tool = tools.find((item) => item.id === openBtn.dataset.id);
if (tool) {
openWebTool(tool);
}
return;
}
const clearBtn = event.target.closest("#clearEmptyBtn");
if (clearBtn) {
resetFilters();
}
});
elements.closeModalBtn.addEventListener("click", closeModal);
elements.detailModal.addEventListener("click", (event) => {
if (event.target === elements.detailModal) {
closeModal();
}
});
elements.overviewBtn.addEventListener("click", openOverviewModal);
elements.closeOverviewModalBtn.addEventListener("click", closeOverviewModal);
elements.overviewModal.addEventListener("click", (event) => {
if (event.target === elements.overviewModal) {
closeOverviewModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (elements.detailModal.classList.contains("open")) {
closeModal();
}
if (elements.overviewModal.classList.contains("open")) {
closeOverviewModal();
}
}
});
window.addEventListener("scroll", updateHeaderScrollState, {passive: true});
}
function init() {
buildOptions();
bindEvents();
updateHeaderScrollState();
render();
}
init();

View File

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

View File

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

View File

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

View File

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

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
client/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

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

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

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

View File

@@ -2,20 +2,15 @@
<div> <div>
<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="ToolsShow 首页" @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>ToolsShow</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>
<a href="/admin">管理端</a>
<button <button
type="button" type="button"
class="nav-btn" class="nav-btn"
@@ -102,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>
@@ -138,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>
@@ -211,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>
@@ -225,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>
@@ -236,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>
@@ -285,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;
@@ -294,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,
}); });
@@ -524,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();
@@ -572,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) {
@@ -582,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 '下载';
} }
@@ -607,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('浏览器阻止了新窗口,请允许弹窗后重试');
@@ -627,7 +643,7 @@ async function triggerLaunch(tool) {
window.location.assign(actionUrl); window.location.assign(actionUrl);
return; return;
} }
showToast(`${tool.name} 下载任务已创建`); showToast(`${tool.name} 已开始下载`);
} }
await Promise.all([loadTools(), refreshOverview()]); await Promise.all([loadTools(), refreshOverview()]);

View File

@@ -65,7 +65,7 @@
@logout="handleLogout" @logout="handleLogout"
/> />
<main class="dashboard-main" :class="{ 'with-kpi': activeMenu === 'overview' }"> <main class="dashboard-main" :class="{ 'with-kpi': isOverviewRoute }">
<AdminTopbar <AdminTopbar
v-model:search="topSearch" v-model:search="topSearch"
:section-title="sectionTitle" :section-title="sectionTitle"
@@ -73,63 +73,13 @@
@refresh="refreshCurrentSection" @refresh="refreshCurrentSection"
@open-overview="openOverviewSection" @open-overview="openOverviewSection"
@open-tools="openToolsSection" @open-tools="openToolsSection"
@open-categories="openCategoriesSection"
@open-audit="openAuditSection" @open-audit="openAuditSection"
/> />
<AdminKpiRow <router-view v-slot="{ Component }">
v-if="activeMenu === 'overview'" <component :is="Component" v-bind="currentPageProps" v-on="currentPageEvents" />
:kpi-cards="kpiCards" </router-view>
:format-number="formatNumber"
/>
<AdminOverviewSection
v-show="activeMenu === 'overview'"
v-model:trend-range="trendRange"
:trend-polyline="trendPolyline"
:trend-markers="trendMarkers"
:device-traffic="deviceTraffic"
:location-traffic="locationTraffic"
/>
<AdminToolsSection
v-show="activeMenu === 'tools'"
:tool-filters="consoleStore.toolFilters"
:category-loading="consoleStore.categoryLoading"
:categories="consoleStore.categories"
:status-options="statusOptions"
:access-mode-options="accessModeOptions"
:tool-loading="consoleStore.toolLoading"
:tools="consoleStore.tools"
:tool-pagination="consoleStore.toolPagination"
:status-tag-type="statusTagType"
:access-mode-tag-type="accessModeTagType"
:format-number="formatNumber"
:format-date="formatDate"
@search="searchTools"
@reset="resetToolFilters"
@create="openCreateToolDialog"
@edit="openEditToolDialog"
@artifact="openArtifactDialog"
@status="openStatusDialog"
@mode="openModeDialog"
@delete="deleteTool"
@page-change="handleToolPageChange"
@size-change="handleToolSizeChange"
/>
<AdminAuditSection
v-show="activeMenu === 'audit'"
:audit-filters="consoleStore.auditFilters"
:audit-loading="consoleStore.auditLoading"
:audit-logs="consoleStore.auditLogs"
:audit-pagination="consoleStore.auditPagination"
:format-date-time="formatDateTime"
:stringify-body="stringifyBody"
@search="searchAuditLogs"
@reset="resetAuditFilters"
@page-change="handleAuditPageChange"
@size-change="handleAuditSizeChange"
/>
</main> </main>
</div> </div>
@@ -141,12 +91,24 @@
:tool-form-rules="toolFormRules" :tool-form-rules="toolFormRules"
:categories="consoleStore.categories" :categories="consoleStore.categories"
:category-loading="consoleStore.categoryLoading" :category-loading="consoleStore.categoryLoading"
:tags="consoleStore.tags"
:tag-loading="consoleStore.tagLoading"
:access-mode-options="accessModeOptions" :access-mode-options="accessModeOptions"
:status-options="statusOptions" :status-options="statusOptions"
:submitting="toolDialog.submitting" :submitting="toolDialog.submitting"
@submit="submitToolForm" @submit="submitToolForm"
/> />
<CategoryFormDialog
ref="categoryDialogFormRef"
v-model:visible="categoryDialog.visible"
:mode="categoryDialog.mode"
:category-form="categoryForm"
:category-form-rules="categoryFormRules"
:submitting="categoryDialog.submitting"
@submit="submitCategoryForm"
/>
<ArtifactDialog <ArtifactDialog
v-model:visible="artifactDialog.visible" v-model:visible="artifactDialog.visible"
:artifact-dialog="artifactDialog" :artifact-dialog="artifactDialog"
@@ -182,89 +144,148 @@
<script setup> <script setup>
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { getApiErrorMessage } from '../api'; import { getApiErrorMessage } from '../api';
import AdminKpiRow from './components/AdminKpiRow.vue';
import AdminSidebar from './components/AdminSidebar.vue'; import AdminSidebar from './components/AdminSidebar.vue';
import AdminTopbar from './components/AdminTopbar.vue'; import AdminTopbar from './components/AdminTopbar.vue';
import AccessModeDialog from './components/AccessModeDialog.vue'; import AccessModeDialog from './components/AccessModeDialog.vue';
import AdminAuditSection from './components/AdminAuditSection.vue';
import AdminOverviewSection from './components/AdminOverviewSection.vue';
import AdminToolsSection from './components/AdminToolsSection.vue';
import ArtifactDialog from './components/ArtifactDialog.vue'; import ArtifactDialog from './components/ArtifactDialog.vue';
import CategoryFormDialog from './components/CategoryFormDialog.vue';
import StatusDialog from './components/StatusDialog.vue'; import StatusDialog from './components/StatusDialog.vue';
import ToolFormDialog from './components/ToolFormDialog.vue'; import ToolFormDialog from './components/ToolFormDialog.vue';
import { useAdminAuthStore } from './stores/auth'; import { useAdminAuthStore } from './stores/auth';
import { useAdminConsoleStore } from './stores/console'; import { useAdminConsoleStore } from './stores/console';
const router = useRouter(); const router = useRouter();
const route = useRoute();
const authStore = useAdminAuthStore(); const authStore = useAdminAuthStore();
const consoleStore = useAdminConsoleStore(); const consoleStore = useAdminConsoleStore();
const activeMenu = ref('overview'); const ADMIN_SECTION_ROUTE_MAP = {
overview: '/admin/overview',
tools: '/admin/tools',
categories: '/admin/categories',
audit: '/admin/auditlogs',
};
const activeMenu = computed(() => {
const menuKey = route.meta?.menuKey;
return typeof menuKey === 'string' && menuKey ? menuKey : 'tools';
});
const topSearch = ref(''); const 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(() => {
if (activeMenu.value === 'tools') { const routeTitle = route.meta?.sectionTitle;
return 'Tools'; return typeof routeTitle === 'string' && routeTitle ? routeTitle : 'Tools';
}
if (activeMenu.value === 'audit') {
return 'Audit Logs';
}
return 'Overview';
}); });
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(() => const currentPageProps = computed(() => {
trendValues if (activeMenu.value === 'overview') {
.map((_, idx) => { return {
const point = calcTrendPoint(idx); kpiCards: kpiCards.value,
return `${point.x},${point.y}`; loadingOverview: consoleStore.overviewLoading,
}) overview: consoleStore.overview,
.join(' '), formatNumber,
); formatDate,
};
}
if (activeMenu.value === 'categories') {
return {
categoryFilters: consoleStore.categoryFilters,
categoryLoading: consoleStore.categoryLoading,
categories: consoleStore.categories,
};
}
if (activeMenu.value === 'audit') {
return {
auditFilters: consoleStore.auditFilters,
auditLoading: consoleStore.auditLoading,
auditLogs: consoleStore.auditLogs,
auditPagination: consoleStore.auditPagination,
formatDateTime,
stringifyBody,
};
}
return {
toolFilters: consoleStore.toolFilters,
categoryLoading: consoleStore.categoryLoading,
categories: consoleStore.categories,
statusOptions,
accessModeOptions,
toolLoading: consoleStore.toolLoading,
tools: consoleStore.tools,
toolPagination: consoleStore.toolPagination,
statusTagType,
accessModeTagType,
formatNumber,
formatDate,
};
});
const currentPageEvents = computed(() => {
if (activeMenu.value === 'overview') {
return {};
}
if (activeMenu.value === 'categories') {
return {
search: searchCategories,
reset: resetCategoryFilters,
create: openCreateCategoryDialog,
edit: openEditCategoryDialog,
delete: deleteCategory,
};
}
if (activeMenu.value === 'audit') {
return {
search: searchAuditLogs,
reset: resetAuditFilters,
'page-change': handleAuditPageChange,
'size-change': handleAuditSizeChange,
};
}
return {
search: searchTools,
reset: resetToolFilters,
create: openCreateToolDialog,
edit: openEditToolDialog,
artifact: openArtifactDialog,
status: openStatusDialog,
mode: openModeDialog,
delete: deleteTool,
'page-change': handleToolPageChange,
'size-change': handleToolSizeChange,
};
});
const loginFormRef = ref(null); const loginFormRef = ref(null);
const loginForm = reactive({ const loginForm = reactive({
@@ -295,11 +316,13 @@ const accessModeOptions = [
]; ];
const toolDialogFormRef = ref(null); const toolDialogFormRef = ref(null);
const categoryDialogFormRef = ref(null);
function createEmptyToolForm() { function createEmptyToolForm() {
return { return {
name: '', name: '',
categoryId: '', categoryId: '',
tagIds: [],
description: '', description: '',
rating: 0, rating: 0,
featuresText: '', featuresText: '',
@@ -318,6 +341,18 @@ const toolFormRules = {
{ min: 2, max: 120, message: '工具名称长度为 2-120 位', trigger: 'blur' }, { min: 2, max: 120, message: '工具名称长度为 2-120 位', trigger: 'blur' },
], ],
categoryId: [{ required: true, message: '请选择工具分类', trigger: 'change' }], categoryId: [{ required: true, message: '请选择工具分类', trigger: 'change' }],
tagIds: [
{
validator: (_rule, value, callback) => {
if (Array.isArray(value) && value.length > 20) {
callback(new Error('最多选择 20 个标签'));
return;
}
callback();
},
trigger: 'change',
},
],
description: [ description: [
{ required: true, message: '请输入工具简介', trigger: 'blur' }, { required: true, message: '请输入工具简介', trigger: 'blur' },
{ min: 10, max: 2000, message: '工具简介长度为 10-2000 位', trigger: 'blur' }, { min: 10, max: 2000, message: '工具简介长度为 10-2000 位', trigger: 'blur' },
@@ -326,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',
@@ -358,6 +394,43 @@ const toolDialog = reactive({
submitting: false, submitting: false,
}); });
function createEmptyCategoryForm() {
return {
name: '',
sortOrder: 100,
};
}
const categoryForm = reactive(createEmptyCategoryForm());
const categoryFormRules = {
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 1, max: 80, message: '分类名称长度为 1-80 位', trigger: 'blur' },
],
sortOrder: [
{ required: true, message: '请输入排序值', trigger: 'change' },
{
validator: (_rule, value, callback) => {
const numeric = Number(value);
if (!Number.isInteger(numeric) || numeric < 0 || numeric > 9999) {
callback(new Error('排序值必须是 0-9999 的整数'));
return;
}
callback();
},
trigger: 'change',
},
],
};
const categoryDialog = reactive({
visible: false,
mode: 'create',
id: '',
submitting: false,
});
const artifactDialog = reactive({ const artifactDialog = reactive({
visible: false, visible: false,
toolId: '', toolId: '',
@@ -396,24 +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 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 '-';
@@ -522,10 +591,94 @@ function normalizeFeatureText(value) {
.slice(0, 20); .slice(0, 20);
} }
function normalizeTagName(value) {
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) {
const selected = Array.isArray(values) ? values : [];
const sourceTags = Array.isArray(consoleStore.tags) ? consoleStore.tags : [];
const knownTagIdSet = new Set(sourceTags.map((item) => item.id));
const knownTagNameToId = new Map(
sourceTags
.map((item) => [normalizeTagName(item.name).toLowerCase(), item.id])
.filter(([name]) => Boolean(name)),
);
const existingTagIds = [];
const newTagNames = [];
const seenTagIds = new Set();
const seenTagNames = new Set();
selected.forEach((item) => {
const raw = String(item ?? '');
const normalized = normalizeTagName(raw);
if (!normalized) {
return;
}
if (knownTagIdSet.has(raw)) {
if (!seenTagIds.has(raw)) {
existingTagIds.push(raw);
seenTagIds.add(raw);
}
return;
}
const existingIdByName = knownTagNameToId.get(normalized.toLowerCase());
if (existingIdByName) {
if (!seenTagIds.has(existingIdByName)) {
existingTagIds.push(existingIdByName);
seenTagIds.add(existingIdByName);
}
return;
}
if (!seenTagNames.has(normalized.toLowerCase())) {
newTagNames.push(normalized);
seenTagNames.add(normalized.toLowerCase());
}
});
return {
existingTagIds,
newTagNames,
};
}
async function resolveToolTagIds(token) {
const { existingTagIds, newTagNames } = splitTagSelections(toolForm.tagIds);
if (newTagNames.length === 0) {
return existingTagIds;
}
const createdTags = await Promise.all(
newTagNames.map((name) => consoleStore.createTag({ name }, token)),
);
const createdTagIds = createdTags
.map((item) => item?.id)
.filter((id) => typeof id === 'string' && id);
return Array.from(new Set([...existingTagIds, ...createdTagIds]));
}
function resetToolForm() { function resetToolForm() {
Object.assign(toolForm, createEmptyToolForm()); Object.assign(toolForm, createEmptyToolForm());
} }
function resetCategoryForm() {
Object.assign(categoryForm, createEmptyCategoryForm());
}
function resetArtifactForm() { function resetArtifactForm() {
artifactForm.version = ''; artifactForm.version = '';
artifactForm.releaseNotes = ''; artifactForm.releaseNotes = '';
@@ -550,7 +703,9 @@ async function runWithAuth(fn) {
async function initializeAdminData() { async function initializeAdminData() {
try { try {
await Promise.all([ await Promise.all([
consoleStore.loadCategories(), runWithAuth((token) => consoleStore.loadOverview(token)),
runWithAuth((token) => consoleStore.loadCategories(token)),
runWithAuth((token) => consoleStore.loadTags(token)),
runWithAuth((token) => consoleStore.loadTools(token)), runWithAuth((token) => consoleStore.loadTools(token)),
runWithAuth((token) => consoleStore.loadAuditLogs(token)), runWithAuth((token) => consoleStore.loadAuditLogs(token)),
]); ]);
@@ -594,35 +749,50 @@ function goPublic() {
} }
function switchMenu(nextKey) { function switchMenu(nextKey) {
if (!['overview', 'tools', 'audit'].includes(nextKey)) { const targetPath = ADMIN_SECTION_ROUTE_MAP[nextKey];
if (!targetPath) {
ElMessage.info('该菜单为展示项,当前版本暂未开放'); ElMessage.info('该菜单为展示项,当前版本暂未开放');
return; return;
} }
activeMenu.value = nextKey; if (route.path !== targetPath) {
router.push(targetPath);
}
} }
function openOverviewSection() { function openOverviewSection() {
activeMenu.value = 'overview'; switchMenu('overview');
} }
function openToolsSection() { function openToolsSection() {
activeMenu.value = 'tools'; switchMenu('tools');
}
function openCategoriesSection() {
switchMenu('categories');
} }
function openAuditSection() { function openAuditSection() {
activeMenu.value = 'audit'; switchMenu('audit');
} }
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;
} }
if (activeMenu.value === 'categories') {
await loadCategories();
return;
}
if (activeMenu.value === 'audit') { if (activeMenu.value === 'audit') {
await loadAuditLogs(); await loadAuditLogs();
return; return;
} }
await Promise.all([loadTools(), loadAuditLogs()]); await Promise.all([loadOverview(), loadCategories(), loadTools(), loadAuditLogs()]);
} }
function applyTopSearch() { function applyTopSearch() {
@@ -637,10 +807,26 @@ function applyTopSearch() {
return; return;
} }
if (activeMenu.value === 'categories') {
consoleStore.categoryFilters.query = value;
searchCategories();
return;
}
consoleStore.toolFilters.query = value; consoleStore.toolFilters.query = value;
searchTools(); searchTools();
} }
async function searchCategories() {
await loadCategories();
}
async function resetCategoryFilters() {
consoleStore.resetCategoryFilters();
topSearch.value = '';
await loadCategories();
}
async function searchTools() { async function searchTools() {
consoleStore.toolFilters.page = 1; consoleStore.toolFilters.page = 1;
await loadTools(); await loadTools();
@@ -663,6 +849,34 @@ async function handleToolSizeChange(size) {
await loadTools(); await loadTools();
} }
async function loadCategories() {
try {
await runWithAuth((token) => consoleStore.loadCategories(token));
} catch (error) {
if (isUnauthorized(error)) {
await authStore.logout();
consoleStore.$reset();
ElMessage.error('登录已过期,请重新登录');
return;
}
ElMessage.error(getApiErrorMessage(error));
}
}
async function 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));
@@ -677,6 +891,81 @@ async function loadTools() {
} }
} }
function openCreateCategoryDialog() {
categoryDialog.mode = 'create';
categoryDialog.id = '';
resetCategoryForm();
categoryDialog.visible = true;
}
function openEditCategoryDialog(row) {
categoryDialog.mode = 'edit';
categoryDialog.id = row.id;
Object.assign(categoryForm, {
name: row.name || '',
sortOrder: Number(row.sortOrder ?? 100),
});
categoryDialog.visible = true;
}
function buildCategoryPayload() {
return {
name: categoryForm.name.trim(),
sortOrder: Number(categoryForm.sortOrder ?? 100),
};
}
async function submitCategoryForm() {
const formRef = categoryDialogFormRef.value;
if (!formRef) {
return;
}
try {
await formRef.validate();
} catch {
return;
}
categoryDialog.submitting = true;
try {
const payload = buildCategoryPayload();
await runWithAuth((token) => {
if (categoryDialog.mode === 'create') {
return consoleStore.createCategory(payload, token);
}
return consoleStore.updateCategory(categoryDialog.id, payload, token);
});
categoryDialog.visible = false;
ElMessage.success(categoryDialog.mode === 'create' ? '分类已创建' : '分类已更新');
await Promise.all([loadCategories(), loadTools()]);
} catch (error) {
ElMessage.error(getApiErrorMessage(error));
} finally {
categoryDialog.submitting = false;
}
}
async function deleteCategory(row) {
try {
await ElMessageBox.confirm(`确认删除分类「${row.name}」吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await runWithAuth((token) => consoleStore.deleteCategory(row.id, token));
ElMessage.success('分类已删除');
await Promise.all([loadCategories(), loadTools()]);
} catch (error) {
ElMessage.error(getApiErrorMessage(error));
}
}
function openCreateToolDialog() { function openCreateToolDialog() {
toolDialog.mode = 'create'; toolDialog.mode = 'create';
toolDialog.id = ''; toolDialog.id = '';
@@ -690,6 +979,7 @@ function openEditToolDialog(row) {
Object.assign(toolForm, { Object.assign(toolForm, {
name: row.name || '', name: row.name || '',
categoryId: row.category?.id || '', categoryId: row.category?.id || '',
tagIds: Array.isArray(row.tags) ? row.tags.map((item) => item.id).filter(Boolean) : [],
description: row.description || '', description: row.description || '',
rating: Number(row.rating ?? 0), rating: Number(row.rating ?? 0),
featuresText: Array.isArray(row.features) ? row.features.join('\n') : '', featuresText: Array.isArray(row.features) ? row.features.join('\n') : '',
@@ -701,24 +991,21 @@ function openEditToolDialog(row) {
toolDialog.visible = true; toolDialog.visible = true;
} }
function buildToolPayload() { 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,
tags: tagIds,
description: toolForm.description.trim(), description: toolForm.description.trim(),
rating: Number(toolForm.rating ?? 0), rating: Number(toolForm.rating ?? 0),
features: normalizeFeatureText(toolForm.featuresText), features: normalizeFeatureText(toolForm.featuresText),
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;
} }
@@ -736,8 +1023,9 @@ async function submitToolForm() {
toolDialog.submitting = true; toolDialog.submitting = true;
try { try {
const payload = buildToolPayload(); await runWithAuth(async (token) => {
await runWithAuth((token) => { const resolvedTagIds = await resolveToolTagIds(token);
const payload = buildToolPayload(resolvedTagIds);
if (toolDialog.mode === 'create') { if (toolDialog.mode === 'create') {
return consoleStore.createTool(payload, token); return consoleStore.createTool(payload, token);
} }
@@ -933,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 {
@@ -945,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,
@@ -998,6 +1291,12 @@ 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) {
await loadCategories();
}
if (nextMenu === 'tools' && !consoleStore.tools.length) { if (nextMenu === 'tools' && !consoleStore.tools.length) {
await loadTools(); await loadTools();
} }
@@ -1006,15 +1305,6 @@ watch(activeMenu, async (nextMenu) => {
} }
}); });
watch(
() => toolForm.accessMode,
(mode) => {
if (mode === 'download') {
toolForm.openUrl = '';
}
},
);
watch( watch(
() => toolDialog.visible, () => toolDialog.visible,
(visible) => { (visible) => {
@@ -1025,6 +1315,16 @@ watch(
}, },
); );
watch(
() => categoryDialog.visible,
(visible) => {
if (!visible) {
resetCategoryForm();
categoryDialogFormRef.value?.clearValidate?.();
}
},
);
watch( watch(
() => artifactDialog.visible, () => artifactDialog.visible,
(visible) => { (visible) => {

View File

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

View File

@@ -54,6 +54,11 @@ export async function adminGetTools(params, token) {
return unwrap(response.data); return unwrap(response.data);
} }
export async function adminGetOverview(token) {
const response = await http.get('/admin/overview', withToken(token));
return unwrap(response.data);
}
export async function adminCreateTool(payload, token) { export async function adminCreateTool(payload, token) {
const response = await http.post('/admin/tools', payload, withToken(token)); const response = await http.post('/admin/tools', payload, withToken(token));
return unwrap(response.data); return unwrap(response.data);
@@ -120,7 +125,32 @@ export async function adminDeleteArtifact(toolId, artifactId, token) {
return unwrap(response.data); return unwrap(response.data);
} }
export async function adminGetCategories() { export async function adminGetCategories(params, token) {
const response = await http.get('/categories'); const response = await http.get('/admin/categories', withToken(token, { params }));
return unwrap(response.data);
}
export async function adminCreateCategory(payload, token) {
const response = await http.post('/admin/categories', payload, withToken(token));
return unwrap(response.data);
}
export async function adminUpdateCategory(id, payload, token) {
const response = await http.patch(`/admin/categories/${id}`, payload, withToken(token));
return unwrap(response.data);
}
export async function adminDeleteCategory(id, token) {
const response = await http.delete(`/admin/categories/${id}`, withToken(token));
return unwrap(response.data);
}
export async function adminGetTags(token) {
const response = await http.get('/admin/tags', withToken(token));
return unwrap(response.data);
}
export async function adminCreateTag(payload, token) {
const response = await http.post('/admin/tags', payload, withToken(token));
return unwrap(response.data); return unwrap(response.data);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,26 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="标签" prop="tagIds">
<el-select
v-model="toolForm.tagIds"
multiple
filterable
allow-create
default-first-option
:reserve-keyword="false"
placeholder="可多选,也可直接输入新标签"
style="width: 100%"
:loading="tagLoading"
>
<el-option
v-for="item in tags"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="工具简介" prop="description"> <el-form-item label="工具简介" prop="description">
<el-input <el-input
v-model="toolForm.description" v-model="toolForm.description"
@@ -37,8 +57,9 @@
:rows="4" :rows="4"
maxlength="2000" maxlength="2000"
show-word-limit show-word-limit
placeholder="请描述工具用途、适用场景与优势" placeholder="支持 Markdown例如## 用途&#10;支持 **加粗**、`代码`、[链接](https://example.com)"
/> />
<div class="el-form-item__description">简介支持 Markdown 渲染</div>
</el-form-item> </el-form-item>
<el-form-item label="评分" prop="rating"> <el-form-item label="评分" prop="rating">
<el-input-number v-model="toolForm.rating" :min="0" :max="5" :step="0.1" :precision="1" /> <el-input-number v-model="toolForm.rating" :min="0" :max="5" :step="0.1" :precision="1" />
@@ -48,8 +69,9 @@
v-model="toolForm.featuresText" v-model="toolForm.featuresText"
type="textarea" type="textarea"
:rows="4" :rows="4"
placeholder="每行一个功能点,例如&#10;支持离线模式&#10;支持自动更新" placeholder="每行一个功能点,支持 Markdown例如&#10;支持 **离线模式**&#10;支持 [自动更新](https://example.com)"
/> />
<div class="el-form-item__description">每行作为一个功能点并按 Markdown 展示</div>
</el-form-item> </el-form-item>
<el-form-item label="访问方式" prop="accessMode"> <el-form-item label="访问方式" prop="accessMode">
<el-select v-model="toolForm.accessMode" style="width: 100%"> <el-select v-model="toolForm.accessMode" style="width: 100%">
@@ -61,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" />
@@ -116,6 +148,14 @@ defineProps({
type: Boolean, type: Boolean,
required: true, required: true,
}, },
tags: {
type: Array,
required: true,
},
tagLoading: {
type: Boolean,
required: true,
},
accessModeOptions: { accessModeOptions: {
type: Array, type: Array,
required: true, required: true,

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
<template>
<section class="admin-page-overview">
<AdminKpiRow :kpi-cards="kpiCards" :format-number="formatNumber" />
<AdminOverviewSection
:loading="loadingOverview"
:overview="overview"
:format-number="formatNumber"
:format-date="formatDate"
/>
</section>
</template>
<script setup>
import AdminKpiRow from '../components/AdminKpiRow.vue';
import AdminOverviewSection from '../components/AdminOverviewSection.vue';
defineProps({
kpiCards: {
type: Array,
required: true,
},
loadingOverview: {
type: Boolean,
default: false,
},
overview: {
type: Object,
required: true,
},
formatNumber: {
type: Function,
required: true,
},
formatDate: {
type: Function,
required: true,
},
});
</script>

View File

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

View File

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

View File

@@ -1,24 +1,61 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { import {
adminCreateCategory,
adminCreateTag,
adminCreateTool, adminCreateTool,
adminDeleteCategory,
adminDeleteArtifact, adminDeleteArtifact,
adminGetOverview,
adminDeleteTool, adminDeleteTool,
adminGetArtifacts, adminGetArtifacts,
adminGetAuditLogs, adminGetAuditLogs,
adminGetCategories, adminGetCategories,
adminGetTags,
adminGetTools, adminGetTools,
adminSetLatestArtifact, adminSetLatestArtifact,
adminUpdateArtifactStatus, adminUpdateArtifactStatus,
adminUpdateCategory,
adminUpdateTool, adminUpdateTool,
adminUpdateAccessMode, adminUpdateAccessMode,
adminUpdateToolStatus, adminUpdateToolStatus,
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: [],
categoryLoading: false, categoryLoading: false,
categoryFilters: {
query: '',
},
tags: [],
tagLoading: false,
toolFilters: { toolFilters: {
query: '', query: '',
@@ -36,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: '',
@@ -54,15 +93,57 @@ export const useAdminConsoleStore = defineStore('admin-console', {
}, },
}), }),
actions: { actions: {
async loadCategories() { async loadCategories(token) {
this.categoryLoading = true; this.categoryLoading = true;
try { try {
const data = await adminGetCategories(); const data = await adminGetCategories(
{
query: this.categoryFilters.query || undefined,
},
token,
);
this.categories = Array.isArray(data) ? data : []; this.categories = Array.isArray(data) ? data : [];
} finally { } finally {
this.categoryLoading = false; this.categoryLoading = false;
} }
}, },
resetCategoryFilters() {
this.categoryFilters.query = '';
},
async createCategory(payload, token) {
return adminCreateCategory(payload, token);
},
async updateCategory(id, payload, token) {
return adminUpdateCategory(id, payload, token);
},
async deleteCategory(id, token) {
return adminDeleteCategory(id, token);
},
async loadTags(token) {
this.tagLoading = true;
try {
const data = await adminGetTags(token);
this.tags = Array.isArray(data) ? data : [];
} finally {
this.tagLoading = false;
}
},
async createTag(payload, token) {
const created = await adminCreateTag(payload, token);
const createdId = created?.id;
if (!createdId) {
return created;
}
const existingIndex = this.tags.findIndex((item) => item.id === createdId);
if (existingIndex >= 0) {
this.tags.splice(existingIndex, 1, created);
} else {
this.tags.push(created);
}
this.tags.sort((a, b) => String(a?.name ?? '').localeCompare(String(b?.name ?? '')));
return created;
},
setToolPage(page) { setToolPage(page) {
this.toolFilters.page = page; this.toolFilters.page = page;
}, },
@@ -98,6 +179,28 @@ export const useAdminConsoleStore = defineStore('admin-console', {
this.toolLoading = false; this.toolLoading = false;
} }
}, },
async loadOverview(token) {
this.overviewLoading = true;
try {
const data = await adminGetOverview(token);
const defaults = createEmptyOverviewState();
const payload = data && typeof data === 'object' ? data : {};
this.overview = {
...defaults,
...payload,
summary: {
...defaults.summary,
...(payload.summary && typeof payload.summary === 'object' ? payload.summary : {}),
},
dailyActivity: Array.isArray(payload.dailyActivity) ? payload.dailyActivity : [],
topCategories: Array.isArray(payload.topCategories) ? payload.topCategories : [],
topTools: Array.isArray(payload.topTools) ? payload.topTools : [],
};
} finally {
this.overviewLoading = false;
}
},
async updateToolStatus(id, status, token) { async updateToolStatus(id, status, token) {
await adminUpdateToolStatus(id, status, token); await adminUpdateToolStatus(id, status, token);
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,15 +31,19 @@ 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_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` 不存在,则自动创建该账号。
## 3. 构建镜像 ## 3. 构建镜像
@@ -74,9 +78,16 @@ 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/main` - 容器启动命令已在 `Dockerfile` 中定义:`npx prisma migrate deploy && node dist/src/main.js`
- 首次启动会自动执行数据库迁移。 - 首次启动会自动执行数据库迁移。
## 5. 常用运维命令 ## 5. 常用运维命令
@@ -138,4 +149,3 @@ docker build -t toolsshow:latest .
docker rm -f toolsshow-app 2>$null docker rm -f toolsshow-app 2>$null
docker run -d --name toolsshow-app -p 3000:3000 --env-file ./server/.env -v toolsshow_prisma:/app/server/prisma toolsshow:latest docker run -d --name toolsshow-app -p 3000:3000 --env-file ./server/.env -v toolsshow_prisma:/app/server/prisma toolsshow:latest
``` ```

223
docs/TOOLSHOW_ER.drawio Normal file
View File

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

View File

@@ -1,144 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ToolsShow - 工具展示站</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Sora:wght@500;600;700;800&display=swap"
rel="stylesheet"
>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<header class="header-wrap">
<div class="container header">
<a class="brand" href="#" aria-label="ToolsShow 首页">
<span class="brand-mark">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M4 6.5C4 5.67 4.67 5 5.5 5H18.5C19.33 5 20 5.67 20 6.5V17.5C20 18.33 19.33 19 18.5 19H5.5C4.67 19 4 18.33 4 17.5V6.5Z" stroke="currentColor" stroke-width="1.8"/>
<path d="M8 9H16M8 12H16M8 15H13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</span>
<span>ToolsShow</span>
</a>
<nav class="nav" aria-label="主导航">
<a href="#tools">工具列表</a>
<a href="#tools">分类浏览</a>
<a href="#tools">工具中心</a>
<button
id="overviewBtn"
type="button"
class="nav-btn"
aria-controls="overviewModal"
aria-expanded="false"
>
站点概览
</button>
</nav>
</div>
</header>
<main class="container main-content">
<section class="hero">
<div class="hero-main">
<div class="search-row">
<label class="search-box" for="searchInput">
<span class="sr-only">搜索工具</span>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.8"/>
<path d="M16.5 16.5L21 21" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
<input id="searchInput" type="search" placeholder="搜索名称、描述、标签..." autocomplete="off">
</label>
<label class="sr-only" for="categorySelect">按分类筛选</label>
<select id="categorySelect" class="select">
<option value="all">全部分类</option>
</select>
<button id="resetBtn" type="button" class="btn btn-primary">重置筛选</button>
</div>
<div id="hotKeywords" class="hot-keywords">
<span>热门搜索:</span>
</div>
</div>
</section>
<section id="tools">
<div class="tools-layout">
<aside class="category-sidebar" aria-label="分类导航">
<h2 class="sidebar-title">分类导航</h2>
<p class="sidebar-tip">点击分类可快速筛选工具</p>
<div id="categorySidebarList" class="category-sidebar-list"></div>
</aside>
<div class="tools-main">
<div class="toolbar">
<p id="resultTip">正在加载工具数据...</p>
<label class="sr-only" for="sortSelect">排序方式</label>
<select id="sortSelect" class="select">
<option value="popular">按下载量排序</option>
<option value="latest">按更新时间排序</option>
<option value="name">按名称排序</option>
</select>
</div>
<div id="toolGrid" class="tool-grid" aria-live="polite"></div>
<div id="pagination" class="pagination">
<button id="prevBtn" type="button" class="btn">上一页</button>
<span id="pageText">第 1 页</span>
<button id="nextBtn" type="button" class="btn">下一页</button>
</div>
</div>
</div>
</section>
</main>
<div id="detailModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="detailTitle">
<div class="modal">
<div class="modal-head">
<h2 id="detailTitle">工具详情</h2>
<button id="closeModalBtn" type="button" class="icon-btn" aria-label="关闭详情">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</button>
</div>
<p id="detailDescription"></p>
<ul id="detailMeta" class="meta-list"></ul>
<h3>核心能力</h3>
<ul id="detailFeatures" class="feature-list"></ul>
</div>
</div>
<div id="overviewModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="overviewTitle">
<div class="modal">
<div class="modal-head">
<h2 id="overviewTitle">站点概览</h2>
<button id="closeOverviewModalBtn" type="button" class="icon-btn" aria-label="关闭站点概览">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</button>
</div>
<p>展示站当前统计信息与核心能力说明。</p>
<div class="kpi-grid">
<div><strong id="kpiTotal">0</strong><span>工具总数</span></div>
<div><strong id="kpiCategories">0</strong><span>分类数量</span></div>
<div><strong id="kpiDownloads">0</strong><span>累计使用</span></div>
<div><strong id="kpiFiltered">0</strong><span>当前结果</span></div>
</div>
<ul class="tips">
<li>浏览:分页展示工具卡片</li>
<li>搜索:实时匹配关键词</li>
<li>获取:支持模拟下载与网页直达</li>
</ul>
</div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="./app.js"></script>
</body>
</html>

View File

@@ -1,10 +0,0 @@
{
"name": "ToolsShow",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"private": true
}

View File

@@ -1,11 +1,13 @@
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
JWT_REFRESH_SECRET=change_this_refresh_secret JWT_REFRESH_SECRET=change_this_refresh_secret
JWT_ACCESS_EXPIRES_IN=2h JWT_ACCESS_EXPIRES_IN=2h
JWT_REFRESH_EXPIRES_IN=7d JWT_REFRESH_EXPIRES_IN=7d
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_PASSWORD=admin123456
GITLAB_BASE_URL= GITLAB_BASE_URL=
GITLAB_API_BASE= GITLAB_API_BASE=

View File

@@ -11,7 +11,7 @@
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/src/main.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",

View File

@@ -5,6 +5,9 @@ import { AccessModule } from './modules/access/access.module';
import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.module'; import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.module';
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 { AdminOverviewModule } from './modules/admin-overview/admin-overview.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';
import { DownloadsModule } from './modules/downloads/downloads.module'; import { DownloadsModule } from './modules/downloads/downloads.module';
@@ -30,6 +33,9 @@ import { ToolsModule } from './modules/tools/tools.module';
GitlabStorageModule, GitlabStorageModule,
DownloadsModule, DownloadsModule,
AdminAuthModule, AdminAuthModule,
AdminOverviewModule,
AdminCategoriesModule,
AdminTagsModule,
AdminToolsModule, AdminToolsModule,
AdminArtifactsModule, AdminArtifactsModule,
AdminAuditModule, AdminAuditModule,

View File

@@ -51,7 +51,7 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, swaggerConfig); const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
const clientDistPath = process.env.CLIENT_DIST_PATH ?? join(__dirname, '..', 'public'); const clientDistPath = process.env.CLIENT_DIST_PATH ?? join(process.cwd(), 'public');
if (existsSync(clientDistPath)) { if (existsSync(clientDistPath)) {
app.useStaticAssets(clientDistPath); app.useStaticAssets(clientDistPath);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,16 @@
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common'; import { INestApplication, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { AdminUserStatus, PrismaClient } from '@prisma/client';
import argon2 from 'argon2';
import { randomUUID } from 'crypto';
@Injectable() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit { export class PrismaService extends PrismaClient implements OnModuleInit {
private readonly logger = new Logger(PrismaService.name);
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
await this.$connect(); await this.$connect();
await this.$queryRawUnsafe('PRAGMA journal_mode = WAL;'); await this.$queryRawUnsafe('PRAGMA journal_mode = WAL;');
await this.ensureDefaultAdminUser();
} }
async enableShutdownHooks(app: INestApplication): Promise<void> { async enableShutdownHooks(app: INestApplication): Promise<void> {
@@ -13,4 +18,31 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
await app.close(); await app.close();
}); });
} }
private async ensureDefaultAdminUser(): Promise<void> {
const username = process.env.DEFAULT_ADMIN_USERNAME?.trim() || 'admin';
const password = process.env.DEFAULT_ADMIN_PASSWORD || 'admin123456';
const existingAdmin = await this.adminUser.findUnique({
where: { username },
select: { id: true },
});
if (existingAdmin) {
return;
}
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
await this.adminUser.create({
data: {
id: `u_admin_${randomUUID().replace(/-/g, '')}`,
username,
passwordHash,
displayName: 'System Admin',
status: AdminUserStatus.active,
},
});
this.logger.log(`Default admin user initialized: ${username}`);
}
} }

View File

@@ -1,792 +0,0 @@
:root {
--primary: #0a8fb5;
--primary-strong: #0d7697;
--secondary: #22d3ee;
--cta: #16a34a;
--bg: #f3f8fc;
--bg-mesh-a: rgba(34, 211, 238, 0.22);
--bg-mesh-b: rgba(14, 165, 233, 0.18);
--surface: rgba(255, 255, 255, 0.78);
--surface-strong: #ffffff;
--card: #ffffff;
--text: #0f2f3d;
--muted: #4b6674;
--line: rgba(18, 117, 150, 0.2);
--line-strong: rgba(18, 117, 150, 0.34);
--focus: #0ea5e9;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--duration-fast: 160ms;
--duration-normal: 240ms;
--shadow-soft: 0 12px 34px rgba(12, 66, 92, 0.12);
--shadow-lift: 0 16px 40px rgba(12, 66, 92, 0.18);
--glass-blur: 14px;
--radius-lg: 18px;
--radius-md: 12px;
--radius-sm: 8px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: "Manrope", "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.5;
}
html {
scroll-behavior: smooth;
}
body {
min-height: 100vh;
position: relative;
overflow-x: hidden;
background:
radial-gradient(460px 260px at 0% 8%, var(--bg-mesh-a), transparent 75%),
radial-gradient(380px 220px at 98% 4%, var(--bg-mesh-b), transparent 74%),
linear-gradient(180deg, #f7fcff 0%, var(--bg) 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select {
font: inherit;
color: inherit;
}
.container {
width: min(1200px, calc(100% - 32px));
margin: 0 auto;
}
.header-wrap {
position: sticky;
top: 0;
z-index: 10;
width: 100%;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(var(--glass-blur));
transition: box-shadow var(--duration-normal) var(--ease-standard), border-color var(--duration-normal) var(--ease-standard);
}
.header-wrap.is-scrolled {
border-bottom-color: var(--line-strong);
box-shadow: 0 8px 22px rgba(10, 72, 103, 0.1);
}
.header {
padding: 12px 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
font-family: "Sora", sans-serif;
font-weight: 700;
letter-spacing: 0.01em;
}
.brand-mark {
width: 32px;
height: 32px;
border-radius: 10px;
background: var(--primary);
color: #fff;
display: grid;
place-items: center;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
color: var(--muted);
font-size: 14px;
}
.nav a,
.nav-btn {
border-radius: var(--radius-sm);
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0);
background: rgba(255, 255, 255, 0.42);
color: inherit;
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard);
}
.nav a:hover,
.nav-btn:hover {
background: rgba(233, 249, 255, 0.86);
border-color: rgba(20, 143, 179, 0.2);
color: var(--text);
}
.nav-btn.active {
background: rgba(217, 246, 255, 0.9);
color: var(--text);
border-color: rgba(20, 143, 179, 0.34);
}
.hero {
margin-top: 14px;
display: block;
}
.hero-main {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.72));
backdrop-filter: blur(10px);
box-shadow: var(--shadow-soft);
padding: 14px;
}
.main-content {
animation: content-fade-in 420ms var(--ease-standard) both;
}
h1,
h2,
h3 {
margin: 0;
font-family: "Sora", sans-serif;
}
.search-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 10px;
}
.search-box {
min-height: 44px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.94);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
}
.search-box input {
border: none;
outline: none;
width: 100%;
min-height: 42px;
background: transparent;
}
.select,
.btn {
min-height: 44px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.9);
padding: 0 12px;
box-shadow: 0 2px 8px rgba(13, 88, 124, 0.06);
}
.select {
cursor: pointer;
}
.btn {
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
.btn:hover {
background: rgba(241, 252, 255, 0.95);
border-color: var(--line-strong);
box-shadow: 0 8px 20px rgba(13, 88, 124, 0.12);
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), #0ea5c8);
border-color: var(--primary);
color: #fff;
box-shadow: 0 12px 24px rgba(10, 143, 181, 0.24);
}
.btn-primary:hover {
background: linear-gradient(135deg, #0e81a2, #1091b0);
border-color: #0e81a2;
}
.hot-keywords {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.hot-keywords > span {
font-size: 13px;
color: var(--muted);
}
.chip {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.88);
border-radius: 999px;
padding: 7px 12px;
font-size: 13px;
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.chip:hover,
.chip.active {
background: rgba(217, 246, 255, 0.92);
border-color: rgba(18, 117, 150, 0.34);
}
.kpi-grid {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(2, minmax(120px, 1fr));
gap: 10px;
}
.kpi-grid > div {
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(248, 253, 255, 0.92);
padding: 12px;
}
.kpi-grid strong {
display: block;
font-size: 24px;
line-height: 1;
}
.kpi-grid span {
color: var(--muted);
font-size: 13px;
}
.tips {
margin: 12px 0 0;
padding-left: 18px;
color: var(--muted);
display: grid;
gap: 8px;
font-size: 14px;
}
.toolbar {
margin-top: 0;
margin-bottom: 14px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar p {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
gap: 12px;
}
.tools-layout {
margin-top: 14px;
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.tools-main {
min-width: 0;
}
.category-sidebar {
position: sticky;
top: 74px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(150deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.78));
backdrop-filter: blur(12px);
box-shadow: var(--shadow-soft);
padding: 14px;
}
.sidebar-title {
font-size: 18px;
}
.sidebar-tip {
margin: 6px 0 12px;
color: var(--muted);
font-size: 13px;
}
.category-sidebar-list {
display: grid;
gap: 8px;
}
.category-side-btn {
width: 100%;
min-height: 42px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.86);
color: var(--text);
padding: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
text-align: left;
transition:
background-color var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.category-side-btn:hover {
background: rgba(239, 251, 255, 0.96);
border-color: rgba(20, 143, 179, 0.3);
}
.category-side-btn.active {
background: linear-gradient(135deg, rgba(214, 247, 255, 0.94), rgba(225, 252, 255, 0.9));
border-color: rgba(20, 143, 179, 0.34);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.category-side-btn .label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-side-btn .count {
border: 1px solid rgba(18, 117, 150, 0.22);
border-radius: 999px;
background: rgba(241, 252, 255, 0.9);
color: #0b6d8a;
font-size: 12px;
line-height: 1;
padding: 4px 8px;
flex-shrink: 0;
}
.card {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(160deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.82));
backdrop-filter: blur(12px);
box-shadow: var(--shadow-soft);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 280px;
opacity: 0;
transform: translateY(8px);
will-change: transform, opacity;
animation: card-enter 380ms var(--ease-standard) both;
animation-delay: var(--stagger, 0ms);
transition:
border-color var(--duration-normal) var(--ease-standard),
background-color var(--duration-normal) var(--ease-standard),
transform var(--duration-normal) var(--ease-standard),
box-shadow var(--duration-normal) var(--ease-standard);
}
.card:hover {
border-color: rgba(18, 117, 150, 0.34);
background: linear-gradient(160deg, rgba(255, 255, 255, 0.95), rgba(246, 252, 255, 0.88));
transform: translateY(-2px);
box-shadow: var(--shadow-lift);
}
.card-top,
.card-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.category {
border: 1px solid rgba(21, 128, 110, 0.24);
background: rgba(236, 253, 245, 0.9);
color: #0f766e;
font-size: 12px;
border-radius: 999px;
padding: 2px 9px;
}
.card h3 {
font-size: 18px;
}
.desc {
margin: 0;
color: var(--muted);
font-size: 14px;
min-height: 42px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
border: 1px solid rgba(18, 117, 150, 0.2);
background: rgba(236, 251, 255, 0.92);
color: #0c6f8d;
border-radius: 999px;
font-size: 12px;
padding: 2px 8px;
}
.meta-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 4px;
color: var(--muted);
font-size: 13px;
}
.meta-list strong {
color: var(--text);
}
.card-foot {
margin-top: auto;
}
.download-num {
color: var(--muted);
font-size: 13px;
}
.actions {
display: flex;
gap: 8px;
}
.btn-small {
min-height: 36px;
border-radius: var(--radius-sm);
padding: 0 10px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.92);
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.btn-small:hover {
background: rgba(240, 251, 255, 0.95);
border-color: rgba(20, 143, 179, 0.3);
}
.btn-download {
border-color: rgba(22, 163, 74, 0.34);
background: linear-gradient(135deg, rgba(236, 253, 243, 0.95), rgba(220, 252, 231, 0.92));
color: #166534;
}
.btn-download:hover {
border-color: rgba(22, 163, 74, 0.5);
background: linear-gradient(135deg, rgba(220, 252, 231, 0.95), rgba(199, 246, 212, 0.92));
}
.btn-open {
border-color: rgba(14, 165, 233, 0.36);
background: linear-gradient(135deg, rgba(224, 242, 254, 0.96), rgba(207, 234, 254, 0.92));
color: #0b5f87;
}
.btn-open:hover {
border-color: rgba(14, 165, 233, 0.52);
background: linear-gradient(135deg, rgba(209, 233, 253, 0.96), rgba(188, 224, 252, 0.92));
}
.empty {
grid-column: 1 / -1;
border: 1px dashed rgba(18, 117, 150, 0.35);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
padding: 24px;
text-align: center;
color: var(--muted);
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin: 18px 0 34px;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(7, 31, 44, 0.36);
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity var(--duration-normal) var(--ease-standard), visibility var(--duration-normal) var(--ease-standard);
}
.modal-backdrop.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.modal {
width: min(680px, 100%);
max-height: 86vh;
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(150deg, rgba(255, 255, 255, 0.95), rgba(248, 253, 255, 0.88));
backdrop-filter: blur(16px);
box-shadow: var(--shadow-lift);
padding: 20px;
opacity: 0;
transform: translateY(14px) scale(0.985);
transition: transform var(--duration-normal) var(--ease-standard), opacity var(--duration-normal) var(--ease-standard);
}
.modal-backdrop.open .modal {
opacity: 1;
transform: translateY(0) scale(1);
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.modal p {
color: var(--muted);
}
.feature-list {
margin: 0;
padding-left: 18px;
display: grid;
gap: 6px;
}
.icon-btn {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.9);
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.icon-btn:hover {
background: rgba(240, 251, 255, 0.95);
border-color: var(--line-strong);
}
.toast {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 30;
border: 1px solid rgba(14, 157, 127, 0.5);
border-radius: 10px;
background: linear-gradient(135deg, #0d8f82, #0f766e);
box-shadow: 0 12px 28px rgba(6, 78, 73, 0.26);
color: #fff;
padding: 10px 14px;
font-size: 14px;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition: opacity var(--duration-normal) var(--ease-standard), transform var(--duration-normal) var(--ease-standard);
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
@media (hover: hover) and (pointer: fine) {
.btn:hover,
.btn-small:hover,
.icon-btn:hover,
.chip:hover,
.nav a:hover,
.nav-btn:hover {
transform: translateY(-1px);
}
}
.btn:active,
.btn-small:active,
.icon-btn:active,
.chip:active {
transform: translateY(0);
}
@media (max-width: 768px) {
.container {
width: min(1200px, calc(100% - 24px));
}
.header {
flex-direction: column;
align-items: flex-start;
}
.nav {
width: 100%;
}
.search-row {
grid-template-columns: 1fr;
}
.select,
.btn {
width: 100%;
}
}
@media (max-width: 1024px) {
.tools-layout {
grid-template-columns: 1fr;
}
.category-sidebar {
position: static;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
@keyframes content-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes badge-pop {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}