init
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
44
.gitignore
vendored
Normal 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
8
.idea/.gitignore
generated
vendored
Normal 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
32
Dockerfile
Normal 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
474
app.js
Normal 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, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
18
client/index.html
Normal 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
2289
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
client/package.json
Normal file
23
client/package.json
Normal 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
670
client/src/App.vue
Normal 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
3
client/src/RootApp.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
1048
client/src/admin/AdminApp.vue
Normal file
1048
client/src/admin/AdminApp.vue
Normal file
File diff suppressed because it is too large
Load Diff
635
client/src/admin/admin.scss
Normal file
635
client/src/admin/admin.scss
Normal 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
126
client/src/admin/api.js
Normal 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);
|
||||||
|
}
|
||||||
49
client/src/admin/components/AccessModeDialog.vue
Normal file
49
client/src/admin/components/AccessModeDialog.vue
Normal 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>
|
||||||
110
client/src/admin/components/AdminAuditSection.vue
Normal file
110
client/src/admin/components/AdminAuditSection.vue
Normal 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>
|
||||||
29
client/src/admin/components/AdminKpiRow.vue
Normal file
29
client/src/admin/components/AdminKpiRow.vue
Normal 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>
|
||||||
101
client/src/admin/components/AdminOverviewSection.vue
Normal file
101
client/src/admin/components/AdminOverviewSection.vue
Normal 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>
|
||||||
60
client/src/admin/components/AdminSidebar.vue
Normal file
60
client/src/admin/components/AdminSidebar.vue
Normal 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>
|
||||||
188
client/src/admin/components/AdminToolsSection.vue
Normal file
188
client/src/admin/components/AdminToolsSection.vue
Normal 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>
|
||||||
54
client/src/admin/components/AdminTopbar.vue
Normal file
54
client/src/admin/components/AdminTopbar.vue
Normal 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>
|
||||||
164
client/src/admin/components/ArtifactDialog.vue
Normal file
164
client/src/admin/components/ArtifactDialog.vue
Normal 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>
|
||||||
43
client/src/admin/components/StatusDialog.vue
Normal file
43
client/src/admin/components/StatusDialog.vue
Normal 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>
|
||||||
141
client/src/admin/components/ToolFormDialog.vue
Normal file
141
client/src/admin/components/ToolFormDialog.vue
Normal 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="每行一个功能点,例如 支持离线模式 支持自动更新"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
21
client/src/admin/router.js
Normal file
21
client/src/admin/router.js
Normal 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,
|
||||||
|
});
|
||||||
141
client/src/admin/stores/auth.js
Normal file
141
client/src/admin/stores/auth.js
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
164
client/src/admin/stores/console.js
Normal file
164
client/src/admin/stores/console.js
Normal 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
87
client/src/api.js
Normal 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
14
client/src/main.js
Normal 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
861
client/src/style.scss
Normal 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
15
client/vite.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
141
docs/DOCKER_RUN_TOOLSSHOW.md
Normal file
141
docs/DOCKER_RUN_TOOLSSHOW.md
Normal 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
|
||||||
|
```
|
||||||
|
|
||||||
746
docs/NESTJS_BACKEND_DESIGN.md
Normal file
746
docs/NESTJS_BACKEND_DESIGN.md
Normal 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).
|
||||||
144
index.html
Normal file
144
index.html
Normal 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
10
package.json
Normal 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
14
server/.env.example
Normal 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
4
server/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
98
server/README.md
Normal file
98
server/README.md
Normal 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>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](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).
|
||||||
173
server/docs/API_REFERENCE.md
Normal file
173
server/docs/API_REFERENCE.md
Normal 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
1095
server/docs/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
35
server/eslint.config.mjs
Normal file
35
server/eslint.config.mjs
Normal 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
8
server/nest-cli.json
Normal 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
11405
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
94
server/package.json
Normal file
94
server/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
231
server/prisma/migrations/20260326034442_init/migration.sql
Normal file
231
server/prisma/migrations/20260326034442_init/migration.sql
Normal 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");
|
||||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal 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
246
server/prisma/schema.prisma
Normal 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
152
server/prisma/seed.ts
Normal 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();
|
||||||
|
});
|
||||||
44
server/scripts/generate-api-docs.ts
Normal file
44
server/scripts/generate-api-docs.ts
Normal 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
38
server/src/app.module.ts
Normal 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 {}
|
||||||
18
server/src/common/constants/error-codes.ts
Normal file
18
server/src/common/constants/error-codes.ts
Normal 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];
|
||||||
11
server/src/common/decorators/audit.decorator.ts
Normal file
11
server/src/common/decorators/audit.decorator.ts
Normal 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);
|
||||||
4
server/src/common/decorators/public.decorator.ts
Normal file
4
server/src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
13
server/src/common/exceptions/app.exception.ts
Normal file
13
server/src/common/exceptions/app.exception.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
server/src/common/filters/http-exception.filter.ts
Normal file
90
server/src/common/filters/http-exception.filter.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
server/src/common/guards/admin-jwt.guard.ts
Normal file
5
server/src/common/guards/admin-jwt.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminJwtGuard extends AuthGuard('jwt') {}
|
||||||
123
server/src/common/interceptors/audit/admin-audit.interceptor.ts
Normal file
123
server/src/common/interceptors/audit/admin-audit.interceptor.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
server/src/common/interceptors/response.interceptor.ts
Normal file
22
server/src/common/interceptors/response.interceptor.ts
Normal 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(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
67
server/src/main.ts
Normal 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();
|
||||||
21
server/src/modules/access/access.controller.ts
Normal file
21
server/src/modules/access/access.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
server/src/modules/access/access.module.ts
Normal file
9
server/src/modules/access/access.module.ts
Normal 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 {}
|
||||||
120
server/src/modules/access/access.service.ts
Normal file
120
server/src/modules/access/access.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
server/src/modules/access/dto/launch-tool.dto.ts
Normal file
16
server/src/modules/access/dto/launch-tool.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
110
server/src/modules/admin-artifacts/admin-artifacts.controller.ts
Normal file
110
server/src/modules/admin-artifacts/admin-artifacts.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
server/src/modules/admin-artifacts/admin-artifacts.module.ts
Normal file
12
server/src/modules/admin-artifacts/admin-artifacts.module.ts
Normal 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 {}
|
||||||
311
server/src/modules/admin-artifacts/admin-artifacts.service.ts
Normal file
311
server/src/modules/admin-artifacts/admin-artifacts.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
19
server/src/modules/admin-audit/admin-audit.controller.ts
Normal file
19
server/src/modules/admin-audit/admin-audit.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
server/src/modules/admin-audit/admin-audit.module.ts
Normal file
9
server/src/modules/admin-audit/admin-audit.module.ts
Normal 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 {}
|
||||||
74
server/src/modules/admin-audit/admin-audit.service.ts
Normal file
74
server/src/modules/admin-audit/admin-audit.service.ts
Normal 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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
35
server/src/modules/admin-audit/dto/admin-audit-query.dto.ts
Normal file
35
server/src/modules/admin-audit/dto/admin-audit-query.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
41
server/src/modules/admin-auth/admin-auth.controller.ts
Normal file
41
server/src/modules/admin-auth/admin-auth.controller.ts
Normal 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 ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
server/src/modules/admin-auth/admin-auth.module.ts
Normal file
23
server/src/modules/admin-auth/admin-auth.module.ts
Normal 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 {}
|
||||||
189
server/src/modules/admin-auth/admin-auth.service.ts
Normal file
189
server/src/modules/admin-auth/admin-auth.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
server/src/modules/admin-auth/dto/login.dto.ts
Normal file
16
server/src/modules/admin-auth/dto/login.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
8
server/src/modules/admin-auth/dto/refresh-token.dto.ts
Normal file
8
server/src/modules/admin-auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
type: 'access' | 'refresh';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
server/src/modules/admin-tools/admin-tools.controller.ts
Normal file
67
server/src/modules/admin-tools/admin-tools.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
server/src/modules/admin-tools/admin-tools.module.ts
Normal file
11
server/src/modules/admin-tools/admin-tools.module.ts
Normal 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 {}
|
||||||
484
server/src/modules/admin-tools/admin-tools.service.ts
Normal file
484
server/src/modules/admin-tools/admin-tools.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
server/src/modules/admin-tools/dto/admin-tools-query.dto.ts
Normal file
41
server/src/modules/admin-tools/dto/admin-tools-query.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
83
server/src/modules/admin-tools/dto/create-tool.dto.ts
Normal file
83
server/src/modules/admin-tools/dto/create-tool.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
24
server/src/modules/admin-tools/dto/update-access-mode.dto.ts
Normal file
24
server/src/modules/admin-tools/dto/update-access-mode.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
4
server/src/modules/admin-tools/dto/update-tool.dto.ts
Normal file
4
server/src/modules/admin-tools/dto/update-tool.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateToolDto } from './create-tool.dto';
|
||||||
|
|
||||||
|
export class UpdateToolDto extends PartialType(CreateToolDto) {}
|
||||||
15
server/src/modules/categories/categories.controller.ts
Normal file
15
server/src/modules/categories/categories.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
server/src/modules/categories/categories.module.ts
Normal file
9
server/src/modules/categories/categories.module.ts
Normal 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 {}
|
||||||
37
server/src/modules/categories/categories.service.ts
Normal file
37
server/src/modules/categories/categories.service.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
21
server/src/modules/downloads/downloads.controller.ts
Normal file
21
server/src/modules/downloads/downloads.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
server/src/modules/downloads/downloads.module.ts
Normal file
11
server/src/modules/downloads/downloads.module.ts
Normal 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 {}
|
||||||
144
server/src/modules/downloads/downloads.service.ts
Normal file
144
server/src/modules/downloads/downloads.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GitlabStorageService } from './gitlab-storage.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [GitlabStorageService],
|
||||||
|
exports: [GitlabStorageService],
|
||||||
|
})
|
||||||
|
export class GitlabStorageModule {}
|
||||||
150
server/src/modules/gitlab-storage/gitlab-storage.service.ts
Normal file
150
server/src/modules/gitlab-storage/gitlab-storage.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
14
server/src/modules/health/health.controller.ts
Normal file
14
server/src/modules/health/health.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
server/src/modules/health/health.module.ts
Normal file
9
server/src/modules/health/health.module.ts
Normal 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 {}
|
||||||
16
server/src/modules/health/health.service.ts
Normal file
16
server/src/modules/health/health.service.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
15
server/src/modules/keywords/keywords.controller.ts
Normal file
15
server/src/modules/keywords/keywords.controller.ts
Normal 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
Reference in New Issue
Block a user