This commit is contained in:
dlandy
2026-03-27 10:18:26 +08:00
commit 40be11adbf
116 changed files with 26138 additions and 0 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.gitignore
.idea
.vscode
node_modules
**/node_modules
**/dist
**/.DS_Store
client/.vite
client/.cache
server/coverage
server/docs
README.md

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# Dependencies
node_modules/
client/node_modules/
server/node_modules/
# Build outputs
dist/
build/
client/dist/
server/dist/
*.tsbuildinfo
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment files
.env
.env.*
!.env.example
!.env.*.example
# Coverage
coverage/
.nyc_output/
# IDE and editor
.idea/
.vscode/
*.iml
# OS files
.DS_Store
Thumbs.db
Desktop.ini
# Local runtime data
server/prisma/dev.db
server/prisma/dev.db-shm
server/prisma/dev.db-wal
server/storage/

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
ARG NODE_IMAGE=docker.m.daocloud.io/library/node:20-alpine
FROM ${NODE_IMAGE} AS client-builder
WORKDIR /build/client
COPY client/package*.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
FROM ${NODE_IMAGE} AS server-builder
WORKDIR /build/server
COPY server/package*.json ./
RUN npm ci
COPY server/ ./
RUN npx prisma generate
RUN npm run build
FROM ${NODE_IMAGE} AS runtime
WORKDIR /app/server
ENV NODE_ENV=production
ENV PORT=3000
COPY server/package*.json ./
RUN npm ci --omit=dev
COPY --from=server-builder /build/server/dist ./dist
COPY --from=server-builder /build/server/prisma ./prisma
COPY --from=client-builder /build/client/dist ./public
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]

474
app.js Normal file
View File

@@ -0,0 +1,474 @@
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();

18
client/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ToolsShow - Vue3 客户端</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"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2289
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
client/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "toolsshow-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.1",
"element-plus": "^2.11.7",
"pinia": "^2.3.1",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"sass": "^1.98.0",
"vite": "^7.1.5"
}
}

670
client/src/App.vue Normal file
View File

@@ -0,0 +1,670 @@
<template>
<div>
<header class="header-wrap" :class="{ 'is-scrolled': isScrolled }">
<div class="container header">
<a class="brand" href="#" aria-label="ToolsShow 首页" @click.prevent>
<span class="brand-mark">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M4 6.5C4 5.67 4.67 5 5.5 5H18.5C19.33 5 20 5.67 20 6.5V17.5C20 18.33 19.33 19 18.5 19H5.5C4.67 19 4 18.33 4 17.5V6.5Z" stroke="currentColor" stroke-width="1.8" />
<path d="M8 9H16M8 12H16M8 15H13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</span>
<span>ToolsShow</span>
</a>
<nav class="nav" aria-label="主导航">
<a href="#tools">工具列表</a>
<a href="#tools">分类浏览</a>
<a href="#tools">工具中心</a>
<a href="/admin">管理端</a>
<button
type="button"
class="nav-btn"
:class="{ active: overviewModalOpen }"
:aria-expanded="overviewModalOpen ? 'true' : 'false'"
@click="openOverviewModal"
>
站点概览
</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"
v-model="filters.query"
type="search"
placeholder="搜索名称、描述..."
autocomplete="off"
@input="onQueryInput"
/>
</label>
<label class="sr-only" for="categorySelect">按分类筛选</label>
<select id="categorySelect" v-model="filters.category" class="select" :disabled="loadingMeta" @change="onCategoryChange">
<option v-for="item in categoriesWithAll" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
<button type="button" class="btn btn-primary" @click="resetFilters">重置筛选</button>
</div>
<div class="hot-keywords">
<span>热门搜索</span>
<button
v-for="item in hotKeywords"
:key="item.id"
type="button"
class="chip"
:class="{ active: item.keyword === filters.query.trim() }"
@click="applyHotKeyword(item.keyword)"
>
{{ item.keyword }}
</button>
<span v-if="!hotKeywords.length && !loadingMeta" class="hot-empty">暂无热门关键词</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 class="category-sidebar-list">
<button
v-for="item in categoriesWithAll"
:key="`side-${item.id}`"
type="button"
class="category-side-btn"
:class="{ active: filters.category === item.id }"
:aria-pressed="filters.category === item.id ? 'true' : 'false'"
@click="selectCategory(item.id)"
>
<span class="label">{{ item.name }}</span>
<span class="count">{{ formatNumber(resolveCategoryCount(item)) }}</span>
</button>
</div>
</aside>
<div class="tools-main">
<div class="toolbar">
<p class="result-tip">{{ resultTip }}</p>
<label class="sr-only" for="sortSelect">排序方式</label>
<select id="sortSelect" v-model="filters.sortBy" class="select" @change="onSortChange">
<option value="latest">按更新时间排序</option>
<option value="popular">按下载量排序</option>
<option value="rating">按评分排序</option>
<option value="name">按名称排序</option>
</select>
</div>
<div v-if="loadingTools" class="tool-grid">
<article v-for="item in filters.pageSize" :key="`skeleton-${item}`" class="card card-skeleton">
<div class="skeleton-line skeleton-chip"></div>
<div class="skeleton-line skeleton-title"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line skeleton-btn"></div>
</article>
</div>
<div v-else-if="tools.length === 0" class="tool-grid">
<div class="empty">
<p>没有匹配结果请尝试更换关键词或分类</p>
<button type="button" class="btn" @click="resetFilters">清空筛选条件</button>
</div>
</div>
<div v-else class="tool-grid">
<article
v-for="(tool, index) in tools"
:key="tool.id"
class="card"
:style="{ '--stagger': `${index * 40}ms` }"
>
<div class="card-top">
<span class="category">{{ tool.category?.name || '未分类' }}</span>
</div>
<h3>{{ tool.name }}</h3>
<p class="desc">{{ tool.description }}</p>
<div class="tags">
<span v-for="tag in tool.tags" :key="`${tool.id}-${tag}`" class="tag">{{ tag }}</span>
</div>
<ul class="meta-list">
<li>版本<strong>{{ tool.latestVersion || '暂无版本' }}</strong></li>
<li>更新时间<strong>{{ formatDate(tool.updatedAt) }}</strong></li>
</ul>
<div class="card-foot">
<span class="download-num">
{{ toolModeSummary(tool) }}
</span>
<div class="actions">
<button type="button" class="btn-small" @click="openDetailModal(tool.id)">详情</button>
<button
type="button"
class="btn-small"
:class="tool.accessMode === 'web' ? 'btn-open' : 'btn-download'"
:disabled="isLaunchDisabled(tool)"
@click="triggerLaunch(tool)"
>
{{ launchButtonText(tool) }}
</button>
</div>
</div>
</article>
</div>
<div v-if="pagination.totalPages > 1 && !loadingTools" class="pagination">
<button
type="button"
class="btn"
:disabled="pagination.page <= 1"
@click="changePage(pagination.page - 1)"
>
上一页
</button>
<span> {{ pagination.page }} / {{ pagination.totalPages }} </span>
<button
type="button"
class="btn"
:disabled="pagination.page >= pagination.totalPages"
@click="changePage(pagination.page + 1)"
>
下一页
</button>
</div>
</div>
</div>
</section>
</main>
<div class="modal-backdrop" :class="{ open: detailModalOpen }" role="dialog" aria-modal="true" aria-labelledby="detailTitle" @click.self="closeDetailModal">
<div class="modal">
<div class="modal-head">
<h2 id="detailTitle">工具详情</h2>
<button type="button" class="icon-btn" aria-label="关闭详情" @click="closeDetailModal">
<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>
<template v-if="detailLoading">
<p class="modal-muted">正在加载工具详情...</p>
</template>
<template v-else-if="detailError">
<p class="modal-error">{{ detailError }}</p>
</template>
<template v-else-if="detail">
<p>{{ detail.description }}</p>
<ul class="meta-list">
<li>分类<strong>{{ detail.category?.name || '-' }}</strong></li>
<li>评分<strong>{{ Number(detail.rating || 0).toFixed(1) }}</strong></li>
<li>访问方式<strong>{{ detail.accessMode === 'web' ? '网页打开' : '下载安装' }}</strong></li>
<li v-if="detail.accessMode === 'download'">
下载次数<strong>{{ formatNumber(detail.downloadCount) }}</strong>
</li>
<li v-else>
访问次数<strong>{{ formatNumber(detail.openCount) }}</strong>
</li>
<li v-if="detail.accessMode === 'download'">
最新版本<strong>{{ detail.latestVersion || '暂无版本' }}</strong>
</li>
<li v-if="detail.accessMode === 'download'">
文件大小<strong>{{ formatFileSize(detail.fileSize) }}</strong>
</li>
<li v-if="detail.accessMode === 'web' && detail.openUrl">
打开地址
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
</li>
<li>更新时间<strong>{{ formatDate(detail.updatedAt) }}</strong></li>
</ul>
<h3>核心能力</h3>
<ul v-if="detail.features?.length" class="feature-list">
<li v-for="feature in detail.features" :key="`detail-${feature}`">{{ feature }}</li>
</ul>
<p v-else class="modal-muted">暂无能力描述</p>
</template>
</div>
</div>
<div class="modal-backdrop" :class="{ open: overviewModalOpen }" role="dialog" aria-modal="true" aria-labelledby="overviewTitle" @click.self="closeOverviewModal">
<div class="modal">
<div class="modal-head">
<h2 id="overviewTitle">站点概览</h2>
<button type="button" class="icon-btn" aria-label="关闭站点概览" @click="closeOverviewModal">
<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>{{ formatNumber(overview.toolTotal) }}</strong><span>工具总数</span></div>
<div><strong>{{ formatNumber(overview.categoryTotal) }}</strong><span>分类数量</span></div>
<div><strong>{{ formatNumber(overview.downloadTotal) }}</strong><span>累计下载</span></div>
<div><strong>{{ formatNumber(overview.openTotal) }}</strong><span>累计访问</span></div>
<div><strong>{{ formatNumber(pagination.total) }}</strong><span>当前结果</span></div>
</div>
<ul class="tips">
<li>浏览分页展示工具卡片</li>
<li>搜索关键词 + 分类 + 排序组合筛选</li>
<li>获取统一通过 launch 接口完成网页打开或下载</li>
</ul>
</div>
</div>
<div class="toast" :class="{ show: toast.visible }" role="status" aria-live="polite">
{{ toast.message }}
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import {
fetchCategories,
fetchHotKeywords,
fetchOverview,
fetchToolDetail,
fetchTools,
getApiErrorMessage,
launchTool,
resolveActionUrl,
} from './api';
const CLIENT_VERSION = 'web-1.0.0';
const QUERY_DEBOUNCE_MS = 320;
const filters = reactive({
query: '',
category: 'all',
sortBy: 'latest',
page: 1,
pageSize: 6,
});
const categories = ref([]);
const hotKeywords = ref([]);
const tools = ref([]);
const pagination = reactive({
page: 1,
pageSize: 6,
total: 0,
totalPages: 1,
});
const overview = reactive({
toolTotal: 0,
categoryTotal: 0,
downloadTotal: 0,
openTotal: 0,
});
const loadingTools = ref(false);
const loadingMeta = ref(false);
const launchingId = ref('');
const detailModalOpen = ref(false);
const detailLoading = ref(false);
const detailError = ref('');
const detail = ref(null);
const overviewModalOpen = ref(false);
const isScrolled = ref(false);
const toast = reactive({
visible: false,
message: '',
});
let queryTimer = null;
let toastTimer = null;
let toolsRequestToken = 0;
const categoriesWithAll = computed(() => [
{
id: 'all',
name: '全部分类',
toolCount: pagination.total,
},
...categories.value,
]);
const resultTip = computed(() => {
if (loadingTools.value) {
return '正在加载工具数据...';
}
if (pagination.total === 0) {
return '没有匹配结果,请尝试更换关键词或分类。';
}
const start = (pagination.page - 1) * pagination.pageSize + 1;
const end = Math.min(start + tools.value.length - 1, pagination.total);
return `共找到 ${formatNumber(pagination.total)} 个工具,当前显示 ${start}-${end}`;
});
function formatNumber(value) {
const numeric = Number(value);
return new Intl.NumberFormat('zh-CN').format(Number.isFinite(numeric) ? numeric : 0);
}
function formatDate(dateText) {
if (!dateText) {
return '-';
}
const date = new Date(dateText);
if (Number.isNaN(date.getTime())) {
return '-';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
function formatFileSize(bytes) {
const size = Number(bytes);
if (!Number.isFinite(size) || size <= 0) {
return '-';
}
const units = ['B', 'KB', 'MB', 'GB'];
let value = size;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unitIndex]}`;
}
function resolveCategoryCount(item) {
if (item.id === 'all') {
return pagination.total;
}
return item.toolCount ?? 0;
}
function showToast(message) {
toast.message = message;
toast.visible = true;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.visible = false;
}, 2200);
}
async function loadTools() {
const currentToken = ++toolsRequestToken;
loadingTools.value = true;
try {
const payload = await fetchTools({
query: filters.query.trim() || undefined,
category: filters.category,
sortBy: filters.sortBy,
page: filters.page,
pageSize: filters.pageSize,
});
if (currentToken !== toolsRequestToken) {
return;
}
tools.value = Array.isArray(payload?.list) ? payload.list : [];
pagination.page = Number(payload?.pagination?.page ?? filters.page);
pagination.pageSize = Number(payload?.pagination?.pageSize ?? filters.pageSize);
pagination.total = Number(payload?.pagination?.total ?? 0);
pagination.totalPages = Math.max(1, Number(payload?.pagination?.totalPages ?? 1));
} catch (error) {
if (currentToken !== toolsRequestToken) {
return;
}
tools.value = [];
pagination.total = 0;
pagination.totalPages = 1;
showToast(getApiErrorMessage(error));
} finally {
if (currentToken === toolsRequestToken) {
loadingTools.value = false;
}
}
}
async function loadMeta() {
loadingMeta.value = true;
try {
const [categoryData, keywordData, overviewData] = await Promise.all([
fetchCategories(),
fetchHotKeywords(),
fetchOverview(),
]);
categories.value = Array.isArray(categoryData) ? categoryData : [];
hotKeywords.value = Array.isArray(keywordData) ? keywordData : [];
overview.toolTotal = Number(overviewData?.toolTotal ?? 0);
overview.categoryTotal = Number(overviewData?.categoryTotal ?? 0);
overview.downloadTotal = Number(overviewData?.downloadTotal ?? 0);
overview.openTotal = Number(overviewData?.openTotal ?? 0);
} catch (error) {
showToast(`初始化数据失败:${getApiErrorMessage(error)}`);
} finally {
loadingMeta.value = false;
}
}
async function refreshOverview() {
try {
const overviewData = await fetchOverview();
overview.toolTotal = Number(overviewData?.toolTotal ?? 0);
overview.categoryTotal = Number(overviewData?.categoryTotal ?? 0);
overview.downloadTotal = Number(overviewData?.downloadTotal ?? 0);
overview.openTotal = Number(overviewData?.openTotal ?? 0);
} catch {
// Keep existing overview values to avoid interrupting main flow.
}
}
function onQueryInput() {
filters.page = 1;
clearTimeout(queryTimer);
queryTimer = setTimeout(() => {
loadTools();
}, QUERY_DEBOUNCE_MS);
}
function onCategoryChange() {
filters.page = 1;
loadTools();
}
function selectCategory(categoryId) {
if (filters.category === categoryId) {
return;
}
filters.category = categoryId;
filters.page = 1;
loadTools();
}
function onSortChange() {
filters.page = 1;
loadTools();
}
function applyHotKeyword(keyword) {
filters.query = keyword;
filters.page = 1;
clearTimeout(queryTimer);
loadTools();
}
function resetFilters() {
filters.query = '';
filters.category = 'all';
filters.sortBy = 'latest';
filters.page = 1;
clearTimeout(queryTimer);
loadTools();
}
function changePage(nextPage) {
if (nextPage < 1 || nextPage > pagination.totalPages || nextPage === pagination.page) {
return;
}
filters.page = nextPage;
loadTools();
}
async function openDetailModal(toolId) {
detailModalOpen.value = true;
detailLoading.value = true;
detailError.value = '';
detail.value = null;
try {
const data = await fetchToolDetail(toolId);
detail.value = data;
} catch (error) {
detailError.value = getApiErrorMessage(error);
} finally {
detailLoading.value = false;
}
}
function closeDetailModal() {
detailModalOpen.value = false;
}
function openOverviewModal() {
overviewModalOpen.value = true;
refreshOverview();
}
function closeOverviewModal() {
overviewModalOpen.value = false;
}
function isLaunchDisabled(tool) {
if (launchingId.value === tool.id) {
return true;
}
return tool.accessMode === 'download' && !tool.hasArtifact;
}
function launchButtonText(tool) {
if (launchingId.value === tool.id) {
return '处理中...';
}
if (tool.accessMode === 'web') {
return '打开网页';
}
if (!tool.hasArtifact) {
return '暂无可下载包';
}
return '下载';
}
function toolModeSummary(tool) {
if (tool.accessMode === 'download') {
return `下载 ${formatNumber(tool.downloadCount)}`;
}
return `访问 ${formatNumber(tool.openCount)}`;
}
async function triggerLaunch(tool) {
if (isLaunchDisabled(tool)) {
return;
}
launchingId.value = tool.id;
try {
const result = await launchTool(tool.id, {
channel: 'official',
clientVersion: CLIENT_VERSION,
});
const actionUrl = resolveActionUrl(result?.actionUrl);
if (result?.mode === 'web') {
if (result.openIn === 'same_tab') {
window.location.assign(actionUrl);
return;
}
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
if (!page) {
showToast('浏览器阻止了新窗口,请允许弹窗后重试');
return;
}
showToast(`${tool.name} 已在新标签页打开`);
} else if (result?.mode === 'download') {
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
if (!page) {
window.location.assign(actionUrl);
return;
}
showToast(`${tool.name} 下载任务已创建`);
}
await Promise.all([loadTools(), refreshOverview()]);
} catch (error) {
showToast(getApiErrorMessage(error));
} finally {
launchingId.value = '';
}
}
function updateHeaderScrollState() {
isScrolled.value = window.scrollY > 8;
}
function handleKeydown(event) {
if (event.key !== 'Escape') {
return;
}
if (detailModalOpen.value) {
closeDetailModal();
}
if (overviewModalOpen.value) {
closeOverviewModal();
}
}
onMounted(async () => {
window.addEventListener('scroll', updateHeaderScrollState, { passive: true });
document.addEventListener('keydown', handleKeydown);
updateHeaderScrollState();
await Promise.all([loadMeta(), loadTools()]);
});
onBeforeUnmount(() => {
clearTimeout(queryTimer);
clearTimeout(toastTimer);
window.removeEventListener('scroll', updateHeaderScrollState);
document.removeEventListener('keydown', handleKeydown);
});
</script>

3
client/src/RootApp.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

File diff suppressed because it is too large Load Diff

635
client/src/admin/admin.scss Normal file
View File

@@ -0,0 +1,635 @@
@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 {
min-height: 100vh;
background: #e7eaee;
font-family: "Fira Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #1d2430;
}
.admin-center-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.admin-login-card {
width: min(460px, calc(100% - 16px));
border-radius: 0;
border: 1px solid rgba(160, 172, 192, 0.26);
box-shadow: none;
}
.admin-login-title {
display: flex;
align-items: center;
gap: 12px;
}
.admin-login-logo {
width: 42px;
height: 42px;
border-radius: 0;
display: grid;
place-items: center;
background: linear-gradient(135deg, #2f83ed, #379cff);
color: #fff;
font-family: "Fira Code", monospace;
font-weight: 700;
}
.admin-login-title h2 {
margin: 0;
font-size: 22px;
font-family: "Fira Code", monospace;
}
.admin-login-title span {
display: block;
margin-top: 2px;
font-size: 13px;
color: #697386;
}
.admin-login-alert {
margin-bottom: 16px;
}
.admin-login-btn {
width: 100%;
margin-top: 8px;
}
.dashboard-shell {
width: 100%;
margin: 0;
min-height: 100vh;
border-radius: 0;
border: 1px solid rgba(142, 154, 172, 0.3);
background: #eceef1;
box-shadow: none;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
overflow: hidden;
}
.dashboard-sidebar {
border-right: 1px solid rgba(142, 154, 172, 0.24);
padding: 16px 14px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 14px;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 12px;
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 0;
display: grid;
place-items: center;
background: #2f83ed;
color: #fff;
font-size: 16px;
}
.brand-text {
font-family: "Fira Code", monospace;
font-size: 24px;
color: #566579;
letter-spacing: 0.02em;
}
.sidebar-menu {
display: grid;
align-content: start;
gap: 8px;
}
.menu-item {
width: 100%;
min-height: 42px;
border: 1px solid transparent;
border-radius: 14px;
background: rgba(255, 255, 255, 0.45);
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
color: #4a586b;
cursor: pointer;
transition: background-color 220ms ease, border-color 220ms ease, transform 220ms ease;
}
.menu-item:hover {
background: rgba(255, 255, 255, 0.78);
border-color: rgba(84, 111, 146, 0.24);
transform: translateX(1px);
}
.menu-item.active {
background: #fefefe;
color: #1c2a40;
border-color: rgba(84, 111, 146, 0.28);
box-shadow: none;
}
.menu-icon {
font-size: 16px;
}
.sidebar-footer {
display: grid;
gap: 8px;
}
.sidebar-action {
width: 100%;
}
.sidebar-footer .sidebar-action + .sidebar-action {
margin-left: 0;
}
.dashboard-main {
padding: 16px 18px 18px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 14px;
overflow: auto;
}
.dashboard-main.with-kpi {
grid-template-rows: auto auto minmax(0, 1fr);
}
.dashboard-topbar {
min-height: 56px;
border-radius: 0;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(149, 162, 182, 0.26);
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.topbar-crumb {
display: flex;
align-items: center;
gap: 8px;
color: #7b8596;
font-size: 14px;
}
.topbar-crumb strong {
color: #2c3644;
}
.topbar-crumb i {
color: #b1bac8;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.top-search {
width: 220px;
}
.top-search .el-input__wrapper {
border-radius: 12px;
}
.icon-btn {
background: rgba(255, 255, 255, 0.68);
border: 1px solid rgba(141, 154, 174, 0.28);
color: #4b5a6f;
border-radius: 999px;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.92);
}
.kpi-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.kpi-card {
position: relative;
border-radius: 0;
padding: 16px 18px;
color: #fff;
min-height: 104px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.26);
}
.kpi-card.blue {
background: linear-gradient(135deg, #2c80ec, #46a2ff);
}
.kpi-card.dark {
background: linear-gradient(135deg, #111319, #2f3442);
}
.kpi-card p {
margin: 0;
font-size: 14px;
opacity: 0.9;
}
.kpi-value {
margin-top: 10px;
font-size: 36px;
line-height: 1;
font-family: "Fira Code", monospace;
font-weight: 700;
}
.kpi-delta {
position: absolute;
right: 16px;
bottom: 16px;
font-size: 16px;
font-weight: 600;
}
.kpi-delta.up {
color: rgba(236, 255, 246, 0.96);
}
.kpi-delta.down {
color: rgba(255, 226, 226, 0.96);
}
.dashboard-section {
display: grid;
gap: 12px;
}
.panel {
border-radius: 0;
border: 1px solid rgba(144, 157, 177, 0.28);
background: rgba(255, 255, 255, 0.83);
box-shadow: none;
}
.trend-panel {
padding: 14px 16px 8px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.panel-tabs {
display: flex;
gap: 14px;
}
.tab {
border: none;
border-radius: 10px;
background: transparent;
color: #9299a7;
padding: 6px 2px;
font-size: 19px;
cursor: pointer;
font-family: "Fira Sans", sans-serif;
}
.tab.active {
color: #b067ff;
font-weight: 600;
}
.line-chart-wrap {
margin-top: 10px;
position: relative;
}
.line-chart-wrap svg {
width: 100%;
height: 240px;
display: block;
}
.line-main {
fill: none;
stroke: #be88ff;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.line-dot {
fill: #ffffff;
stroke: #39404a;
stroke-width: 3;
}
.line-months {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
text-align: center;
color: #6b7382;
font-size: 13px;
padding: 0 14px 8px;
}
.mini-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.mini-panel {
padding: 14px 16px;
}
.mini-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.mini-head h3 {
margin: 0;
font-size: 26px;
color: #1f4fb8;
font-family: "Fira Code", monospace;
}
.mini-panel:nth-child(2) .mini-head h3 {
color: #2a9a58;
}
.more-btn {
width: 34px;
height: 34px;
border-radius: 10px;
border: none;
background: #f0f2f6;
color: #808796;
cursor: pointer;
}
.bar-grid {
margin-top: 16px;
min-height: 130px;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
align-items: end;
}
.bar-item {
display: grid;
gap: 8px;
justify-items: center;
}
.bar {
width: 100%;
max-width: 46px;
border-radius: 0;
background: #d6d8dc;
transition: background-color 180ms ease, transform 180ms ease;
}
.bar-item.active .bar {
background: #2f83ed;
transform: translateY(-1px);
}
.bar-item span {
font-size: 13px;
color: #5f6878;
}
.data-panel {
padding: 14px;
}
.data-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.data-head h3 {
margin: 0;
font-size: 20px;
color: #2b3647;
font-family: "Fira Code", monospace;
}
.data-head p {
margin: 6px 0 0;
color: #6c7687;
font-size: 13px;
}
.data-head-actions {
display: flex;
gap: 8px;
}
.data-filters {
margin-top: 14px;
display: grid;
grid-template-columns: 1.2fr 1fr 1fr 1fr auto auto;
gap: 10px;
}
.data-table {
margin-top: 12px;
}
.data-pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.admin-request-body {
margin: 0;
max-height: 300px;
overflow: auto;
padding: 10px;
border-radius: 0;
background: #111827;
color: #d1d5db;
font-size: 12px;
line-height: 1.45;
}
.artifact-alert {
margin-bottom: 12px;
}
.artifact-form {
margin-bottom: 8px;
}
.artifact-file-input {
width: 100%;
border: 1px dashed #b8c1cf;
border-radius: 0;
background: #f8fafc;
padding: 8px 10px;
color: #2b3647;
}
.artifact-file-name {
margin: 6px 0 0;
color: #5f6878;
font-size: 12px;
}
.artifact-actions {
margin-bottom: 12px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
.dashboard-main button:focus-visible,
.dashboard-main [role='button']:focus-visible,
.menu-item:focus-visible {
outline: 2px solid #3779ff;
outline-offset: 2px;
}
@media (max-width: 1280px) {
.dashboard-shell {
grid-template-columns: 188px minmax(0, 1fr);
}
.kpi-value {
font-size: 30px;
}
.tab {
font-size: 16px;
}
.mini-head h3 {
font-size: 22px;
}
.data-filters {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.dashboard-shell {
width: 100%;
margin: 0;
border-radius: 0;
min-height: 100vh;
grid-template-columns: 1fr;
}
.dashboard-sidebar {
border-right: none;
border-bottom: 1px solid rgba(142, 154, 172, 0.24);
grid-template-rows: auto auto;
}
.sidebar-menu {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sidebar-footer {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.kpi-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mini-panels {
grid-template-columns: 1fr;
}
.top-search {
width: 160px;
}
}
@media (max-width: 768px) {
.dashboard-main {
padding: 10px;
}
.dashboard-topbar {
flex-direction: column;
align-items: stretch;
}
.topbar-actions {
width: 100%;
flex-wrap: wrap;
}
.top-search {
width: 100%;
}
.kpi-row {
grid-template-columns: 1fr;
}
.data-filters {
grid-template-columns: 1fr;
}
.data-head {
flex-direction: column;
align-items: stretch;
}
.data-head-actions {
justify-content: flex-start;
}
.data-pager {
justify-content: center;
}
.sidebar-menu {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}

126
client/src/admin/api.js Normal file
View File

@@ -0,0 +1,126 @@
import axios from 'axios';
const baseURL = import.meta.env.VITE_API_BASE || '/api/v1';
const http = axios.create({
baseURL,
timeout: 15000,
});
function unwrap(payload) {
if (payload && typeof payload === 'object' && 'code' in payload) {
if (payload.code !== 0) {
const error = new Error(payload.message || '请求失败');
error.payload = payload;
throw error;
}
return payload.data;
}
return payload;
}
function withToken(token, config = {}) {
return {
...config,
headers: {
...(config.headers || {}),
Authorization: `Bearer ${token}`,
},
};
}
export async function adminLogin(body) {
const response = await http.post('/admin/auth/login', body);
return unwrap(response.data);
}
export async function adminRefresh(refreshToken) {
const response = await http.post('/admin/auth/refresh', { refreshToken });
return unwrap(response.data);
}
export async function adminLogout(token) {
const response = await http.post('/admin/auth/logout', null, withToken(token));
return unwrap(response.data);
}
export async function adminMe(token) {
const response = await http.get('/admin/auth/me', withToken(token));
return unwrap(response.data);
}
export async function adminGetTools(params, token) {
const response = await http.get('/admin/tools', withToken(token, { params }));
return unwrap(response.data);
}
export async function adminCreateTool(payload, token) {
const response = await http.post('/admin/tools', payload, withToken(token));
return unwrap(response.data);
}
export async function adminUpdateTool(id, payload, token) {
const response = await http.patch(`/admin/tools/${id}`, payload, withToken(token));
return unwrap(response.data);
}
export async function adminDeleteTool(id, token) {
const response = await http.delete(`/admin/tools/${id}`, withToken(token));
return unwrap(response.data);
}
export async function adminUpdateToolStatus(id, status, token) {
const response = await http.patch(`/admin/tools/${id}/status`, { status }, withToken(token));
return unwrap(response.data);
}
export async function adminUpdateAccessMode(id, payload, token) {
const response = await http.patch(`/admin/tools/${id}/access-mode`, payload, withToken(token));
return unwrap(response.data);
}
export async function adminGetAuditLogs(params, token) {
const response = await http.get('/admin/audit-logs', withToken(token, { params }));
return unwrap(response.data);
}
export async function adminUploadArtifact(toolId, formData, token) {
const response = await http.post(`/admin/tools/${toolId}/artifacts`, formData, withToken(token));
return unwrap(response.data);
}
export async function adminGetArtifacts(toolId, token) {
const response = await http.get(`/admin/tools/${toolId}/artifacts`, withToken(token));
return unwrap(response.data);
}
export async function adminSetLatestArtifact(toolId, artifactId, token) {
const response = await http.patch(
`/admin/tools/${toolId}/artifacts/${artifactId}/latest`,
{},
withToken(token),
);
return unwrap(response.data);
}
export async function adminUpdateArtifactStatus(toolId, artifactId, status, token) {
const response = await http.patch(
`/admin/tools/${toolId}/artifacts/${artifactId}/status`,
{ status },
withToken(token),
);
return unwrap(response.data);
}
export async function adminDeleteArtifact(toolId, artifactId, token) {
const response = await http.delete(
`/admin/tools/${toolId}/artifacts/${artifactId}`,
withToken(token),
);
return unwrap(response.data);
}
export async function adminGetCategories() {
const response = await http.get('/categories');
return unwrap(response.data);
}

View File

@@ -0,0 +1,49 @@
<template>
<el-dialog v-model="visible" title="更新访问方式" width="520px" destroy-on-close>
<el-form label-width="96px">
<el-form-item label="工具">
<span>{{ modeDialog.name }}</span>
</el-form-item>
<el-form-item label="访问模式">
<el-select v-model="modeDialog.accessMode" style="width: 100%">
<el-option
v-for="item in accessModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="modeDialog.accessMode === 'web'" label="Open URL">
<el-input v-model="modeDialog.openUrl" placeholder="https://example.com" />
</el-form-item>
<el-form-item label="新标签页">
<el-switch v-model="modeDialog.openInNewTab" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="modeDialog.submitting" @click="emit('submit')">确认更新</el-button>
</template>
</el-dialog>
</template>
<script setup>
const visible = defineModel('visible', {
type: Boolean,
default: false,
});
defineProps({
modeDialog: {
type: Object,
required: true,
},
accessModeOptions: {
type: Array,
required: true,
},
});
const emit = defineEmits(['submit']);
</script>

View File

@@ -0,0 +1,110 @@
<template>
<section class="dashboard-section">
<article class="panel data-panel">
<header class="data-head">
<h3>Audit Logs</h3>
<p>查看管理端关键操作记录</p>
</header>
<div class="data-filters">
<el-input
v-model="auditFilters.action"
placeholder="操作动作,例如 tool.update"
clearable
@keyup.enter="emit('search')"
/>
<el-input
v-model="auditFilters.resourceType"
placeholder="资源类型,例如 tool"
clearable
@keyup.enter="emit('search')"
/>
<el-input
v-model="auditFilters.adminUserId"
placeholder="管理员ID"
clearable
@keyup.enter="emit('search')"
/>
<el-button type="primary" :loading="auditLoading" @click="emit('search')">查询</el-button>
<el-button @click="emit('reset')">重置</el-button>
</div>
<el-table
:data="auditLogs"
border
stripe
v-loading="auditLoading"
class="data-table"
>
<el-table-column prop="createdAt" label="时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="action" label="动作" min-width="160" />
<el-table-column prop="resourceType" label="资源类型" width="110" />
<el-table-column prop="resourceId" label="资源ID" min-width="150" />
<el-table-column prop="requestMethod" label="方法" width="90" />
<el-table-column prop="requestPath" label="路径" min-width="180" />
<el-table-column label="管理员" min-width="130">
<template #default="{ row }">
{{ row.adminUser?.displayName || row.adminUser?.username || '-' }}
</template>
</el-table-column>
<el-table-column label="请求体" width="130">
<template #default="{ row }">
<el-popover placement="left" trigger="click" :width="360">
<template #reference>
<el-button size="small">查看</el-button>
</template>
<pre class="admin-request-body">{{ stringifyBody(row.requestBody) }}</pre>
</el-popover>
</template>
</el-table-column>
</el-table>
<div class="data-pager">
<el-pagination
v-model:current-page="auditPagination.page"
v-model:page-size="auditPagination.pageSize"
layout="total, sizes, prev, pager, next"
:page-sizes="[20, 50, 100]"
:total="auditPagination.total"
@current-change="emit('page-change', $event)"
@size-change="emit('size-change', $event)"
/>
</div>
</article>
</section>
</template>
<script setup>
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,29 @@
<template>
<section class="kpi-row">
<article
v-for="card in kpiCards"
:key="card.key"
class="kpi-card"
:class="card.theme"
>
<p>{{ card.label }}</p>
<div class="kpi-value">{{ formatNumber(card.value) }}</div>
<span class="kpi-delta" :class="card.delta >= 0 ? 'up' : 'down'">
{{ card.delta >= 0 ? '+' : '' }}{{ card.delta.toFixed(2) }}%
</span>
</article>
</section>
</template>
<script setup>
defineProps({
kpiCards: {
type: Array,
required: true,
},
formatNumber: {
type: Function,
required: true,
},
});
</script>

View File

@@ -0,0 +1,101 @@
<template>
<section class="dashboard-section">
<section class="panel trend-panel">
<header class="panel-head">
<div class="panel-tabs">
<button type="button" class="tab active">Users</button>
<button type="button" class="tab">Projects</button>
<button type="button" class="tab">Operating Status</button>
</div>
<div class="panel-controls">
<el-select v-model="trendRange" size="small" style="width: 108px">
<el-option label="Week" value="week" />
<el-option label="Month" value="month" />
<el-option label="Quarter" value="quarter" />
</el-select>
</div>
</header>
<div class="line-chart-wrap" aria-hidden="true">
<svg viewBox="0 0 760 220" preserveAspectRatio="none">
<polyline class="line-main" :points="trendPolyline" />
<circle
v-for="point in trendMarkers"
:key="`${point.x}-${point.y}`"
class="line-dot"
:cx="point.x"
:cy="point.y"
r="5.5"
/>
</svg>
<div class="line-months">
<span v-for="month in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']" :key="month">{{ month }}</span>
</div>
</div>
</section>
<section class="mini-panels">
<article class="panel mini-panel">
<header class="mini-head">
<h3>Device Traffic</h3>
<button type="button" class="more-btn">···</button>
</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>
</article>
<article class="panel mini-panel">
<header class="mini-head">
<h3>Location Traffic</h3>
<button type="button" class="more-btn">···</button>
</header>
<div class="bar-grid">
<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>
</article>
</section>
</section>
</template>
<script setup>
defineProps({
trendPolyline: {
type: String,
required: true,
},
trendMarkers: {
type: Array,
required: true,
},
deviceTraffic: {
type: Array,
required: true,
},
locationTraffic: {
type: Array,
required: true,
},
});
const trendRange = defineModel('trendRange', {
type: String,
default: 'week',
});
</script>

View File

@@ -0,0 +1,60 @@
<template>
<aside class="dashboard-sidebar">
<div class="sidebar-brand">
<span class="brand-mark">*</span>
<span class="brand-text">snowui</span>
</div>
<div class="sidebar-menu">
<button
v-for="item in menuItems"
:key="item.key"
type="button"
class="menu-item"
:class="{ active: activeMenu === item.key }"
@click="emit('menu-change', item.key)"
>
<el-icon class="menu-icon">
<component :is="item.icon" />
</el-icon>
<span>{{ item.label }}</span>
</button>
</div>
<div class="sidebar-footer">
<el-button class="sidebar-action" @click="emit('go-public')">返回前台</el-button>
<el-button class="sidebar-action" type="danger" plain @click="emit('logout')">退出</el-button>
</div>
</aside>
</template>
<script setup>
import {
ChatDotRound,
DataAnalysis,
Document,
Management,
Setting,
ShoppingBag,
User,
} from '@element-plus/icons-vue';
defineProps({
activeMenu: {
type: String,
required: true,
},
});
const emit = defineEmits(['menu-change', 'go-public', 'logout']);
const menuItems = [
{ key: 'overview', label: 'Overview', icon: DataAnalysis },
{ key: 'tools', label: 'Tool Management', icon: ShoppingBag },
{ key: 'audit', label: 'Audit Logs', icon: Document },
{ key: 'projects', label: 'Projects', icon: Management },
{ key: 'profile', label: 'User Profile', icon: User },
{ key: 'account', label: 'Account', icon: Setting },
{ key: 'social', label: 'Social', icon: ChatDotRound },
];
</script>

View File

@@ -0,0 +1,188 @@
<template>
<section class="dashboard-section">
<article class="panel data-panel">
<header class="data-head">
<div>
<h3>Tools 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">
<el-input
v-model="toolFilters.query"
placeholder="搜索名称或描述"
clearable
@keyup.enter="emit('search')"
/>
<el-select
v-model="toolFilters.categoryId"
placeholder="全部分类"
clearable
:loading="categoryLoading"
>
<el-option
v-for="item in categories"
:key="item.id"
:label="`${item.name} (${item.toolCount})`"
:value="item.id"
/>
</el-select>
<el-select
v-model="toolFilters.status"
placeholder="全部状态"
clearable
>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="toolFilters.accessMode"
placeholder="全部访问模式"
clearable
>
<el-option
v-for="item in accessModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" :loading="toolLoading" @click="emit('search')">查询</el-button>
<el-button @click="emit('reset')">重置</el-button>
</div>
<el-table
:data="tools"
border
stripe
v-loading="toolLoading"
class="data-table"
>
<el-table-column prop="name" label="工具名称" min-width="180" />
<el-table-column label="分类" min-width="120">
<template #default="{ row }">
{{ row.category?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="访问方式" width="120">
<template #default="{ row }">
<el-tag :type="accessModeTagType(row.accessMode)" effect="light">{{ row.accessMode }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="rating" label="评分" width="80" />
<el-table-column label="下载/访问" width="150">
<template #default="{ row }">
{{ formatNumber(row.downloadCount) }} / {{ formatNumber(row.openCount) }}
</template>
</el-table-column>
<el-table-column label="最近更新" width="120">
<template #default="{ row }">
{{ formatDate(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="390" 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="success" plain @click="emit('artifact', 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="danger" plain @click="emit('delete', row)">删除</el-button>
</el-space>
</template>
</el-table-column>
</el-table>
<div class="data-pager">
<el-pagination
v-model:current-page="toolPagination.page"
v-model:page-size="toolPagination.pageSize"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 30, 50]"
:total="toolPagination.total"
@current-change="emit('page-change', $event)"
@size-change="emit('size-change', $event)"
/>
</div>
</article>
</section>
</template>
<script setup>
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

@@ -0,0 +1,54 @@
<template>
<header class="dashboard-topbar">
<div class="topbar-crumb">
<span>Dashboards</span>
<i>/</i>
<strong>{{ sectionTitle }}</strong>
</div>
<div class="topbar-actions">
<el-input
v-model="search"
class="top-search"
placeholder="Search"
clearable
@keyup.enter="emit('apply-search')"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button class="icon-btn" circle @click="emit('refresh')">
<el-icon><Refresh /></el-icon>
</el-button>
<el-button class="icon-btn" circle @click="emit('open-overview')">
<el-icon><DataAnalysis /></el-icon>
</el-button>
<el-button class="icon-btn" circle @click="emit('open-tools')">
<el-icon><Grid /></el-icon>
</el-button>
<el-button class="icon-btn" circle @click="emit('open-audit')">
<el-icon><Document /></el-icon>
</el-button>
</div>
</header>
</template>
<script setup>
import { DataAnalysis, Document, Grid, Refresh, Search } from '@element-plus/icons-vue';
defineProps({
sectionTitle: {
type: String,
required: true,
},
});
const search = defineModel('search', {
type: String,
default: '',
});
const emit = defineEmits(['apply-search', 'refresh', 'open-overview', 'open-tools', 'open-audit']);
</script>

View File

@@ -0,0 +1,164 @@
<template>
<el-dialog
v-model="visible"
title="工具包上传与维护"
width="860px"
destroy-on-close
>
<el-alert
:title="`当前工具:${artifactDialog.toolName || '-'}`"
type="info"
:closable="false"
class="artifact-alert"
/>
<el-form :model="artifactForm" label-width="96px" class="artifact-form">
<el-form-item label="版本号">
<el-input v-model="artifactForm.version" placeholder="例如1.0.0" />
</el-form-item>
<el-form-item label="发布说明">
<el-input
v-model="artifactForm.releaseNotes"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="可填写本次发布说明"
/>
</el-form-item>
<el-form-item label="设为最新">
<el-switch v-model="artifactForm.isLatest" />
</el-form-item>
<el-form-item label="上传文件">
<input
:key="fileInputKey"
class="artifact-file-input"
type="file"
accept=".zip,.tar.gz,.tgz,.exe,.dmg,.pkg,.msi"
@change="handleFileChange"
/>
<p v-if="artifactForm.fileName" class="artifact-file-name">已选择{{ artifactForm.fileName }}</p>
</el-form-item>
</el-form>
<div class="artifact-actions">
<el-button type="primary" :loading="artifactDialog.uploading" @click="emit('upload')">上传安装包</el-button>
<el-button :loading="artifactDialog.loading" @click="emit('refresh')">刷新列表</el-button>
</div>
<el-table
:data="artifactDialog.artifacts"
border
stripe
v-loading="artifactDialog.loading"
class="data-table"
>
<el-table-column prop="version" label="版本" width="130" />
<el-table-column prop="fileName" label="文件名" min-width="190" />
<el-table-column label="大小" width="110">
<template #default="{ row }">
{{ formatFileSize(row.fileSizeBytes) }}
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="artifactStatusTagType(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="最新" width="90">
<template #default="{ row }">
<el-tag v-if="row.isLatest" type="success" effect="light">latest</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="上传时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-space>
<el-button
v-if="!row.isLatest && row.status === 'active'"
size="small"
type="primary"
plain
:loading="artifactDialog.actionLoadingId === row.id"
@click="emit('set-latest', row)"
>
设为最新
</el-button>
<el-button
v-if="row.status === 'active'"
size="small"
plain
:loading="artifactDialog.actionLoadingId === row.id"
@click="emit('change-status', row, 'deprecated')"
>
标记废弃
</el-button>
<el-button
v-if="row.status === 'deprecated'"
size="small"
plain
:loading="artifactDialog.actionLoadingId === row.id"
@click="emit('change-status', row, 'active')"
>
恢复启用
</el-button>
<el-button
v-if="row.status !== 'deleted'"
size="small"
type="danger"
plain
:loading="artifactDialog.actionLoadingId === row.id"
@click="emit('delete-artifact', row)"
>
删除
</el-button>
</el-space>
</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
<script setup>
const visible = defineModel('visible', {
type: Boolean,
default: false,
});
const emit = defineEmits(['upload', 'refresh', 'file-change', 'set-latest', 'change-status', 'delete-artifact']);
defineProps({
artifactDialog: {
type: Object,
required: true,
},
artifactForm: {
type: Object,
required: true,
},
fileInputKey: {
type: Number,
required: true,
},
formatFileSize: {
type: Function,
required: true,
},
formatDateTime: {
type: Function,
required: true,
},
artifactStatusTagType: {
type: Function,
required: true,
},
});
function handleFileChange(event) {
const file = event?.target?.files?.[0] || null;
emit('file-change', file);
}
</script>

View File

@@ -0,0 +1,43 @@
<template>
<el-dialog v-model="visible" title="更新工具状态" width="420px" destroy-on-close>
<el-form label-width="86px">
<el-form-item label="工具">
<span>{{ statusDialog.name }}</span>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="statusDialog.status" style="width: 100%">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="statusDialog.submitting" @click="emit('submit')">确认更新</el-button>
</template>
</el-dialog>
</template>
<script setup>
const visible = defineModel('visible', {
type: Boolean,
default: false,
});
defineProps({
statusDialog: {
type: Object,
required: true,
},
statusOptions: {
type: Array,
required: true,
},
});
const emit = defineEmits(['submit']);
</script>

View File

@@ -0,0 +1,141 @@
<template>
<el-dialog
v-model="visible"
:title="mode === 'create' ? '新增工具' : '编辑工具'"
width="700px"
destroy-on-close
>
<el-form
ref="toolFormRef"
:model="toolForm"
:rules="toolFormRules"
label-width="92px"
>
<el-form-item label="工具名称" prop="name">
<el-input v-model="toolForm.name" placeholder="例如Dev Helper" maxlength="120" show-word-limit />
</el-form-item>
<el-form-item label="工具分类" prop="categoryId">
<el-select
v-model="toolForm.categoryId"
placeholder="请选择分类"
filterable
style="width: 100%"
:loading="categoryLoading"
>
<el-option
v-for="item in categories"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="工具简介" prop="description">
<el-input
v-model="toolForm.description"
type="textarea"
:rows="4"
maxlength="2000"
show-word-limit
placeholder="请描述工具用途、适用场景与优势"
/>
</el-form-item>
<el-form-item label="评分" prop="rating">
<el-input-number v-model="toolForm.rating" :min="0" :max="5" :step="0.1" :precision="1" />
</el-form-item>
<el-form-item label="功能点">
<el-input
v-model="toolForm.featuresText"
type="textarea"
:rows="4"
placeholder="每行一个功能点,例如&#10;支持离线模式&#10;支持自动更新"
/>
</el-form-item>
<el-form-item label="访问方式" prop="accessMode">
<el-select v-model="toolForm.accessMode" style="width: 100%">
<el-option
v-for="item in accessModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="toolForm.accessMode === 'web'" label="Open URL" prop="openUrl">
<el-input v-model="toolForm.openUrl" placeholder="https://example.com" />
</el-form-item>
<el-form-item label="新标签页">
<el-switch v-model="toolForm.openInNewTab" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="toolForm.status" style="width: 100%">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</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,
},
toolForm: {
type: Object,
required: true,
},
toolFormRules: {
type: Object,
required: true,
},
categories: {
type: Array,
required: true,
},
categoryLoading: {
type: Boolean,
required: true,
},
accessModeOptions: {
type: Array,
required: true,
},
statusOptions: {
type: Array,
required: true,
},
submitting: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['submit']);
const toolFormRef = ref(null);
defineExpose({
validate: () => toolFormRef.value?.validate?.(),
clearValidate: () => toolFormRef.value?.clearValidate?.(),
});
</script>

View File

@@ -0,0 +1,21 @@
import { createRouter, createWebHistory } from 'vue-router';
import PublicApp from '../App.vue';
import AdminApp from './AdminApp.vue';
const routes = [
{
path: '/',
name: 'public-home',
component: PublicApp,
},
{
path: '/admin',
name: 'admin-home',
component: AdminApp,
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});

View File

@@ -0,0 +1,141 @@
import { defineStore } from 'pinia';
import { adminLogin, adminLogout, adminMe, adminRefresh } from '../api';
const STORAGE_KEY = 'toolsshow_admin_auth';
function parseStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
return JSON.parse(raw);
} catch {
return null;
}
}
export const useAdminAuthStore = defineStore('admin-auth', {
state: () => ({
accessToken: '',
refreshToken: '',
expiresIn: 0,
profile: null,
initialized: false,
bootstrapping: false,
loggingIn: false,
}),
getters: {
isAuthenticated(state) {
return Boolean(state.accessToken && state.profile?.id);
},
},
actions: {
persist() {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
expiresIn: this.expiresIn,
profile: this.profile,
}),
);
},
hydrate() {
const stored = parseStorage();
if (!stored) {
return;
}
this.accessToken = stored.accessToken || '';
this.refreshToken = stored.refreshToken || '';
this.expiresIn = Number(stored.expiresIn || 0);
this.profile = stored.profile || null;
},
setTokens(data) {
this.accessToken = data.accessToken || '';
this.refreshToken = data.refreshToken || this.refreshToken;
this.expiresIn = Number(data.expiresIn || 0);
this.persist();
},
clearSession() {
this.accessToken = '';
this.refreshToken = '';
this.expiresIn = 0;
this.profile = null;
localStorage.removeItem(STORAGE_KEY);
},
async initialize() {
if (this.initialized) {
return;
}
this.hydrate();
this.bootstrapping = true;
try {
if (!this.accessToken) {
return;
}
await this.fetchMe();
} catch {
try {
if (this.refreshToken) {
await this.refreshAccessToken();
await this.fetchMe();
} else {
this.clearSession();
}
} catch {
this.clearSession();
}
} finally {
this.initialized = true;
this.bootstrapping = false;
}
},
async login(payload) {
this.loggingIn = true;
try {
const data = await adminLogin(payload);
this.accessToken = data.accessToken || '';
this.refreshToken = data.refreshToken || '';
this.expiresIn = Number(data.expiresIn || 0);
this.profile = data.profile || null;
this.persist();
if (!this.profile) {
await this.fetchMe();
}
} finally {
this.loggingIn = false;
}
},
async refreshAccessToken() {
if (!this.refreshToken) {
throw new Error('refresh token not available');
}
const data = await adminRefresh(this.refreshToken);
this.setTokens(data);
return this.accessToken;
},
async fetchMe() {
if (!this.accessToken) {
throw new Error('access token not available');
}
const data = await adminMe(this.accessToken);
this.profile = data;
this.persist();
return data;
},
async logout() {
try {
if (this.accessToken) {
await adminLogout(this.accessToken);
}
} finally {
this.clearSession();
}
},
},
});

View File

@@ -0,0 +1,164 @@
import { defineStore } from 'pinia';
import {
adminCreateTool,
adminDeleteArtifact,
adminDeleteTool,
adminGetArtifacts,
adminGetAuditLogs,
adminGetCategories,
adminGetTools,
adminSetLatestArtifact,
adminUpdateArtifactStatus,
adminUpdateTool,
adminUpdateAccessMode,
adminUpdateToolStatus,
adminUploadArtifact,
} from '../api';
export const useAdminConsoleStore = defineStore('admin-console', {
state: () => ({
categories: [],
categoryLoading: false,
toolFilters: {
query: '',
categoryId: '',
status: '',
accessMode: '',
page: 1,
pageSize: 10,
},
tools: [],
toolLoading: false,
toolPagination: {
page: 1,
pageSize: 10,
total: 0,
totalPages: 1,
},
auditFilters: {
action: '',
resourceType: '',
adminUserId: '',
page: 1,
pageSize: 20,
},
auditLogs: [],
auditLoading: false,
auditPagination: {
page: 1,
pageSize: 20,
total: 0,
totalPages: 1,
},
}),
actions: {
async loadCategories() {
this.categoryLoading = true;
try {
const data = await adminGetCategories();
this.categories = Array.isArray(data) ? data : [];
} finally {
this.categoryLoading = false;
}
},
setToolPage(page) {
this.toolFilters.page = page;
},
resetToolFilters() {
this.toolFilters.query = '';
this.toolFilters.categoryId = '';
this.toolFilters.status = '';
this.toolFilters.accessMode = '';
this.toolFilters.page = 1;
},
async loadTools(token) {
this.toolLoading = true;
try {
const data = await adminGetTools(
{
query: this.toolFilters.query || undefined,
categoryId: this.toolFilters.categoryId || undefined,
status: this.toolFilters.status || undefined,
accessMode: this.toolFilters.accessMode || undefined,
page: this.toolFilters.page,
pageSize: this.toolFilters.pageSize,
},
token,
);
this.tools = Array.isArray(data?.list) ? data.list : [];
this.toolPagination.page = Number(data?.pagination?.page ?? this.toolFilters.page);
this.toolPagination.pageSize = Number(data?.pagination?.pageSize ?? this.toolFilters.pageSize);
this.toolPagination.total = Number(data?.pagination?.total ?? 0);
this.toolPagination.totalPages = Math.max(1, Number(data?.pagination?.totalPages ?? 1));
} finally {
this.toolLoading = false;
}
},
async updateToolStatus(id, status, token) {
await adminUpdateToolStatus(id, status, token);
},
async createTool(payload, token) {
return adminCreateTool(payload, token);
},
async updateTool(id, payload, token) {
return adminUpdateTool(id, payload, token);
},
async deleteTool(id, token) {
return adminDeleteTool(id, token);
},
async updateAccessMode(id, payload, token) {
await adminUpdateAccessMode(id, payload, token);
},
async uploadArtifact(toolId, formData, token) {
return adminUploadArtifact(toolId, formData, token);
},
async getArtifacts(toolId, token) {
return adminGetArtifacts(toolId, token);
},
async setLatestArtifact(toolId, artifactId, token) {
return adminSetLatestArtifact(toolId, artifactId, token);
},
async updateArtifactStatus(toolId, artifactId, status, token) {
return adminUpdateArtifactStatus(toolId, artifactId, status, token);
},
async deleteArtifact(toolId, artifactId, token) {
return adminDeleteArtifact(toolId, artifactId, token);
},
setAuditPage(page) {
this.auditFilters.page = page;
},
resetAuditFilters() {
this.auditFilters.action = '';
this.auditFilters.resourceType = '';
this.auditFilters.adminUserId = '';
this.auditFilters.page = 1;
},
async loadAuditLogs(token) {
this.auditLoading = true;
try {
const data = await adminGetAuditLogs(
{
action: this.auditFilters.action || undefined,
resourceType: this.auditFilters.resourceType || undefined,
adminUserId: this.auditFilters.adminUserId || undefined,
page: this.auditFilters.page,
pageSize: this.auditFilters.pageSize,
},
token,
);
this.auditLogs = Array.isArray(data?.list) ? data.list : [];
this.auditPagination.page = Number(data?.pagination?.page ?? this.auditFilters.page);
this.auditPagination.pageSize = Number(data?.pagination?.pageSize ?? this.auditFilters.pageSize);
this.auditPagination.total = Number(data?.pagination?.total ?? 0);
this.auditPagination.totalPages = Math.max(1, Number(data?.pagination?.totalPages ?? 1));
} finally {
this.auditLoading = false;
}
},
},
});

87
client/src/api.js Normal file
View File

@@ -0,0 +1,87 @@
import axios from 'axios';
const baseURL = import.meta.env.VITE_API_BASE || '/api/v1';
const apiOrigin = /^https?:\/\//.test(baseURL) ? new URL(baseURL).origin : window.location.origin;
const http = axios.create({
baseURL,
timeout: 15000,
});
function unwrap(payload) {
if (payload && typeof payload === 'object' && 'code' in payload) {
if (payload.code !== 0) {
throw new Error(payload.message || '请求失败');
}
return payload.data;
}
return payload;
}
export async function apiGet(url, config = {}) {
const response = await http.get(url, config);
return unwrap(response.data);
}
export async function apiPost(url, data, config = {}) {
const response = await http.post(url, data, config);
return unwrap(response.data);
}
export function fetchTools(params) {
return apiGet('/tools', { params });
}
export function fetchToolDetail(id) {
return apiGet(`/tools/${id}`);
}
export function fetchCategories() {
return apiGet('/categories');
}
export function fetchHotKeywords() {
return apiGet('/keywords/hot');
}
export function fetchOverview() {
return apiGet('/overview');
}
export function launchTool(id, payload) {
return apiPost(`/tools/${id}/launch`, payload);
}
export function resolveActionUrl(actionUrl) {
if (!actionUrl) {
return '';
}
if (/^https?:\/\//.test(actionUrl)) {
return actionUrl;
}
return new URL(actionUrl, apiOrigin).toString();
}
export function getApiErrorMessage(error) {
if (axios.isAxiosError(error)) {
const data = error.response?.data;
if (data && typeof data === 'object' && 'message' in data && data.message) {
if (Array.isArray(data.message)) {
return data.message.join('; ');
}
return String(data.message);
}
if (error.message) {
return error.message;
}
}
if (error instanceof Error) {
return error.message;
}
return '请求失败,请稍后重试';
}

14
client/src/main.js Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import RootApp from './RootApp.vue';
import { router } from './admin/router';
import './style.scss';
import './admin/admin.scss';
const app = createApp(RootApp);
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
app.mount('#app');

861
client/src/style.scss Normal file
View File

@@ -0,0 +1,861 @@
: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);
}
.hot-empty {
font-size: 13px;
}
.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);
}
.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-skeleton {
pointer-events: none;
animation: none;
opacity: 1;
transform: none;
}
.skeleton-line {
width: 100%;
height: 10px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(226, 240, 245, 0.9), rgba(241, 249, 252, 0.96), rgba(226, 240, 245, 0.9));
background-size: 200% 100%;
animation: shimmer 1.2s linear infinite;
}
.skeleton-chip {
width: 40%;
}
.skeleton-title {
width: 62%;
height: 18px;
}
.skeleton-btn {
width: 48%;
height: 32px;
margin-top: auto;
}
.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-small:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.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);
}
.modal-muted {
color: var(--muted);
}
.modal-error {
border: 1px solid rgba(220, 38, 38, 0.22);
background: rgba(254, 242, 242, 0.82);
color: #b91c1c;
border-radius: var(--radius-sm);
padding: 10px 12px;
margin: 10px 0;
}
.inline-link {
color: var(--primary-strong);
word-break: break-all;
}
.inline-link:hover {
text-decoration: underline;
}
.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);
}
.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;
}
.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 shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}

15
client/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});

View File

@@ -0,0 +1,141 @@
# ToolsShow 项目 Docker 运行指南(仅 `docker run`
本文档只针对当前项目,使用 `docker build` + `docker run` 启动,不使用 Compose。
## 1. 前提
- 已安装 Docker
- 在项目根目录执行命令:`C:/Users/User/WebstormProjects/ToolsShow`
先验证 Docker
```bash
docker version
```
## 2. 准备环境变量文件
本项目后端需要 `.env`。推荐直接基于模板创建:
```bash
cp server/.env.example server/.env
```
Windows PowerShell 可用:
```powershell
Copy-Item server/.env.example server/.env
```
至少确认以下项存在(`server/.env`
```env
PORT=3000
DATABASE_URL="file:./dev.db"
JWT_ACCESS_SECRET=change_this_access_secret
JWT_REFRESH_SECRET=change_this_refresh_secret
```
说明:
- 当前项目 Prisma 使用 `SQLite``server/prisma/schema.prisma`)。
- `DATABASE_URL="file:./dev.db"` 对应数据库文件在容器内路径 `/app/server/prisma/dev.db`
## 3. 构建镜像
在项目根目录执行:
```bash
docker build -t toolsshow:latest .
```
## 4. 启动容器(只用 docker run
### 4.1 最小启动命令
```bash
docker run -d \
--name toolsshow-app \
-p 3000:3000 \
--env-file ./server/.env \
toolsshow:latest
```
### 4.2 推荐启动命令(带 SQLite 持久化)
建议挂载 `server/prisma`,保证数据库文件重建容器后仍保留:
```bash
docker run -d \
--name toolsshow-app \
-p 3000:3000 \
--env-file ./server/.env \
-v toolsshow_prisma:/app/server/prisma \
toolsshow:latest
```
说明:
- 容器启动命令已在 `Dockerfile` 中定义:`npx prisma migrate deploy && node dist/main`
- 首次启动会自动执行数据库迁移。
## 5. 常用运维命令
查看运行状态:
```bash
docker ps
```
查看日志:
```bash
docker logs -f toolsshow-app
```
停止/启动容器:
```bash
docker stop toolsshow-app
docker start toolsshow-app
```
删除容器(不会删除命名卷):
```bash
docker rm -f toolsshow-app
```
查看卷:
```bash
docker volume ls
```
删除 SQLite 数据卷(危险操作,会清数据):
```bash
docker volume rm toolsshow_prisma
```
## 6. 访问地址
- 应用地址:`http://localhost:3000`
- 若启动后无法访问,请先看日志:`docker logs toolsshow-app`
## 7. 一次性复制命令(推荐)
```bash
docker build -t toolsshow:latest . && \
docker rm -f toolsshow-app 2>/dev/null || true && \
docker run -d --name toolsshow-app -p 3000:3000 --env-file ./server/.env -v toolsshow_prisma:/app/server/prisma toolsshow:latest
```
PowerShell 对应写法:
```powershell
docker build -t toolsshow:latest .
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
```

View File

@@ -0,0 +1,746 @@
# ToolsShow NestJS Backend Design (v1.3 - Hybrid Access: Web + Download)
## 1. Overview
Current project is a static frontend (`index.html + app.js`) with in-memory tool data.
A new business constraint is introduced:
- some tools are opened directly via web URL (no download)
- some tools still require package download
This design keeps SQLite + self-hosted GitLab storage, and upgrades backend to support both access modes in one unified model.
## 2. Scope
### 2.1 In Scope (v1.3)
- Public APIs for tools/categories/keywords/overview
- Hybrid tool access:
- `web` mode: open target URL
- `download` mode: ticket + GitLab-backed file stream
- Admin backend APIs:
- admin login/logout/token refresh
- tool/category/tag/keyword management
- tool access-mode management
- artifact upload/version management for download-mode tools
- audit log query
- SQLite schema design and migration plan
- GitLab integration design for artifact upload/download
### 2.2 Out of Scope (v1.3)
- Multi-tenant architecture
- Fine-grained role/permission system (all active admins share capability)
- Recommendation engine
## 3. Tech Stack Selection
| Category | Selection | Reason |
|---|---|---|
| Runtime | Node.js 20 LTS | stable NestJS ecosystem |
| Framework | NestJS + TypeScript | modular architecture + DI |
| ORM | Prisma | schema/migration/type-safe client |
| Database | SQLite (`dev.db`) | low ops overhead and enough for current scale |
| File Storage | Self-hosted GitLab (Generic Package Registry) | artifact versioning and centralized storage |
| Auth | JWT (admin only) | simple and mature |
| Validation | `class-validator` + `class-transformer` | DTO safety |
| API Docs | Swagger | clear FE/BE contract |
| Logging | `pino` (`nestjs-pino`) | structured logging |
### 3.1 SQLite Decisions
- Enable WAL mode (`PRAGMA journal_mode=WAL`) for read/write concurrency.
- Use one writable instance in v1 to reduce lock contention.
- Use `TEXT` ids for flexible business identifiers.
- Use `DATETIME` and ISO-8601 API serialization.
### 3.2 Access Mode Strategy
- `access_mode = web`:
- tool is opened by URL
- no artifact required
- `access_mode = download`:
- tool requires at least one active artifact
- artifact stored in GitLab
Publish constraints:
- tool can be `published` only when mode requirements are satisfied.
## 4. Architecture Design
### 4.1 Layered Structure
- Controller Layer: route + DTO validation + response mapping
- Application Layer: use-case orchestration (query, launch, upload, download)
- Domain Layer: mode constraints, publish constraints, version constraints
- Infrastructure Layer: Prisma repository, GitLab client, cache, auth, logging
### 4.2 Module Breakdown
| Module | Responsibility | Depends On |
|---|---|---|
| `ToolModule` | public tool list/detail/search | Prisma, Cache |
| `CategoryModule` | category list + count | Prisma, Cache |
| `KeywordModule` | hot keywords | Prisma |
| `OverviewModule` | KPI aggregation | Prisma, Cache |
| `AccessModule` | unified launch entry for web/download modes | Prisma, GitlabStorage |
| `DownloadModule` | consume download ticket and stream package | Prisma, GitlabStorage |
| `ArtifactModule` | artifact metadata query | Prisma |
| `GitlabStorageModule` | GitLab upload/download encapsulation | HTTP client, Config |
| `AdminAuthModule` | admin auth | Prisma, JWT |
| `AdminToolModule` | tool CRUD + access mode setup | Prisma, AdminAuth |
| `AdminArtifactModule` | artifact upload/version management | Prisma, GitlabStorage |
| `AdminCategoryModule` | category CRUD/reorder | Prisma, AdminAuth |
| `AdminTagModule` | tag CRUD/binding | Prisma, AdminAuth |
| `AdminKeywordModule` | keyword management | Prisma, AdminAuth |
| `AdminUserModule` | admin user management | Prisma, AdminAuth |
| `AdminAuditModule` | audit log query | Prisma, AdminAuth |
| `HealthModule` | liveness/readiness | DB/GitLab |
### 4.3 Unified Launch Flow
1. Frontend calls `POST /tools/:id/launch`.
2. Backend checks `access_mode`:
- `web`: return target URL and record open event
- `download`: create short-lived ticket and return download URL
3. Frontend follows returned action URL.
4. For download mode, `GET /downloads/:ticket` streams file from GitLab.
## 5. API Contract
Base path: `/api/v1`
### 5.1 Unified Response Format
```json
{
"code": 0,
"message": "ok",
"data": {},
"traceId": "7f9b4c8f-3fdf-4f9f-9d2c-8d969ad4c5f1",
"timestamp": "2026-03-26T10:10:00.000Z"
}
```
### 5.2 Public APIs
#### 1) Query tools
- `GET /tools`
- Query:
- `query` (optional)
- `category` (optional, default `all`)
- `sortBy` (`popular|latest|rating|name`)
- `page` (default `1`)
- `pageSize` (default `6`, max `50`)
- Each tool includes:
- `accessMode`: `web | download`
- `openUrl` (nullable; present in `web` mode)
- `hasArtifact` (boolean; meaningful in `download` mode)
#### 2) Tool detail
- `GET /tools/:id`
- Returns mode-specific usage hints:
- `web`: `openUrl`
- `download`: `latestVersion`, `fileSize`, `downloadReady`
#### 3) Category list
- `GET /categories`
#### 4) Hot keywords
- `GET /keywords/hot`
#### 5) Site overview KPI
- `GET /overview`
- Includes:
- `toolTotal`
- `categoryTotal`
- `downloadTotal`
- `openTotal`
### 5.3 Public Launch + Download APIs
#### 1) Unified launch endpoint
- `POST /tools/:id/launch`
- Body (optional):
```json
{
"channel": "official",
"clientVersion": "web-1.0.0"
}
```
- Web mode response example:
```json
{
"mode": "web",
"actionUrl": "https://example-tool.com/app",
"openIn": "new_tab"
}
```
- Download mode response example:
```json
{
"mode": "download",
"ticket": "dl_tk_7f8a2b...",
"expiresInSec": 120,
"actionUrl": "/api/v1/downloads/dl_tk_7f8a2b..."
}
```
#### 2) Consume download ticket
- `GET /downloads/:ticket`
- Behavior:
- validate ticket and expiration
- resolve artifact metadata
- stream file from GitLab
- write `download_records` and increment download counters
### 5.4 Admin Auth APIs
All admin APIs use `/admin` prefix.
- `POST /admin/auth/login`
- `POST /admin/auth/refresh`
- `POST /admin/auth/logout`
- `GET /admin/auth/me`
Login response example:
```json
{
"accessToken": "jwt-access-token",
"refreshToken": "jwt-refresh-token",
"expiresIn": 7200,
"profile": {
"id": "u_admin_001",
"username": "admin",
"displayName": "System Admin"
}
}
```
### 5.5 Admin Tool APIs
- `GET /admin/tools`
- `POST /admin/tools`
- `GET /admin/tools/:id`
- `PATCH /admin/tools/:id`
- `PATCH /admin/tools/:id/status`
- `PATCH /admin/tools/:id/access-mode`
- `DELETE /admin/tools/:id` (soft delete)
`POST/PATCH /admin/tools` request core fields:
- `name`
- `categoryId`
- `description`
- `tags`
- `features`
- `accessMode` (`web|download`)
- `openUrl` (required when `accessMode=web`)
### 5.6 Admin Artifact APIs (Download Mode Only)
#### 1) Upload artifact file
- `POST /admin/tools/:id/artifacts`
- Content-Type: `multipart/form-data`
- Form fields:
- `file` (required)
- `version` (required)
- `releaseNotes` (optional)
- `isLatest` (optional, default `true`)
Validation:
- tool must be `accessMode=download`
- version must be unique within tool
- file type/size must pass policy
#### 2) List tool artifacts
- `GET /admin/tools/:id/artifacts`
#### 3) Set latest artifact
- `PATCH /admin/tools/:id/artifacts/:artifactId/latest`
#### 4) Deprecate artifact
- `PATCH /admin/tools/:id/artifacts/:artifactId/status`
#### 5) Delete artifact metadata
- `DELETE /admin/tools/:id/artifacts/:artifactId`
### 5.7 Admin Taxonomy APIs
- `GET /admin/categories`
- `POST /admin/categories`
- `PATCH /admin/categories/:id`
- `DELETE /admin/categories/:id`
- `PATCH /admin/categories/reorder`
- `GET /admin/tags`
- `POST /admin/tags`
- `PATCH /admin/tags/:id`
- `DELETE /admin/tags/:id`
- `GET /admin/keywords/hot`
- `PUT /admin/keywords/hot`
### 5.8 Admin User and Audit APIs
- `GET /admin/users`
- `POST /admin/users`
- `PATCH /admin/users/:id`
- `PATCH /admin/users/:id/status`
- `GET /admin/audit-logs`
### 5.9 Error Codes
| Code | Meaning |
|---|---|
| `1001` | validation failed |
| `1002` | unauthorized |
| `1003` | forbidden |
| `1004` | resource not found |
| `1005` | conflict (duplicate/version conflict) |
| `1010` | invalid credentials |
| `1011` | token invalid/expired |
| `1201` | GitLab upload failed |
| `1202` | GitLab download failed |
| `1203` | artifact not available |
| `1204` | download ticket invalid/expired |
| `1210` | tool access mode mismatch |
| `1211` | web open URL not configured |
| `1500` | internal server error |
## 6. Data Model Design (SQLite)
SQLite file: `server/prisma/dev.db`
### 6.1 Table: `tools`
| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` | TEXT | PK | business id (`tool_1`) |
| `name` | TEXT | not null | tool name |
| `slug` | TEXT | unique | URL-friendly id |
| `category_id` | TEXT | FK -> categories.id | category |
| `description` | TEXT | not null | summary |
| `rating` | REAL | check 0~5 | score |
| `download_count` | INTEGER | default 0 | successful downloads |
| `open_count` | INTEGER | default 0 | successful web opens |
| `access_mode` | TEXT | not null default `download` | `web|download` |
| `open_url` | TEXT | nullable | target URL for web mode |
| `open_in_new_tab` | INTEGER | default 1 | 1/0 |
| `latest_artifact_id` | TEXT | nullable | FK -> tool_artifacts.id |
| `status` | TEXT | default `draft` | `draft/published/archived` |
| `updated_at` | TEXT | not null | `YYYY-MM-DD` |
| `is_deleted` | INTEGER | default 0 | soft delete flag |
| `created_at` | DATETIME | default current_timestamp | created time |
| `modified_at` | DATETIME | default current_timestamp | updated time |
Recommended checks:
- `access_mode IN ('web','download')`
- `status IN ('draft','published','archived')`
- `access_mode != 'web' OR open_url IS NOT NULL`
Indexes:
- `idx_tools_category_id`
- `idx_tools_status`
- `idx_tools_access_mode`
- `idx_tools_download_count`
- `idx_tools_open_count`
- `idx_tools_updated_at`
- `idx_tools_rating`
- `idx_tools_name`
### 6.2 Table: `tool_artifacts`
| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` | TEXT | PK | artifact id |
| `tool_id` | TEXT | FK -> tools.id | owner tool |
| `version` | TEXT | not null | version |
| `file_name` | TEXT | not null | package filename |
| `file_size_bytes` | INTEGER | not null | size |
| `sha256` | TEXT | not null | checksum |
| `mime_type` | TEXT | nullable | content type |
| `gitlab_project_id` | INTEGER | not null | GitLab project id |
| `gitlab_package_name` | TEXT | not null | package path segment |
| `gitlab_package_version` | TEXT | not null | usually equals `version` |
| `gitlab_file_path` | TEXT | not null | package file path |
| `status` | TEXT | default `active` | `active/deprecated/deleted` |
| `release_notes` | TEXT | nullable | release notes |
| `uploaded_by` | TEXT | FK -> admin_users.id | operator |
| `created_at` | DATETIME | default current_timestamp | upload time |
Unique / Index:
- `uk_tool_version (tool_id, version)`
- `idx_artifact_tool_id`
- `idx_artifact_status`
### 6.3 Table: `categories`
| Field | Type | Constraint |
|---|---|---|
| `id` | TEXT | PK |
| `name` | TEXT | unique, not null |
| `sort_order` | INTEGER | default 100 |
| `is_deleted` | INTEGER | default 0 |
### 6.4 Table: `tags`
| Field | Type | Constraint |
|---|---|---|
| `id` | TEXT | PK |
| `name` | TEXT | unique, not null |
| `is_deleted` | INTEGER | default 0 |
### 6.5 Table: `tool_tags`
| Field | Type | Constraint |
|---|---|---|
| `tool_id` | TEXT | FK -> tools.id |
| `tag_id` | TEXT | FK -> tags.id |
| `(tool_id, tag_id)` | - | composite PK |
### 6.6 Table: `tool_features`
| Field | Type | Constraint |
|---|---|---|
| `id` | TEXT | PK |
| `tool_id` | TEXT | FK -> tools.id |
| `feature_text` | TEXT | not null |
| `sort_order` | INTEGER | default 100 |
### 6.7 Table: `hot_keywords`
| Field | Type | Constraint |
|---|---|---|
| `id` | TEXT | PK |
| `keyword` | TEXT | unique, not null |
| `sort_order` | INTEGER | default 100 |
| `is_active` | INTEGER | default 1 |
### 6.8 Table: `download_tickets`
| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` | INTEGER | PK AUTOINCREMENT | internal id |
| `ticket` | TEXT | unique, not null | public token |
| `tool_id` | TEXT | FK -> tools.id | target tool |
| `artifact_id` | TEXT | FK -> tool_artifacts.id | target artifact |
| `channel` | TEXT | nullable | source |
| `client_version` | TEXT | nullable | app version |
| `request_ip` | TEXT | nullable | requester ip |
| `expires_at` | DATETIME | not null | expiry |
| `consumed_at` | DATETIME | nullable | consume time |
| `created_at` | DATETIME | default current_timestamp | create time |
### 6.9 Table: `download_records`
| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` | INTEGER | PK AUTOINCREMENT | record id |
| `tool_id` | TEXT | FK -> tools.id | downloaded tool |
| `artifact_id` | TEXT | FK -> tool_artifacts.id | downloaded artifact |
| `ticket` | TEXT | nullable | download ticket |
| `downloaded_at` | DATETIME | default current_timestamp | event time |
| `client_ip` | TEXT | nullable | requester ip |
| `user_agent` | TEXT | nullable | requester ua |
| `channel` | TEXT | nullable | source |
| `client_version` | TEXT | nullable | frontend version |
| `status` | TEXT | default `success` | `success/failed` |
| `error_message` | TEXT | nullable | failure reason |
### 6.10 Table: `open_records`
| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` | INTEGER | PK AUTOINCREMENT | record id |
| `tool_id` | TEXT | FK -> tools.id | opened tool |
| `opened_at` | DATETIME | default current_timestamp | event time |
| `client_ip` | TEXT | nullable | requester ip |
| `user_agent` | TEXT | nullable | requester ua |
| `channel` | TEXT | nullable | source |
| `client_version` | TEXT | nullable | frontend version |
| `referer` | TEXT | nullable | referer URL |
### 6.11 Table: `admin_users`
| Field | Type | Constraint |
|---|---|---|
| `id` | TEXT | PK |
| `username` | TEXT | unique, not null |
| `password_hash` | TEXT | not null |
| `display_name` | TEXT | nullable |
| `status` | TEXT | default `active` |
| `last_login_at` | DATETIME | nullable |
| `created_at` | DATETIME | default current_timestamp |
| `modified_at` | DATETIME | default current_timestamp |
### 6.12 Table: `admin_audit_logs`
| Field | Type | Constraint | Description |
|---|---|---|---|
| `id` | INTEGER | PK AUTOINCREMENT | log id |
| `admin_user_id` | TEXT | FK -> admin_users.id | operator |
| `action` | TEXT | not null | e.g. `artifact.upload` |
| `resource_type` | TEXT | not null | `tool/artifact/category` |
| `resource_id` | TEXT | nullable | target id |
| `request_method` | TEXT | not null | `POST/PATCH/DELETE` |
| `request_path` | TEXT | not null | route path |
| `request_body` | TEXT | nullable | masked json |
| `ip` | TEXT | nullable | operator ip |
| `user_agent` | TEXT | nullable | operator ua |
| `created_at` | DATETIME | default current_timestamp | op time |
## 7. Access Mode Business Rules
### 7.1 Rule Matrix
| Scenario | Required Fields | Allowed Operations |
|---|---|---|
| `access_mode=web` | `open_url` | launch as URL open, no artifact upload required |
| `access_mode=download` | at least one `active` artifact | launch as download ticket |
### 7.2 Publish Validation
Tool can be published only when:
- common fields valid (`name/category/description`)
- if `web` mode: `open_url` is valid URL
- if `download` mode: has active `latest_artifact_id`
### 7.3 Mode Switch Rules
- `web -> download`:
- must upload at least one artifact before publish
- `download -> web`:
- `open_url` required
- existing artifacts can be retained for history but not used in launch path
## 8. GitLab Upload/Download Design
### 8.1 Required Environment Variables
- `GITLAB_BASE_URL` (e.g. `https://gitlab.company.local`)
- `GITLAB_API_BASE` (e.g. `https://gitlab.company.local/api/v4`)
- `GITLAB_PROJECT_ID`
- `GITLAB_TOKEN` (PAT/Project Access Token/Deploy Token)
- `GITLAB_PACKAGE_NAME_PREFIX` (default `toolsshow`)
- `DOWNLOAD_TICKET_TTL_SEC` (default `120`)
- `UPLOAD_MAX_SIZE_MB` (default `512`)
### 8.2 Upload Flow (Download Mode Only)
1. Admin calls `POST /admin/tools/:id/artifacts` with file + version.
2. Backend validates tool mode is `download`.
3. Backend validates file policy and version uniqueness.
4. Backend computes SHA-256 checksum.
5. Backend uploads file to GitLab Generic Package Registry:
- `PUT /projects/:id/packages/generic/:packageName/:version/:fileName`
6. Backend stores artifact metadata in SQLite.
7. Optionally sets this artifact as latest.
8. Writes admin audit log.
### 8.3 Download Flow
1. Client calls `POST /tools/:id/launch`.
2. For download mode, backend creates ticket and returns `actionUrl`.
3. Client calls `GET /downloads/:ticket`.
4. Backend validates ticket and streams file from GitLab.
5. Backend writes `download_records` and increments `download_count`.
### 8.4 Web Open Flow
1. Client calls `POST /tools/:id/launch`.
2. For web mode, backend returns `open_url`.
3. Backend writes `open_records` and increments `open_count`.
4. Frontend opens URL in browser.
## 9. Admin Backend Design (No Roles)
### 9.1 Capability List
| Capability | Description |
|---|---|
| Dashboard | view KPI and trends |
| Tool Management | create/edit/publish/archive/delete tools |
| Access Mode Management | configure web/download mode and constraints |
| Artifact Management | upload and maintain versions for download tools |
| Category/Tag/Keyword | maintain taxonomy and hot keywords |
| Admin User Management | create/disable admin accounts |
| Audit Logs | query write-operation logs |
### 9.2 Auth and Authorization
- Only `JwtAuthGuard` for admin-protected APIs.
- No role/permission table and no RBAC.
- All active admins have same capability.
- Disabled admins cannot login or refresh token.
### 9.3 Admin Write Workflow
1. Request passes JWT auth and admin status check.
2. Service validates business rules (including access mode rules).
3. Service writes DB and optionally calls GitLab API.
4. Audit interceptor records operation.
5. Cache keys are invalidated.
## 10. Security, Reliability, and Performance
- Public endpoints:
- anonymous read for query APIs
- rate limit for `launch` and `downloads` endpoints
- Admin endpoints:
- password hashed with `argon2id`
- login failure counter and temporary lock
- Upload security:
- extension/MIME whitelist
- max size limit
- checksum verification
- Web URL security:
- validate URL format and optional domain whitelist
- block private-network targets if needed
- Error handling:
- global exception filter with stable error schema
- SQLite reliability:
- periodic `dev.db` backup
- lock latency monitoring
## 11. Caching Strategy
- Cache targets:
- tool list query (`query+category+sort+page+pageSize`)
- overview KPI
- categories and hot keywords
- TTL:
- tools list: 60s
- overview/categories/keywords: 120s
- Invalidation:
- tool mode/status updates
- artifact upload/status updates
- category/tag/keyword writes
- counter updates (`download_count/open_count`)
## 12. Recommended Project Structure
```text
server/
src/
main.ts
app.module.ts
common/
filters/
interceptors/
guards/
decorators/
constants/
modules/
health/
tools/
categories/
keywords/
overview/
access/
downloads/
artifacts/
gitlab-storage/
admin-auth/
admin-tools/
admin-artifacts/
admin-categories/
admin-tags/
admin-keywords/
admin-users/
admin-audit/
prisma/
prisma.service.ts
prisma/
schema.prisma
migrations/
seed.ts
test/
public-tools.e2e-spec.ts
public-launch.e2e-spec.ts
public-download.e2e-spec.ts
admin-auth.e2e-spec.ts
admin-tools.e2e-spec.ts
admin-artifacts.e2e-spec.ts
```
## 13. Frontend Integration Mapping
Public frontend changes:
- replace local `tools` with `GET /api/v1/tools`
- each tool card reads `accessMode`
- click primary action:
- call `POST /api/v1/tools/:id/launch`
- if response `mode=web`, use `window.open(actionUrl, '_blank')`
- if response `mode=download`, navigate to returned download URL
Admin frontend changes:
- tool form adds `accessMode` selector (`web|download`)
- when mode is `web`: show `openUrl` field, hide artifact upload block
- when mode is `download`: show artifact upload/version block
- mode switch prompts validation hints before publish
## 14. Implementation Plan
1. Initialize NestJS app in `server/` with Prisma(SQLite).
2. Build schema and seed base data.
3. Implement public query APIs (`tools/categories/keywords/overview`).
4. Implement unified launch endpoint with mode branching.
5. Implement GitLab client + download-mode artifact upload APIs.
6. Implement download ticket consumption and stream proxy.
7. Implement admin auth, tool mode management, taxonomy, audit logs.
8. Add Swagger and unit/e2e tests.
## 15. Test Strategy
- Unit tests:
- tool query and sorting logic
- launch service mode branching (`web` vs `download`)
- artifact upload validation + checksum + metadata persistence
- ticket create/consume/expire logic
- E2E tests:
- `GET /tools` returns mode-specific fields
- `POST /tools/:id/launch` for web mode returns URL and writes open record
- `POST /tools/:id/launch` for download mode returns ticket
- `GET /downloads/:ticket` streams file and writes download record
- `POST /admin/tools/:id/artifacts` rejects when tool mode is `web`
## 16. Risks and Open Questions
- Confirm whether all web URLs are external only, or include internal SSO links.
- Confirm whether web-open events need anti-abuse strategy similar to download.
- Confirm max artifact size and whether chunk upload is required.
- Confirm whether artifact deletion should also trigger GitLab deletion immediately.
- Confirm whether mode switch should be restricted once tool is published.
## 17. Delivery Note
This design is updated for:
- SQLite database
- admin backend without role/permission model
- mixed tool access modes (`web` + `download`)
- GitLab-based upload/download for download-mode tools only
After confirmation, next step is `代码实现` (NestJS scaffold + hybrid launch flow + GitLab integration baseline).

BIN
img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

144
index.html Normal file
View File

@@ -0,0 +1,144 @@
<!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>

10
package.json Normal file
View File

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

14
server/.env.example Normal file
View File

@@ -0,0 +1,14 @@
PORT=3000
DATABASE_URL="file:./dev.db"
DOWNLOAD_TICKET_TTL_SEC=120
JWT_ACCESS_SECRET=change_this_access_secret
JWT_REFRESH_SECRET=change_this_refresh_secret
JWT_ACCESS_EXPIRES_IN=2h
JWT_REFRESH_EXPIRES_IN=7d
GITLAB_BASE_URL=
GITLAB_API_BASE=
GITLAB_PROJECT_ID=
GITLAB_TOKEN=
GITLAB_PACKAGE_NAME_PREFIX=toolsshow

4
server/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
server/README.md Normal file
View File

@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

View File

@@ -0,0 +1,173 @@
# ToolsShow API Reference
Base URL: `/api/v1`
All JSON responses (except binary download stream) use the wrapper:
```json
{
"code": 0,
"message": "ok",
"data": {},
"traceId": "uuid",
"timestamp": "2026-03-26T10:10:00.000Z"
}
```
## Public APIs
| Method | Path | Description |
|---|---|---|
| GET | `/health` | Liveness/readiness check |
| GET | `/tools` | Query tools list (query/category/sort/page/pageSize) |
| GET | `/tools/:id` | Tool detail |
| POST | `/tools/:id/launch` | Unified launch (web/download) |
| GET | `/downloads/:ticket` | Consume ticket and stream artifact |
| GET | `/categories` | Category list with tool count |
| GET | `/keywords/hot` | Hot keyword list |
| GET | `/overview` | KPI overview |
### `GET /tools` query params
| Param | Type | Required | Default |
|---|---|---|---|
| `query` | string | No | - |
| `category` | string | No | `all` |
| `sortBy` | `popular \| latest \| rating \| name` | No | `latest` |
| `page` | number | No | `1` |
| `pageSize` | number (1-50) | No | `6` |
### `POST /tools/:id/launch` body
```json
{
"channel": "official",
"clientVersion": "web-1.0.0"
}
```
Web mode response:
```json
{
"mode": "web",
"actionUrl": "https://example.com",
"openIn": "new_tab"
}
```
Download mode response:
```json
{
"mode": "download",
"ticket": "dl_tk_xxx",
"expiresInSec": 120,
"actionUrl": "/api/v1/downloads/dl_tk_xxx"
}
```
## Admin Auth APIs
Auth header for protected admin APIs:
```text
Authorization: Bearer <accessToken>
```
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | `/admin/auth/login` | No | Admin login |
| POST | `/admin/auth/refresh` | No | Refresh tokens |
| POST | `/admin/auth/logout` | Yes | Admin logout |
| GET | `/admin/auth/me` | Yes | Current admin profile |
Default seeded admin account:
```text
username: admin
password: admin123456
```
## Admin Tool APIs
| Method | Path | Description |
|---|---|---|
| GET | `/admin/tools` | Query tools |
| POST | `/admin/tools` | Create tool |
| GET | `/admin/tools/:id` | Tool detail |
| PATCH | `/admin/tools/:id` | Update tool |
| PATCH | `/admin/tools/:id/status` | Update tool status |
| PATCH | `/admin/tools/:id/access-mode` | Switch access mode |
| DELETE | `/admin/tools/:id` | Soft delete tool |
### Create/Update tool payload core fields
```json
{
"name": "Tool Name",
"categoryId": "cat_dev",
"description": "Tool description",
"rating": 4.5,
"tags": ["tag_hot", "tag_free"],
"features": ["Feature A", "Feature B"],
"accessMode": "web",
"openUrl": "https://example.com",
"openInNewTab": true,
"status": "draft"
}
```
## Admin Artifact APIs
| Method | Path | Description |
|---|---|---|
| POST | `/admin/tools/:id/artifacts` | Upload artifact (`multipart/form-data`) |
| GET | `/admin/tools/:id/artifacts` | List artifacts |
| PATCH | `/admin/tools/:id/artifacts/:artifactId/latest` | Set latest artifact |
| PATCH | `/admin/tools/:id/artifacts/:artifactId/status` | Update artifact status |
| DELETE | `/admin/tools/:id/artifacts/:artifactId` | Soft delete artifact metadata |
### Upload form fields
| Field | Type | Required |
|---|---|---|
| `file` | binary | Yes |
| `version` | string | Yes |
| `releaseNotes` | string | No |
| `isLatest` | boolean | No (default `true`) |
## Admin Audit APIs
| Method | Path | Description |
|---|---|---|
| GET | `/admin/audit-logs` | Query admin audit logs |
Query params:
| Param | Type | Required |
|---|---|---|
| `action` | string | No |
| `resourceType` | string | No |
| `adminUserId` | string | No |
| `page` | number | No |
| `pageSize` | number | No |
## Error Codes
| Code | Meaning |
|---|---|
| `1001` | validation failed |
| `1002` | unauthorized |
| `1003` | forbidden |
| `1004` | resource not found |
| `1005` | conflict |
| `1010` | invalid credentials |
| `1011` | token invalid/expired |
| `1201` | GitLab upload failed |
| `1202` | GitLab download failed |
| `1203` | artifact not available |
| `1204` | download ticket invalid/expired |
| `1210` | tool access mode mismatch |
| `1211` | web open URL not configured |
| `1500` | internal server error |

1095
server/docs/openapi.json Normal file

File diff suppressed because it is too large Load Diff

35
server/eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
server/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11405
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

94
server/package.json Normal file
View File

@@ -0,0 +1,94 @@
{
"name": "server",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "prisma db seed",
"docs:api": "node -r ts-node/register scripts/generate-api-docs.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.6",
"@prisma/client": "^6.16.2",
"@types/multer": "^2.1.0",
"argon2": "^0.44.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"multer": "^2.1.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"prisma": "^6.16.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}

View File

@@ -0,0 +1,231 @@
-- CreateTable
CREATE TABLE "tools" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"category_id" TEXT NOT NULL,
"description" TEXT NOT NULL,
"rating" REAL NOT NULL DEFAULT 0,
"download_count" INTEGER NOT NULL DEFAULT 0,
"open_count" INTEGER NOT NULL DEFAULT 0,
"access_mode" TEXT NOT NULL DEFAULT 'download',
"open_url" TEXT,
"open_in_new_tab" BOOLEAN NOT NULL DEFAULT true,
"latest_artifact_id" TEXT,
"status" TEXT NOT NULL DEFAULT 'draft',
"updated_at" TEXT NOT NULL,
"is_deleted" BOOLEAN NOT NULL DEFAULT false,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"modified_at" DATETIME NOT NULL,
CONSTRAINT "tools_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "tools_latest_artifact_id_fkey" FOREIGN KEY ("latest_artifact_id") REFERENCES "tool_artifacts" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "tool_artifacts" (
"id" TEXT NOT NULL PRIMARY KEY,
"tool_id" TEXT NOT NULL,
"version" TEXT NOT NULL,
"file_name" TEXT NOT NULL,
"file_size_bytes" INTEGER NOT NULL,
"sha256" TEXT NOT NULL,
"mime_type" TEXT,
"gitlab_project_id" INTEGER NOT NULL,
"gitlab_package_name" TEXT NOT NULL,
"gitlab_package_version" TEXT NOT NULL,
"gitlab_file_path" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active',
"release_notes" TEXT,
"uploaded_by" TEXT,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tool_artifacts_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "tool_artifacts_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "admin_users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "categories" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 100,
"is_deleted" BOOLEAN NOT NULL DEFAULT false
);
-- CreateTable
CREATE TABLE "tags" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"is_deleted" BOOLEAN NOT NULL DEFAULT false
);
-- CreateTable
CREATE TABLE "tool_tags" (
"tool_id" TEXT NOT NULL,
"tag_id" TEXT NOT NULL,
PRIMARY KEY ("tool_id", "tag_id"),
CONSTRAINT "tool_tags_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "tool_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "tool_features" (
"id" TEXT NOT NULL PRIMARY KEY,
"tool_id" TEXT NOT NULL,
"feature_text" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 100,
CONSTRAINT "tool_features_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "hot_keywords" (
"id" TEXT NOT NULL PRIMARY KEY,
"keyword" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 100,
"is_active" BOOLEAN NOT NULL DEFAULT true
);
-- CreateTable
CREATE TABLE "download_tickets" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"ticket" TEXT NOT NULL,
"tool_id" TEXT NOT NULL,
"artifact_id" TEXT NOT NULL,
"channel" TEXT,
"client_version" TEXT,
"request_ip" TEXT,
"expires_at" DATETIME NOT NULL,
"consumed_at" DATETIME,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "download_tickets_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "download_tickets_artifact_id_fkey" FOREIGN KEY ("artifact_id") REFERENCES "tool_artifacts" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "download_records" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"tool_id" TEXT NOT NULL,
"artifact_id" TEXT NOT NULL,
"ticket" TEXT,
"downloaded_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"client_ip" TEXT,
"user_agent" TEXT,
"channel" TEXT,
"client_version" TEXT,
"status" TEXT NOT NULL DEFAULT 'success',
"error_message" TEXT,
CONSTRAINT "download_records_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "download_records_artifact_id_fkey" FOREIGN KEY ("artifact_id") REFERENCES "tool_artifacts" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "open_records" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"tool_id" TEXT NOT NULL,
"opened_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"client_ip" TEXT,
"user_agent" TEXT,
"channel" TEXT,
"client_version" TEXT,
"referer" TEXT,
CONSTRAINT "open_records_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "admin_users" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"display_name" TEXT,
"status" TEXT NOT NULL DEFAULT 'active',
"last_login_at" DATETIME,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"modified_at" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "admin_audit_logs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"admin_user_id" TEXT,
"action" TEXT NOT NULL,
"resource_type" TEXT NOT NULL,
"resource_id" TEXT,
"request_method" TEXT NOT NULL,
"request_path" TEXT NOT NULL,
"request_body" TEXT,
"ip" TEXT,
"user_agent" TEXT,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "admin_audit_logs_admin_user_id_fkey" FOREIGN KEY ("admin_user_id") REFERENCES "admin_users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "tools_slug_key" ON "tools"("slug");
-- CreateIndex
CREATE INDEX "idx_tools_category_id" ON "tools"("category_id");
-- CreateIndex
CREATE INDEX "idx_tools_status" ON "tools"("status");
-- CreateIndex
CREATE INDEX "idx_tools_access_mode" ON "tools"("access_mode");
-- CreateIndex
CREATE INDEX "idx_tools_download_count" ON "tools"("download_count");
-- CreateIndex
CREATE INDEX "idx_tools_open_count" ON "tools"("open_count");
-- CreateIndex
CREATE INDEX "idx_tools_updated_at" ON "tools"("updated_at");
-- CreateIndex
CREATE INDEX "idx_tools_rating" ON "tools"("rating");
-- CreateIndex
CREATE INDEX "idx_tools_name" ON "tools"("name");
-- CreateIndex
CREATE INDEX "idx_artifact_tool_id" ON "tool_artifacts"("tool_id");
-- CreateIndex
CREATE INDEX "idx_artifact_status" ON "tool_artifacts"("status");
-- CreateIndex
CREATE UNIQUE INDEX "uk_tool_version" ON "tool_artifacts"("tool_id", "version");
-- CreateIndex
CREATE UNIQUE INDEX "categories_name_key" ON "categories"("name");
-- CreateIndex
CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
-- CreateIndex
CREATE INDEX "idx_tool_feature_tool_id" ON "tool_features"("tool_id");
-- CreateIndex
CREATE UNIQUE INDEX "hot_keywords_keyword_key" ON "hot_keywords"("keyword");
-- CreateIndex
CREATE UNIQUE INDEX "download_tickets_ticket_key" ON "download_tickets"("ticket");
-- CreateIndex
CREATE INDEX "idx_download_tickets_tool_id" ON "download_tickets"("tool_id");
-- CreateIndex
CREATE INDEX "idx_download_tickets_expires_at" ON "download_tickets"("expires_at");
-- CreateIndex
CREATE INDEX "idx_download_records_tool_id" ON "download_records"("tool_id");
-- CreateIndex
CREATE INDEX "idx_download_records_artifact_id" ON "download_records"("artifact_id");
-- CreateIndex
CREATE INDEX "idx_open_records_tool_id" ON "open_records"("tool_id");
-- CreateIndex
CREATE UNIQUE INDEX "admin_users_username_key" ON "admin_users"("username");
-- CreateIndex
CREATE INDEX "idx_admin_audit_logs_user_id" ON "admin_audit_logs"("admin_user_id");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

246
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,246 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
enum AccessMode {
web
download
}
enum ToolStatus {
draft
published
archived
}
enum ArtifactStatus {
active
deprecated
deleted
}
enum DownloadRecordStatus {
success
failed
}
enum AdminUserStatus {
active
disabled
}
model Tool {
id String @id
name String
slug String @unique
categoryId String @map("category_id")
description String
rating Float @default(0)
downloadCount Int @default(0) @map("download_count")
openCount Int @default(0) @map("open_count")
accessMode AccessMode @default(download) @map("access_mode")
openUrl String? @map("open_url")
openInNewTab Boolean @default(true) @map("open_in_new_tab")
latestArtifactId String? @map("latest_artifact_id")
status ToolStatus @default(draft)
updatedAt String @map("updated_at")
isDeleted Boolean @default(false) @map("is_deleted")
createdAt DateTime @default(now()) @map("created_at")
modifiedAt DateTime @updatedAt @map("modified_at")
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
latestArtifact ToolArtifact? @relation("ToolLatestArtifact", fields: [latestArtifactId], references: [id], onDelete: SetNull)
artifacts ToolArtifact[] @relation("ToolArtifacts")
tags ToolTag[]
features ToolFeature[]
downloadTickets DownloadTicket[]
downloadRecords DownloadRecord[]
openRecords OpenRecord[]
@@index([categoryId], map: "idx_tools_category_id")
@@index([status], map: "idx_tools_status")
@@index([accessMode], map: "idx_tools_access_mode")
@@index([downloadCount], map: "idx_tools_download_count")
@@index([openCount], map: "idx_tools_open_count")
@@index([updatedAt], map: "idx_tools_updated_at")
@@index([rating], map: "idx_tools_rating")
@@index([name], map: "idx_tools_name")
@@map("tools")
}
model ToolArtifact {
id String @id
toolId String @map("tool_id")
version String
fileName String @map("file_name")
fileSizeBytes Int @map("file_size_bytes")
sha256 String
mimeType String? @map("mime_type")
gitlabProjectId Int @map("gitlab_project_id")
gitlabPackageName String @map("gitlab_package_name")
gitlabPackageVersion String @map("gitlab_package_version")
gitlabFilePath String @map("gitlab_file_path")
status ArtifactStatus @default(active)
releaseNotes String? @map("release_notes")
uploadedBy String? @map("uploaded_by")
createdAt DateTime @default(now()) @map("created_at")
tool Tool @relation("ToolArtifacts", fields: [toolId], references: [id], onDelete: Restrict)
latestForTool Tool[] @relation("ToolLatestArtifact")
uploader AdminUser? @relation(fields: [uploadedBy], references: [id], onDelete: SetNull)
downloadTickets DownloadTicket[]
downloadRecords DownloadRecord[]
@@unique([toolId, version], map: "uk_tool_version")
@@index([toolId], map: "idx_artifact_tool_id")
@@index([status], map: "idx_artifact_status")
@@map("tool_artifacts")
}
model Category {
id String @id
name String @unique
sortOrder Int @default(100) @map("sort_order")
isDeleted Boolean @default(false) @map("is_deleted")
tools Tool[]
@@map("categories")
}
model Tag {
id String @id
name String @unique
isDeleted Boolean @default(false) @map("is_deleted")
tools ToolTag[]
@@map("tags")
}
model ToolTag {
toolId String @map("tool_id")
tagId String @map("tag_id")
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([toolId, tagId])
@@map("tool_tags")
}
model ToolFeature {
id String @id
toolId String @map("tool_id")
featureText String @map("feature_text")
sortOrder Int @default(100) @map("sort_order")
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
@@index([toolId], map: "idx_tool_feature_tool_id")
@@map("tool_features")
}
model HotKeyword {
id String @id
keyword String @unique
sortOrder Int @default(100) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
@@map("hot_keywords")
}
model DownloadTicket {
id Int @id @default(autoincrement())
ticket String @unique
toolId String @map("tool_id")
artifactId String @map("artifact_id")
channel String?
clientVersion String? @map("client_version")
requestIp String? @map("request_ip")
expiresAt DateTime @map("expires_at")
consumedAt DateTime? @map("consumed_at")
createdAt DateTime @default(now()) @map("created_at")
tool Tool @relation(fields: [toolId], references: [id], onDelete: Restrict)
artifact ToolArtifact @relation(fields: [artifactId], references: [id], onDelete: Restrict)
@@index([toolId], map: "idx_download_tickets_tool_id")
@@index([expiresAt], map: "idx_download_tickets_expires_at")
@@map("download_tickets")
}
model DownloadRecord {
id Int @id @default(autoincrement())
toolId String @map("tool_id")
artifactId String @map("artifact_id")
ticket String?
downloadedAt DateTime @default(now()) @map("downloaded_at")
clientIp String? @map("client_ip")
userAgent String? @map("user_agent")
channel String?
clientVersion String? @map("client_version")
status DownloadRecordStatus @default(success)
errorMessage String? @map("error_message")
tool Tool @relation(fields: [toolId], references: [id], onDelete: Restrict)
artifact ToolArtifact @relation(fields: [artifactId], references: [id], onDelete: Restrict)
@@index([toolId], map: "idx_download_records_tool_id")
@@index([artifactId], map: "idx_download_records_artifact_id")
@@map("download_records")
}
model OpenRecord {
id Int @id @default(autoincrement())
toolId String @map("tool_id")
openedAt DateTime @default(now()) @map("opened_at")
clientIp String? @map("client_ip")
userAgent String? @map("user_agent")
channel String?
clientVersion String? @map("client_version")
referer String?
tool Tool @relation(fields: [toolId], references: [id], onDelete: Restrict)
@@index([toolId], map: "idx_open_records_tool_id")
@@map("open_records")
}
model AdminUser {
id String @id
username String @unique
passwordHash String @map("password_hash")
displayName String? @map("display_name")
status AdminUserStatus @default(active)
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
modifiedAt DateTime @updatedAt @map("modified_at")
artifacts ToolArtifact[]
auditLogs AdminAuditLog[]
@@map("admin_users")
}
model AdminAuditLog {
id Int @id @default(autoincrement())
adminUserId String? @map("admin_user_id")
action String
resourceType String @map("resource_type")
resourceId String? @map("resource_id")
requestMethod String @map("request_method")
requestPath String @map("request_path")
requestBody String? @map("request_body")
ip String?
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: SetNull)
@@index([adminUserId], map: "idx_admin_audit_logs_user_id")
@@map("admin_audit_logs")
}

152
server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,152 @@
import argon2 from 'argon2';
import { PrismaClient, AccessMode, ToolStatus, ArtifactStatus, AdminUserStatus } from '@prisma/client';
const prisma = new PrismaClient();
function todayDateString(): string {
return new Date().toISOString().slice(0, 10);
}
async function main() {
const nowDate = todayDateString();
await prisma.$transaction([
prisma.toolTag.deleteMany(),
prisma.toolFeature.deleteMany(),
prisma.downloadRecord.deleteMany(),
prisma.downloadTicket.deleteMany(),
prisma.openRecord.deleteMany(),
prisma.adminAuditLog.deleteMany(),
prisma.toolArtifact.deleteMany(),
prisma.tool.deleteMany(),
prisma.tag.deleteMany(),
prisma.category.deleteMany(),
prisma.hotKeyword.deleteMany(),
prisma.adminUser.deleteMany(),
]);
await prisma.category.createMany({
data: [
{ id: 'cat_ai', name: 'AI', sortOrder: 10 },
{ id: 'cat_dev', name: 'Developer', sortOrder: 20 },
{ id: 'cat_ops', name: 'Operations', sortOrder: 30 },
],
});
await prisma.tag.createMany({
data: [
{ id: 'tag_hot', name: 'Hot' },
{ id: 'tag_free', name: 'Free' },
{ id: 'tag_official', name: 'Official' },
],
});
const webToolId = 'tool_web_001';
const downloadToolId = 'tool_dl_001';
const artifactId = 'art_001';
await prisma.tool.create({
data: {
id: webToolId,
name: 'OpenAI Playground',
slug: 'openai-playground',
categoryId: 'cat_ai',
description: 'OpenAI web playground for prompt testing.',
rating: 4.8,
downloadCount: 0,
openCount: 128,
accessMode: AccessMode.web,
openUrl: 'https://platform.openai.com/playground',
openInNewTab: true,
status: ToolStatus.published,
updatedAt: nowDate,
features: {
createMany: {
data: [
{ id: 'feat_web_001', featureText: 'Prompt debugging', sortOrder: 10 },
{ id: 'feat_web_002', featureText: 'Model parameter tuning', sortOrder: 20 },
],
},
},
tags: {
create: [{ tagId: 'tag_hot' }, { tagId: 'tag_official' }],
},
},
});
await prisma.tool.create({
data: {
id: downloadToolId,
name: 'ToolsShow Desktop',
slug: 'toolsshow-desktop',
categoryId: 'cat_dev',
description: 'Desktop bundle for local workflows.',
rating: 4.6,
downloadCount: 52,
openCount: 0,
accessMode: AccessMode.download,
status: ToolStatus.published,
updatedAt: nowDate,
features: {
createMany: {
data: [
{ id: 'feat_dl_001', featureText: 'Offline usage', sortOrder: 10 },
{ id: 'feat_dl_002', featureText: 'Bundled plugins', sortOrder: 20 },
],
},
},
tags: {
create: [{ tagId: 'tag_free' }],
},
artifacts: {
create: {
id: artifactId,
version: '1.0.0',
fileName: 'toolsshow-desktop-1.0.0.zip',
fileSizeBytes: 12_345_678,
sha256: 'sample-sha256-not-real',
mimeType: 'application/zip',
gitlabProjectId: 0,
gitlabPackageName: 'toolsshow/toolsshow-desktop',
gitlabPackageVersion: '1.0.0',
gitlabFilePath: 'storage/toolsshow-desktop-1.0.0.zip',
status: ArtifactStatus.active,
releaseNotes: 'Initial release',
},
},
},
});
await prisma.tool.update({
where: { id: downloadToolId },
data: { latestArtifactId: artifactId },
});
await prisma.hotKeyword.createMany({
data: [
{ id: 'kw_001', keyword: 'agent', sortOrder: 10, isActive: true },
{ id: 'kw_002', keyword: 'automation', sortOrder: 20, isActive: true },
{ id: 'kw_003', keyword: 'open-source', sortOrder: 30, isActive: true },
],
});
const passwordHash = await argon2.hash('admin123456', { type: argon2.argon2id });
await prisma.adminUser.create({
data: {
id: 'u_admin_001',
username: 'admin',
passwordHash,
displayName: 'System Admin',
status: AdminUserStatus.active,
},
});
}
main()
.catch(async (error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,44 @@
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
async function generateApiDocs() {
const app = await NestFactory.create(AppModule, {
logger: false,
});
app.setGlobalPrefix('api/v1');
const swaggerConfig = new DocumentBuilder()
.setTitle('ToolsShow Backend API')
.setDescription('Generated OpenAPI document for ToolsShow backend.')
.setVersion('1.0.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
'admin-access-token',
)
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
const docsDir = join(process.cwd(), 'docs');
mkdirSync(docsDir, { recursive: true });
writeFileSync(join(docsDir, 'openapi.json'), JSON.stringify(document, null, 2), 'utf-8');
const prisma = app.get(PrismaService);
await prisma.$disconnect();
await app.close();
process.exit(0);
}
generateApiDocs().catch((error) => {
console.error(error);
process.exitCode = 1;
});

38
server/src/app.module.ts Normal file
View File

@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { AccessModule } from './modules/access/access.module';
import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.module';
import { AdminAuditModule } from './modules/admin-audit/admin-audit.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { AdminToolsModule } from './modules/admin-tools/admin-tools.module';
import { CategoriesModule } from './modules/categories/categories.module';
import { DownloadsModule } from './modules/downloads/downloads.module';
import { GitlabStorageModule } from './modules/gitlab-storage/gitlab-storage.module';
import { HealthModule } from './modules/health/health.module';
import { KeywordsModule } from './modules/keywords/keywords.module';
import { OverviewModule } from './modules/overview/overview.module';
import { ToolsModule } from './modules/tools/tools.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env', '.env.example'],
}),
PrismaModule,
HealthModule,
ToolsModule,
CategoriesModule,
KeywordsModule,
OverviewModule,
AccessModule,
GitlabStorageModule,
DownloadsModule,
AdminAuthModule,
AdminToolsModule,
AdminArtifactsModule,
AdminAuditModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,18 @@
export const ERROR_CODES = {
VALIDATION_FAILED: 1001,
UNAUTHORIZED: 1002,
FORBIDDEN: 1003,
NOT_FOUND: 1004,
CONFLICT: 1005,
INVALID_CREDENTIALS: 1010,
TOKEN_INVALID: 1011,
GITLAB_UPLOAD_FAILED: 1201,
GITLAB_DOWNLOAD_FAILED: 1202,
ARTIFACT_NOT_AVAILABLE: 1203,
DOWNLOAD_TICKET_INVALID: 1204,
TOOL_ACCESS_MODE_MISMATCH: 1210,
WEB_OPEN_URL_NOT_CONFIGURED: 1211,
INTERNAL_SERVER_ERROR: 1500,
} as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];

View File

@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
export const AUDIT_METADATA_KEY = 'audit:meta';
export interface AuditMetadata {
action: string;
resourceType: string;
resourceIdParam?: string;
}
export const Audit = (meta: AuditMetadata) => SetMetadata(AUDIT_METADATA_KEY, meta);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,13 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import type { ErrorCode } from '../constants/error-codes';
export class AppException extends HttpException {
constructor(
public readonly errorCode: ErrorCode,
message: string,
status: HttpStatus = HttpStatus.BAD_REQUEST,
public readonly details?: unknown,
) {
super({ message, details }, status);
}
}

View File

@@ -0,0 +1,90 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import type { Response } from 'express';
import { ERROR_CODES, type ErrorCode } from '../constants/error-codes';
import { AppException } from '../exceptions/app.exception';
import type { RequestWithContext } from '../interfaces/request-with-context.interface';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<RequestWithContext>();
const traceId = request.traceId ?? 'unknown-trace-id';
const timestamp = new Date().toISOString();
const path = request.url;
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let code: ErrorCode = ERROR_CODES.INTERNAL_SERVER_ERROR;
let message = 'internal server error';
let details: unknown;
if (exception instanceof AppException) {
status = exception.getStatus();
code = exception.errorCode;
const payload = exception.getResponse() as { message?: string; details?: unknown };
message = payload?.message ?? exception.message;
details = payload?.details ?? exception.details;
} else if (exception instanceof HttpException) {
status = exception.getStatus();
code = this.statusToCode(status);
const payload = exception.getResponse();
if (typeof payload === 'string') {
message = payload;
} else if (typeof payload === 'object' && payload) {
const p = payload as { message?: string | string[]; error?: string };
if (Array.isArray(p.message)) {
message = p.message.join('; ');
details = p.message;
} else {
message = p.message ?? p.error ?? message;
}
} else {
message = exception.message;
}
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(exception.message, exception.stack);
} else {
this.logger.error(`Unknown exception: ${String(exception)}`);
}
response.status(status).json({
code,
message,
data: null,
details: details ?? null,
traceId,
timestamp,
path,
});
}
private statusToCode(status: number): ErrorCode {
switch (status) {
case HttpStatus.BAD_REQUEST:
return ERROR_CODES.VALIDATION_FAILED;
case HttpStatus.UNAUTHORIZED:
return ERROR_CODES.UNAUTHORIZED;
case HttpStatus.FORBIDDEN:
return ERROR_CODES.FORBIDDEN;
case HttpStatus.NOT_FOUND:
return ERROR_CODES.NOT_FOUND;
case HttpStatus.CONFLICT:
return ERROR_CODES.CONFLICT;
default:
return ERROR_CODES.INTERNAL_SERVER_ERROR;
}
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class AdminJwtGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,123 @@
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { type Observable, catchError, tap, throwError } from 'rxjs';
import { AUDIT_METADATA_KEY, type AuditMetadata } from '../../decorators/audit.decorator';
import type { RequestWithContext } from '../../interfaces/request-with-context.interface';
import { PrismaService } from '../../../prisma/prisma.service';
@Injectable()
export class AdminAuditInterceptor implements NestInterceptor {
private readonly logger = new Logger(AdminAuditInterceptor.name);
constructor(
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const meta = this.reflector.get<AuditMetadata>(AUDIT_METADATA_KEY, context.getHandler());
if (!meta) {
return next.handle();
}
const request = context.switchToHttp().getRequest<RequestWithContext>();
const method = request.method;
const path = request.originalUrl ?? request.url;
return next.handle().pipe(
tap(() => {
void this.writeAuditLog(request, meta, method, path, true);
}),
catchError((error) => {
void this.writeAuditLog(request, meta, method, path, false, error);
return throwError(() => error);
}),
);
}
private async writeAuditLog(
request: RequestWithContext,
meta: AuditMetadata,
method: string,
path: string,
success: boolean,
error?: unknown,
): Promise<void> {
try {
const resourceIdParam = meta.resourceIdParam ?? 'id';
const rawResourceId = request.params?.[resourceIdParam];
const resourceId = Array.isArray(rawResourceId) ? rawResourceId[0] : rawResourceId;
const body = this.maskSensitiveBody(request.body);
await this.prisma.adminAuditLog.create({
data: {
adminUserId: request.user?.sub,
action: success ? meta.action : `${meta.action}.failed`,
resourceType: meta.resourceType,
resourceId: resourceId ?? null,
requestMethod: method,
requestPath: path,
requestBody: body ? JSON.stringify(body) : null,
ip: this.extractIp(request),
userAgent: this.extractHeader(request, 'user-agent'),
},
});
if (!success && error) {
this.logger.warn(
`Audit error recorded for action=${meta.action}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
} catch (error) {
this.logger.error(
`Failed to persist audit log for action=${meta.action}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
private maskSensitiveBody(body: unknown): unknown {
if (!body || typeof body !== 'object') {
return body;
}
const cloned = { ...(body as Record<string, unknown>) };
const fieldsToMask = ['password', 'accessToken', 'refreshToken', 'token'];
for (const field of fieldsToMask) {
if (field in cloned) {
cloned[field] = '***';
}
}
return cloned;
}
private extractIp(request: RequestWithContext): string | undefined {
const forwarded = request.headers['x-forwarded-for'];
if (Array.isArray(forwarded) && forwarded.length > 0) {
return forwarded[0]?.split(',')[0]?.trim();
}
if (typeof forwarded === 'string') {
return forwarded.split(',')[0]?.trim();
}
return request.ip;
}
private extractHeader(request: RequestWithContext, name: string): string | undefined {
const value = request.headers[name];
if (!value) {
return undefined;
}
return Array.isArray(value) ? value[0] : value;
}
}

View File

@@ -0,0 +1,22 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map, type Observable } from 'rxjs';
import type { RequestWithContext } from '../interfaces/request-with-context.interface';
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, unknown> {
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<RequestWithContext>();
const traceId = request.traceId ?? 'unknown-trace-id';
return next.handle().pipe(
map((data) => ({
code: 0,
message: 'ok',
data: data ?? null,
traceId,
timestamp: new Date().toISOString(),
})),
);
}
}

View File

@@ -0,0 +1,10 @@
import type { Request } from 'express';
export interface RequestWithContext extends Request {
traceId?: string;
user?: {
sub: string;
username: string;
type: 'access';
};
}

67
server/src/main.ts Normal file
View File

@@ -0,0 +1,67 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { randomUUID } from 'crypto';
import { existsSync } from 'fs';
import { join } from 'path';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import type { RequestWithContext } from './common/interfaces/request-with-context.interface';
import { PrismaService } from './prisma/prisma.service';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const prisma = app.get(PrismaService);
app.use((req: RequestWithContext, res, next) => {
req.traceId = req.headers['x-trace-id']?.toString() ?? randomUUID();
res.setHeader('x-trace-id', req.traceId);
next();
});
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
app.useGlobalInterceptors(new ResponseInterceptor());
app.useGlobalFilters(new HttpExceptionFilter());
const swaggerConfig = new DocumentBuilder()
.setTitle('ToolsShow Backend API')
.setDescription('NestJS backend for ToolsShow with hybrid web/download tool access.')
.setVersion('1.0.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
'admin-access-token',
)
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);
const clientDistPath = process.env.CLIENT_DIST_PATH ?? join(__dirname, '..', 'public');
if (existsSync(clientDistPath)) {
app.useStaticAssets(clientDistPath);
const expressApp = app.getHttpAdapter().getInstance();
expressApp.get(/^(?!\/api(?:\/|$)).*/, (_req, res) => {
res.sendFile(join(clientDistPath, 'index.html'));
});
}
await prisma.enableShutdownHooks(app);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View File

@@ -0,0 +1,21 @@
import { Body, Controller, Param, Post, Req } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { LaunchToolDto } from './dto/launch-tool.dto';
import { AccessService } from './access.service';
@ApiTags('public-launch')
@Controller('tools')
export class AccessController {
constructor(private readonly accessService: AccessService) {}
@Post(':id/launch')
@ApiOperation({ summary: 'Unified launch endpoint (web/download)' })
launchTool(
@Param('id') id: string,
@Body() body: LaunchToolDto,
@Req() request: RequestWithContext,
) {
return this.accessService.launchTool(id, body, request);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AccessController } from './access.controller';
import { AccessService } from './access.service';
@Module({
controllers: [AccessController],
providers: [AccessService],
})
export class AccessModule {}

View File

@@ -0,0 +1,120 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ArtifactStatus, ToolStatus } from '@prisma/client';
import { randomUUID } from 'crypto';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { PrismaService } from '../../prisma/prisma.service';
import { LaunchToolDto } from './dto/launch-tool.dto';
@Injectable()
export class AccessService {
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {}
async launchTool(toolId: string, body: LaunchToolDto, request: RequestWithContext) {
const tool = await this.prisma.tool.findFirst({
where: {
id: toolId,
isDeleted: false,
status: ToolStatus.published,
},
include: {
latestArtifact: true,
},
});
if (!tool) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
}
if (tool.accessMode === 'web') {
if (!tool.openUrl) {
throw new AppException(
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
'web open url is not configured',
HttpStatus.CONFLICT,
);
}
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 {
mode: 'web' as const,
actionUrl: tool.openUrl,
openIn: tool.openInNewTab ? 'new_tab' : 'same_tab',
};
}
if (!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active) {
throw new AppException(
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
'artifact not available for this download tool',
HttpStatus.CONFLICT,
);
}
const ttlSec = this.configService.get<number>('DOWNLOAD_TICKET_TTL_SEC', 120);
const expiresAt = new Date(Date.now() + ttlSec * 1000);
const ticket = `dl_tk_${randomUUID().replace(/-/g, '')}`;
await this.prisma.downloadTicket.create({
data: {
ticket,
toolId: tool.id,
artifactId: tool.latestArtifact.id,
channel: body.channel,
clientVersion: body.clientVersion,
requestIp: this.extractIp(request),
expiresAt,
},
});
return {
mode: 'download' as const,
ticket,
expiresInSec: ttlSec,
actionUrl: `/api/v1/downloads/${ticket}`,
};
}
private extractIp(request: RequestWithContext): string | undefined {
const forwarded = request.headers['x-forwarded-for'];
if (Array.isArray(forwarded) && forwarded.length > 0) {
return forwarded[0]?.split(',')[0]?.trim();
}
if (typeof forwarded === 'string') {
return forwarded.split(',')[0]?.trim();
}
return request.ip;
}
private extractHeader(
request: RequestWithContext,
name: string,
): string | undefined {
const value = request.headers[name];
if (!value) {
return undefined;
}
return Array.isArray(value) ? value[0] : value;
}
}

View File

@@ -0,0 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class LaunchToolDto {
@ApiPropertyOptional({ example: 'official' })
@IsOptional()
@IsString()
@MaxLength(64)
channel?: string;
@ApiPropertyOptional({ example: 'web-1.0.0' })
@IsOptional()
@IsString()
@MaxLength(64)
clientVersion?: string;
}

View File

@@ -0,0 +1,110 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Req,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
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 type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { UpdateArtifactStatusDto } from './dto/update-artifact-status.dto';
import { UploadArtifactDto } from './dto/upload-artifact.dto';
import { AdminArtifactsService } from './admin-artifacts.service';
@ApiTags('admin-artifacts')
@ApiBearerAuth('admin-access-token')
@UseGuards(AdminJwtGuard)
@UseInterceptors(AdminAuditInterceptor)
@Controller('admin/tools/:id/artifacts')
export class AdminArtifactsController {
constructor(private readonly adminArtifactsService: AdminArtifactsService) {}
@Post()
@Audit({ action: 'artifact.upload', resourceType: 'artifact', resourceIdParam: 'id' })
@ApiOperation({ summary: 'Upload artifact file for tool' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
version: { type: 'string', example: '1.0.0' },
releaseNotes: { type: 'string', example: 'Initial release' },
isLatest: { type: 'boolean', example: true },
},
required: ['file', 'version'],
},
})
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: {
fileSize: 512 * 1024 * 1024,
},
}),
)
uploadArtifact(
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body() body: UploadArtifactDto,
@Req() request: RequestWithContext,
) {
return this.adminArtifactsService.uploadArtifact(id, file, body, request.user?.sub);
}
@Get()
@ApiOperation({ summary: 'List tool artifacts' })
listArtifacts(@Param('id') id: string) {
return this.adminArtifactsService.listToolArtifacts(id);
}
@Patch(':artifactId/latest')
@Audit({
action: 'artifact.set_latest',
resourceType: 'artifact',
resourceIdParam: 'artifactId',
})
@ApiOperation({ summary: 'Set latest artifact' })
setLatestArtifact(@Param('id') id: string, @Param('artifactId') artifactId: string) {
return this.adminArtifactsService.setLatestArtifact(id, artifactId);
}
@Patch(':artifactId/status')
@Audit({
action: 'artifact.update_status',
resourceType: 'artifact',
resourceIdParam: 'artifactId',
})
@ApiOperation({ summary: 'Update artifact status' })
updateArtifactStatus(
@Param('id') id: string,
@Param('artifactId') artifactId: string,
@Body() body: UpdateArtifactStatusDto,
) {
return this.adminArtifactsService.updateArtifactStatus(id, artifactId, body.status);
}
@Delete(':artifactId')
@Audit({ action: 'artifact.delete', resourceType: 'artifact', resourceIdParam: 'artifactId' })
@ApiOperation({ summary: 'Delete artifact metadata (soft via status=deleted)' })
deleteArtifact(@Param('id') id: string, @Param('artifactId') artifactId: string) {
return this.adminArtifactsService.deleteArtifact(id, artifactId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
import { GitlabStorageModule } from '../gitlab-storage/gitlab-storage.module';
import { AdminArtifactsController } from './admin-artifacts.controller';
import { AdminArtifactsService } from './admin-artifacts.service';
@Module({
imports: [GitlabStorageModule],
controllers: [AdminArtifactsController],
providers: [AdminArtifactsService, AdminAuditInterceptor],
})
export class AdminArtifactsModule {}

View File

@@ -0,0 +1,311 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { ArtifactStatus } from '@prisma/client';
import { createHash, 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 { GitlabStorageService } from '../gitlab-storage/gitlab-storage.service';
import { UploadArtifactDto } from './dto/upload-artifact.dto';
@Injectable()
export class AdminArtifactsService {
constructor(
private readonly prisma: PrismaService,
private readonly gitlabStorageService: GitlabStorageService,
) {}
async uploadArtifact(toolId: string, file: Express.Multer.File | undefined, body: UploadArtifactDto, adminId?: string) {
const tool = await this.assertDownloadModeTool(toolId);
this.assertUploadFile(file);
const uploadMaxSizeMb = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 512);
if (file!.size > uploadMaxSizeMb * 1024 * 1024) {
throw new AppException(
ERROR_CODES.VALIDATION_FAILED,
`file size exceeds ${uploadMaxSizeMb}MB`,
HttpStatus.BAD_REQUEST,
);
}
const exists = await this.prisma.toolArtifact.findFirst({
where: {
toolId,
version: body.version,
},
select: {
id: true,
},
});
if (exists) {
throw new AppException(ERROR_CODES.CONFLICT, 'version already exists', HttpStatus.CONFLICT);
}
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
const uploadResult = await this.gitlabStorageService.uploadArtifact({
toolId,
version: body.version,
fileName: file!.originalname,
mimeType: file!.mimetype,
buffer: file!.buffer,
});
const artifactId = this.generateBusinessId('art');
await this.prisma.$transaction(async (tx) => {
await tx.toolArtifact.create({
data: {
id: artifactId,
toolId,
version: body.version,
fileName: file!.originalname,
fileSizeBytes: file!.size,
sha256,
mimeType: file!.mimetype,
gitlabProjectId: uploadResult.gitlabProjectId,
gitlabPackageName: uploadResult.gitlabPackageName,
gitlabPackageVersion: uploadResult.gitlabPackageVersion,
gitlabFilePath: uploadResult.gitlabFilePath,
releaseNotes: body.releaseNotes,
status: ArtifactStatus.active,
uploadedBy: adminId,
},
});
const setLatest = body.isLatest ?? true;
if (setLatest) {
await tx.tool.update({
where: { id: tool.id },
data: {
latestArtifactId: artifactId,
updatedAt: this.getDateOnlyString(),
},
});
}
});
return this.getArtifactById(toolId, artifactId);
}
async listToolArtifacts(toolId: string) {
await this.assertToolExists(toolId);
const artifacts = await this.prisma.toolArtifact.findMany({
where: {
toolId,
},
orderBy: {
createdAt: 'desc',
},
});
const tool = await this.prisma.tool.findUnique({
where: {
id: toolId,
},
select: {
latestArtifactId: true,
},
});
return artifacts.map((item) => ({
id: item.id,
version: item.version,
fileName: item.fileName,
fileSizeBytes: item.fileSizeBytes,
sha256: item.sha256,
mimeType: item.mimeType,
status: item.status,
releaseNotes: item.releaseNotes,
isLatest: tool?.latestArtifactId === item.id,
createdAt: item.createdAt,
}));
}
async setLatestArtifact(toolId: string, artifactId: string) {
const artifact = await this.assertArtifactBelongsToTool(toolId, artifactId);
if (artifact.status !== ArtifactStatus.active) {
throw new AppException(
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
'only active artifact can be set as latest',
HttpStatus.CONFLICT,
);
}
await this.prisma.tool.update({
where: {
id: toolId,
},
data: {
latestArtifactId: artifactId,
updatedAt: this.getDateOnlyString(),
},
});
return this.getArtifactById(toolId, artifactId);
}
async updateArtifactStatus(toolId: string, artifactId: string, status: ArtifactStatus) {
const artifact = await this.assertArtifactBelongsToTool(toolId, artifactId);
await this.prisma.$transaction(async (tx) => {
await tx.toolArtifact.update({
where: { id: artifact.id },
data: {
status,
},
});
if (status !== ArtifactStatus.active) {
const tool = await tx.tool.findUnique({
where: { id: toolId },
select: { latestArtifactId: true },
});
if (tool?.latestArtifactId === artifactId) {
const fallback = await tx.toolArtifact.findFirst({
where: {
toolId,
id: {
not: artifactId,
},
status: ArtifactStatus.active,
},
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
},
});
await tx.tool.update({
where: { id: toolId },
data: {
latestArtifactId: fallback?.id ?? null,
updatedAt: this.getDateOnlyString(),
},
});
}
}
});
return this.getArtifactById(toolId, artifactId);
}
async deleteArtifact(toolId: string, artifactId: string) {
await this.updateArtifactStatus(toolId, artifactId, ArtifactStatus.deleted);
return {
success: true,
id: artifactId,
};
}
private async assertToolExists(toolId: string) {
const tool = await this.prisma.tool.findFirst({
where: {
id: toolId,
isDeleted: false,
},
select: {
id: true,
},
});
if (!tool) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
}
return tool;
}
private async assertDownloadModeTool(toolId: string) {
const tool = await this.prisma.tool.findFirst({
where: {
id: toolId,
isDeleted: false,
},
select: {
id: true,
accessMode: true,
},
});
if (!tool) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
}
if (tool.accessMode !== 'download') {
throw new AppException(
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
'artifact upload is only allowed for download mode tool',
HttpStatus.CONFLICT,
);
}
return tool;
}
private async assertArtifactBelongsToTool(toolId: string, artifactId: string) {
await this.assertToolExists(toolId);
const artifact = await this.prisma.toolArtifact.findFirst({
where: {
id: artifactId,
toolId,
},
});
if (!artifact) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'artifact not found', HttpStatus.NOT_FOUND);
}
return artifact;
}
private async getArtifactById(toolId: string, artifactId: string) {
const artifact = await this.assertArtifactBelongsToTool(toolId, artifactId);
const tool = await this.prisma.tool.findUnique({
where: { id: toolId },
select: {
latestArtifactId: true,
},
});
return {
id: artifact.id,
toolId: artifact.toolId,
version: artifact.version,
fileName: artifact.fileName,
fileSizeBytes: artifact.fileSizeBytes,
sha256: artifact.sha256,
mimeType: artifact.mimeType,
gitlabProjectId: artifact.gitlabProjectId,
gitlabPackageName: artifact.gitlabPackageName,
gitlabPackageVersion: artifact.gitlabPackageVersion,
gitlabFilePath: artifact.gitlabFilePath,
status: artifact.status,
releaseNotes: artifact.releaseNotes,
isLatest: tool?.latestArtifactId === artifact.id,
createdAt: artifact.createdAt,
};
}
private assertUploadFile(file: Express.Multer.File | undefined): asserts file is Express.Multer.File {
if (!file) {
throw new AppException(ERROR_CODES.VALIDATION_FAILED, 'file is required', HttpStatus.BAD_REQUEST);
}
const fileName = file.originalname.toLowerCase();
const allowedExtensions = ['.zip', '.tar.gz', '.tgz', '.exe', '.dmg', '.pkg', '.msi'];
const isAllowed = allowedExtensions.some((ext) => fileName.endsWith(ext));
if (!isAllowed) {
throw new AppException(
ERROR_CODES.VALIDATION_FAILED,
`file extension is not allowed: ${file.originalname}`,
HttpStatus.BAD_REQUEST,
);
}
}
private generateBusinessId(prefix: string): string {
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
}
private getDateOnlyString(): string {
return new Date().toISOString().slice(0, 10);
}
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArtifactStatus } from '@prisma/client';
import { IsEnum } from 'class-validator';
export class UpdateArtifactStatusDto {
@ApiProperty({ enum: ArtifactStatus })
@IsEnum(ArtifactStatus)
status!: ArtifactStatus;
}

View File

@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UploadArtifactDto {
@ApiProperty({ example: '1.0.0' })
@IsString()
@MinLength(1)
@MaxLength(64)
version!: string;
@ApiPropertyOptional({ example: 'Initial release' })
@IsOptional()
@IsString()
@MaxLength(5000)
releaseNotes?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isLatest?: boolean;
}

View File

@@ -0,0 +1,19 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
import { AdminAuditQueryDto } from './dto/admin-audit-query.dto';
import { AdminAuditService } from './admin-audit.service';
@ApiTags('admin-audit')
@ApiBearerAuth('admin-access-token')
@UseGuards(AdminJwtGuard)
@Controller('admin/audit-logs')
export class AdminAuditController {
constructor(private readonly adminAuditService: AdminAuditService) {}
@Get()
@ApiOperation({ summary: 'Query admin audit logs' })
getAuditLogs(@Query() query: AdminAuditQueryDto) {
return this.adminAuditService.getAuditLogs(query);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AdminAuditController } from './admin-audit.controller';
import { AdminAuditService } from './admin-audit.service';
@Module({
controllers: [AdminAuditController],
providers: [AdminAuditService],
})
export class AdminAuditModule {}

View File

@@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AdminAuditQueryDto } from './dto/admin-audit-query.dto';
@Injectable()
export class AdminAuditService {
constructor(private readonly prisma: PrismaService) {}
async getAuditLogs(query: AdminAuditQueryDto) {
const page = query.page ?? 1;
const pageSize = Math.min(query.pageSize ?? 20, 100);
const where: Prisma.AdminAuditLogWhereInput = {};
if (query.action) {
where.action = { contains: query.action };
}
if (query.resourceType) {
where.resourceType = query.resourceType;
}
if (query.adminUserId) {
where.adminUserId = query.adminUserId;
}
const [total, logs] = await this.prisma.$transaction([
this.prisma.adminAuditLog.count({ where }),
this.prisma.adminAuditLog.findMany({
where,
include: {
adminUser: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
list: logs.map((item) => ({
id: item.id,
action: item.action,
resourceType: item.resourceType,
resourceId: item.resourceId,
requestMethod: item.requestMethod,
requestPath: item.requestPath,
requestBody: item.requestBody,
ip: item.ip,
userAgent: item.userAgent,
createdAt: item.createdAt,
adminUser: item.adminUser
? {
id: item.adminUser.id,
username: item.adminUser.username,
displayName: item.adminUser.displayName,
}
: null,
})),
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
}

View File

@@ -0,0 +1,35 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class AdminAuditQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
action?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
resourceType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
adminUserId?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 20, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
}

View File

@@ -0,0 +1,41 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { AdminAuthService } from './admin-auth.service';
@ApiTags('admin-auth')
@Controller('admin/auth')
export class AdminAuthController {
constructor(private readonly adminAuthService: AdminAuthService) {}
@Post('login')
@ApiOperation({ summary: 'Admin login' })
login(@Body() body: LoginDto) {
return this.adminAuthService.login(body);
}
@Post('refresh')
@ApiOperation({ summary: 'Refresh admin token' })
refresh(@Body() body: RefreshTokenDto) {
return this.adminAuthService.refresh(body.refreshToken);
}
@Post('logout')
@UseGuards(AdminJwtGuard)
@ApiBearerAuth('admin-access-token')
@ApiOperation({ summary: 'Admin logout' })
logout() {
return this.adminAuthService.logout();
}
@Get('me')
@UseGuards(AdminJwtGuard)
@ApiBearerAuth('admin-access-token')
@ApiOperation({ summary: 'Get current admin profile' })
me(@Req() request: RequestWithContext) {
return this.adminAuthService.getMe(request.user?.sub ?? '');
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AdminAuthController } from './admin-auth.controller';
import { AdminAuthService } from './admin-auth.service';
import { AdminJwtStrategy } from './strategies/admin-jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_ACCESS_SECRET', 'change_this_access_secret'),
}),
}),
],
controllers: [AdminAuthController],
providers: [AdminAuthService, AdminJwtStrategy],
exports: [AdminAuthService],
})
export class AdminAuthModule {}

View File

@@ -0,0 +1,189 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AdminUserStatus } from '@prisma/client';
import argon2 from 'argon2';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
import { PrismaService } from '../../prisma/prisma.service';
import type { LoginDto } from './dto/login.dto';
import type { JwtPayload } from './interfaces/jwt-payload.interface';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AdminAuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async login(body: LoginDto) {
const user = await this.prisma.adminUser.findUnique({
where: {
username: body.username,
},
});
if (!user || user.status !== AdminUserStatus.active) {
throw new AppException(
ERROR_CODES.INVALID_CREDENTIALS,
'invalid username or password',
HttpStatus.UNAUTHORIZED,
);
}
const isValidPassword = await argon2.verify(user.passwordHash, body.password);
if (!isValidPassword) {
throw new AppException(
ERROR_CODES.INVALID_CREDENTIALS,
'invalid username or password',
HttpStatus.UNAUTHORIZED,
);
}
await this.prisma.adminUser.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
const tokens = await this.issueTokens({
sub: user.id,
username: user.username,
type: 'access',
});
return {
...tokens,
profile: {
id: user.id,
username: user.username,
displayName: user.displayName ?? user.username,
},
};
}
async refresh(refreshToken: string) {
const payload = await this.verifyRefreshToken(refreshToken);
const user = await this.prisma.adminUser.findUnique({
where: { id: payload.sub },
});
if (!user || user.status !== AdminUserStatus.active) {
throw new AppException(
ERROR_CODES.TOKEN_INVALID,
'token invalid or expired',
HttpStatus.UNAUTHORIZED,
);
}
return this.issueTokens({
sub: user.id,
username: user.username,
type: 'access',
});
}
async getMe(userId: string) {
const user = await this.prisma.adminUser.findUnique({
where: { id: userId },
});
if (!user || user.status !== AdminUserStatus.active) {
throw new AppException(
ERROR_CODES.UNAUTHORIZED,
'admin user not available',
HttpStatus.UNAUTHORIZED,
);
}
return {
id: user.id,
username: user.username,
displayName: user.displayName ?? user.username,
status: user.status,
lastLoginAt: user.lastLoginAt,
};
}
async logout() {
return {
success: true,
};
}
private async issueTokens(payload: JwtPayload) {
const accessSecret = this.configService.get<string>(
'JWT_ACCESS_SECRET',
'change_this_access_secret',
);
const refreshSecret = this.configService.get<string>(
'JWT_REFRESH_SECRET',
'change_this_refresh_secret',
);
const accessExpiresInRaw = this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h');
const refreshExpiresInRaw = this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
const accessExpiresIn = this.parseExpiresInSeconds(accessExpiresInRaw);
const refreshExpiresIn = this.parseExpiresInSeconds(refreshExpiresInRaw);
const accessToken = await this.jwtService.signAsync(payload, {
secret: accessSecret,
expiresIn: accessExpiresIn,
});
const refreshToken = await this.jwtService.signAsync(
{
...payload,
type: 'refresh' as const,
},
{
secret: refreshSecret,
expiresIn: refreshExpiresIn,
},
);
return {
accessToken,
refreshToken,
expiresIn: accessExpiresIn,
};
}
private async verifyRefreshToken(token: string): Promise<JwtPayload> {
try {
const refreshSecret = this.configService.get<string>(
'JWT_REFRESH_SECRET',
'change_this_refresh_secret',
);
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
secret: refreshSecret,
});
if (payload.type !== 'refresh') {
throw new Error('invalid refresh token type');
}
return payload;
} catch {
throw new AppException(
ERROR_CODES.TOKEN_INVALID,
'token invalid or expired',
HttpStatus.UNAUTHORIZED,
);
}
}
private parseExpiresInSeconds(expiresIn: string): number {
if (/^\d+$/.test(expiresIn)) {
return Number(expiresIn);
}
if (expiresIn.endsWith('h')) {
return Number(expiresIn.replace('h', '')) * 3600;
}
if (expiresIn.endsWith('m')) {
return Number(expiresIn.replace('m', '')) * 60;
}
if (expiresIn.endsWith('d')) {
return Number(expiresIn.replace('d', '')) * 86400;
}
return 7200;
}
}

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MaxLength, MinLength } from 'class-validator';
export class LoginDto {
@ApiProperty({ example: 'admin' })
@IsString()
@MinLength(3)
@MaxLength(64)
username!: string;
@ApiProperty({ example: 'admin123456' })
@IsString()
@MinLength(6)
@MaxLength(128)
password!: string;
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@ApiProperty()
@IsString()
refreshToken!: string;
}

View File

@@ -0,0 +1,5 @@
export interface JwtPayload {
sub: string;
username: string;
type: 'access' | 'refresh';
}

View File

@@ -0,0 +1,23 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { JwtPayload } from '../interfaces/jwt-payload.interface';
@Injectable()
export class AdminJwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_ACCESS_SECRET', 'change_this_access_secret'),
});
}
async validate(payload: JwtPayload): Promise<JwtPayload> {
if (payload.type !== 'access') {
throw new UnauthorizedException('invalid access token');
}
return payload;
}
}

View File

@@ -0,0 +1,67 @@
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 { AdminToolsService } from './admin-tools.service';
import { AdminToolsQueryDto } from './dto/admin-tools-query.dto';
import { CreateToolDto } from './dto/create-tool.dto';
import { UpdateAccessModeDto } from './dto/update-access-mode.dto';
import { UpdateToolStatusDto } from './dto/update-tool-status.dto';
import { UpdateToolDto } from './dto/update-tool.dto';
@ApiTags('admin-tools')
@ApiBearerAuth('admin-access-token')
@UseGuards(AdminJwtGuard)
@UseInterceptors(AdminAuditInterceptor)
@Controller('admin/tools')
export class AdminToolsController {
constructor(private readonly adminToolsService: AdminToolsService) {}
@Get()
@ApiOperation({ summary: 'Admin query tools' })
getTools(@Query() query: AdminToolsQueryDto) {
return this.adminToolsService.getTools(query);
}
@Post()
@Audit({ action: 'tool.create', resourceType: 'tool' })
@ApiOperation({ summary: 'Admin create tool' })
createTool(@Body() body: CreateToolDto) {
return this.adminToolsService.createTool(body);
}
@Get(':id')
@ApiOperation({ summary: 'Admin get tool detail' })
getToolById(@Param('id') id: string) {
return this.adminToolsService.getToolById(id);
}
@Patch(':id')
@Audit({ action: 'tool.update', resourceType: 'tool' })
@ApiOperation({ summary: 'Admin update tool' })
updateTool(@Param('id') id: string, @Body() body: UpdateToolDto) {
return this.adminToolsService.updateTool(id, body);
}
@Patch(':id/status')
@Audit({ action: 'tool.update_status', resourceType: 'tool' })
@ApiOperation({ summary: 'Admin update tool status' })
updateToolStatus(@Param('id') id: string, @Body() body: UpdateToolStatusDto) {
return this.adminToolsService.updateToolStatus(id, body.status);
}
@Patch(':id/access-mode')
@Audit({ action: 'tool.update_access_mode', resourceType: 'tool' })
@ApiOperation({ summary: 'Admin update tool access mode' })
updateAccessMode(@Param('id') id: string, @Body() body: UpdateAccessModeDto) {
return this.adminToolsService.updateAccessMode(id, body);
}
@Delete(':id')
@Audit({ action: 'tool.delete', resourceType: 'tool' })
@ApiOperation({ summary: 'Admin soft-delete tool' })
deleteTool(@Param('id') id: string) {
return this.adminToolsService.deleteTool(id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
import { AdminToolsController } from './admin-tools.controller';
import { AdminToolsService } from './admin-tools.service';
@Module({
controllers: [AdminToolsController],
providers: [AdminToolsService, AdminAuditInterceptor],
exports: [AdminToolsService],
})
export class AdminToolsModule {}

View File

@@ -0,0 +1,484 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { AccessMode, ArtifactStatus, Prisma, ToolStatus } from '@prisma/client';
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 { AdminToolsQueryDto } from './dto/admin-tools-query.dto';
import { CreateToolDto } from './dto/create-tool.dto';
import { UpdateAccessModeDto } from './dto/update-access-mode.dto';
import { UpdateToolDto } from './dto/update-tool.dto';
@Injectable()
export class AdminToolsService {
constructor(private readonly prisma: PrismaService) {}
async getTools(query: AdminToolsQueryDto) {
const page = query.page ?? 1;
const pageSize = Math.min(query.pageSize ?? 10, 50);
const where: Prisma.ToolWhereInput = {
isDeleted: false,
};
if (query.query) {
where.OR = [
{ name: { contains: query.query } },
{ description: { contains: query.query } },
];
}
if (query.categoryId) {
where.categoryId = query.categoryId;
}
if (query.status) {
where.status = query.status;
}
if (query.accessMode) {
where.accessMode = query.accessMode;
}
const [total, tools] = await this.prisma.$transaction([
this.prisma.tool.count({ where }),
this.prisma.tool.findMany({
where,
include: {
category: true,
tags: {
include: {
tag: true,
},
},
features: {
orderBy: {
sortOrder: 'asc',
},
},
latestArtifact: true,
},
orderBy: {
modifiedAt: 'desc',
},
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
list: tools.map((tool) => this.mapTool(tool)),
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async createTool(body: CreateToolDto) {
await this.assertCategoryExists(body.categoryId);
await this.assertTagsExist(body.tags ?? []);
if ((body.status ?? ToolStatus.draft) === ToolStatus.published) {
this.assertPublishInput(body.accessMode, body.openUrl, undefined);
}
const toolId = this.generateBusinessId('tool');
const slug = await this.ensureUniqueSlug(this.slugify(body.name));
const updatedAt = this.getDateOnlyString();
await this.prisma.tool.create({
data: {
id: toolId,
name: body.name.trim(),
slug,
categoryId: body.categoryId,
description: body.description.trim(),
rating: body.rating ?? 0,
accessMode: body.accessMode,
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
openInNewTab: body.openInNewTab ?? true,
status: body.status ?? ToolStatus.draft,
updatedAt,
tags:
body.tags && body.tags.length > 0
? {
createMany: {
data: body.tags.map((tagId) => ({ tagId })),
},
}
: undefined,
features:
body.features && body.features.length > 0
? {
createMany: {
data: body.features.map((feature, index) => ({
id: this.generateBusinessId('feat'),
featureText: feature,
sortOrder: (index + 1) * 10,
})),
},
}
: undefined,
},
});
return this.getToolById(toolId);
}
async getToolById(id: string) {
const tool = await this.prisma.tool.findFirst({
where: {
id,
isDeleted: false,
},
include: {
category: true,
tags: {
include: {
tag: true,
},
},
features: {
orderBy: {
sortOrder: 'asc',
},
},
latestArtifact: true,
},
});
if (!tool) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
}
return this.mapTool(tool);
}
async updateTool(id: string, body: UpdateToolDto) {
const existingTool = await this.getToolEntity(id);
const nextAccessMode = body.accessMode ?? existingTool.accessMode;
const nextOpenUrl =
body.openUrl !== undefined
? body.openUrl
: nextAccessMode === AccessMode.web
? existingTool.openUrl
: null;
const nextStatus = body.status ?? existingTool.status;
if (body.categoryId) {
await this.assertCategoryExists(body.categoryId);
}
if (body.tags) {
await this.assertTagsExist(body.tags);
}
this.assertModeSwitchConstraint(existingTool.status, nextAccessMode, nextOpenUrl, existingTool);
if (nextStatus === ToolStatus.published) {
this.assertPublishInput(nextAccessMode, nextOpenUrl ?? undefined, existingTool.latestArtifact);
}
const updatedAt = this.getDateOnlyString();
await this.prisma.$transaction(async (tx) => {
await tx.tool.update({
where: { id },
data: {
name: body.name?.trim(),
categoryId: body.categoryId,
description: body.description?.trim(),
rating: body.rating,
accessMode: body.accessMode,
openUrl: body.openUrl,
openInNewTab: body.openInNewTab,
status: body.status,
updatedAt,
},
});
if (body.tags) {
await tx.toolTag.deleteMany({ where: { toolId: id } });
if (body.tags.length > 0) {
await tx.toolTag.createMany({
data: body.tags.map((tagId) => ({
toolId: id,
tagId,
})),
});
}
}
if (body.features) {
await tx.toolFeature.deleteMany({ where: { toolId: id } });
if (body.features.length > 0) {
await tx.toolFeature.createMany({
data: body.features.map((feature, index) => ({
id: this.generateBusinessId('feat'),
toolId: id,
featureText: feature,
sortOrder: (index + 1) * 10,
})),
});
}
}
});
return this.getToolById(id);
}
async updateToolStatus(id: string, status: ToolStatus) {
const tool = await this.getToolEntity(id);
if (status === ToolStatus.published) {
this.assertPublishInput(tool.accessMode, tool.openUrl ?? undefined, tool.latestArtifact);
}
await this.prisma.tool.update({
where: { id },
data: {
status,
updatedAt: this.getDateOnlyString(),
},
});
return this.getToolById(id);
}
async updateAccessMode(id: string, body: UpdateAccessModeDto) {
const tool = await this.getToolEntity(id);
this.assertModeSwitchConstraint(
tool.status,
body.accessMode,
body.openUrl,
tool,
tool.accessMode !== body.accessMode,
);
await this.prisma.tool.update({
where: { id },
data: {
accessMode: body.accessMode,
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
openInNewTab: body.openInNewTab ?? tool.openInNewTab,
updatedAt: this.getDateOnlyString(),
},
});
return this.getToolById(id);
}
async deleteTool(id: string) {
await this.getToolEntity(id);
await this.prisma.tool.update({
where: { id },
data: {
isDeleted: true,
status: ToolStatus.archived,
updatedAt: this.getDateOnlyString(),
},
});
return {
success: true,
id,
};
}
private async getToolEntity(id: string) {
const tool = await this.prisma.tool.findFirst({
where: {
id,
isDeleted: false,
},
include: {
category: true,
latestArtifact: true,
tags: {
include: {
tag: true,
},
},
features: {
orderBy: {
sortOrder: 'asc',
},
},
},
});
if (!tool) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
}
return tool;
}
private async assertCategoryExists(categoryId: string) {
const category = await this.prisma.category.findFirst({
where: {
id: categoryId,
isDeleted: false,
},
select: {
id: true,
},
});
if (!category) {
throw new AppException(
ERROR_CODES.NOT_FOUND,
`category not found: ${categoryId}`,
HttpStatus.NOT_FOUND,
);
}
}
private async assertTagsExist(tagIds: string[]) {
if (tagIds.length === 0) {
return;
}
const count = await this.prisma.tag.count({
where: {
id: {
in: tagIds,
},
isDeleted: false,
},
});
if (count !== tagIds.length) {
throw new AppException(ERROR_CODES.VALIDATION_FAILED, 'contains unknown tag ids');
}
}
private assertPublishInput(
accessMode: AccessMode,
openUrl?: string,
latestArtifact?: { status: ArtifactStatus } | null,
) {
if (accessMode === AccessMode.web) {
if (!openUrl) {
throw new AppException(
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
'openUrl is required for web mode publish',
HttpStatus.CONFLICT,
);
}
return;
}
if (!latestArtifact || latestArtifact.status !== ArtifactStatus.active) {
throw new AppException(
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
'download mode tool requires one active latest artifact before publish',
HttpStatus.CONFLICT,
);
}
}
private assertModeSwitchConstraint(
currentStatus: ToolStatus,
targetMode: AccessMode,
openUrl: string | null | undefined,
tool: { latestArtifact?: { status: ArtifactStatus } | null },
isSwitching = false,
) {
if (targetMode === AccessMode.web && !openUrl) {
throw new AppException(
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
'openUrl is required when switching to web mode',
HttpStatus.CONFLICT,
);
}
if (
isSwitching &&
targetMode === AccessMode.download &&
currentStatus === ToolStatus.published &&
(!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active)
) {
throw new AppException(
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
'published tool cannot switch to download mode without active artifact',
HttpStatus.CONFLICT,
);
}
}
private mapTool(
tool: Prisma.ToolGetPayload<{
include: {
category: true;
tags: { include: { tag: true } };
features: true;
latestArtifact: true;
};
}>,
) {
return {
id: tool.id,
name: tool.name,
slug: tool.slug,
category: {
id: tool.category.id,
name: tool.category.name,
},
description: tool.description,
rating: tool.rating,
status: tool.status,
accessMode: tool.accessMode,
openUrl: tool.openUrl,
openInNewTab: tool.openInNewTab,
downloadCount: tool.downloadCount,
openCount: tool.openCount,
latestArtifact: tool.latestArtifact
? {
id: tool.latestArtifact.id,
version: tool.latestArtifact.version,
status: tool.latestArtifact.status,
fileName: tool.latestArtifact.fileName,
fileSizeBytes: tool.latestArtifact.fileSizeBytes,
}
: null,
tags: tool.tags.map((item) => ({
id: item.tag.id,
name: item.tag.name,
})),
features: tool.features.map((item) => item.featureText),
updatedAt: tool.updatedAt,
createdAt: tool.createdAt,
modifiedAt: tool.modifiedAt,
};
}
private generateBusinessId(prefix: string): string {
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
}
private getDateOnlyString(): string {
return new Date().toISOString().slice(0, 10);
}
private slugify(value: string): string {
const slug = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 80);
return slug || 'tool';
}
private async ensureUniqueSlug(baseSlug: string): Promise<string> {
let slug = baseSlug;
let suffix = 1;
while (await this.prisma.tool.findUnique({ where: { slug }, select: { id: true } })) {
slug = `${baseSlug}-${suffix}`;
suffix += 1;
}
return slug;
}
}

View File

@@ -0,0 +1,41 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { AccessMode, ToolStatus } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class AdminToolsQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
query?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
categoryId?: string;
@ApiPropertyOptional({ enum: ToolStatus })
@IsOptional()
@IsEnum(ToolStatus)
status?: ToolStatus;
@ApiPropertyOptional({ enum: AccessMode })
@IsOptional()
@IsEnum(AccessMode)
accessMode?: AccessMode;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 10, maximum: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
pageSize?: number;
}

View File

@@ -0,0 +1,83 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AccessMode, ToolStatus } from '@prisma/client';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUrl,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateToolDto {
@ApiProperty()
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@ApiProperty()
@IsString()
categoryId!: string;
@ApiProperty()
@IsString()
@MinLength(10)
@MaxLength(2000)
description!: string;
@ApiPropertyOptional({ minimum: 0, maximum: 5, default: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
@Max(5)
rating?: number;
@ApiPropertyOptional({ type: [String], description: 'Tag ids' })
@IsOptional()
@IsArray()
@ArrayUnique()
@ArrayMaxSize(20)
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@ArrayMaxSize(20)
@IsString({ each: true })
features?: string[];
@ApiProperty({ enum: AccessMode, default: AccessMode.download })
@IsEnum(AccessMode)
accessMode!: AccessMode;
@ApiPropertyOptional({ description: 'Required when accessMode=web' })
@ValidateIf((obj: CreateToolDto) => obj.accessMode === AccessMode.web)
@IsString()
@IsUrl({
require_protocol: true,
})
openUrl?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
openInNewTab?: boolean;
@ApiPropertyOptional({ enum: ToolStatus, default: ToolStatus.draft })
@IsOptional()
@IsEnum(ToolStatus)
status?: ToolStatus;
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AccessMode } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsOptional, IsString, IsUrl, ValidateIf } from 'class-validator';
export class UpdateAccessModeDto {
@ApiProperty({ enum: AccessMode })
@IsEnum(AccessMode)
accessMode!: AccessMode;
@ApiPropertyOptional({ description: 'Required when accessMode=web' })
@ValidateIf((obj: UpdateAccessModeDto) => obj.accessMode === AccessMode.web)
@IsString()
@IsUrl({
require_protocol: true,
})
openUrl?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
openInNewTab?: boolean;
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { ToolStatus } from '@prisma/client';
import { IsEnum } from 'class-validator';
export class UpdateToolStatusDto {
@ApiProperty({ enum: ToolStatus })
@IsEnum(ToolStatus)
status!: ToolStatus;
}

View File

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

View File

@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CategoriesService } from './categories.service';
@ApiTags('public-categories')
@Controller('categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Get()
@ApiOperation({ summary: 'Get categories with tool count' })
getCategories() {
return this.categoriesService.getCategories();
}
}

View File

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

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { ToolStatus } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
async getCategories() {
const categories = await this.prisma.category.findMany({
where: {
isDeleted: false,
},
include: {
tools: {
where: {
isDeleted: false,
status: ToolStatus.published,
},
select: {
id: true,
},
},
},
orderBy: {
sortOrder: 'asc',
},
});
return categories.map((category) => ({
id: category.id,
name: category.name,
sortOrder: category.sortOrder,
toolCount: category.tools.length,
}));
}
}

View File

@@ -0,0 +1,21 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { DownloadsService } from './downloads.service';
@ApiTags('public-downloads')
@Controller('downloads')
export class DownloadsController {
constructor(private readonly downloadsService: DownloadsService) {}
@Get(':ticket')
@ApiOperation({ summary: 'Consume ticket and stream artifact file' })
async consumeTicket(
@Param('ticket') ticket: string,
@Req() request: Request,
@Res() response: Response,
) {
await this.downloadsService.consumeTicketAndStream(ticket, request as RequestWithContext, response);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { GitlabStorageModule } from '../gitlab-storage/gitlab-storage.module';
import { DownloadsController } from './downloads.controller';
import { DownloadsService } from './downloads.service';
@Module({
imports: [GitlabStorageModule],
controllers: [DownloadsController],
providers: [DownloadsService],
})
export class DownloadsModule {}

View File

@@ -0,0 +1,144 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { ArtifactStatus, DownloadRecordStatus, ToolStatus } from '@prisma/client';
import type { Response } from 'express';
import { pipeline } from 'stream/promises';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { PrismaService } from '../../prisma/prisma.service';
import { GitlabStorageService } from '../gitlab-storage/gitlab-storage.service';
@Injectable()
export class DownloadsService {
constructor(
private readonly prisma: PrismaService,
private readonly gitlabStorageService: GitlabStorageService,
) {}
async consumeTicketAndStream(ticket: string, request: RequestWithContext, response: Response) {
const now = new Date();
const ticketEntity = await this.prisma.downloadTicket.findUnique({
where: { ticket },
include: {
tool: true,
artifact: true,
},
});
if (!ticketEntity) {
throw new AppException(
ERROR_CODES.DOWNLOAD_TICKET_INVALID,
'download ticket not found',
HttpStatus.NOT_FOUND,
);
}
if (ticketEntity.consumedAt || ticketEntity.expiresAt < now) {
throw new AppException(
ERROR_CODES.DOWNLOAD_TICKET_INVALID,
'download ticket expired or already consumed',
HttpStatus.GONE,
);
}
if (
ticketEntity.tool.status !== ToolStatus.published ||
ticketEntity.tool.isDeleted ||
ticketEntity.artifact.status !== ArtifactStatus.active
) {
throw new AppException(
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
'artifact is not available',
HttpStatus.CONFLICT,
);
}
let downloadStream;
try {
downloadStream = await this.gitlabStorageService.getArtifactStream(ticketEntity.artifact);
} catch (error) {
await this.prisma.downloadRecord.create({
data: {
toolId: ticketEntity.toolId,
artifactId: ticketEntity.artifactId,
ticket: ticketEntity.ticket,
clientIp: this.extractIp(request),
userAgent: request.headers['user-agent'],
channel: ticketEntity.channel,
clientVersion: ticketEntity.clientVersion,
status: DownloadRecordStatus.failed,
errorMessage: error instanceof Error ? error.message : String(error),
},
});
throw error;
}
await this.prisma.$transaction(async (tx) => {
const consumed = await tx.downloadTicket.updateMany({
where: {
ticket,
consumedAt: null,
expiresAt: {
gte: now,
},
},
data: {
consumedAt: new Date(),
},
});
if (consumed.count !== 1) {
throw new AppException(
ERROR_CODES.DOWNLOAD_TICKET_INVALID,
'download ticket already consumed',
HttpStatus.GONE,
);
}
await tx.downloadRecord.create({
data: {
toolId: ticketEntity.toolId,
artifactId: ticketEntity.artifactId,
ticket: ticketEntity.ticket,
clientIp: this.extractIp(request),
userAgent: request.headers['user-agent'],
channel: ticketEntity.channel,
clientVersion: ticketEntity.clientVersion,
status: DownloadRecordStatus.success,
},
});
await tx.tool.update({
where: { id: ticketEntity.toolId },
data: {
downloadCount: {
increment: 1,
},
},
});
});
response.setHeader(
'Content-Type',
downloadStream.mimeType ?? 'application/octet-stream; charset=binary',
);
response.setHeader('Content-Length', String(downloadStream.fileSize));
response.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(downloadStream.fileName)}"`,
);
await pipeline(downloadStream.stream, response);
}
private extractIp(request: RequestWithContext): string | undefined {
const forwarded = request.headers['x-forwarded-for'];
if (Array.isArray(forwarded) && forwarded.length > 0) {
return forwarded[0]?.split(',')[0]?.trim();
}
if (typeof forwarded === 'string') {
return forwarded.split(',')[0]?.trim();
}
return request.ip;
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { GitlabStorageService } from './gitlab-storage.service';
@Module({
providers: [GitlabStorageService],
exports: [GitlabStorageService],
})
export class GitlabStorageModule {}

View File

@@ -0,0 +1,150 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { ToolArtifact } from '@prisma/client';
import { createReadStream, existsSync } from 'fs';
import { mkdir, writeFile } from 'fs/promises';
import { dirname, resolve } from 'path';
import { Readable } from 'stream';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
export interface ArtifactDownloadStream {
stream: NodeJS.ReadableStream;
fileName: string;
mimeType?: string;
fileSize: number;
}
export interface ArtifactUploadInput {
toolId: string;
version: string;
fileName: string;
mimeType?: string;
buffer: Buffer;
}
export interface ArtifactUploadResult {
gitlabProjectId: number;
gitlabPackageName: string;
gitlabPackageVersion: string;
gitlabFilePath: string;
}
@Injectable()
export class GitlabStorageService {
constructor(private readonly configService: ConfigService) {}
async getArtifactStream(artifact: ToolArtifact): Promise<ArtifactDownloadStream> {
const gitlabApiBase = this.configService.get<string>('GITLAB_API_BASE');
const gitlabToken = this.configService.get<string>('GITLAB_TOKEN');
if (gitlabApiBase && gitlabToken && artifact.gitlabProjectId > 0) {
return this.downloadFromGitlab(artifact, gitlabApiBase, gitlabToken);
}
return this.readFromLocalStorage(artifact);
}
async uploadArtifact(input: ArtifactUploadInput): Promise<ArtifactUploadResult> {
const gitlabApiBase = this.configService.get<string>('GITLAB_API_BASE');
const gitlabToken = this.configService.get<string>('GITLAB_TOKEN');
const projectId = Number(this.configService.get<string>('GITLAB_PROJECT_ID', '0'));
const packagePrefix = this.configService.get<string>('GITLAB_PACKAGE_NAME_PREFIX', 'toolsshow');
const packageName = `${packagePrefix}/${input.toolId}`;
if (gitlabApiBase && gitlabToken && projectId > 0) {
const url = `${gitlabApiBase}/projects/${encodeURIComponent(
String(projectId),
)}/packages/generic/${encodeURIComponent(packageName)}/${encodeURIComponent(
input.version,
)}/${encodeURIComponent(input.fileName)}`;
const response = await fetch(url, {
method: 'PUT',
headers: {
'PRIVATE-TOKEN': gitlabToken,
'Content-Type': input.mimeType ?? 'application/octet-stream',
},
body: input.buffer as unknown as BodyInit,
});
if (!response.ok) {
throw new AppException(
ERROR_CODES.GITLAB_UPLOAD_FAILED,
'failed to upload artifact to GitLab',
HttpStatus.BAD_GATEWAY,
);
}
return {
gitlabProjectId: projectId,
gitlabPackageName: packageName,
gitlabPackageVersion: input.version,
gitlabFilePath: `${packageName}/${input.version}/${input.fileName}`,
};
}
const localRelativePath = `storage/uploads/${input.toolId}/${input.version}/${input.fileName}`;
const localAbsolutePath = resolve(process.cwd(), localRelativePath);
await mkdir(dirname(localAbsolutePath), { recursive: true });
await writeFile(localAbsolutePath, input.buffer);
return {
gitlabProjectId: 0,
gitlabPackageName: packageName,
gitlabPackageVersion: input.version,
gitlabFilePath: localRelativePath.replace(/\\/g, '/'),
};
}
private async downloadFromGitlab(
artifact: ToolArtifact,
gitlabApiBase: string,
gitlabToken: string,
): Promise<ArtifactDownloadStream> {
const url = `${gitlabApiBase}/projects/${encodeURIComponent(
String(artifact.gitlabProjectId),
)}/packages/generic/${encodeURIComponent(artifact.gitlabPackageName)}/${encodeURIComponent(
artifact.gitlabPackageVersion,
)}/${encodeURIComponent(artifact.fileName)}`;
const response = await fetch(url, {
headers: {
'PRIVATE-TOKEN': gitlabToken,
},
});
if (!response.ok || !response.body) {
throw new AppException(
ERROR_CODES.GITLAB_DOWNLOAD_FAILED,
'failed to download artifact from GitLab',
HttpStatus.BAD_GATEWAY,
);
}
return {
stream: Readable.fromWeb(response.body as any),
fileName: artifact.fileName,
mimeType: artifact.mimeType ?? undefined,
fileSize: artifact.fileSizeBytes,
};
}
private readFromLocalStorage(artifact: ToolArtifact): ArtifactDownloadStream {
const filePath = resolve(process.cwd(), artifact.gitlabFilePath);
if (!existsSync(filePath)) {
throw new AppException(
ERROR_CODES.GITLAB_DOWNLOAD_FAILED,
`artifact file not found: ${artifact.gitlabFilePath}`,
HttpStatus.NOT_FOUND,
);
}
return {
stream: createReadStream(filePath),
fileName: artifact.fileName,
mimeType: artifact.mimeType ?? undefined,
fileSize: artifact.fileSizeBytes,
};
}
}

View File

@@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { HealthService } from './health.service';
@ApiTags('health')
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get()
getHealth() {
return this.healthService.getHealth();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class HealthService {
constructor(private readonly prisma: PrismaService) {}
async getHealth(): Promise<{ status: 'ok'; database: 'up'; timestamp: string }> {
await this.prisma.$queryRaw`SELECT 1`;
return {
status: 'ok',
database: 'up',
timestamp: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { KeywordsService } from './keywords.service';
@ApiTags('public-keywords')
@Controller('keywords')
export class KeywordsController {
constructor(private readonly keywordsService: KeywordsService) {}
@Get('hot')
@ApiOperation({ summary: 'Get hot keywords' })
getHotKeywords() {
return this.keywordsService.getHotKeywords();
}
}

Some files were not shown because too many files have changed in this diff Show More