Compare commits
4 Commits
40be11adbf
...
e6c2d76238
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6c2d76238 | ||
|
|
5a6328561f | ||
|
|
19c27dd7f8 | ||
|
|
b627f8c020 |
@@ -19,14 +19,18 @@ FROM ${NODE_IMAGE} AS runtime
|
|||||||
WORKDIR /app/server
|
WORKDIR /app/server
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
ENV CLIENT_DIST_PATH=/app/server/public
|
||||||
|
|
||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
COPY --from=server-builder /build/server/dist ./dist
|
COPY --from=server-builder /build/server/dist ./dist
|
||||||
COPY --from=server-builder /build/server/prisma ./prisma
|
COPY --from=server-builder /build/server/prisma ./prisma
|
||||||
|
RUN npx prisma generate
|
||||||
COPY --from=client-builder /build/client/dist ./public
|
COPY --from=client-builder /build/client/dist ./public
|
||||||
|
COPY docker/runtime-entrypoint.sh /usr/local/bin/toolsshow-entrypoint
|
||||||
|
RUN chmod +x /usr/local/bin/toolsshow-entrypoint
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]
|
CMD ["toolsshow-entrypoint"]
|
||||||
|
|||||||
474
app.js
474
app.js
@@ -1,474 +0,0 @@
|
|||||||
const tools = [
|
|
||||||
{id: "tool_1", name: "ClipFlow", category: "开发效率", description: "轻量级代码片段管理工具,支持团队共享与快速插入。", tags: ["代码片段", "团队协作", "VSCode"], version: "2.4.1", size: "12.3 MB", downloads: 36850, updatedAt: "2026-03-12", features: ["多工作区同步", "标签归档", "快捷键模板插入"]},
|
|
||||||
{id: "tool_2", name: "TaskOrbit", category: "团队协作", description: "面向产品团队的任务看板与冲刺管理工具。", tags: ["看板", "项目管理", "甘特图"], version: "1.9.0", url: "https://example.com/tools/taskorbit", downloads: 29210, updatedAt: "2026-03-18", features: ["多视图任务管理", "冲刺模板", "成员工作负载分析"]},
|
|
||||||
{id: "tool_3", name: "PixelLint", category: "设计协作", description: "设计稿一致性检查工具,可自动扫描颜色与间距规范。", tags: ["设计规范", "Figma", "UI 质检"], version: "3.1.2", size: "7.8 MB", downloads: 21430, updatedAt: "2026-02-25", features: ["组件一致性对比", "批量标注问题", "导出质检报告"]},
|
|
||||||
{id: "tool_4", name: "DataSparrow", category: "数据分析", description: "可视化数据清洗与探索平台,适合中小团队快速上手。", tags: ["可视化", "数据清洗", "BI"], version: "4.0.0", url: "https://example.com/tools/datasparrow", downloads: 17680, updatedAt: "2026-03-21", features: ["拖拽式数据流", "字段质量检测", "图表模板库"]},
|
|
||||||
{id: "tool_5", name: "ShipMate", category: "自动化", description: "一键打包发布脚本管理器,统一多环境部署流程。", tags: ["CI/CD", "部署", "脚本"], version: "2.0.3", size: "10.1 MB", downloads: 25600, updatedAt: "2026-03-09", features: ["多环境变量模板", "发布回滚", "构建流水线监控"]},
|
|
||||||
{id: "tool_6", name: "InsightPanel", category: "数据分析", description: "业务指标仪表盘构建器,支持实时数据看板。", tags: ["仪表盘", "指标", "实时看板"], version: "5.2.1", url: "https://example.com/tools/insightpanel", downloads: 40980, updatedAt: "2026-03-20", features: ["实时刷新组件", "阈值告警", "多数据源连接"]},
|
|
||||||
{id: "tool_7", name: "CloudWatchdog", category: "运维监控", description: "基础设施健康监控工具,支持可视化告警规则。", tags: ["告警", "监控", "日志分析"], version: "1.6.8", size: "15.4 MB", downloads: 19870, updatedAt: "2026-03-15", features: ["阈值策略库", "异常聚合", "告警分级通知"]},
|
|
||||||
{id: "tool_8", name: "FormForge", category: "开发效率", description: "表单构建器,支持低代码生成校验规则与提交流程。", tags: ["低代码", "表单", "校验"], version: "3.7.5", size: "9.2 MB", downloads: 23200, updatedAt: "2026-03-11", features: ["可视化表单设计", "字段联动", "提交数据导出"]},
|
|
||||||
{id: "tool_9", name: "QuerySprint", category: "数据分析", description: "面向分析师的 SQL 协作平台,支持查询片段共享。", tags: ["SQL", "查询优化", "协作"], version: "2.8.4", url: "https://example.com/tools/querysprint", downloads: 27540, updatedAt: "2026-02-18", features: ["查询版本管理", "性能诊断建议", "团队模版库"]},
|
|
||||||
{id: "tool_10", name: "TestPilot", category: "自动化", description: "自动化测试流程编排工具,支持 API 与 UI 混合测试。", tags: ["自动化测试", "API", "回归测试"], version: "4.3.0", size: "22.4 MB", downloads: 31420, updatedAt: "2026-03-17", features: ["测试用例可视化", "失败重跑策略", "测试报告仪表盘"]},
|
|
||||||
{id: "tool_11", name: "BrandBoard", category: "设计协作", description: "品牌资产管理工具,统一素材规范与组件资产。", tags: ["品牌资产", "设计系统", "素材管理"], version: "1.4.2", url: "https://example.com/tools/brandboard", downloads: 16890, updatedAt: "2026-03-08", features: ["版本化素材库", "品牌规范手册", "跨团队共享链接"]},
|
|
||||||
{id: "tool_12", name: "DeployLens", category: "运维监控", description: "发布质量追踪平台,聚合版本、错误率与回滚记录。", tags: ["发布追踪", "SRE", "质量分析"], version: "2.2.6", url: "https://example.com/tools/deploylens", downloads: 22160, updatedAt: "2026-03-19", features: ["发布健康指标", "回滚影响分析", "问题根因视图"]}
|
|
||||||
];
|
|
||||||
|
|
||||||
const state = {query: "", category: "all", sortBy: "popular", page: 1, pageSize: 6};
|
|
||||||
const keywords = ["自动化", "设计系统", "仪表盘", "监控", "协作"];
|
|
||||||
let toastTimer = null;
|
|
||||||
|
|
||||||
const elements = {
|
|
||||||
headerWrap: document.querySelector(".header-wrap"),
|
|
||||||
overviewBtn: document.getElementById("overviewBtn"),
|
|
||||||
searchInput: document.getElementById("searchInput"),
|
|
||||||
categorySelect: document.getElementById("categorySelect"),
|
|
||||||
categorySidebarList: document.getElementById("categorySidebarList"),
|
|
||||||
sortSelect: document.getElementById("sortSelect"),
|
|
||||||
resetBtn: document.getElementById("resetBtn"),
|
|
||||||
hotKeywords: document.getElementById("hotKeywords"),
|
|
||||||
resultTip: document.getElementById("resultTip"),
|
|
||||||
toolGrid: document.getElementById("toolGrid"),
|
|
||||||
pagination: document.getElementById("pagination"),
|
|
||||||
prevBtn: document.getElementById("prevBtn"),
|
|
||||||
nextBtn: document.getElementById("nextBtn"),
|
|
||||||
pageText: document.getElementById("pageText"),
|
|
||||||
detailModal: document.getElementById("detailModal"),
|
|
||||||
closeModalBtn: document.getElementById("closeModalBtn"),
|
|
||||||
overviewModal: document.getElementById("overviewModal"),
|
|
||||||
closeOverviewModalBtn: document.getElementById("closeOverviewModalBtn"),
|
|
||||||
detailTitle: document.getElementById("detailTitle"),
|
|
||||||
detailDescription: document.getElementById("detailDescription"),
|
|
||||||
detailMeta: document.getElementById("detailMeta"),
|
|
||||||
detailFeatures: document.getElementById("detailFeatures"),
|
|
||||||
toast: document.getElementById("toast"),
|
|
||||||
kpiTotal: document.getElementById("kpiTotal"),
|
|
||||||
kpiCategories: document.getElementById("kpiCategories"),
|
|
||||||
kpiDownloads: document.getElementById("kpiDownloads"),
|
|
||||||
kpiFiltered: document.getElementById("kpiFiltered")
|
|
||||||
};
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.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();
|
|
||||||
2
client/.env.development.example
Normal file
2
client/.env.development.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE=/api/v1
|
||||||
|
VITE_API_PROXY_TARGET=http://localhost:3000
|
||||||
@@ -3,16 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ToolsShow - Vue3 客户端</title>
|
<title>资源导航站点</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="icon" type="image/png" sizes="256x256" href="/favicon.png" />
|
||||||
<link
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Sora:wght@500;600;700;800&display=swap"
|
<link rel="stylesheet" href="/local-fonts.css" />
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
30
client/package-lock.json
generated
30
client/package-lock.json
generated
@@ -9,7 +9,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"element-plus": "^2.11.7",
|
"element-plus": "^2.11.7",
|
||||||
|
"marked": "^17.0.5",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
@@ -1257,6 +1259,13 @@
|
|||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/web-bluetooth": {
|
"node_modules/@types/web-bluetooth": {
|
||||||
"version": "0.0.20",
|
"version": "0.0.20",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||||
@@ -1518,6 +1527,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1893,6 +1911,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "17.0.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/marked/-/marked-17.0.5.tgz",
|
||||||
|
"integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"element-plus": "^2.11.7",
|
"element-plus": "^2.11.7",
|
||||||
|
"marked": "^17.0.5",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
|
|||||||
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
client/public/favicon.png
Normal file
BIN
client/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
40
client/public/favicon.svg
Normal file
40
client/public/favicon.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="tile" x1="16" y1="18" x2="48" y2="50" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#E8FBFF"/>
|
||||||
|
<stop offset="1" stop-color="#BCEFFA"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="needle" x1="34" y1="17" x2="47" y2="31" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#FFF8D9"/>
|
||||||
|
<stop offset="1" stop-color="#F4FCFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g filter="url(#shadow)">
|
||||||
|
<rect x="15" y="17" width="14" height="12" rx="4" fill="url(#tile)" stroke="#0A8FB5" stroke-width="2"/>
|
||||||
|
<rect x="15" y="33" width="14" height="16" rx="4" fill="url(#tile)" stroke="#0A8FB5" stroke-width="2"/>
|
||||||
|
<rect x="33" y="35" width="15" height="14" rx="4" fill="url(#tile)" fill-opacity="0.92" stroke="#0A8FB5" stroke-width="2"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path d="M35.2 17.4L49.2 22.4L38.2 33.3L35.6 27.2L29.6 24.6L35.2 17.4Z" fill="url(#needle)"/>
|
||||||
|
<path d="M35.2 17.4L40.3 27.1L49.2 22.4" stroke="#74DFF2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<path d="M18.5 22.2H25.5" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M18.5 37.8H25.5" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M18.5 42.8H24" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M36.5 40.8H44.5" stroke="#0C7D9E" stroke-opacity="0.64" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M36.5 45.6H42.5" stroke="#0C7D9E" stroke-opacity="0.64" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="11" y="14" width="41" height="39" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="1.5"/>
|
||||||
|
<feGaussianBlur stdDeviation="1.5"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="out"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0.016 0 0 0 0 0.282 0 0 0 0 0.38 0 0 0 0.14 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_1"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_1" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
6
client/public/local-fonts.css
Normal file
6
client/public/local-fonts.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
:root {
|
||||||
|
--font-sans: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
--font-display: "Avenir Next", "Trebuchet MS", "Segoe UI", "PingFang SC", sans-serif;
|
||||||
|
--font-admin-sans: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
--font-admin-mono: "Cascadia Code", "Consolas", "SFMono-Regular", monospace;
|
||||||
|
}
|
||||||
@@ -2,20 +2,15 @@
|
|||||||
<div>
|
<div>
|
||||||
<header class="header-wrap" :class="{ 'is-scrolled': isScrolled }">
|
<header class="header-wrap" :class="{ 'is-scrolled': isScrolled }">
|
||||||
<div class="container header">
|
<div class="container header">
|
||||||
<a class="brand" href="#" aria-label="ToolsShow 首页" @click.prevent>
|
<a class="brand" href="#" aria-label="Tools工具" @click.prevent>
|
||||||
<span class="brand-mark">
|
<span class="brand-mark" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<img src="/favicon.svg" alt="" width="32" height="32" />
|
||||||
<path d="M4 6.5C4 5.67 4.67 5 5.5 5H18.5C19.33 5 20 5.67 20 6.5V17.5C20 18.33 19.33 19 18.5 19H5.5C4.67 19 4 18.33 4 17.5V6.5Z" stroke="currentColor" stroke-width="1.8" />
|
|
||||||
<path d="M8 9H16M8 12H16M8 15H13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<span>ToolsShow</span>
|
<span>资源导航</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="nav" aria-label="主导航">
|
<nav class="nav" aria-label="主导航">
|
||||||
<a href="#tools">工具列表</a>
|
<a href="#tools">工具列表</a>
|
||||||
<a href="#tools">分类浏览</a>
|
<a href="#tools">分类浏览</a>
|
||||||
<a href="#tools">工具中心</a>
|
|
||||||
<a href="/admin">管理端</a>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="nav-btn"
|
class="nav-btn"
|
||||||
@@ -102,6 +97,7 @@
|
|||||||
<p class="result-tip">{{ resultTip }}</p>
|
<p class="result-tip">{{ resultTip }}</p>
|
||||||
<label class="sr-only" for="sortSelect">排序方式</label>
|
<label class="sr-only" for="sortSelect">排序方式</label>
|
||||||
<select id="sortSelect" v-model="filters.sortBy" class="select" @change="onSortChange">
|
<select id="sortSelect" v-model="filters.sortBy" class="select" @change="onSortChange">
|
||||||
|
<option value="created">按创建时间排序</option>
|
||||||
<option value="latest">按更新时间排序</option>
|
<option value="latest">按更新时间排序</option>
|
||||||
<option value="popular">按下载量排序</option>
|
<option value="popular">按下载量排序</option>
|
||||||
<option value="rating">按评分排序</option>
|
<option value="rating">按评分排序</option>
|
||||||
@@ -138,7 +134,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>{{ tool.name }}</h3>
|
<h3>{{ tool.name }}</h3>
|
||||||
<p class="desc">{{ tool.description }}</p>
|
<div class="desc markdown markdown-inline" v-html="renderInlineMarkdown(tool.description)"></div>
|
||||||
|
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span v-for="tag in tool.tags" :key="`${tool.id}-${tag}`" class="tag">{{ tag }}</span>
|
<span v-for="tag in tool.tags" :key="`${tool.id}-${tag}`" class="tag">{{ tag }}</span>
|
||||||
@@ -211,7 +207,7 @@
|
|||||||
<p class="modal-error">{{ detailError }}</p>
|
<p class="modal-error">{{ detailError }}</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<p>{{ detail.description }}</p>
|
<div class="markdown markdown-detail" v-html="renderMarkdown(detail.description)"></div>
|
||||||
<ul class="meta-list">
|
<ul class="meta-list">
|
||||||
<li>分类:<strong>{{ detail.category?.name || '-' }}</strong></li>
|
<li>分类:<strong>{{ detail.category?.name || '-' }}</strong></li>
|
||||||
<li>评分:<strong>{{ Number(detail.rating || 0).toFixed(1) }}</strong></li>
|
<li>评分:<strong>{{ Number(detail.rating || 0).toFixed(1) }}</strong></li>
|
||||||
@@ -225,9 +221,13 @@
|
|||||||
<li v-if="detail.accessMode === 'download'">
|
<li v-if="detail.accessMode === 'download'">
|
||||||
最新版本:<strong>{{ detail.latestVersion || '暂无版本' }}</strong>
|
最新版本:<strong>{{ detail.latestVersion || '暂无版本' }}</strong>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="detail.accessMode === 'download'">
|
<li v-if="detail.accessMode === 'download' && detail.fileSize !== null">
|
||||||
文件大小:<strong>{{ formatFileSize(detail.fileSize) }}</strong>
|
文件大小:<strong>{{ formatFileSize(detail.fileSize) }}</strong>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-if="detail.accessMode === 'download' && detail.openUrl">
|
||||||
|
下载地址:
|
||||||
|
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
|
||||||
|
</li>
|
||||||
<li v-if="detail.accessMode === 'web' && detail.openUrl">
|
<li v-if="detail.accessMode === 'web' && detail.openUrl">
|
||||||
打开地址:
|
打开地址:
|
||||||
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
|
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
|
||||||
@@ -236,7 +236,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<h3>核心能力</h3>
|
<h3>核心能力</h3>
|
||||||
<ul v-if="detail.features?.length" class="feature-list">
|
<ul v-if="detail.features?.length" class="feature-list">
|
||||||
<li v-for="feature in detail.features" :key="`detail-${feature}`">{{ feature }}</li>
|
<li v-for="(feature, featureIndex) in detail.features" :key="`detail-${featureIndex}`">
|
||||||
|
<div class="markdown markdown-inline" v-html="renderInlineMarkdown(feature)"></div>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="modal-muted">暂无能力描述</p>
|
<p v-else class="modal-muted">暂无能力描述</p>
|
||||||
</template>
|
</template>
|
||||||
@@ -285,8 +287,10 @@ import {
|
|||||||
fetchTools,
|
fetchTools,
|
||||||
getApiErrorMessage,
|
getApiErrorMessage,
|
||||||
launchTool,
|
launchTool,
|
||||||
|
notifyToolInteraction,
|
||||||
resolveActionUrl,
|
resolveActionUrl,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
import { renderInlineMarkdown, renderMarkdown } from './utils/markdown';
|
||||||
|
|
||||||
const CLIENT_VERSION = 'web-1.0.0';
|
const CLIENT_VERSION = 'web-1.0.0';
|
||||||
const QUERY_DEBOUNCE_MS = 320;
|
const QUERY_DEBOUNCE_MS = 320;
|
||||||
@@ -294,7 +298,7 @@ const QUERY_DEBOUNCE_MS = 320;
|
|||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
query: '',
|
query: '',
|
||||||
category: 'all',
|
category: 'all',
|
||||||
sortBy: 'latest',
|
sortBy: 'created',
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 6,
|
pageSize: 6,
|
||||||
});
|
});
|
||||||
@@ -524,7 +528,7 @@ function applyHotKeyword(keyword) {
|
|||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
filters.query = '';
|
filters.query = '';
|
||||||
filters.category = 'all';
|
filters.category = 'all';
|
||||||
filters.sortBy = 'latest';
|
filters.sortBy = 'created';
|
||||||
filters.page = 1;
|
filters.page = 1;
|
||||||
clearTimeout(queryTimer);
|
clearTimeout(queryTimer);
|
||||||
loadTools();
|
loadTools();
|
||||||
@@ -572,7 +576,7 @@ function isLaunchDisabled(tool) {
|
|||||||
if (launchingId.value === tool.id) {
|
if (launchingId.value === tool.id) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return tool.accessMode === 'download' && !tool.hasArtifact;
|
return tool.accessMode === 'download' && !tool.downloadReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
function launchButtonText(tool) {
|
function launchButtonText(tool) {
|
||||||
@@ -582,8 +586,8 @@ function launchButtonText(tool) {
|
|||||||
if (tool.accessMode === 'web') {
|
if (tool.accessMode === 'web') {
|
||||||
return '打开网页';
|
return '打开网页';
|
||||||
}
|
}
|
||||||
if (!tool.hasArtifact) {
|
if (!tool.downloadReady) {
|
||||||
return '暂无可下载包';
|
return '暂无可下载资源';
|
||||||
}
|
}
|
||||||
return '下载';
|
return '下载';
|
||||||
}
|
}
|
||||||
@@ -607,14 +611,26 @@ async function triggerLaunch(tool) {
|
|||||||
channel: 'official',
|
channel: 'official',
|
||||||
clientVersion: CLIENT_VERSION,
|
clientVersion: CLIENT_VERSION,
|
||||||
});
|
});
|
||||||
|
const isWebLaunch = result?.mode === 'web';
|
||||||
|
const isDownloadLaunch = result?.mode === 'download';
|
||||||
|
if (isWebLaunch || isDownloadLaunch) {
|
||||||
|
notifyToolInteraction(tool.id, {
|
||||||
|
action: isWebLaunch ? 'open' : 'download',
|
||||||
|
channel: 'official',
|
||||||
|
clientVersion: CLIENT_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const actionUrl = resolveActionUrl(result?.actionUrl);
|
const actionUrl = resolveActionUrl(result?.actionUrl);
|
||||||
|
|
||||||
if (result?.mode === 'web') {
|
if (isWebLaunch || isDownloadLaunch) {
|
||||||
if (result.openIn === 'same_tab') {
|
if (result.openIn === 'same_tab') {
|
||||||
window.location.assign(actionUrl);
|
window.location.assign(actionUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWebLaunch) {
|
||||||
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
|
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
|
||||||
if (!page) {
|
if (!page) {
|
||||||
showToast('浏览器阻止了新窗口,请允许弹窗后重试');
|
showToast('浏览器阻止了新窗口,请允许弹窗后重试');
|
||||||
@@ -627,7 +643,7 @@ async function triggerLaunch(tool) {
|
|||||||
window.location.assign(actionUrl);
|
window.location.assign(actionUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showToast(`${tool.name} 下载任务已创建`);
|
showToast(`${tool.name} 已开始下载`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([loadTools(), refreshOverview()]);
|
await Promise.all([loadTools(), refreshOverview()]);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main class="dashboard-main" :class="{ 'with-kpi': activeMenu === 'overview' }">
|
<main class="dashboard-main" :class="{ 'with-kpi': isOverviewRoute }">
|
||||||
<AdminTopbar
|
<AdminTopbar
|
||||||
v-model:search="topSearch"
|
v-model:search="topSearch"
|
||||||
:section-title="sectionTitle"
|
:section-title="sectionTitle"
|
||||||
@@ -73,63 +73,13 @@
|
|||||||
@refresh="refreshCurrentSection"
|
@refresh="refreshCurrentSection"
|
||||||
@open-overview="openOverviewSection"
|
@open-overview="openOverviewSection"
|
||||||
@open-tools="openToolsSection"
|
@open-tools="openToolsSection"
|
||||||
|
@open-categories="openCategoriesSection"
|
||||||
@open-audit="openAuditSection"
|
@open-audit="openAuditSection"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminKpiRow
|
<router-view v-slot="{ Component }">
|
||||||
v-if="activeMenu === 'overview'"
|
<component :is="Component" v-bind="currentPageProps" v-on="currentPageEvents" />
|
||||||
:kpi-cards="kpiCards"
|
</router-view>
|
||||||
:format-number="formatNumber"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdminOverviewSection
|
|
||||||
v-show="activeMenu === 'overview'"
|
|
||||||
v-model:trend-range="trendRange"
|
|
||||||
:trend-polyline="trendPolyline"
|
|
||||||
:trend-markers="trendMarkers"
|
|
||||||
:device-traffic="deviceTraffic"
|
|
||||||
:location-traffic="locationTraffic"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdminToolsSection
|
|
||||||
v-show="activeMenu === 'tools'"
|
|
||||||
:tool-filters="consoleStore.toolFilters"
|
|
||||||
:category-loading="consoleStore.categoryLoading"
|
|
||||||
:categories="consoleStore.categories"
|
|
||||||
:status-options="statusOptions"
|
|
||||||
:access-mode-options="accessModeOptions"
|
|
||||||
:tool-loading="consoleStore.toolLoading"
|
|
||||||
:tools="consoleStore.tools"
|
|
||||||
:tool-pagination="consoleStore.toolPagination"
|
|
||||||
:status-tag-type="statusTagType"
|
|
||||||
:access-mode-tag-type="accessModeTagType"
|
|
||||||
:format-number="formatNumber"
|
|
||||||
:format-date="formatDate"
|
|
||||||
@search="searchTools"
|
|
||||||
@reset="resetToolFilters"
|
|
||||||
@create="openCreateToolDialog"
|
|
||||||
@edit="openEditToolDialog"
|
|
||||||
@artifact="openArtifactDialog"
|
|
||||||
@status="openStatusDialog"
|
|
||||||
@mode="openModeDialog"
|
|
||||||
@delete="deleteTool"
|
|
||||||
@page-change="handleToolPageChange"
|
|
||||||
@size-change="handleToolSizeChange"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdminAuditSection
|
|
||||||
v-show="activeMenu === 'audit'"
|
|
||||||
:audit-filters="consoleStore.auditFilters"
|
|
||||||
:audit-loading="consoleStore.auditLoading"
|
|
||||||
:audit-logs="consoleStore.auditLogs"
|
|
||||||
:audit-pagination="consoleStore.auditPagination"
|
|
||||||
:format-date-time="formatDateTime"
|
|
||||||
:stringify-body="stringifyBody"
|
|
||||||
@search="searchAuditLogs"
|
|
||||||
@reset="resetAuditFilters"
|
|
||||||
@page-change="handleAuditPageChange"
|
|
||||||
@size-change="handleAuditSizeChange"
|
|
||||||
/>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,12 +91,24 @@
|
|||||||
:tool-form-rules="toolFormRules"
|
:tool-form-rules="toolFormRules"
|
||||||
:categories="consoleStore.categories"
|
:categories="consoleStore.categories"
|
||||||
:category-loading="consoleStore.categoryLoading"
|
:category-loading="consoleStore.categoryLoading"
|
||||||
|
:tags="consoleStore.tags"
|
||||||
|
:tag-loading="consoleStore.tagLoading"
|
||||||
:access-mode-options="accessModeOptions"
|
:access-mode-options="accessModeOptions"
|
||||||
:status-options="statusOptions"
|
:status-options="statusOptions"
|
||||||
:submitting="toolDialog.submitting"
|
:submitting="toolDialog.submitting"
|
||||||
@submit="submitToolForm"
|
@submit="submitToolForm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CategoryFormDialog
|
||||||
|
ref="categoryDialogFormRef"
|
||||||
|
v-model:visible="categoryDialog.visible"
|
||||||
|
:mode="categoryDialog.mode"
|
||||||
|
:category-form="categoryForm"
|
||||||
|
:category-form-rules="categoryFormRules"
|
||||||
|
:submitting="categoryDialog.submitting"
|
||||||
|
@submit="submitCategoryForm"
|
||||||
|
/>
|
||||||
|
|
||||||
<ArtifactDialog
|
<ArtifactDialog
|
||||||
v-model:visible="artifactDialog.visible"
|
v-model:visible="artifactDialog.visible"
|
||||||
:artifact-dialog="artifactDialog"
|
:artifact-dialog="artifactDialog"
|
||||||
@@ -182,89 +144,148 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { getApiErrorMessage } from '../api';
|
import { getApiErrorMessage } from '../api';
|
||||||
import AdminKpiRow from './components/AdminKpiRow.vue';
|
|
||||||
import AdminSidebar from './components/AdminSidebar.vue';
|
import AdminSidebar from './components/AdminSidebar.vue';
|
||||||
import AdminTopbar from './components/AdminTopbar.vue';
|
import AdminTopbar from './components/AdminTopbar.vue';
|
||||||
import AccessModeDialog from './components/AccessModeDialog.vue';
|
import AccessModeDialog from './components/AccessModeDialog.vue';
|
||||||
import AdminAuditSection from './components/AdminAuditSection.vue';
|
|
||||||
import AdminOverviewSection from './components/AdminOverviewSection.vue';
|
|
||||||
import AdminToolsSection from './components/AdminToolsSection.vue';
|
|
||||||
import ArtifactDialog from './components/ArtifactDialog.vue';
|
import ArtifactDialog from './components/ArtifactDialog.vue';
|
||||||
|
import CategoryFormDialog from './components/CategoryFormDialog.vue';
|
||||||
import StatusDialog from './components/StatusDialog.vue';
|
import StatusDialog from './components/StatusDialog.vue';
|
||||||
import ToolFormDialog from './components/ToolFormDialog.vue';
|
import ToolFormDialog from './components/ToolFormDialog.vue';
|
||||||
import { useAdminAuthStore } from './stores/auth';
|
import { useAdminAuthStore } from './stores/auth';
|
||||||
import { useAdminConsoleStore } from './stores/console';
|
import { useAdminConsoleStore } from './stores/console';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const authStore = useAdminAuthStore();
|
const authStore = useAdminAuthStore();
|
||||||
const consoleStore = useAdminConsoleStore();
|
const consoleStore = useAdminConsoleStore();
|
||||||
|
|
||||||
const activeMenu = ref('overview');
|
const ADMIN_SECTION_ROUTE_MAP = {
|
||||||
|
overview: '/admin/overview',
|
||||||
|
tools: '/admin/tools',
|
||||||
|
categories: '/admin/categories',
|
||||||
|
audit: '/admin/auditlogs',
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeMenu = computed(() => {
|
||||||
|
const menuKey = route.meta?.menuKey;
|
||||||
|
return typeof menuKey === 'string' && menuKey ? menuKey : 'tools';
|
||||||
|
});
|
||||||
const topSearch = ref('');
|
const topSearch = ref('');
|
||||||
const trendRange = ref('week');
|
|
||||||
|
|
||||||
const trendValues = [63, 66, 55, 58, 59, 67, 66, 65, 74, 62, 65, 60, 64, 65];
|
|
||||||
const trendMarkers = computed(() => [2, 4, 6, 8, 10, 12].map((idx) => calcTrendPoint(idx)));
|
|
||||||
|
|
||||||
const deviceTraffic = [
|
|
||||||
{ name: 'Linux', value: 46, active: false },
|
|
||||||
{ name: 'Mac', value: 72, active: false },
|
|
||||||
{ name: 'iOS', value: 54, active: false },
|
|
||||||
{ name: 'Windows', value: 80, active: false },
|
|
||||||
{ name: 'Android', value: 60, active: true },
|
|
||||||
{ name: 'Other', value: 34, active: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const locationTraffic = [
|
|
||||||
{ name: 'US', value: 52, active: false },
|
|
||||||
{ name: 'Canada', value: 74, active: false },
|
|
||||||
{ name: 'Mexico', value: 60, active: false },
|
|
||||||
{ name: 'China', value: 35, active: false },
|
|
||||||
{ name: 'Japan', value: 80, active: true },
|
|
||||||
{ name: 'Australia', value: 45, active: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sectionTitle = computed(() => {
|
const sectionTitle = computed(() => {
|
||||||
if (activeMenu.value === 'tools') {
|
const routeTitle = route.meta?.sectionTitle;
|
||||||
return 'Tools';
|
return typeof routeTitle === 'string' && routeTitle ? routeTitle : 'Tools';
|
||||||
}
|
|
||||||
if (activeMenu.value === 'audit') {
|
|
||||||
return 'Audit Logs';
|
|
||||||
}
|
|
||||||
return 'Overview';
|
|
||||||
});
|
});
|
||||||
|
const isOverviewRoute = computed(() => route.meta?.withKpi === true);
|
||||||
|
|
||||||
|
const overviewSummary = computed(() => consoleStore.overview.summary || {});
|
||||||
|
|
||||||
const kpiCards = computed(() => {
|
const kpiCards = computed(() => {
|
||||||
const toolTotal = consoleStore.toolPagination.total;
|
const summary = overviewSummary.value;
|
||||||
const openTotal = consoleStore.tools.reduce((sum, item) => sum + Number(item.openCount || 0), 0);
|
const publishRate = formatPercent(summary.publishedTotal, summary.toolTotal);
|
||||||
const downloadTotal = consoleStore.tools.reduce((sum, item) => sum + Number(item.downloadCount || 0), 0);
|
const downloadReadyRate = formatPercent(summary.downloadReadyToolTotal, summary.downloadToolTotal);
|
||||||
const publishedCount = consoleStore.tools.filter((item) => item.status === 'published').length;
|
|
||||||
const auditTotal = consoleStore.auditPagination.total;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ key: 'views', label: 'Views', value: toolTotal, delta: 11.01, theme: 'blue' },
|
{ key: 'tool-total', label: '工具总数', value: summary.toolTotal, note: `已发布率 ${publishRate}`, theme: 'blue' },
|
||||||
{ key: 'visits', label: 'Visits', value: openTotal, delta: -0.03, theme: 'dark' },
|
{ key: 'category-total', label: '分类总数', value: summary.categoryTotal, note: `标签 ${formatNumber(summary.tagTotal)}`, theme: 'dark' },
|
||||||
{ key: 'new-users', label: 'New Users', value: publishedCount, delta: 15.03, theme: 'blue' },
|
{ key: 'open-total', label: '累计访问', value: summary.openTotal, note: `交互总量 ${formatNumber(summary.interactionTotal)}`, theme: 'blue' },
|
||||||
|
{ key: 'download-total', label: '累计下载', value: summary.downloadTotal, note: `下载模式 ${formatNumber(summary.downloadToolTotal)}`, theme: 'dark' },
|
||||||
{
|
{
|
||||||
key: 'active-users',
|
key: 'download-ready',
|
||||||
label: 'Active Users',
|
label: '下载就绪工具',
|
||||||
value: downloadTotal + auditTotal,
|
value: summary.downloadReadyToolTotal,
|
||||||
delta: 6.08,
|
note: `就绪率 ${downloadReadyRate}`,
|
||||||
theme: 'dark',
|
theme: 'blue',
|
||||||
},
|
},
|
||||||
|
{ key: 'audit-total', label: '审计日志总量', value: summary.auditLogTotal, note: `活跃版本 ${formatNumber(summary.activeArtifactTotal)}`, theme: 'dark' },
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
const trendPolyline = computed(() =>
|
const currentPageProps = computed(() => {
|
||||||
trendValues
|
if (activeMenu.value === 'overview') {
|
||||||
.map((_, idx) => {
|
return {
|
||||||
const point = calcTrendPoint(idx);
|
kpiCards: kpiCards.value,
|
||||||
return `${point.x},${point.y}`;
|
loadingOverview: consoleStore.overviewLoading,
|
||||||
})
|
overview: consoleStore.overview,
|
||||||
.join(' '),
|
formatNumber,
|
||||||
);
|
formatDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMenu.value === 'categories') {
|
||||||
|
return {
|
||||||
|
categoryFilters: consoleStore.categoryFilters,
|
||||||
|
categoryLoading: consoleStore.categoryLoading,
|
||||||
|
categories: consoleStore.categories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMenu.value === 'audit') {
|
||||||
|
return {
|
||||||
|
auditFilters: consoleStore.auditFilters,
|
||||||
|
auditLoading: consoleStore.auditLoading,
|
||||||
|
auditLogs: consoleStore.auditLogs,
|
||||||
|
auditPagination: consoleStore.auditPagination,
|
||||||
|
formatDateTime,
|
||||||
|
stringifyBody,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolFilters: consoleStore.toolFilters,
|
||||||
|
categoryLoading: consoleStore.categoryLoading,
|
||||||
|
categories: consoleStore.categories,
|
||||||
|
statusOptions,
|
||||||
|
accessModeOptions,
|
||||||
|
toolLoading: consoleStore.toolLoading,
|
||||||
|
tools: consoleStore.tools,
|
||||||
|
toolPagination: consoleStore.toolPagination,
|
||||||
|
statusTagType,
|
||||||
|
accessModeTagType,
|
||||||
|
formatNumber,
|
||||||
|
formatDate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentPageEvents = computed(() => {
|
||||||
|
if (activeMenu.value === 'overview') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMenu.value === 'categories') {
|
||||||
|
return {
|
||||||
|
search: searchCategories,
|
||||||
|
reset: resetCategoryFilters,
|
||||||
|
create: openCreateCategoryDialog,
|
||||||
|
edit: openEditCategoryDialog,
|
||||||
|
delete: deleteCategory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMenu.value === 'audit') {
|
||||||
|
return {
|
||||||
|
search: searchAuditLogs,
|
||||||
|
reset: resetAuditFilters,
|
||||||
|
'page-change': handleAuditPageChange,
|
||||||
|
'size-change': handleAuditSizeChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
search: searchTools,
|
||||||
|
reset: resetToolFilters,
|
||||||
|
create: openCreateToolDialog,
|
||||||
|
edit: openEditToolDialog,
|
||||||
|
artifact: openArtifactDialog,
|
||||||
|
status: openStatusDialog,
|
||||||
|
mode: openModeDialog,
|
||||||
|
delete: deleteTool,
|
||||||
|
'page-change': handleToolPageChange,
|
||||||
|
'size-change': handleToolSizeChange,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const loginFormRef = ref(null);
|
const loginFormRef = ref(null);
|
||||||
const loginForm = reactive({
|
const loginForm = reactive({
|
||||||
@@ -295,11 +316,13 @@ const accessModeOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const toolDialogFormRef = ref(null);
|
const toolDialogFormRef = ref(null);
|
||||||
|
const categoryDialogFormRef = ref(null);
|
||||||
|
|
||||||
function createEmptyToolForm() {
|
function createEmptyToolForm() {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
|
tagIds: [],
|
||||||
description: '',
|
description: '',
|
||||||
rating: 0,
|
rating: 0,
|
||||||
featuresText: '',
|
featuresText: '',
|
||||||
@@ -318,6 +341,18 @@ const toolFormRules = {
|
|||||||
{ min: 2, max: 120, message: '工具名称长度为 2-120 位', trigger: 'blur' },
|
{ min: 2, max: 120, message: '工具名称长度为 2-120 位', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
categoryId: [{ required: true, message: '请选择工具分类', trigger: 'change' }],
|
categoryId: [{ required: true, message: '请选择工具分类', trigger: 'change' }],
|
||||||
|
tagIds: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (Array.isArray(value) && value.length > 20) {
|
||||||
|
callback(new Error('最多选择 20 个标签'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
],
|
||||||
description: [
|
description: [
|
||||||
{ required: true, message: '请输入工具简介', trigger: 'blur' },
|
{ required: true, message: '请输入工具简介', trigger: 'blur' },
|
||||||
{ min: 10, max: 2000, message: '工具简介长度为 10-2000 位', trigger: 'blur' },
|
{ min: 10, max: 2000, message: '工具简介长度为 10-2000 位', trigger: 'blur' },
|
||||||
@@ -326,23 +361,24 @@ const toolFormRules = {
|
|||||||
openUrl: [
|
openUrl: [
|
||||||
{
|
{
|
||||||
validator: (_rule, value, callback) => {
|
validator: (_rule, value, callback) => {
|
||||||
if (toolForm.accessMode !== 'web') {
|
const normalized = String(value || '').trim();
|
||||||
|
if (toolForm.accessMode !== 'web' && !normalized) {
|
||||||
callback();
|
callback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!value || !String(value).trim()) {
|
if (!normalized) {
|
||||||
callback(new Error('网页模式必须填写 Open URL'));
|
callback(new Error('网页模式必须填写 Open URL'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(String(value).trim());
|
const parsed = new URL(normalized);
|
||||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
callback(new Error('Open URL 必须是 http/https 地址'));
|
callback(new Error('地址必须是 http/https 链接'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
callback();
|
callback();
|
||||||
} catch {
|
} catch {
|
||||||
callback(new Error('Open URL 格式不正确'));
|
callback(new Error('地址格式不正确'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
trigger: 'blur',
|
trigger: 'blur',
|
||||||
@@ -358,6 +394,43 @@ const toolDialog = reactive({
|
|||||||
submitting: false,
|
submitting: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createEmptyCategoryForm() {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
sortOrder: 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryForm = reactive(createEmptyCategoryForm());
|
||||||
|
|
||||||
|
const categoryFormRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入分类名称', trigger: 'blur' },
|
||||||
|
{ min: 1, max: 80, message: '分类名称长度为 1-80 位', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
sortOrder: [
|
||||||
|
{ required: true, message: '请输入排序值', trigger: 'change' },
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isInteger(numeric) || numeric < 0 || numeric > 9999) {
|
||||||
|
callback(new Error('排序值必须是 0-9999 的整数'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryDialog = reactive({
|
||||||
|
visible: false,
|
||||||
|
mode: 'create',
|
||||||
|
id: '',
|
||||||
|
submitting: false,
|
||||||
|
});
|
||||||
|
|
||||||
const artifactDialog = reactive({
|
const artifactDialog = reactive({
|
||||||
visible: false,
|
visible: false,
|
||||||
toolId: '',
|
toolId: '',
|
||||||
@@ -396,24 +469,20 @@ const modeDialog = reactive({
|
|||||||
|
|
||||||
const currentToken = computed(() => authStore.accessToken);
|
const currentToken = computed(() => authStore.accessToken);
|
||||||
|
|
||||||
function calcTrendPoint(index) {
|
|
||||||
const width = 760;
|
|
||||||
const height = 220;
|
|
||||||
const xPadding = 16;
|
|
||||||
const yPadding = 18;
|
|
||||||
const min = 50;
|
|
||||||
const max = 80;
|
|
||||||
const x = xPadding + (index * (width - xPadding * 2)) / (trendValues.length - 1);
|
|
||||||
const normalized = (trendValues[index] - min) / (max - min);
|
|
||||||
const y = height - yPadding - normalized * (height - yPadding * 2);
|
|
||||||
return { x, y };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
return new Intl.NumberFormat('zh-CN').format(Number.isFinite(numeric) ? numeric : 0);
|
return new Intl.NumberFormat('zh-CN').format(Number.isFinite(numeric) ? numeric : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPercent(value, total) {
|
||||||
|
const numerator = Number(value || 0);
|
||||||
|
const denominator = Number(total || 0);
|
||||||
|
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
|
||||||
|
return '0.0%';
|
||||||
|
}
|
||||||
|
return `${((numerator / denominator) * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateText) {
|
function formatDate(dateText) {
|
||||||
if (!dateText) {
|
if (!dateText) {
|
||||||
return '-';
|
return '-';
|
||||||
@@ -522,10 +591,94 @@ function normalizeFeatureText(value) {
|
|||||||
.slice(0, 20);
|
.slice(0, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTagName(value) {
|
||||||
|
return String(value || '').trim().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHttpUrl(value) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(String(value || '').trim());
|
||||||
|
return ['http:', 'https:'].includes(parsed.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTagSelections(values) {
|
||||||
|
const selected = Array.isArray(values) ? values : [];
|
||||||
|
const sourceTags = Array.isArray(consoleStore.tags) ? consoleStore.tags : [];
|
||||||
|
const knownTagIdSet = new Set(sourceTags.map((item) => item.id));
|
||||||
|
const knownTagNameToId = new Map(
|
||||||
|
sourceTags
|
||||||
|
.map((item) => [normalizeTagName(item.name).toLowerCase(), item.id])
|
||||||
|
.filter(([name]) => Boolean(name)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingTagIds = [];
|
||||||
|
const newTagNames = [];
|
||||||
|
const seenTagIds = new Set();
|
||||||
|
const seenTagNames = new Set();
|
||||||
|
|
||||||
|
selected.forEach((item) => {
|
||||||
|
const raw = String(item ?? '');
|
||||||
|
const normalized = normalizeTagName(raw);
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knownTagIdSet.has(raw)) {
|
||||||
|
if (!seenTagIds.has(raw)) {
|
||||||
|
existingTagIds.push(raw);
|
||||||
|
seenTagIds.add(raw);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIdByName = knownTagNameToId.get(normalized.toLowerCase());
|
||||||
|
if (existingIdByName) {
|
||||||
|
if (!seenTagIds.has(existingIdByName)) {
|
||||||
|
existingTagIds.push(existingIdByName);
|
||||||
|
seenTagIds.add(existingIdByName);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seenTagNames.has(normalized.toLowerCase())) {
|
||||||
|
newTagNames.push(normalized);
|
||||||
|
seenTagNames.add(normalized.toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
existingTagIds,
|
||||||
|
newTagNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveToolTagIds(token) {
|
||||||
|
const { existingTagIds, newTagNames } = splitTagSelections(toolForm.tagIds);
|
||||||
|
if (newTagNames.length === 0) {
|
||||||
|
return existingTagIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdTags = await Promise.all(
|
||||||
|
newTagNames.map((name) => consoleStore.createTag({ name }, token)),
|
||||||
|
);
|
||||||
|
const createdTagIds = createdTags
|
||||||
|
.map((item) => item?.id)
|
||||||
|
.filter((id) => typeof id === 'string' && id);
|
||||||
|
|
||||||
|
return Array.from(new Set([...existingTagIds, ...createdTagIds]));
|
||||||
|
}
|
||||||
|
|
||||||
function resetToolForm() {
|
function resetToolForm() {
|
||||||
Object.assign(toolForm, createEmptyToolForm());
|
Object.assign(toolForm, createEmptyToolForm());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetCategoryForm() {
|
||||||
|
Object.assign(categoryForm, createEmptyCategoryForm());
|
||||||
|
}
|
||||||
|
|
||||||
function resetArtifactForm() {
|
function resetArtifactForm() {
|
||||||
artifactForm.version = '';
|
artifactForm.version = '';
|
||||||
artifactForm.releaseNotes = '';
|
artifactForm.releaseNotes = '';
|
||||||
@@ -550,7 +703,9 @@ async function runWithAuth(fn) {
|
|||||||
async function initializeAdminData() {
|
async function initializeAdminData() {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
consoleStore.loadCategories(),
|
runWithAuth((token) => consoleStore.loadOverview(token)),
|
||||||
|
runWithAuth((token) => consoleStore.loadCategories(token)),
|
||||||
|
runWithAuth((token) => consoleStore.loadTags(token)),
|
||||||
runWithAuth((token) => consoleStore.loadTools(token)),
|
runWithAuth((token) => consoleStore.loadTools(token)),
|
||||||
runWithAuth((token) => consoleStore.loadAuditLogs(token)),
|
runWithAuth((token) => consoleStore.loadAuditLogs(token)),
|
||||||
]);
|
]);
|
||||||
@@ -594,35 +749,50 @@ function goPublic() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function switchMenu(nextKey) {
|
function switchMenu(nextKey) {
|
||||||
if (!['overview', 'tools', 'audit'].includes(nextKey)) {
|
const targetPath = ADMIN_SECTION_ROUTE_MAP[nextKey];
|
||||||
|
if (!targetPath) {
|
||||||
ElMessage.info('该菜单为展示项,当前版本暂未开放');
|
ElMessage.info('该菜单为展示项,当前版本暂未开放');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activeMenu.value = nextKey;
|
if (route.path !== targetPath) {
|
||||||
|
router.push(targetPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openOverviewSection() {
|
function openOverviewSection() {
|
||||||
activeMenu.value = 'overview';
|
switchMenu('overview');
|
||||||
}
|
}
|
||||||
|
|
||||||
function openToolsSection() {
|
function openToolsSection() {
|
||||||
activeMenu.value = 'tools';
|
switchMenu('tools');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCategoriesSection() {
|
||||||
|
switchMenu('categories');
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAuditSection() {
|
function openAuditSection() {
|
||||||
activeMenu.value = 'audit';
|
switchMenu('audit');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCurrentSection() {
|
async function refreshCurrentSection() {
|
||||||
|
if (activeMenu.value === 'overview') {
|
||||||
|
await loadOverview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeMenu.value === 'tools') {
|
if (activeMenu.value === 'tools') {
|
||||||
await loadTools();
|
await loadTools();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (activeMenu.value === 'categories') {
|
||||||
|
await loadCategories();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeMenu.value === 'audit') {
|
if (activeMenu.value === 'audit') {
|
||||||
await loadAuditLogs();
|
await loadAuditLogs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await Promise.all([loadTools(), loadAuditLogs()]);
|
await Promise.all([loadOverview(), loadCategories(), loadTools(), loadAuditLogs()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTopSearch() {
|
function applyTopSearch() {
|
||||||
@@ -637,10 +807,26 @@ function applyTopSearch() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeMenu.value === 'categories') {
|
||||||
|
consoleStore.categoryFilters.query = value;
|
||||||
|
searchCategories();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
consoleStore.toolFilters.query = value;
|
consoleStore.toolFilters.query = value;
|
||||||
searchTools();
|
searchTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchCategories() {
|
||||||
|
await loadCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetCategoryFilters() {
|
||||||
|
consoleStore.resetCategoryFilters();
|
||||||
|
topSearch.value = '';
|
||||||
|
await loadCategories();
|
||||||
|
}
|
||||||
|
|
||||||
async function searchTools() {
|
async function searchTools() {
|
||||||
consoleStore.toolFilters.page = 1;
|
consoleStore.toolFilters.page = 1;
|
||||||
await loadTools();
|
await loadTools();
|
||||||
@@ -663,6 +849,34 @@ async function handleToolSizeChange(size) {
|
|||||||
await loadTools();
|
await loadTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCategories() {
|
||||||
|
try {
|
||||||
|
await runWithAuth((token) => consoleStore.loadCategories(token));
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnauthorized(error)) {
|
||||||
|
await authStore.logout();
|
||||||
|
consoleStore.$reset();
|
||||||
|
ElMessage.error('登录已过期,请重新登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ElMessage.error(getApiErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverview() {
|
||||||
|
try {
|
||||||
|
await runWithAuth((token) => consoleStore.loadOverview(token));
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnauthorized(error)) {
|
||||||
|
await authStore.logout();
|
||||||
|
consoleStore.$reset();
|
||||||
|
ElMessage.error('登录已过期,请重新登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ElMessage.error(getApiErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTools() {
|
async function loadTools() {
|
||||||
try {
|
try {
|
||||||
await runWithAuth((token) => consoleStore.loadTools(token));
|
await runWithAuth((token) => consoleStore.loadTools(token));
|
||||||
@@ -677,6 +891,81 @@ async function loadTools() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCreateCategoryDialog() {
|
||||||
|
categoryDialog.mode = 'create';
|
||||||
|
categoryDialog.id = '';
|
||||||
|
resetCategoryForm();
|
||||||
|
categoryDialog.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditCategoryDialog(row) {
|
||||||
|
categoryDialog.mode = 'edit';
|
||||||
|
categoryDialog.id = row.id;
|
||||||
|
Object.assign(categoryForm, {
|
||||||
|
name: row.name || '',
|
||||||
|
sortOrder: Number(row.sortOrder ?? 100),
|
||||||
|
});
|
||||||
|
categoryDialog.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCategoryPayload() {
|
||||||
|
return {
|
||||||
|
name: categoryForm.name.trim(),
|
||||||
|
sortOrder: Number(categoryForm.sortOrder ?? 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCategoryForm() {
|
||||||
|
const formRef = categoryDialogFormRef.value;
|
||||||
|
if (!formRef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await formRef.validate();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryDialog.submitting = true;
|
||||||
|
try {
|
||||||
|
const payload = buildCategoryPayload();
|
||||||
|
await runWithAuth((token) => {
|
||||||
|
if (categoryDialog.mode === 'create') {
|
||||||
|
return consoleStore.createCategory(payload, token);
|
||||||
|
}
|
||||||
|
return consoleStore.updateCategory(categoryDialog.id, payload, token);
|
||||||
|
});
|
||||||
|
categoryDialog.visible = false;
|
||||||
|
ElMessage.success(categoryDialog.mode === 'create' ? '分类已创建' : '分类已更新');
|
||||||
|
await Promise.all([loadCategories(), loadTools()]);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(getApiErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
categoryDialog.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCategory(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除分类「${row.name}」吗?`, '删除确认', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runWithAuth((token) => consoleStore.deleteCategory(row.id, token));
|
||||||
|
ElMessage.success('分类已删除');
|
||||||
|
await Promise.all([loadCategories(), loadTools()]);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(getApiErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateToolDialog() {
|
function openCreateToolDialog() {
|
||||||
toolDialog.mode = 'create';
|
toolDialog.mode = 'create';
|
||||||
toolDialog.id = '';
|
toolDialog.id = '';
|
||||||
@@ -690,6 +979,7 @@ function openEditToolDialog(row) {
|
|||||||
Object.assign(toolForm, {
|
Object.assign(toolForm, {
|
||||||
name: row.name || '',
|
name: row.name || '',
|
||||||
categoryId: row.category?.id || '',
|
categoryId: row.category?.id || '',
|
||||||
|
tagIds: Array.isArray(row.tags) ? row.tags.map((item) => item.id).filter(Boolean) : [],
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
rating: Number(row.rating ?? 0),
|
rating: Number(row.rating ?? 0),
|
||||||
featuresText: Array.isArray(row.features) ? row.features.join('\n') : '',
|
featuresText: Array.isArray(row.features) ? row.features.join('\n') : '',
|
||||||
@@ -701,24 +991,21 @@ function openEditToolDialog(row) {
|
|||||||
toolDialog.visible = true;
|
toolDialog.visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolPayload() {
|
function buildToolPayload(tagIds) {
|
||||||
|
const openUrl = toolForm.openUrl.trim();
|
||||||
const payload = {
|
const payload = {
|
||||||
name: toolForm.name.trim(),
|
name: toolForm.name.trim(),
|
||||||
categoryId: toolForm.categoryId,
|
categoryId: toolForm.categoryId,
|
||||||
|
tags: tagIds,
|
||||||
description: toolForm.description.trim(),
|
description: toolForm.description.trim(),
|
||||||
rating: Number(toolForm.rating ?? 0),
|
rating: Number(toolForm.rating ?? 0),
|
||||||
features: normalizeFeatureText(toolForm.featuresText),
|
features: normalizeFeatureText(toolForm.featuresText),
|
||||||
accessMode: toolForm.accessMode,
|
accessMode: toolForm.accessMode,
|
||||||
openInNewTab: toolForm.openInNewTab,
|
openInNewTab: toolForm.openInNewTab,
|
||||||
status: toolForm.status,
|
status: toolForm.status,
|
||||||
|
openUrl: openUrl || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (toolForm.accessMode === 'web') {
|
|
||||||
payload.openUrl = toolForm.openUrl.trim();
|
|
||||||
} else if (toolDialog.mode === 'edit') {
|
|
||||||
payload.openUrl = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,8 +1023,9 @@ async function submitToolForm() {
|
|||||||
|
|
||||||
toolDialog.submitting = true;
|
toolDialog.submitting = true;
|
||||||
try {
|
try {
|
||||||
const payload = buildToolPayload();
|
await runWithAuth(async (token) => {
|
||||||
await runWithAuth((token) => {
|
const resolvedTagIds = await resolveToolTagIds(token);
|
||||||
|
const payload = buildToolPayload(resolvedTagIds);
|
||||||
if (toolDialog.mode === 'create') {
|
if (toolDialog.mode === 'create') {
|
||||||
return consoleStore.createTool(payload, token);
|
return consoleStore.createTool(payload, token);
|
||||||
}
|
}
|
||||||
@@ -933,10 +1221,15 @@ function openModeDialog(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitAccessModeUpdate() {
|
async function submitAccessModeUpdate() {
|
||||||
if (modeDialog.accessMode === 'web' && !modeDialog.openUrl.trim()) {
|
const openUrl = modeDialog.openUrl.trim();
|
||||||
|
if (modeDialog.accessMode === 'web' && !openUrl) {
|
||||||
ElMessage.warning('网页模式必须填写 Open URL');
|
ElMessage.warning('网页模式必须填写 Open URL');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (openUrl && !isValidHttpUrl(openUrl)) {
|
||||||
|
ElMessage.warning('请输入有效的 http/https 地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
modeDialog.submitting = true;
|
modeDialog.submitting = true;
|
||||||
try {
|
try {
|
||||||
@@ -945,7 +1238,7 @@ async function submitAccessModeUpdate() {
|
|||||||
modeDialog.id,
|
modeDialog.id,
|
||||||
{
|
{
|
||||||
accessMode: modeDialog.accessMode,
|
accessMode: modeDialog.accessMode,
|
||||||
openUrl: modeDialog.accessMode === 'web' ? modeDialog.openUrl.trim() : undefined,
|
openUrl: openUrl || null,
|
||||||
openInNewTab: modeDialog.openInNewTab,
|
openInNewTab: modeDialog.openInNewTab,
|
||||||
},
|
},
|
||||||
token,
|
token,
|
||||||
@@ -998,6 +1291,12 @@ async function loadAuditLogs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(activeMenu, async (nextMenu) => {
|
watch(activeMenu, async (nextMenu) => {
|
||||||
|
if (nextMenu === 'overview' && !consoleStore.overview.generatedAt) {
|
||||||
|
await loadOverview();
|
||||||
|
}
|
||||||
|
if (nextMenu === 'categories' && !consoleStore.categories.length) {
|
||||||
|
await loadCategories();
|
||||||
|
}
|
||||||
if (nextMenu === 'tools' && !consoleStore.tools.length) {
|
if (nextMenu === 'tools' && !consoleStore.tools.length) {
|
||||||
await loadTools();
|
await loadTools();
|
||||||
}
|
}
|
||||||
@@ -1006,15 +1305,6 @@ watch(activeMenu, async (nextMenu) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
|
||||||
() => toolForm.accessMode,
|
|
||||||
(mode) => {
|
|
||||||
if (mode === 'download') {
|
|
||||||
toolForm.openUrl = '';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => toolDialog.visible,
|
() => toolDialog.visible,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
@@ -1025,6 +1315,16 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => categoryDialog.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (!visible) {
|
||||||
|
resetCategoryForm();
|
||||||
|
categoryDialogFormRef.value?.clearValidate?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => artifactDialog.visible,
|
() => artifactDialog.visible,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600;700&family=Fira+Sans:wght@400;500;600;700&display=swap');
|
|
||||||
|
|
||||||
.admin-ref {
|
.admin-ref {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #e7eaee;
|
background: #e7eaee;
|
||||||
font-family: "Fira Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
|
font-family: var(--font-admin-sans);
|
||||||
color: #1d2430;
|
color: #1d2430;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,14 +34,14 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
background: linear-gradient(135deg, #2f83ed, #379cff);
|
background: linear-gradient(135deg, #2f83ed, #379cff);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: "Fira Code", monospace;
|
font-family: var(--font-admin-mono);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-login-title h2 {
|
.admin-login-title h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-family: "Fira Code", monospace;
|
font-family: var(--font-admin-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-login-title span {
|
.admin-login-title span {
|
||||||
@@ -102,7 +100,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-text {
|
.brand-text {
|
||||||
font-family: "Fira Code", monospace;
|
font-family: var(--font-admin-mono);
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: #566579;
|
color: #566579;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
@@ -168,7 +166,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-main.with-kpi {
|
.dashboard-main.with-kpi {
|
||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar {
|
.dashboard-topbar {
|
||||||
@@ -226,7 +224,7 @@
|
|||||||
|
|
||||||
.kpi-row {
|
.kpi-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +233,7 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
min-height: 104px;
|
min-height: 112px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.26);
|
border: 1px solid rgba(255, 255, 255, 0.26);
|
||||||
}
|
}
|
||||||
@@ -256,26 +254,18 @@
|
|||||||
|
|
||||||
.kpi-value {
|
.kpi-value {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 36px;
|
font-size: 32px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-family: "Fira Code", monospace;
|
font-family: var(--font-admin-mono);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-delta {
|
.kpi-note {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 16px;
|
left: 18px;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
font-size: 16px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
opacity: 0.9;
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-delta.up {
|
|
||||||
color: rgba(236, 255, 246, 0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-delta.down {
|
|
||||||
color: rgba(255, 226, 226, 0.96);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-section {
|
.dashboard-section {
|
||||||
@@ -283,6 +273,78 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-dashboard {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid {
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-block {
|
||||||
|
border: 1px solid rgba(144, 157, 177, 0.24);
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-block-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #253244;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4f5e73;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-head strong {
|
||||||
|
color: #1f2a3a;
|
||||||
|
font-family: var(--font-admin-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(145, 161, 186, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar > span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #3d8dff, #2268de);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-bottom {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-loading {
|
||||||
|
color: #5f6878;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: 1px solid rgba(144, 157, 177, 0.28);
|
border: 1px solid rgba(144, 157, 177, 0.28);
|
||||||
@@ -314,7 +376,7 @@
|
|||||||
padding: 6px 2px;
|
padding: 6px 2px;
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: "Fira Sans", sans-serif;
|
font-family: var(--font-admin-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
@@ -377,7 +439,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
color: #1f4fb8;
|
color: #1f4fb8;
|
||||||
font-family: "Fira Code", monospace;
|
font-family: var(--font-admin-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-panel:nth-child(2) .mini-head h3 {
|
.mini-panel:nth-child(2) .mini-head h3 {
|
||||||
@@ -442,7 +504,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: #2b3647;
|
color: #2b3647;
|
||||||
font-family: "Fira Code", monospace;
|
font-family: var(--font-admin-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-head p {
|
.data-head p {
|
||||||
@@ -463,6 +525,10 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-filters {
|
||||||
|
grid-template-columns: 1.2fr auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
.data-table {
|
.data-table {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
@@ -542,6 +608,10 @@
|
|||||||
.data-filters {
|
.data-filters {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
@@ -571,6 +641,10 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-bottom {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.mini-panels {
|
.mini-panels {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -603,6 +677,10 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-grid {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.data-filters {
|
.data-filters {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ export async function adminGetTools(params, token) {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function adminGetOverview(token) {
|
||||||
|
const response = await http.get('/admin/overview', withToken(token));
|
||||||
|
return unwrap(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function adminCreateTool(payload, token) {
|
export async function adminCreateTool(payload, token) {
|
||||||
const response = await http.post('/admin/tools', payload, withToken(token));
|
const response = await http.post('/admin/tools', payload, withToken(token));
|
||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
@@ -120,7 +125,32 @@ export async function adminDeleteArtifact(toolId, artifactId, token) {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adminGetCategories() {
|
export async function adminGetCategories(params, token) {
|
||||||
const response = await http.get('/categories');
|
const response = await http.get('/admin/categories', withToken(token, { params }));
|
||||||
|
return unwrap(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminCreateCategory(payload, token) {
|
||||||
|
const response = await http.post('/admin/categories', payload, withToken(token));
|
||||||
|
return unwrap(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminUpdateCategory(id, payload, token) {
|
||||||
|
const response = await http.patch(`/admin/categories/${id}`, payload, withToken(token));
|
||||||
|
return unwrap(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminDeleteCategory(id, token) {
|
||||||
|
const response = await http.delete(`/admin/categories/${id}`, withToken(token));
|
||||||
|
return unwrap(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminGetTags(token) {
|
||||||
|
const response = await http.get('/admin/tags', withToken(token));
|
||||||
|
return unwrap(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminCreateTag(payload, token) {
|
||||||
|
const response = await http.post('/admin/tags', payload, withToken(token));
|
||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,14 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="modeDialog.accessMode === 'web'" label="Open URL">
|
<el-form-item
|
||||||
<el-input v-model="modeDialog.openUrl" placeholder="https://example.com" />
|
v-if="modeDialog.accessMode === 'web' || modeDialog.accessMode === 'download'"
|
||||||
|
:label="modeDialog.accessMode === 'web' ? 'Open URL' : '下载地址'"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="modeDialog.openUrl"
|
||||||
|
:placeholder="modeDialog.accessMode === 'web' ? 'https://example.com' : 'https://gitlab.example.com/...' "
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="新标签页">
|
<el-form-item label="新标签页">
|
||||||
<el-switch v-model="modeDialog.openInNewTab" />
|
<el-switch v-model="modeDialog.openInNewTab" />
|
||||||
|
|||||||
69
client/src/admin/components/AdminCategoriesSection.vue
Normal file
69
client/src/admin/components/AdminCategoriesSection.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<article class="panel data-panel">
|
||||||
|
<header class="data-head">
|
||||||
|
<div>
|
||||||
|
<h3>Category Management</h3>
|
||||||
|
<p>支持工具分类新增、编辑与删除维护</p>
|
||||||
|
</div>
|
||||||
|
<div class="data-head-actions">
|
||||||
|
<el-button type="primary" @click="emit('create')">新增分类</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="data-filters category-filters">
|
||||||
|
<el-input
|
||||||
|
v-model="categoryFilters.query"
|
||||||
|
placeholder="搜索分类名称"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="emit('search')"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" :loading="categoryLoading" @click="emit('search')">查询</el-button>
|
||||||
|
<el-button @click="emit('reset')">重置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="categories"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
v-loading="categoryLoading"
|
||||||
|
class="data-table"
|
||||||
|
>
|
||||||
|
<el-table-column prop="name" label="分类名称" min-width="220" />
|
||||||
|
<el-table-column prop="sortOrder" label="排序值" width="120" />
|
||||||
|
<el-table-column label="工具数" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ Number(row.toolCount || 0) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="220" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-space>
|
||||||
|
<el-button size="small" type="primary" plain @click="emit('edit', row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" plain @click="emit('delete', row)">删除</el-button>
|
||||||
|
</el-space>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
categoryFilters: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
categoryLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['search', 'reset', 'create', 'edit', 'delete']);
|
||||||
|
</script>
|
||||||
@@ -8,9 +8,7 @@
|
|||||||
>
|
>
|
||||||
<p>{{ card.label }}</p>
|
<p>{{ card.label }}</p>
|
||||||
<div class="kpi-value">{{ formatNumber(card.value) }}</div>
|
<div class="kpi-value">{{ formatNumber(card.value) }}</div>
|
||||||
<span class="kpi-delta" :class="card.delta >= 0 ? 'up' : 'down'">
|
<span v-if="card.note" class="kpi-note">{{ card.note }}</span>
|
||||||
{{ card.delta >= 0 ? '+' : '' }}{{ card.delta.toFixed(2) }}%
|
|
||||||
</span>
|
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,101 +1,230 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="dashboard-section">
|
<section class="dashboard-section overview-dashboard">
|
||||||
<section class="panel trend-panel">
|
<section class="panel overview-grid">
|
||||||
<header class="panel-head">
|
<article class="overview-block">
|
||||||
<div class="panel-tabs">
|
<header class="overview-block-head">
|
||||||
<button type="button" class="tab active">Users</button>
|
<h3>工具状态分布</h3>
|
||||||
<button type="button" class="tab">Projects</button>
|
</header>
|
||||||
<button type="button" class="tab">Operating Status</button>
|
<ul class="metric-list">
|
||||||
</div>
|
<li v-for="item in statusStats" :key="item.key" class="metric-item">
|
||||||
<div class="panel-controls">
|
<div class="metric-head">
|
||||||
<el-select v-model="trendRange" size="small" style="width: 108px">
|
<span>{{ item.label }}</span>
|
||||||
<el-option label="Week" value="week" />
|
<strong>{{ formatNumber(item.value) }}</strong>
|
||||||
<el-option label="Month" value="month" />
|
</div>
|
||||||
<el-option label="Quarter" value="quarter" />
|
<div class="metric-bar">
|
||||||
</el-select>
|
<span :style="{ width: `${item.ratio}%` }"></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
<div class="line-chart-wrap" aria-hidden="true">
|
<article class="overview-block">
|
||||||
<svg viewBox="0 0 760 220" preserveAspectRatio="none">
|
<header class="overview-block-head">
|
||||||
<polyline class="line-main" :points="trendPolyline" />
|
<h3>访问模式分布</h3>
|
||||||
<circle
|
</header>
|
||||||
v-for="point in trendMarkers"
|
<ul class="metric-list">
|
||||||
:key="`${point.x}-${point.y}`"
|
<li v-for="item in accessModeStats" :key="item.key" class="metric-item">
|
||||||
class="line-dot"
|
<div class="metric-head">
|
||||||
:cx="point.x"
|
<span>{{ item.label }}</span>
|
||||||
:cy="point.y"
|
<strong>{{ formatNumber(item.value) }}</strong>
|
||||||
r="5.5"
|
</div>
|
||||||
/>
|
<div class="metric-bar">
|
||||||
</svg>
|
<span :style="{ width: `${item.ratio}%` }"></span>
|
||||||
<div class="line-months">
|
</div>
|
||||||
<span v-for="month in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']" :key="month">{{ month }}</span>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mini-panels">
|
<section class="panel data-panel">
|
||||||
<article class="panel mini-panel">
|
<header class="data-head">
|
||||||
<header class="mini-head">
|
<div>
|
||||||
<h3>Device Traffic</h3>
|
<h3>近 7 天访问行为</h3>
|
||||||
<button type="button" class="more-btn">···</button>
|
<p>每日打开、下载、审计日志动作统计</p>
|
||||||
</header>
|
|
||||||
<div class="bar-grid">
|
|
||||||
<div
|
|
||||||
v-for="item in deviceTraffic"
|
|
||||||
:key="item.name"
|
|
||||||
class="bar-item"
|
|
||||||
:class="{ active: item.active }"
|
|
||||||
>
|
|
||||||
<div class="bar" :style="{ height: `${item.value}%` }"></div>
|
|
||||||
<span>{{ item.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</header>
|
||||||
|
<el-table :data="activityRows" size="small" stripe class="data-table">
|
||||||
|
<el-table-column prop="date" label="日期" min-width="120" />
|
||||||
|
<el-table-column label="打开次数" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatNumber(row.openCount) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="下载次数" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatNumber(row.downloadCount) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="交互总量" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatNumber(row.interactionTotal) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="审计日志" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatNumber(row.auditCount) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
<article class="panel mini-panel">
|
<section class="overview-bottom">
|
||||||
<header class="mini-head">
|
<section class="panel data-panel">
|
||||||
<h3>Location Traffic</h3>
|
<header class="data-head">
|
||||||
<button type="button" class="more-btn">···</button>
|
<div>
|
||||||
</header>
|
<h3>分类贡献 Top 8</h3>
|
||||||
<div class="bar-grid">
|
<p>按交互总量排序</p>
|
||||||
<div
|
|
||||||
v-for="item in locationTraffic"
|
|
||||||
:key="item.name"
|
|
||||||
class="bar-item"
|
|
||||||
:class="{ active: item.active }"
|
|
||||||
>
|
|
||||||
<div class="bar" :style="{ height: `${item.value}%` }"></div>
|
|
||||||
<span>{{ item.name }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</article>
|
<el-table :data="topCategoriesRows" size="small" stripe class="data-table">
|
||||||
|
<el-table-column prop="categoryName" label="分类" min-width="140" />
|
||||||
|
<el-table-column label="工具数" min-width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatNumber(row.toolTotal) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="访问量" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatNumber(row.openTotal) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="下载量" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatNumber(row.downloadTotal) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="交互总量" min-width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatNumber(row.interactionTotal) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel data-panel">
|
||||||
|
<header class="data-head">
|
||||||
|
<div>
|
||||||
|
<h3>工具价值 Top 8</h3>
|
||||||
|
<p>按交互总量排序</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<el-table :data="topToolsRows" size="small" stripe class="data-table">
|
||||||
|
<el-table-column prop="name" label="工具" min-width="160" />
|
||||||
|
<el-table-column prop="categoryName" label="分类" min-width="120" />
|
||||||
|
<el-table-column label="模式" min-width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.accessMode === 'web' ? '网页' : '下载' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="交互总量" min-width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatNumber(row.interactionTotal) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最近更新" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.updatedAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="loading" class="panel data-panel overview-loading">
|
||||||
|
正在加载业务概览数据...
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { computed } from 'vue';
|
||||||
trendPolyline: {
|
|
||||||
type: String,
|
const props = defineProps({
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
trendMarkers: {
|
formatNumber: {
|
||||||
type: Array,
|
type: Function,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
deviceTraffic: {
|
formatDate: {
|
||||||
type: Array,
|
type: Function,
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
locationTraffic: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const trendRange = defineModel('trendRange', {
|
const summary = computed(() => props.overview?.summary || {});
|
||||||
type: String,
|
|
||||||
default: 'week',
|
function toPercent(value, total) {
|
||||||
|
const numerator = Number(value || 0);
|
||||||
|
const denominator = Number(total || 0);
|
||||||
|
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const ratio = (numerator / denominator) * 100;
|
||||||
|
return Math.max(0, Math.min(100, Number(ratio.toFixed(1))));
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStats = computed(() => {
|
||||||
|
const total = Number(summary.value.toolTotal || 0);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'published',
|
||||||
|
label: '已发布',
|
||||||
|
value: summary.value.publishedTotal || 0,
|
||||||
|
ratio: toPercent(summary.value.publishedTotal, total),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'draft',
|
||||||
|
label: '草稿',
|
||||||
|
value: summary.value.draftTotal || 0,
|
||||||
|
ratio: toPercent(summary.value.draftTotal, total),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'archived',
|
||||||
|
label: '已归档',
|
||||||
|
value: summary.value.archivedTotal || 0,
|
||||||
|
ratio: toPercent(summary.value.archivedTotal, total),
|
||||||
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const accessModeStats = computed(() => {
|
||||||
|
const total = Number(summary.value.toolTotal || 0);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'download',
|
||||||
|
label: '下载模式',
|
||||||
|
value: summary.value.downloadToolTotal || 0,
|
||||||
|
ratio: toPercent(summary.value.downloadToolTotal, total),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'web',
|
||||||
|
label: '网页模式',
|
||||||
|
value: summary.value.webToolTotal || 0,
|
||||||
|
ratio: toPercent(summary.value.webToolTotal, total),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ready',
|
||||||
|
label: '下载就绪',
|
||||||
|
value: summary.value.downloadReadyToolTotal || 0,
|
||||||
|
ratio: toPercent(summary.value.downloadReadyToolTotal, summary.value.downloadToolTotal || 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const activityRows = computed(() =>
|
||||||
|
Array.isArray(props.overview?.dailyActivity) ? props.overview.dailyActivity : [],
|
||||||
|
);
|
||||||
|
const topCategoriesRows = computed(() =>
|
||||||
|
Array.isArray(props.overview?.topCategories) ? props.overview.topCategories : [],
|
||||||
|
);
|
||||||
|
const topToolsRows = computed(() =>
|
||||||
|
Array.isArray(props.overview?.topTools) ? props.overview.topTools : [],
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<aside class="dashboard-sidebar">
|
<aside class="dashboard-sidebar">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<span class="brand-mark">*</span>
|
<span class="brand-mark">*</span>
|
||||||
<span class="brand-text">snowui</span>
|
<span class="brand-text">管理端</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-menu">
|
<div class="sidebar-menu">
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
ChatDotRound,
|
ChatDotRound,
|
||||||
|
CollectionTag,
|
||||||
DataAnalysis,
|
DataAnalysis,
|
||||||
Document,
|
Document,
|
||||||
Management,
|
Management,
|
||||||
@@ -50,11 +51,12 @@ const emit = defineEmits(['menu-change', 'go-public', 'logout']);
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ key: 'overview', label: 'Overview', icon: DataAnalysis },
|
{ key: 'overview', label: 'Overview', icon: DataAnalysis },
|
||||||
{ key: 'tools', label: 'Tool Management', icon: ShoppingBag },
|
{ key: 'tools', label: '工具管理', icon: ShoppingBag },
|
||||||
{ key: 'audit', label: 'Audit Logs', icon: Document },
|
{ key: 'categories', label: '分类管理', icon: CollectionTag },
|
||||||
{ key: 'projects', label: 'Projects', icon: Management },
|
{ key: 'audit', label: '审计日志', icon: Document },
|
||||||
{ key: 'profile', label: 'User Profile', icon: User },
|
// { key: 'projects', label: 'Projects', icon: Management },
|
||||||
{ key: 'account', label: 'Account', icon: Setting },
|
// { key: 'profile', label: 'User Profile', icon: User },
|
||||||
{ key: 'social', label: 'Social', icon: ChatDotRound },
|
// { key: 'account', label: 'Account', icon: Setting },
|
||||||
|
// { key: 'social', label: 'Social', icon: ChatDotRound },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<header class="data-head">
|
<header class="data-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>Tools Management</h3>
|
<h3>Tools Management</h3>
|
||||||
<p>支持工具信息维护、包上传与版本管理</p>
|
<p>支持工具信息维护、包上传或 GitLab 下载地址配置</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="data-head-actions">
|
<div class="data-head-actions">
|
||||||
<el-button type="primary" @click="emit('create')">新增工具</el-button>
|
<el-button type="primary" @click="emit('create')">新增工具</el-button>
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-space>
|
<el-space>
|
||||||
<el-button size="small" type="primary" plain @click="emit('edit', row)">编辑</el-button>
|
<el-button size="small" type="primary" plain @click="emit('edit', row)">编辑</el-button>
|
||||||
<el-button size="small" type="success" plain @click="emit('artifact', row)">上传包</el-button>
|
<el-button size="small" type="success" plain @click="emit('artifact', row)">包管理</el-button>
|
||||||
<el-button size="small" @click="emit('status', row)">改状态</el-button>
|
<el-button size="small" @click="emit('status', row)">改状态</el-button>
|
||||||
<el-button size="small" type="primary" plain @click="emit('mode', row)">改访问方式</el-button>
|
<el-button size="small" type="primary" plain @click="emit('mode', row)">改访问方式</el-button>
|
||||||
<el-button size="small" type="danger" plain @click="emit('delete', row)">删除</el-button>
|
<el-button size="small" type="danger" plain @click="emit('delete', row)">删除</el-button>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
<el-button class="icon-btn" circle @click="emit('open-tools')">
|
<el-button class="icon-btn" circle @click="emit('open-tools')">
|
||||||
<el-icon><Grid /></el-icon>
|
<el-icon><Grid /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button class="icon-btn" circle @click="emit('open-categories')">
|
||||||
|
<el-icon><CollectionTag /></el-icon>
|
||||||
|
</el-button>
|
||||||
<el-button class="icon-btn" circle @click="emit('open-audit')">
|
<el-button class="icon-btn" circle @click="emit('open-audit')">
|
||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -36,7 +39,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { DataAnalysis, Document, Grid, Refresh, Search } from '@element-plus/icons-vue';
|
import { CollectionTag, DataAnalysis, Document, Grid, Refresh, Search } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
@@ -50,5 +53,12 @@ const search = defineModel('search', {
|
|||||||
default: '',
|
default: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['apply-search', 'refresh', 'open-overview', 'open-tools', 'open-audit']);
|
const emit = defineEmits([
|
||||||
|
'apply-search',
|
||||||
|
'refresh',
|
||||||
|
'open-overview',
|
||||||
|
'open-tools',
|
||||||
|
'open-categories',
|
||||||
|
'open-audit',
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
75
client/src/admin/components/CategoryFormDialog.vue
Normal file
75
client/src/admin/components/CategoryFormDialog.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="mode === 'create' ? '新增分类' : '编辑分类'"
|
||||||
|
width="520px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="categoryFormRef"
|
||||||
|
:model="categoryForm"
|
||||||
|
:rules="categoryFormRules"
|
||||||
|
label-width="88px"
|
||||||
|
>
|
||||||
|
<el-form-item label="分类名称" prop="name">
|
||||||
|
<el-input
|
||||||
|
v-model="categoryForm.name"
|
||||||
|
placeholder="请输入分类名称"
|
||||||
|
maxlength="80"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序值" prop="sortOrder">
|
||||||
|
<el-input-number
|
||||||
|
v-model="categoryForm.sortOrder"
|
||||||
|
:min="0"
|
||||||
|
:max="9999"
|
||||||
|
:step="10"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="emit('submit')">
|
||||||
|
{{ mode === 'create' ? '创建分类' : '保存修改' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const visible = defineModel('visible', {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
categoryForm: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
categoryFormRules: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
submitting: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit']);
|
||||||
|
|
||||||
|
const categoryFormRef = ref(null);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
validate: () => categoryFormRef.value?.validate?.(),
|
||||||
|
clearValidate: () => categoryFormRef.value?.clearValidate?.(),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -30,6 +30,26 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="标签" prop="tagIds">
|
||||||
|
<el-select
|
||||||
|
v-model="toolForm.tagIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
:reserve-keyword="false"
|
||||||
|
placeholder="可多选,也可直接输入新标签"
|
||||||
|
style="width: 100%"
|
||||||
|
:loading="tagLoading"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in tags"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="工具简介" prop="description">
|
<el-form-item label="工具简介" prop="description">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="toolForm.description"
|
v-model="toolForm.description"
|
||||||
@@ -37,8 +57,9 @@
|
|||||||
:rows="4"
|
:rows="4"
|
||||||
maxlength="2000"
|
maxlength="2000"
|
||||||
show-word-limit
|
show-word-limit
|
||||||
placeholder="请描述工具用途、适用场景与优势"
|
placeholder="支持 Markdown,例如:## 用途 支持 **加粗**、`代码`、[链接](https://example.com)"
|
||||||
/>
|
/>
|
||||||
|
<div class="el-form-item__description">简介支持 Markdown 渲染。</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="评分" prop="rating">
|
<el-form-item label="评分" prop="rating">
|
||||||
<el-input-number v-model="toolForm.rating" :min="0" :max="5" :step="0.1" :precision="1" />
|
<el-input-number v-model="toolForm.rating" :min="0" :max="5" :step="0.1" :precision="1" />
|
||||||
@@ -48,8 +69,9 @@
|
|||||||
v-model="toolForm.featuresText"
|
v-model="toolForm.featuresText"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="4"
|
:rows="4"
|
||||||
placeholder="每行一个功能点,例如 支持离线模式 支持自动更新"
|
placeholder="每行一个功能点,支持 Markdown,例如 支持 **离线模式** 支持 [自动更新](https://example.com)"
|
||||||
/>
|
/>
|
||||||
|
<div class="el-form-item__description">每行作为一个功能点并按 Markdown 展示。</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="访问方式" prop="accessMode">
|
<el-form-item label="访问方式" prop="accessMode">
|
||||||
<el-select v-model="toolForm.accessMode" style="width: 100%">
|
<el-select v-model="toolForm.accessMode" style="width: 100%">
|
||||||
@@ -61,8 +83,18 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="toolForm.accessMode === 'web'" label="Open URL" prop="openUrl">
|
<el-form-item
|
||||||
<el-input v-model="toolForm.openUrl" placeholder="https://example.com" />
|
v-if="toolForm.accessMode === 'web' || toolForm.accessMode === 'download'"
|
||||||
|
:label="toolForm.accessMode === 'web' ? 'Open URL' : '下载地址'"
|
||||||
|
prop="openUrl"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="toolForm.openUrl"
|
||||||
|
:placeholder="toolForm.accessMode === 'web' ? 'https://example.com' : 'https://gitlab.example.com/...' "
|
||||||
|
/>
|
||||||
|
<div class="el-form-item__description">
|
||||||
|
{{ toolForm.accessMode === 'web' ? '网页模式下用于打开页面。' : '下载模式下可直接填写 GitLab 下载地址,不上传文件也可使用。' }}
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="新标签页">
|
<el-form-item label="新标签页">
|
||||||
<el-switch v-model="toolForm.openInNewTab" />
|
<el-switch v-model="toolForm.openInNewTab" />
|
||||||
@@ -116,6 +148,14 @@ defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
tags: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
tagLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
accessModeOptions: {
|
accessModeOptions: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
|
|||||||
47
client/src/admin/pages/AdminAuditLogsPage.vue
Normal file
47
client/src/admin/pages/AdminAuditLogsPage.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<AdminAuditSection
|
||||||
|
:audit-filters="auditFilters"
|
||||||
|
:audit-loading="auditLoading"
|
||||||
|
:audit-logs="auditLogs"
|
||||||
|
:audit-pagination="auditPagination"
|
||||||
|
:format-date-time="formatDateTime"
|
||||||
|
:stringify-body="stringifyBody"
|
||||||
|
@search="emit('search')"
|
||||||
|
@reset="emit('reset')"
|
||||||
|
@page-change="emit('page-change', $event)"
|
||||||
|
@size-change="emit('size-change', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import AdminAuditSection from '../components/AdminAuditSection.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
auditFilters: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
auditLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
auditLogs: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
auditPagination: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
formatDateTime: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
stringifyBody: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['search', 'reset', 'page-change', 'size-change']);
|
||||||
|
</script>
|
||||||
33
client/src/admin/pages/AdminCategoriesPage.vue
Normal file
33
client/src/admin/pages/AdminCategoriesPage.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<AdminCategoriesSection
|
||||||
|
:category-filters="categoryFilters"
|
||||||
|
:category-loading="categoryLoading"
|
||||||
|
:categories="categories"
|
||||||
|
@search="emit('search')"
|
||||||
|
@reset="emit('reset')"
|
||||||
|
@create="emit('create')"
|
||||||
|
@edit="emit('edit', $event)"
|
||||||
|
@delete="emit('delete', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import AdminCategoriesSection from '../components/AdminCategoriesSection.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
categoryFilters: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
categoryLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['search', 'reset', 'create', 'edit', 'delete']);
|
||||||
|
</script>
|
||||||
40
client/src/admin/pages/AdminOverviewPage.vue
Normal file
40
client/src/admin/pages/AdminOverviewPage.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<section class="admin-page-overview">
|
||||||
|
<AdminKpiRow :kpi-cards="kpiCards" :format-number="formatNumber" />
|
||||||
|
|
||||||
|
<AdminOverviewSection
|
||||||
|
:loading="loadingOverview"
|
||||||
|
:overview="overview"
|
||||||
|
:format-number="formatNumber"
|
||||||
|
:format-date="formatDate"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import AdminKpiRow from '../components/AdminKpiRow.vue';
|
||||||
|
import AdminOverviewSection from '../components/AdminOverviewSection.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
kpiCards: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
loadingOverview: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
formatNumber: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
formatDate: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
94
client/src/admin/pages/AdminToolsPage.vue
Normal file
94
client/src/admin/pages/AdminToolsPage.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<AdminToolsSection
|
||||||
|
:tool-filters="toolFilters"
|
||||||
|
:category-loading="categoryLoading"
|
||||||
|
:categories="categories"
|
||||||
|
:status-options="statusOptions"
|
||||||
|
:access-mode-options="accessModeOptions"
|
||||||
|
:tool-loading="toolLoading"
|
||||||
|
:tools="tools"
|
||||||
|
:tool-pagination="toolPagination"
|
||||||
|
:status-tag-type="statusTagType"
|
||||||
|
:access-mode-tag-type="accessModeTagType"
|
||||||
|
:format-number="formatNumber"
|
||||||
|
:format-date="formatDate"
|
||||||
|
@search="emit('search')"
|
||||||
|
@reset="emit('reset')"
|
||||||
|
@create="emit('create')"
|
||||||
|
@edit="emit('edit', $event)"
|
||||||
|
@artifact="emit('artifact', $event)"
|
||||||
|
@status="emit('status', $event)"
|
||||||
|
@mode="emit('mode', $event)"
|
||||||
|
@delete="emit('delete', $event)"
|
||||||
|
@page-change="emit('page-change', $event)"
|
||||||
|
@size-change="emit('size-change', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import AdminToolsSection from '../components/AdminToolsSection.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
toolFilters: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
categoryLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
statusOptions: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
accessModeOptions: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
toolLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
toolPagination: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
statusTagType: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
accessModeTagType: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
formatNumber: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
formatDate: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'search',
|
||||||
|
'reset',
|
||||||
|
'create',
|
||||||
|
'edit',
|
||||||
|
'artifact',
|
||||||
|
'status',
|
||||||
|
'mode',
|
||||||
|
'delete',
|
||||||
|
'page-change',
|
||||||
|
'size-change',
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import PublicApp from '../App.vue';
|
import PublicApp from '../App.vue';
|
||||||
import AdminApp from './AdminApp.vue';
|
import AdminApp from './AdminApp.vue';
|
||||||
|
import AdminAuditLogsPage from './pages/AdminAuditLogsPage.vue';
|
||||||
|
import AdminCategoriesPage from './pages/AdminCategoriesPage.vue';
|
||||||
|
import AdminOverviewPage from './pages/AdminOverviewPage.vue';
|
||||||
|
import AdminToolsPage from './pages/AdminToolsPage.vue';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -10,8 +14,50 @@ const routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
name: 'admin-home',
|
|
||||||
component: AdminApp,
|
component: AdminApp,
|
||||||
|
redirect: '/admin/overview',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'overview',
|
||||||
|
name: 'admin-overview',
|
||||||
|
component: AdminOverviewPage,
|
||||||
|
meta: {
|
||||||
|
menuKey: 'overview',
|
||||||
|
sectionTitle: 'Overview',
|
||||||
|
withKpi: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tools',
|
||||||
|
name: 'admin-tools',
|
||||||
|
component: AdminToolsPage,
|
||||||
|
meta: {
|
||||||
|
menuKey: 'tools',
|
||||||
|
sectionTitle: 'Tools',
|
||||||
|
withKpi: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'categories',
|
||||||
|
name: 'admin-categories',
|
||||||
|
component: AdminCategoriesPage,
|
||||||
|
meta: {
|
||||||
|
menuKey: 'categories',
|
||||||
|
sectionTitle: 'Categories',
|
||||||
|
withKpi: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'auditlogs',
|
||||||
|
name: 'admin-auditlogs',
|
||||||
|
component: AdminAuditLogsPage,
|
||||||
|
meta: {
|
||||||
|
menuKey: 'audit',
|
||||||
|
sectionTitle: 'Audit Logs',
|
||||||
|
withKpi: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,61 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import {
|
import {
|
||||||
|
adminCreateCategory,
|
||||||
|
adminCreateTag,
|
||||||
adminCreateTool,
|
adminCreateTool,
|
||||||
|
adminDeleteCategory,
|
||||||
adminDeleteArtifact,
|
adminDeleteArtifact,
|
||||||
|
adminGetOverview,
|
||||||
adminDeleteTool,
|
adminDeleteTool,
|
||||||
adminGetArtifacts,
|
adminGetArtifacts,
|
||||||
adminGetAuditLogs,
|
adminGetAuditLogs,
|
||||||
adminGetCategories,
|
adminGetCategories,
|
||||||
|
adminGetTags,
|
||||||
adminGetTools,
|
adminGetTools,
|
||||||
adminSetLatestArtifact,
|
adminSetLatestArtifact,
|
||||||
adminUpdateArtifactStatus,
|
adminUpdateArtifactStatus,
|
||||||
|
adminUpdateCategory,
|
||||||
adminUpdateTool,
|
adminUpdateTool,
|
||||||
adminUpdateAccessMode,
|
adminUpdateAccessMode,
|
||||||
adminUpdateToolStatus,
|
adminUpdateToolStatus,
|
||||||
adminUploadArtifact,
|
adminUploadArtifact,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
|
|
||||||
|
function createEmptyOverviewState() {
|
||||||
|
return {
|
||||||
|
generatedAt: '',
|
||||||
|
summary: {
|
||||||
|
toolTotal: 0,
|
||||||
|
draftTotal: 0,
|
||||||
|
publishedTotal: 0,
|
||||||
|
archivedTotal: 0,
|
||||||
|
categoryTotal: 0,
|
||||||
|
tagTotal: 0,
|
||||||
|
webToolTotal: 0,
|
||||||
|
downloadToolTotal: 0,
|
||||||
|
downloadReadyToolTotal: 0,
|
||||||
|
openTotal: 0,
|
||||||
|
downloadTotal: 0,
|
||||||
|
interactionTotal: 0,
|
||||||
|
artifactTotal: 0,
|
||||||
|
activeArtifactTotal: 0,
|
||||||
|
auditLogTotal: 0,
|
||||||
|
},
|
||||||
|
dailyActivity: [],
|
||||||
|
topCategories: [],
|
||||||
|
topTools: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const useAdminConsoleStore = defineStore('admin-console', {
|
export const useAdminConsoleStore = defineStore('admin-console', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
categories: [],
|
categories: [],
|
||||||
categoryLoading: false,
|
categoryLoading: false,
|
||||||
|
categoryFilters: {
|
||||||
|
query: '',
|
||||||
|
},
|
||||||
|
tags: [],
|
||||||
|
tagLoading: false,
|
||||||
|
|
||||||
toolFilters: {
|
toolFilters: {
|
||||||
query: '',
|
query: '',
|
||||||
@@ -36,6 +73,8 @@ export const useAdminConsoleStore = defineStore('admin-console', {
|
|||||||
total: 0,
|
total: 0,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
},
|
},
|
||||||
|
overviewLoading: false,
|
||||||
|
overview: createEmptyOverviewState(),
|
||||||
|
|
||||||
auditFilters: {
|
auditFilters: {
|
||||||
action: '',
|
action: '',
|
||||||
@@ -54,15 +93,57 @@ export const useAdminConsoleStore = defineStore('admin-console', {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async loadCategories() {
|
async loadCategories(token) {
|
||||||
this.categoryLoading = true;
|
this.categoryLoading = true;
|
||||||
try {
|
try {
|
||||||
const data = await adminGetCategories();
|
const data = await adminGetCategories(
|
||||||
|
{
|
||||||
|
query: this.categoryFilters.query || undefined,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
);
|
||||||
this.categories = Array.isArray(data) ? data : [];
|
this.categories = Array.isArray(data) ? data : [];
|
||||||
} finally {
|
} finally {
|
||||||
this.categoryLoading = false;
|
this.categoryLoading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
resetCategoryFilters() {
|
||||||
|
this.categoryFilters.query = '';
|
||||||
|
},
|
||||||
|
async createCategory(payload, token) {
|
||||||
|
return adminCreateCategory(payload, token);
|
||||||
|
},
|
||||||
|
async updateCategory(id, payload, token) {
|
||||||
|
return adminUpdateCategory(id, payload, token);
|
||||||
|
},
|
||||||
|
async deleteCategory(id, token) {
|
||||||
|
return adminDeleteCategory(id, token);
|
||||||
|
},
|
||||||
|
async loadTags(token) {
|
||||||
|
this.tagLoading = true;
|
||||||
|
try {
|
||||||
|
const data = await adminGetTags(token);
|
||||||
|
this.tags = Array.isArray(data) ? data : [];
|
||||||
|
} finally {
|
||||||
|
this.tagLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async createTag(payload, token) {
|
||||||
|
const created = await adminCreateTag(payload, token);
|
||||||
|
const createdId = created?.id;
|
||||||
|
if (!createdId) {
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = this.tags.findIndex((item) => item.id === createdId);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
this.tags.splice(existingIndex, 1, created);
|
||||||
|
} else {
|
||||||
|
this.tags.push(created);
|
||||||
|
}
|
||||||
|
this.tags.sort((a, b) => String(a?.name ?? '').localeCompare(String(b?.name ?? '')));
|
||||||
|
return created;
|
||||||
|
},
|
||||||
setToolPage(page) {
|
setToolPage(page) {
|
||||||
this.toolFilters.page = page;
|
this.toolFilters.page = page;
|
||||||
},
|
},
|
||||||
@@ -98,6 +179,28 @@ export const useAdminConsoleStore = defineStore('admin-console', {
|
|||||||
this.toolLoading = false;
|
this.toolLoading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async loadOverview(token) {
|
||||||
|
this.overviewLoading = true;
|
||||||
|
try {
|
||||||
|
const data = await adminGetOverview(token);
|
||||||
|
const defaults = createEmptyOverviewState();
|
||||||
|
const payload = data && typeof data === 'object' ? data : {};
|
||||||
|
|
||||||
|
this.overview = {
|
||||||
|
...defaults,
|
||||||
|
...payload,
|
||||||
|
summary: {
|
||||||
|
...defaults.summary,
|
||||||
|
...(payload.summary && typeof payload.summary === 'object' ? payload.summary : {}),
|
||||||
|
},
|
||||||
|
dailyActivity: Array.isArray(payload.dailyActivity) ? payload.dailyActivity : [],
|
||||||
|
topCategories: Array.isArray(payload.topCategories) ? payload.topCategories : [],
|
||||||
|
topTools: Array.isArray(payload.topTools) ? payload.topTools : [],
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.overviewLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
async updateToolStatus(id, status, token) {
|
async updateToolStatus(id, status, token) {
|
||||||
await adminUpdateToolStatus(id, status, token);
|
await adminUpdateToolStatus(id, status, token);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,6 +64,53 @@ export function resolveActionUrl(actionUrl) {
|
|||||||
return new URL(actionUrl, apiOrigin).toString();
|
return new URL(actionUrl, apiOrigin).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveApiUrl(path) {
|
||||||
|
if (/^https?:\/\//.test(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return new URL(path, apiOrigin).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyToolInteraction(toolId, payload) {
|
||||||
|
const pathBase = baseURL.replace(/\/$/, '');
|
||||||
|
const endpoint = `${pathBase}/tools/${toolId}/interaction`;
|
||||||
|
const url = resolveApiUrl(endpoint);
|
||||||
|
const body = JSON.stringify(payload || {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||||
|
const queued = navigator.sendBeacon(
|
||||||
|
url,
|
||||||
|
new Blob([body], { type: 'application/json' }),
|
||||||
|
);
|
||||||
|
if (queued) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore tracking errors to avoid interrupting user flow.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof fetch === 'function') {
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
keepalive: true,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}).catch(() => {
|
||||||
|
// Ignore tracking errors to avoid interrupting user flow.
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
http.post(`/tools/${toolId}/interaction`, payload).catch(() => {
|
||||||
|
// Ignore tracking errors to avoid interrupting user flow.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getApiErrorMessage(error) {
|
export function getApiErrorMessage(error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const data = error.response?.data;
|
const data = error.response?.data;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: "Manrope", "PingFang SC", "Microsoft YaHei", sans-serif;
|
font-family: var(--font-sans);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ select {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font-family: "Sora", sans-serif;
|
font-family: var(--font-display);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
@@ -108,11 +108,13 @@ select {
|
|||||||
.brand-mark {
|
.brand-mark {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 10px;
|
display: block;
|
||||||
background: var(--primary);
|
}
|
||||||
color: #fff;
|
|
||||||
display: grid;
|
.brand-mark img {
|
||||||
place-items: center;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@@ -173,7 +175,7 @@ h1,
|
|||||||
h2,
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Sora", sans-serif;
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-row {
|
.search-row {
|
||||||
@@ -408,11 +410,9 @@ h3 {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
opacity: 0;
|
opacity: 1;
|
||||||
transform: translateY(8px);
|
transform: translateY(0);
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
animation: card-enter 380ms var(--ease-standard) both;
|
|
||||||
animation-delay: var(--stagger, 0ms);
|
|
||||||
transition:
|
transition:
|
||||||
border-color var(--duration-normal) var(--ease-standard),
|
border-color var(--duration-normal) var(--ease-standard),
|
||||||
background-color var(--duration-normal) var(--ease-standard),
|
background-color var(--duration-normal) var(--ease-standard),
|
||||||
@@ -486,6 +486,66 @@ h3 {
|
|||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card .desc.markdown-inline {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown {
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre) {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown :where(h1, h2, h3, h4, h5, h6) {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown :where(p, li) {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown :where(ul, ol) {
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown :where(p, ul, ol, blockquote, pre):last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown a {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(15, 47, 61, 0.08);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown pre {
|
||||||
|
background: rgba(15, 47, 61, 0.08);
|
||||||
|
border: 1px solid rgba(18, 117, 150, 0.16);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tags {
|
.tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -650,6 +710,10 @@ h3 {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-detail {
|
||||||
|
margin: 10px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-muted {
|
.modal-muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -679,18 +743,31 @@ h3 {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feature-list li {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
background-color var(--duration-fast) var(--ease-standard),
|
background-color var(--duration-fast) var(--ease-standard),
|
||||||
transform var(--duration-fast) var(--ease-standard);
|
transform var(--duration-fast) var(--ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-btn svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-btn:hover {
|
.icon-btn:hover {
|
||||||
background: rgba(240, 251, 255, 0.95);
|
background: rgba(240, 251, 255, 0.95);
|
||||||
border-color: var(--line-strong);
|
border-color: var(--line-strong);
|
||||||
@@ -778,6 +855,15 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.card {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
animation: card-enter 380ms var(--ease-standard) both;
|
||||||
|
animation-delay: var(--stagger, 0ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn:active,
|
.btn:active,
|
||||||
.btn-small:active,
|
.btn-small:active,
|
||||||
.icon-btn:active,
|
.icon-btn:active,
|
||||||
|
|||||||
72
client/src/utils/markdown.js
Normal file
72
client/src/utils/markdown.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const markdownCache = new Map();
|
||||||
|
const inlineCache = new Map();
|
||||||
|
|
||||||
|
function sanitizeHtml(html) {
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
USE_PROFILES: { html: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSafeString(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdown(source) {
|
||||||
|
const parsed = marked.parse(source);
|
||||||
|
if (typeof parsed === 'string') {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInlineMarkdown(source) {
|
||||||
|
const parsed = marked.parseInline(source);
|
||||||
|
if (typeof parsed === 'string') {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMarkdown(value) {
|
||||||
|
const source = toSafeString(value);
|
||||||
|
if (!source) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markdownCache.has(source)) {
|
||||||
|
return markdownCache.get(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = sanitizeHtml(parseMarkdown(source));
|
||||||
|
markdownCache.set(source, html);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderInlineMarkdown(value) {
|
||||||
|
const source = toSafeString(value);
|
||||||
|
if (!source) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inlineCache.has(source)) {
|
||||||
|
return inlineCache.get(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = sanitizeHtml(parseInlineMarkdown(source));
|
||||||
|
inlineCache.set(source, html);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [vue()],
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
server: {
|
const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:3000';
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
return {
|
||||||
'/api': {
|
plugins: [vue()],
|
||||||
target: 'http://localhost:3000',
|
server: {
|
||||||
changeOrigin: true,
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
35
docker/runtime-entrypoint.sh
Normal file
35
docker/runtime-entrypoint.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
normalize_wrapped_env() {
|
||||||
|
var_name="$1"
|
||||||
|
|
||||||
|
if ! eval "[ \"\${$var_name+x}\" = x ]"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "current_value=\${$var_name}"
|
||||||
|
|
||||||
|
case "$current_value" in
|
||||||
|
\"*\")
|
||||||
|
normalized_value=${current_value#\"}
|
||||||
|
normalized_value=${normalized_value%\"}
|
||||||
|
;;
|
||||||
|
\'*\')
|
||||||
|
normalized_value=${current_value#\'}
|
||||||
|
normalized_value=${normalized_value%\'}
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
normalized_value=$current_value
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export "$var_name=$normalized_value"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Docker --env-file keeps surrounding quotes as literal characters.
|
||||||
|
# Prisma expects an unquoted SQLite URL such as file:./dev.db.
|
||||||
|
normalize_wrapped_env DATABASE_URL
|
||||||
|
|
||||||
|
npx prisma migrate deploy
|
||||||
|
exec node dist/src/main.js
|
||||||
266
docs/2026-03-27-12-09-设计-下载大文件功能.md
Normal file
266
docs/2026-03-27-12-09-设计-下载大文件功能.md
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# 设计:下载大文件功能(v1)
|
||||||
|
|
||||||
|
- 文档类别:设计(系统设计)
|
||||||
|
- 创建时间:2026-03-27 12:09 (Asia/Shanghai)
|
||||||
|
- 适用项目:ToolsShow(NestJS + Prisma + GitLab Generic Package)
|
||||||
|
- 关联模块:`access`、`downloads`、`gitlab-storage`
|
||||||
|
|
||||||
|
## 1. 背景与问题
|
||||||
|
|
||||||
|
当前下载链路(`POST /tools/:id/launch` -> `GET /downloads/:ticket`)可以完成普通文件下载,但在大文件场景存在明显短板:
|
||||||
|
|
||||||
|
1. 现有 ticket 为“一次性消费”(`consumedAt` 写入后不可重用),网络中断后无法原 ticket 续传。
|
||||||
|
2. `GET /downloads/:ticket` 仅做整文件流式透传,没有 `Range` / `206 Partial Content`,无法断点续传。
|
||||||
|
3. 对下载过程缺少阶段化记录(开始、部分成功、失败重试),难以统计真实下载质量。
|
||||||
|
4. 大文件下载失败时用户体验较差(必须回到前端重新触发 launch)。
|
||||||
|
|
||||||
|
## 2. 目标与非目标
|
||||||
|
|
||||||
|
### 2.1 目标
|
||||||
|
|
||||||
|
1. 支持标准 HTTP 断点续传(`Range`、`Accept-Ranges`、`Content-Range`、`206`)。
|
||||||
|
2. 网络中断后允许在有效期内继续下载,不要求重新 launch。
|
||||||
|
3. 在不改变“统一 launch 入口”前提下完成兼容升级。
|
||||||
|
4. 增加大文件下载可观测性(失败率、平均耗时、重试次数、完成率)。
|
||||||
|
5. 兼容两种存储后端:GitLab 远端包与本地文件回退存储。
|
||||||
|
|
||||||
|
### 2.2 非目标(本期不做)
|
||||||
|
|
||||||
|
1. P2P/BT 分发。
|
||||||
|
2. CDN 回源策略编排。
|
||||||
|
3. 客户端多线程分片加速协议(先支持标准浏览器与下载器)。
|
||||||
|
|
||||||
|
## 3. 设计原则
|
||||||
|
|
||||||
|
1. **兼容优先**:旧接口可保留短期兼容,前端可灰度切换。
|
||||||
|
2. **安全优先**:令牌短期有效、可撤销、与工具/制品强绑定。
|
||||||
|
3. **可恢复优先**:会话级下载权限 > 一次性 ticket。
|
||||||
|
4. **可运维优先**:必须可追踪失败原因与瓶颈位置(应用层/存储层/网络层)。
|
||||||
|
|
||||||
|
## 4. 总体方案
|
||||||
|
|
||||||
|
采用“**下载会话(Download Session)+ Range 流式传输**”替代“一次性 ticket + 全量下载”。
|
||||||
|
|
||||||
|
### 4.1 核心变化
|
||||||
|
|
||||||
|
1. 下载模式 launch 不再返回一次性 ticket,而是返回可续传会话 token。
|
||||||
|
2. 新下载接口支持 `HEAD` 与 `GET + Range`。
|
||||||
|
3. 会话在有效期内可多次请求同一文件不同字节区间。
|
||||||
|
4. 下载记录改为“会话聚合 + 分段记录”,用于分析中断与重试。
|
||||||
|
|
||||||
|
### 4.2 兼容策略
|
||||||
|
|
||||||
|
1. 保留 `GET /downloads/:ticket`(旧)1-2 个版本周期。
|
||||||
|
2. 新增 `GET /downloads/sessions/:token/file`(新)。
|
||||||
|
3. 前端先读 launch 返回字段,若存在 `sessionToken` 则走新链路,否则走旧链路。
|
||||||
|
|
||||||
|
## 5. 数据模型设计
|
||||||
|
|
||||||
|
> 以下为设计层面的 Prisma 结构草案,具体字段可在实现阶段微调。
|
||||||
|
|
||||||
|
### 5.1 新增表:`download_sessions`
|
||||||
|
|
||||||
|
- `id`:String (UUID)
|
||||||
|
- `sessionToken`:String (Unique)
|
||||||
|
- `toolId`:String
|
||||||
|
- `artifactId`:String
|
||||||
|
- `channel`:String?
|
||||||
|
- `clientVersion`:String?
|
||||||
|
- `requestIp`:String?
|
||||||
|
- `userAgent`:String?
|
||||||
|
- `expiresAt`:DateTime
|
||||||
|
- `lastAccessAt`:DateTime
|
||||||
|
- `completedAt`:DateTime?
|
||||||
|
- `revokedAt`:DateTime?
|
||||||
|
- `createdAt`:DateTime
|
||||||
|
|
||||||
|
索引建议:
|
||||||
|
- `(sessionToken unique)`
|
||||||
|
- `(expiresAt)`
|
||||||
|
- `(artifactId, expiresAt)`
|
||||||
|
|
||||||
|
### 5.2 新增表:`download_session_chunks`(可选但建议)
|
||||||
|
|
||||||
|
- `id`:Int (auto increment)
|
||||||
|
- `sessionId`:String
|
||||||
|
- `rangeStart`:BigInt
|
||||||
|
- `rangeEnd`:BigInt
|
||||||
|
- `bytesSent`:BigInt
|
||||||
|
- `status`:`success | failed | cancelled`
|
||||||
|
- `errorMessage`:String?
|
||||||
|
- `durationMs`:Int?
|
||||||
|
- `createdAt`:DateTime
|
||||||
|
|
||||||
|
说明:用于分析大文件下载过程中的断点位置、失败区间、重试质量。
|
||||||
|
|
||||||
|
## 6. API 设计
|
||||||
|
|
||||||
|
Base path: `/api/v1`
|
||||||
|
|
||||||
|
### 6.1 Launch(下载模式)响应升级
|
||||||
|
|
||||||
|
`POST /tools/:id/launch`
|
||||||
|
|
||||||
|
下载模式响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "download",
|
||||||
|
"sessionToken": "dl_sess_xxx",
|
||||||
|
"expiresInSec": 3600,
|
||||||
|
"actionUrl": "/api/v1/downloads/sessions/dl_sess_xxx/file",
|
||||||
|
"resumeSupported": true,
|
||||||
|
"file": {
|
||||||
|
"name": "tool-v2.1.0.zip",
|
||||||
|
"size": 2147483648,
|
||||||
|
"sha256": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 文件元信息探测
|
||||||
|
|
||||||
|
`HEAD /downloads/sessions/:token/file`
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- `Accept-Ranges: bytes`
|
||||||
|
- `Content-Length`
|
||||||
|
- `ETag`(建议使用 artifact sha256)
|
||||||
|
- `Content-Disposition`
|
||||||
|
|
||||||
|
### 6.3 下载文件(支持 Range)
|
||||||
|
|
||||||
|
`GET /downloads/sessions/:token/file`
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
- 可选 `Range: bytes=0-1048575`
|
||||||
|
|
||||||
|
响应:
|
||||||
|
- 无 Range:`200` + 全量流
|
||||||
|
- 有效 Range:`206` + 指定区间流
|
||||||
|
- 非法 Range:`416`
|
||||||
|
|
||||||
|
关键响应头:
|
||||||
|
- `Accept-Ranges: bytes`
|
||||||
|
- `Content-Range`
|
||||||
|
- `Content-Length`
|
||||||
|
- `Content-Type`
|
||||||
|
- `Content-Disposition`
|
||||||
|
- `ETag`
|
||||||
|
|
||||||
|
### 6.4 会话失效
|
||||||
|
|
||||||
|
- 过期或撤销:`410 Gone`
|
||||||
|
- 无效 token:`404` 或 `401`(按安全策略统一)
|
||||||
|
|
||||||
|
## 7. 服务端实现设计
|
||||||
|
|
||||||
|
## 7.1 AccessService 改造
|
||||||
|
|
||||||
|
文件:`server/src/modules/access/access.service.ts`
|
||||||
|
|
||||||
|
1. 下载模式不再创建一次性 `downloadTicket`,改为创建 `downloadSession`。
|
||||||
|
2. 默认会话 TTL 建议 1 小时(可配置 `DOWNLOAD_SESSION_TTL_SEC`)。
|
||||||
|
3. 返回 `sessionToken + actionUrl + file meta`。
|
||||||
|
|
||||||
|
## 7.2 DownloadsController 改造
|
||||||
|
|
||||||
|
文件:`server/src/modules/downloads/downloads.controller.ts`
|
||||||
|
|
||||||
|
新增路由:
|
||||||
|
- `HEAD /downloads/sessions/:token/file`
|
||||||
|
- `GET /downloads/sessions/:token/file`
|
||||||
|
|
||||||
|
保留旧路由:
|
||||||
|
- `GET /downloads/:ticket`(兼容期)
|
||||||
|
|
||||||
|
## 7.3 DownloadsService 改造
|
||||||
|
|
||||||
|
文件:`server/src/modules/downloads/downloads.service.ts`
|
||||||
|
|
||||||
|
新增能力:
|
||||||
|
1. 解析并校验 Range。
|
||||||
|
2. 校验会话有效性、工具状态、制品状态。
|
||||||
|
3. 根据 Range 组装响应头并返回 `200/206/416`。
|
||||||
|
4. 在响应关闭/中断时记录 chunk 结果。
|
||||||
|
5. 当客户端拿到完整文件后标记 `completedAt`(可通过全量下载成功或累计字节判定)。
|
||||||
|
|
||||||
|
## 7.4 GitlabStorageService 改造
|
||||||
|
|
||||||
|
文件:`server/src/modules/gitlab-storage/gitlab-storage.service.ts`
|
||||||
|
|
||||||
|
新增方法建议:
|
||||||
|
- `getArtifactStream(artifact, range?)`
|
||||||
|
- `headArtifact(artifact)`
|
||||||
|
|
||||||
|
实现要点:
|
||||||
|
1. 远端 GitLab 下载请求透传 `Range` 头。
|
||||||
|
2. 若 GitLab 返回 `206`,直接桥接状态码与头。
|
||||||
|
3. 若远端不支持 Range,则回退为 `200` 全量(并在响应中标记 `resumeSupported=false`)。
|
||||||
|
4. 本地文件场景使用 `createReadStream(path, { start, end })`。
|
||||||
|
|
||||||
|
## 8. 安全与风控
|
||||||
|
|
||||||
|
1. `sessionToken` 使用高熵随机串;数据库只保存 hash(推荐)。
|
||||||
|
2. 会话与 `toolId/artifactId` 强绑定,防止跨资源复用。
|
||||||
|
3. 限制并发分段请求数(例如单会话最多 4 并发)。
|
||||||
|
4. 单 IP / 单工具限流,防止恶意刷取带宽。
|
||||||
|
5. 所有下载响应增加 `X-Content-Type-Options: nosniff`。
|
||||||
|
|
||||||
|
## 9. 观测指标
|
||||||
|
|
||||||
|
1. `download_session_started_total`
|
||||||
|
2. `download_chunk_success_total`
|
||||||
|
3. `download_chunk_failed_total`
|
||||||
|
4. `download_session_completed_total`
|
||||||
|
5. `download_resume_ratio`(续传请求占比)
|
||||||
|
6. `download_5xx_ratio`
|
||||||
|
7. `p95_chunk_duration_ms`
|
||||||
|
|
||||||
|
日志字段建议:`traceId`、`sessionId`、`artifactId`、`rangeStart`、`rangeEnd`、`bytesSent`、`status`、`errorCode`。
|
||||||
|
|
||||||
|
## 10. 迁移与发布计划
|
||||||
|
|
||||||
|
### Phase 1(后端可用)
|
||||||
|
|
||||||
|
1. 落库 `download_sessions`。
|
||||||
|
2. 新增会话下载接口 + Range 支持。
|
||||||
|
3. Access 返回新字段。
|
||||||
|
4. 保留旧 ticket 接口。
|
||||||
|
|
||||||
|
### Phase 2(前端切换)
|
||||||
|
|
||||||
|
1. 前端优先走 `sessionToken` 新链路。
|
||||||
|
2. 引入失败重试与断点续传提示。
|
||||||
|
3. 观察 1 周核心指标。
|
||||||
|
|
||||||
|
### Phase 3(收敛)
|
||||||
|
|
||||||
|
1. 宣布下线旧 ticket 下载接口。
|
||||||
|
2. 清理旧表和兼容逻辑(按版本策略执行)。
|
||||||
|
|
||||||
|
## 11. 风险与应对
|
||||||
|
|
||||||
|
1. **GitLab Range 兼容性不一致**:先做能力探测(HEAD/小范围 GET),不支持时降级。
|
||||||
|
2. **高并发导致服务端带宽占满**:增加会话并发限制 + 网关限流。
|
||||||
|
3. **大文件长连接导致 Node 资源占用**:严格使用流式处理,避免读入内存;设置连接超时与中断清理。
|
||||||
|
4. **统计口径变化**:区分“会话完成”与“分段成功”,避免误解下载成功率。
|
||||||
|
|
||||||
|
## 12. 验收标准
|
||||||
|
|
||||||
|
1. 2GB 文件可在中断后 5 分钟内基于同一 `sessionToken` 成功续传。
|
||||||
|
2. `Range` 请求返回符合 RFC 7233 的响应码与头部。
|
||||||
|
3. 下载中断、失败、成功均有可追踪日志。
|
||||||
|
4. 旧版前端不改动时仍可通过旧 ticket 接口下载。
|
||||||
|
|
||||||
|
## 13. 对应当前代码的最小改造清单
|
||||||
|
|
||||||
|
1. `access.service.ts`:创建并返回 `downloadSession`。
|
||||||
|
2. `downloads.controller.ts`:新增 `HEAD/GET /downloads/sessions/:token/file`。
|
||||||
|
3. `downloads.service.ts`:实现会话校验、Range 解析、206/416 响应、chunk 记录。
|
||||||
|
4. `gitlab-storage.service.ts`:支持 Range 透传与本地分段读取。
|
||||||
|
5. `prisma/schema.prisma`:新增会话与分段记录模型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
本设计文档用于“下载大文件功能”研发基线,可在进入实现阶段前补充更细的 DTO、错误码扩展和数据库 migration 细节。
|
||||||
@@ -31,15 +31,19 @@ Copy-Item server/.env.example server/.env
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
PORT=3000
|
PORT=3000
|
||||||
DATABASE_URL="file:./dev.db"
|
DATABASE_URL=file:./dev.db
|
||||||
JWT_ACCESS_SECRET=change_this_access_secret
|
JWT_ACCESS_SECRET=change_this_access_secret
|
||||||
JWT_REFRESH_SECRET=change_this_refresh_secret
|
JWT_REFRESH_SECRET=change_this_refresh_secret
|
||||||
|
DEFAULT_ADMIN_USERNAME=admin
|
||||||
|
DEFAULT_ADMIN_PASSWORD=admin123456
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 当前项目 Prisma 使用 `SQLite`(`server/prisma/schema.prisma`)。
|
- 当前项目 Prisma 使用 `SQLite`(`server/prisma/schema.prisma`)。
|
||||||
- `DATABASE_URL="file:./dev.db"` 对应数据库文件在容器内路径 `/app/server/prisma/dev.db`。
|
- `DATABASE_URL=file:./dev.db` 对应数据库文件在容器内路径 `/app/server/prisma/dev.db`。
|
||||||
|
- `docker run --env-file` 会把环境变量中的外层引号当作实际内容保留,所以这里不要写成 `DATABASE_URL="file:./dev.db"`。
|
||||||
|
- 应用启动后会自动检查管理员账号;若 `DEFAULT_ADMIN_USERNAME` 不存在,则自动创建该账号。
|
||||||
|
|
||||||
## 3. 构建镜像
|
## 3. 构建镜像
|
||||||
|
|
||||||
@@ -74,9 +78,16 @@ docker run -d \
|
|||||||
toolsshow:latest
|
toolsshow:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
docker run -d `
|
||||||
|
--name toolsshow-app `
|
||||||
|
-p 3000:3000 `
|
||||||
|
--env-file .\server\.env `
|
||||||
|
-v "${PWD}\server\prisma:/app/server/prisma" `
|
||||||
|
toolsshow:latest
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 容器启动命令已在 `Dockerfile` 中定义:`npx prisma migrate deploy && node dist/main`。
|
- 容器启动命令已在 `Dockerfile` 中定义:`npx prisma migrate deploy && node dist/src/main.js`。
|
||||||
- 首次启动会自动执行数据库迁移。
|
- 首次启动会自动执行数据库迁移。
|
||||||
|
|
||||||
## 5. 常用运维命令
|
## 5. 常用运维命令
|
||||||
@@ -138,4 +149,3 @@ docker build -t toolsshow:latest .
|
|||||||
docker rm -f toolsshow-app 2>$null
|
docker rm -f toolsshow-app 2>$null
|
||||||
docker run -d --name toolsshow-app -p 3000:3000 --env-file ./server/.env -v toolsshow_prisma:/app/server/prisma toolsshow:latest
|
docker run -d --name toolsshow-app -p 3000:3000 --env-file ./server/.env -v toolsshow_prisma:/app/server/prisma toolsshow:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
223
docs/TOOLSHOW_ER.drawio
Normal file
223
docs/TOOLSHOW_ER.drawio
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<mxfile host="app.diagrams.net" modified="2026-03-27T00:00:00.000Z" agent="Codex" version="24.7.17">
|
||||||
|
<diagram id="toolsshow-er" name="ToolsShow-ER">
|
||||||
|
<mxGraphModel dx="1800" dy="980" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="4200" pageHeight="2200" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0"/>
|
||||||
|
<mxCell id="1" parent="0"/>
|
||||||
|
|
||||||
|
<mxCell id="e_categories" value="categories" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="80" y="220" width="150" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="e_tools" value="tools" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="360" y="220" width="170" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="e_artifacts" value="tool_artifacts" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="700" y="220" width="190" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="e_admin_users" value="admin_users" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="1040" y="220" width="170" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="e_hot_keywords" value="hot_keywords" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="80" y="520" width="170" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="e_tags" value="tags" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="360" y="520" width="150" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="e_tool_tags" value="tool_tags" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="560" y="520" width="170" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="e_tickets" value="download_tickets" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="760" y="520" width="210" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="e_records" value="download_records" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="1010" y="520" width="210" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="e_features" value="tool_features" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="360" y="820" width="170" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="e_open_records" value="open_records" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="700" y="820" width="190" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="e_audit_logs" value="admin_audit_logs" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="1020" y="820" width="220" height="56" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="r_cat_tools" value="belongs_to_category" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="260" y="226" width="80" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_tools_artifacts" value="has_artifact" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="580" y="226" width="90" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_tools_latest" value="latest_artifact_ref" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="560" y="120" width="120" height="50" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_admin_artifacts" value="uploaded_by" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="930" y="226" width="90" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="r_tools_tooltags" value="tool_ref" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="460" y="370" width="80" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_tags_tooltags" value="tag_ref" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="520" y="526" width="80" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_tools_features" value="has_feature" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="400" y="710" width="90" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="r_tools_tickets" value="creates_ticket" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="620" y="406" width="100" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_artifacts_tickets" value="targets_artifact" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="800" y="406" width="110" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="r_tools_records" value="download_of_tool" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="820" y="620" width="110" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_artifacts_records" value="download_of_artifact" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="950" y="406" width="130" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_tools_open" value="open_event" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="620" y="716" width="90" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_admin_audit" value="operates" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="1080" y="680" width="90" height="44" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="a_categories_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="70" y="130" width="90" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="a_tools_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="360" y="130" width="90" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="a_artifacts_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="700" y="130" width="90" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="a_admin_users_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="1040" y="130" width="90" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="a_hot_keywords_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="80" y="600" width="90" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="a_tags_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="320" y="600" width="90" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="a_tool_tags_pk" value="tool_id + tag_id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="560" y="600" width="150" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="a_tickets_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="760" y="600" width="90" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="a_records_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="1010" y="600" width="90" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="a_features_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="360" y="900" width="90" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="a_open_records_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="700" y="900" width="90" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="a_audit_logs_id" value="id (PK)" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#333333;fontSize=11;fontStyle=4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="1020" y="900" width="90" height="36" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l1" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_categories" target="r_cat_tools"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l2" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_cat_tools" target="e_tools"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l3" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_artifacts"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l4" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_artifacts" target="e_artifacts"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l5" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_latest"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l6" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_latest" target="e_artifacts"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l7" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_admin_users" target="r_admin_artifacts"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l8" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_admin_artifacts" target="e_artifacts"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l9" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_tooltags"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l10" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_tooltags" target="e_tool_tags"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l11" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tags" target="r_tags_tooltags"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l12" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tags_tooltags" target="e_tool_tags"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l13" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_features"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l14" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_features" target="e_features"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l15" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_tickets"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l16" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_tickets" target="e_tickets"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l17" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_artifacts" target="r_artifacts_tickets"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l18" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_artifacts_tickets" target="e_tickets"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l19" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_records"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l20" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_records" target="e_records"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l21" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_artifacts" target="r_artifacts_records"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l22" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_artifacts_records" target="e_records"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l23" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_tools" target="r_tools_open"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l24" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_tools_open" target="e_open_records"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="l25" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="e_admin_users" target="r_admin_audit"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="l26" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1.5;" edge="1" parent="1" source="r_admin_audit" target="e_audit_logs"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="la1" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_categories" target="a_categories_id"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="la2" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_tools" target="a_tools_id"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="la3" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_artifacts" target="a_artifacts_id"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="la4" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_admin_users" target="a_admin_users_id"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="la5" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_hot_keywords" target="a_hot_keywords_id"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="la6" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_tags" target="a_tags_id"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="la7" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_tool_tags" target="a_tool_tags_pk"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="la8" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_tickets" target="a_tickets_id"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="la9" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_records" target="a_records_id"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="la10" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_features" target="a_features_id"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="la11" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_open_records" target="a_open_records_id"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="la12" style="endArrow=none;html=1;strokeColor=#333333;strokeWidth=1;" edge="1" parent="1" source="e_audit_logs" target="a_audit_logs_id"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c1" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="232" y="238" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c2" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="345" y="238" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c3" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="535" y="238" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c4" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="675" y="238" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c5" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="545" y="148" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c6" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="683" y="148" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c7" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="1018" y="238" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c8" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="915" y="238" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c9" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="448" y="350" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c10" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="544" y="448" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c11" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="510" y="538" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c12" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="606" y="538" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c13" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="428" y="690" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c14" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="428" y="760" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c15" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="598" y="404" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c16" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="736" y="468" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c17" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="776" y="392" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c18" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="898" y="468" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c19" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="808" y="608" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c20" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="940" y="608" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c21" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="938" y="392" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c22" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="1086" y="468" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c23" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="598" y="704" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c24" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="728" y="774" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
<mxCell id="c25" value="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="1080" y="664" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
<mxCell id="c26" value="n" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;" vertex="1" parent="1"><mxGeometry x="1160" y="772" width="20" height="20" as="geometry"/></mxCell>
|
||||||
|
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>
|
||||||
144
index.html
144
index.html
@@ -1,144 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>ToolsShow - 工具展示站</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Sora:wght@500;600;700;800&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
>
|
|
||||||
<link rel="stylesheet" href="./styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header class="header-wrap">
|
|
||||||
<div class="container header">
|
|
||||||
<a class="brand" href="#" aria-label="ToolsShow 首页">
|
|
||||||
<span class="brand-mark">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<path d="M4 6.5C4 5.67 4.67 5 5.5 5H18.5C19.33 5 20 5.67 20 6.5V17.5C20 18.33 19.33 19 18.5 19H5.5C4.67 19 4 18.33 4 17.5V6.5Z" stroke="currentColor" stroke-width="1.8"/>
|
|
||||||
<path d="M8 9H16M8 12H16M8 15H13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span>ToolsShow</span>
|
|
||||||
</a>
|
|
||||||
<nav class="nav" aria-label="主导航">
|
|
||||||
<a href="#tools">工具列表</a>
|
|
||||||
<a href="#tools">分类浏览</a>
|
|
||||||
<a href="#tools">工具中心</a>
|
|
||||||
<button
|
|
||||||
id="overviewBtn"
|
|
||||||
type="button"
|
|
||||||
class="nav-btn"
|
|
||||||
aria-controls="overviewModal"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
站点概览
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container main-content">
|
|
||||||
<section class="hero">
|
|
||||||
<div class="hero-main">
|
|
||||||
<div class="search-row">
|
|
||||||
<label class="search-box" for="searchInput">
|
|
||||||
<span class="sr-only">搜索工具</span>
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.8"/>
|
|
||||||
<path d="M16.5 16.5L21 21" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<input id="searchInput" type="search" placeholder="搜索名称、描述、标签..." autocomplete="off">
|
|
||||||
</label>
|
|
||||||
<label class="sr-only" for="categorySelect">按分类筛选</label>
|
|
||||||
<select id="categorySelect" class="select">
|
|
||||||
<option value="all">全部分类</option>
|
|
||||||
</select>
|
|
||||||
<button id="resetBtn" type="button" class="btn btn-primary">重置筛选</button>
|
|
||||||
</div>
|
|
||||||
<div id="hotKeywords" class="hot-keywords">
|
|
||||||
<span>热门搜索:</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="tools">
|
|
||||||
<div class="tools-layout">
|
|
||||||
<aside class="category-sidebar" aria-label="分类导航">
|
|
||||||
<h2 class="sidebar-title">分类导航</h2>
|
|
||||||
<p class="sidebar-tip">点击分类可快速筛选工具</p>
|
|
||||||
<div id="categorySidebarList" class="category-sidebar-list"></div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="tools-main">
|
|
||||||
<div class="toolbar">
|
|
||||||
<p id="resultTip">正在加载工具数据...</p>
|
|
||||||
<label class="sr-only" for="sortSelect">排序方式</label>
|
|
||||||
<select id="sortSelect" class="select">
|
|
||||||
<option value="popular">按下载量排序</option>
|
|
||||||
<option value="latest">按更新时间排序</option>
|
|
||||||
<option value="name">按名称排序</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="toolGrid" class="tool-grid" aria-live="polite"></div>
|
|
||||||
|
|
||||||
<div id="pagination" class="pagination">
|
|
||||||
<button id="prevBtn" type="button" class="btn">上一页</button>
|
|
||||||
<span id="pageText">第 1 页</span>
|
|
||||||
<button id="nextBtn" type="button" class="btn">下一页</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div id="detailModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="detailTitle">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2 id="detailTitle">工具详情</h2>
|
|
||||||
<button id="closeModalBtn" type="button" class="icon-btn" aria-label="关闭详情">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p id="detailDescription"></p>
|
|
||||||
<ul id="detailMeta" class="meta-list"></ul>
|
|
||||||
<h3>核心能力</h3>
|
|
||||||
<ul id="detailFeatures" class="feature-list"></ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="overviewModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="overviewTitle">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2 id="overviewTitle">站点概览</h2>
|
|
||||||
<button id="closeOverviewModalBtn" type="button" class="icon-btn" aria-label="关闭站点概览">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p>展示站当前统计信息与核心能力说明。</p>
|
|
||||||
<div class="kpi-grid">
|
|
||||||
<div><strong id="kpiTotal">0</strong><span>工具总数</span></div>
|
|
||||||
<div><strong id="kpiCategories">0</strong><span>分类数量</span></div>
|
|
||||||
<div><strong id="kpiDownloads">0</strong><span>累计使用</span></div>
|
|
||||||
<div><strong id="kpiFiltered">0</strong><span>当前结果</span></div>
|
|
||||||
</div>
|
|
||||||
<ul class="tips">
|
|
||||||
<li>浏览:分页展示工具卡片</li>
|
|
||||||
<li>搜索:实时匹配关键词</li>
|
|
||||||
<li>获取:支持模拟下载与网页直达</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
|
||||||
<script src="./app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
10
package.json
10
package.json
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ToolsShow",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "app.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"private": true
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
DATABASE_URL="file:./dev.db"
|
DATABASE_URL=file:./dev.db
|
||||||
DOWNLOAD_TICKET_TTL_SEC=120
|
DOWNLOAD_TICKET_TTL_SEC=120
|
||||||
|
|
||||||
JWT_ACCESS_SECRET=change_this_access_secret
|
JWT_ACCESS_SECRET=change_this_access_secret
|
||||||
JWT_REFRESH_SECRET=change_this_refresh_secret
|
JWT_REFRESH_SECRET=change_this_refresh_secret
|
||||||
JWT_ACCESS_EXPIRES_IN=2h
|
JWT_ACCESS_EXPIRES_IN=2h
|
||||||
JWT_REFRESH_EXPIRES_IN=7d
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
DEFAULT_ADMIN_USERNAME=admin
|
||||||
|
DEFAULT_ADMIN_PASSWORD=admin123456
|
||||||
|
|
||||||
GITLAB_BASE_URL=
|
GITLAB_BASE_URL=
|
||||||
GITLAB_API_BASE=
|
GITLAB_API_BASE=
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/src/main.js",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { AccessModule } from './modules/access/access.module';
|
|||||||
import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.module';
|
import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.module';
|
||||||
import { AdminAuditModule } from './modules/admin-audit/admin-audit.module';
|
import { AdminAuditModule } from './modules/admin-audit/admin-audit.module';
|
||||||
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
|
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
|
||||||
|
import { AdminCategoriesModule } from './modules/admin-categories/admin-categories.module';
|
||||||
|
import { AdminOverviewModule } from './modules/admin-overview/admin-overview.module';
|
||||||
|
import { AdminTagsModule } from './modules/admin-tags/admin-tags.module';
|
||||||
import { AdminToolsModule } from './modules/admin-tools/admin-tools.module';
|
import { AdminToolsModule } from './modules/admin-tools/admin-tools.module';
|
||||||
import { CategoriesModule } from './modules/categories/categories.module';
|
import { CategoriesModule } from './modules/categories/categories.module';
|
||||||
import { DownloadsModule } from './modules/downloads/downloads.module';
|
import { DownloadsModule } from './modules/downloads/downloads.module';
|
||||||
@@ -30,6 +33,9 @@ import { ToolsModule } from './modules/tools/tools.module';
|
|||||||
GitlabStorageModule,
|
GitlabStorageModule,
|
||||||
DownloadsModule,
|
DownloadsModule,
|
||||||
AdminAuthModule,
|
AdminAuthModule,
|
||||||
|
AdminOverviewModule,
|
||||||
|
AdminCategoriesModule,
|
||||||
|
AdminTagsModule,
|
||||||
AdminToolsModule,
|
AdminToolsModule,
|
||||||
AdminArtifactsModule,
|
AdminArtifactsModule,
|
||||||
AdminAuditModule,
|
AdminAuditModule,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ async function bootstrap() {
|
|||||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
const clientDistPath = process.env.CLIENT_DIST_PATH ?? join(__dirname, '..', 'public');
|
const clientDistPath = process.env.CLIENT_DIST_PATH ?? join(process.cwd(), 'public');
|
||||||
if (existsSync(clientDistPath)) {
|
if (existsSync(clientDistPath)) {
|
||||||
app.useStaticAssets(clientDistPath);
|
app.useStaticAssets(clientDistPath);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Body, Controller, Param, Post, Req } from '@nestjs/common';
|
|||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||||
import { LaunchToolDto } from './dto/launch-tool.dto';
|
import { LaunchToolDto } from './dto/launch-tool.dto';
|
||||||
|
import { TrackToolInteractionDto } from './dto/track-tool-interaction.dto';
|
||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
|
|
||||||
@ApiTags('public-launch')
|
@ApiTags('public-launch')
|
||||||
@@ -18,4 +19,14 @@ export class AccessController {
|
|||||||
) {
|
) {
|
||||||
return this.accessService.launchTool(id, body, request);
|
return this.accessService.launchTool(id, body, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/interaction')
|
||||||
|
@ApiOperation({ summary: 'Track tool interaction (open/download) asynchronously' })
|
||||||
|
trackInteraction(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: TrackToolInteractionDto,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.accessService.trackInteraction(id, body, request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AppException } from '../../common/exceptions/app.exception';
|
|||||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { LaunchToolDto } from './dto/launch-tool.dto';
|
import { LaunchToolDto } from './dto/launch-tool.dto';
|
||||||
|
import { ToolInteractionAction, TrackToolInteractionDto } from './dto/track-tool-interaction.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccessService {
|
export class AccessService {
|
||||||
@@ -40,23 +41,6 @@ export class AccessService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.$transaction([
|
|
||||||
this.prisma.openRecord.create({
|
|
||||||
data: {
|
|
||||||
toolId: tool.id,
|
|
||||||
channel: body.channel,
|
|
||||||
clientVersion: body.clientVersion,
|
|
||||||
clientIp: this.extractIp(request),
|
|
||||||
userAgent: request.headers['user-agent'],
|
|
||||||
referer: this.extractHeader(request, 'referer'),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.tool.update({
|
|
||||||
where: { id: tool.id },
|
|
||||||
data: { openCount: { increment: 1 } },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: 'web' as const,
|
mode: 'web' as const,
|
||||||
actionUrl: tool.openUrl,
|
actionUrl: tool.openUrl,
|
||||||
@@ -64,6 +48,14 @@ export class AccessService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tool.openUrl) {
|
||||||
|
return {
|
||||||
|
mode: 'download' as const,
|
||||||
|
actionUrl: tool.openUrl,
|
||||||
|
openIn: tool.openInNewTab ? 'new_tab' : 'same_tab',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active) {
|
if (!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active) {
|
||||||
throw new AppException(
|
throw new AppException(
|
||||||
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
||||||
@@ -93,9 +85,63 @@ export class AccessService {
|
|||||||
ticket,
|
ticket,
|
||||||
expiresInSec: ttlSec,
|
expiresInSec: ttlSec,
|
||||||
actionUrl: `/api/v1/downloads/${ticket}`,
|
actionUrl: `/api/v1/downloads/${ticket}`,
|
||||||
|
openIn: 'new_tab' as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async trackInteraction(
|
||||||
|
toolId: string,
|
||||||
|
body: TrackToolInteractionDto,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const tool = await this.prisma.tool.findFirst({
|
||||||
|
where: {
|
||||||
|
id: toolId,
|
||||||
|
isDeleted: false,
|
||||||
|
status: ToolStatus.published,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === ToolInteractionAction.open) {
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.openRecord.create({
|
||||||
|
data: {
|
||||||
|
toolId: tool.id,
|
||||||
|
channel: body.channel,
|
||||||
|
clientVersion: body.clientVersion,
|
||||||
|
clientIp: this.extractIp(request),
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
referer: this.extractHeader(request, 'referer'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.tool.update({
|
||||||
|
where: { id: tool.id },
|
||||||
|
data: { openCount: { increment: 1 } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.tool.update({
|
||||||
|
where: { id: tool.id },
|
||||||
|
data: {
|
||||||
|
downloadCount: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
private extractIp(request: RequestWithContext): string | undefined {
|
private extractIp(request: RequestWithContext): string | undefined {
|
||||||
const forwarded = request.headers['x-forwarded-for'];
|
const forwarded = request.headers['x-forwarded-for'];
|
||||||
if (Array.isArray(forwarded) && forwarded.length > 0) {
|
if (Array.isArray(forwarded) && forwarded.length > 0) {
|
||||||
|
|||||||
25
server/src/modules/access/dto/track-tool-interaction.dto.ts
Normal file
25
server/src/modules/access/dto/track-tool-interaction.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export enum ToolInteractionAction {
|
||||||
|
open = 'open',
|
||||||
|
download = 'download',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TrackToolInteractionDto {
|
||||||
|
@ApiProperty({ enum: ToolInteractionAction })
|
||||||
|
@IsEnum(ToolInteractionAction)
|
||||||
|
action!: ToolInteractionAction;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'official' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
channel?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'web-1.0.0' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
clientVersion?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Audit } from '../../common/decorators/audit.decorator';
|
||||||
|
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||||
|
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
|
||||||
|
import { AdminCategoriesService } from './admin-categories.service';
|
||||||
|
import { AdminCategoriesQueryDto } from './dto/admin-categories-query.dto';
|
||||||
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
|
|
||||||
|
@ApiTags('admin-categories')
|
||||||
|
@ApiBearerAuth('admin-access-token')
|
||||||
|
@UseGuards(AdminJwtGuard)
|
||||||
|
@UseInterceptors(AdminAuditInterceptor)
|
||||||
|
@Controller('admin/categories')
|
||||||
|
export class AdminCategoriesController {
|
||||||
|
constructor(private readonly adminCategoriesService: AdminCategoriesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Admin list categories' })
|
||||||
|
getCategories(@Query() query: AdminCategoriesQueryDto) {
|
||||||
|
return this.adminCategoriesService.getCategories(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Audit({ action: 'category.create', resourceType: 'category' })
|
||||||
|
@ApiOperation({ summary: 'Admin create category' })
|
||||||
|
createCategory(@Body() body: CreateCategoryDto) {
|
||||||
|
return this.adminCategoriesService.createCategory(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Audit({ action: 'category.update', resourceType: 'category' })
|
||||||
|
@ApiOperation({ summary: 'Admin update category' })
|
||||||
|
updateCategory(@Param('id') id: string, @Body() body: UpdateCategoryDto) {
|
||||||
|
return this.adminCategoriesService.updateCategory(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Audit({ action: 'category.delete', resourceType: 'category' })
|
||||||
|
@ApiOperation({ summary: 'Admin delete category' })
|
||||||
|
deleteCategory(@Param('id') id: string) {
|
||||||
|
return this.adminCategoriesService.deleteCategory(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AdminCategoriesController } from './admin-categories.controller';
|
||||||
|
import { AdminCategoriesService } from './admin-categories.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AdminCategoriesController],
|
||||||
|
providers: [AdminCategoriesService],
|
||||||
|
})
|
||||||
|
export class AdminCategoriesModule {}
|
||||||
207
server/src/modules/admin-categories/admin-categories.service.ts
Normal file
207
server/src/modules/admin-categories/admin-categories.service.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||||
|
import { AppException } from '../../common/exceptions/app.exception';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AdminCategoriesQueryDto } from './dto/admin-categories-query.dto';
|
||||||
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminCategoriesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getCategories(query: AdminCategoriesQueryDto) {
|
||||||
|
const keyword = query.query?.trim();
|
||||||
|
const categories = await this.prisma.category.findMany({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
...(keyword
|
||||||
|
? {
|
||||||
|
name: {
|
||||||
|
contains: keyword,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tools: {
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return categories.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
toolCount: item.tools.length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCategory(body: CreateCategoryDto) {
|
||||||
|
const name = body.name.trim();
|
||||||
|
const existing = await this.prisma.category.findUnique({
|
||||||
|
where: { name },
|
||||||
|
include: {
|
||||||
|
tools: {
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.isDeleted) {
|
||||||
|
throw new AppException(
|
||||||
|
ERROR_CODES.CONFLICT,
|
||||||
|
'category name already exists',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const restored = await this.prisma.category.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
isDeleted: false,
|
||||||
|
sortOrder: body.sortOrder ?? existing.sortOrder,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tools: {
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: restored.id,
|
||||||
|
name: restored.name,
|
||||||
|
sortOrder: restored.sortOrder,
|
||||||
|
toolCount: restored.tools.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await this.prisma.category.create({
|
||||||
|
data: {
|
||||||
|
id: this.generateBusinessId('cat'),
|
||||||
|
name,
|
||||||
|
sortOrder: body.sortOrder ?? 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: category.id,
|
||||||
|
name: category.name,
|
||||||
|
sortOrder: category.sortOrder,
|
||||||
|
toolCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCategory(id: string, body: UpdateCategoryDto) {
|
||||||
|
const existing = await this.getCategoryEntity(id);
|
||||||
|
const name =
|
||||||
|
body.name !== undefined
|
||||||
|
? body.name.trim()
|
||||||
|
: existing.name;
|
||||||
|
|
||||||
|
await this.assertNameAvailable(name, id);
|
||||||
|
|
||||||
|
const updated = await this.prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: body.name !== undefined ? name : undefined,
|
||||||
|
sortOrder: body.sortOrder,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tools: {
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
sortOrder: updated.sortOrder,
|
||||||
|
toolCount: updated.tools.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCategory(id: string) {
|
||||||
|
await this.getCategoryEntity(id);
|
||||||
|
|
||||||
|
const usedCount = await this.prisma.tool.count({
|
||||||
|
where: {
|
||||||
|
categoryId: id,
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (usedCount > 0) {
|
||||||
|
throw new AppException(
|
||||||
|
ERROR_CODES.CONFLICT,
|
||||||
|
'category is still used by tools',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
isDeleted: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCategoryEntity(id: string) {
|
||||||
|
const category = await this.prisma.category.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!category) {
|
||||||
|
throw new AppException(ERROR_CODES.NOT_FOUND, 'category not found', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertNameAvailable(name: string, excludeId?: string) {
|
||||||
|
const conflict = await this.prisma.category.findUnique({
|
||||||
|
where: { name },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (conflict && conflict.id !== excludeId) {
|
||||||
|
throw new AppException(ERROR_CODES.CONFLICT, 'category name already exists', HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateBusinessId(prefix: string): string {
|
||||||
|
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class AdminCategoriesQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Category name query' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
query?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCategoryDto {
|
||||||
|
@ApiProperty({ description: 'Category name' })
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Sort order', default: 100, minimum: 0, maximum: 9999 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(9999)
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateCategoryDto } from './create-category.dto';
|
||||||
|
|
||||||
|
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||||
|
import { AdminOverviewService } from './admin-overview.service';
|
||||||
|
|
||||||
|
@ApiTags('admin-overview')
|
||||||
|
@ApiBearerAuth('admin-access-token')
|
||||||
|
@UseGuards(AdminJwtGuard)
|
||||||
|
@Controller('admin/overview')
|
||||||
|
export class AdminOverviewController {
|
||||||
|
constructor(private readonly adminOverviewService: AdminOverviewService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get admin dashboard overview metrics' })
|
||||||
|
getOverview() {
|
||||||
|
return this.adminOverviewService.getOverview();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AdminOverviewController } from './admin-overview.controller';
|
||||||
|
import { AdminOverviewService } from './admin-overview.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AdminOverviewController],
|
||||||
|
providers: [AdminOverviewService],
|
||||||
|
})
|
||||||
|
export class AdminOverviewModule {}
|
||||||
300
server/src/modules/admin-overview/admin-overview.service.ts
Normal file
300
server/src/modules/admin-overview/admin-overview.service.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AccessMode, ArtifactStatus, DownloadRecordStatus, ToolStatus } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
type DailyRange = {
|
||||||
|
date: string;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminOverviewService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getOverview() {
|
||||||
|
const [
|
||||||
|
categoryTotal,
|
||||||
|
tagTotal,
|
||||||
|
artifactTotal,
|
||||||
|
activeArtifactTotal,
|
||||||
|
auditLogTotal,
|
||||||
|
downloadReadyToolTotal,
|
||||||
|
toolStatusBuckets,
|
||||||
|
accessModeBuckets,
|
||||||
|
publishedTraffic,
|
||||||
|
publishedTools,
|
||||||
|
dailyActivity,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.prisma.category.count({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.tag.count({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.toolArtifact.count({
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
not: ArtifactStatus.deleted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.toolArtifact.count({
|
||||||
|
where: {
|
||||||
|
status: ArtifactStatus.active,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.adminAuditLog.count(),
|
||||||
|
this.prisma.tool.count({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
accessMode: AccessMode.download,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
openUrl: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
openUrl: {
|
||||||
|
not: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
latestArtifact: {
|
||||||
|
is: {
|
||||||
|
status: ArtifactStatus.active,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.tool.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
_all: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.tool.groupBy({
|
||||||
|
by: ['accessMode'],
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
_all: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.tool.aggregate({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
status: ToolStatus.published,
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
openCount: true,
|
||||||
|
downloadCount: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.tool.findMany({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
status: ToolStatus.published,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
categoryId: true,
|
||||||
|
category: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accessMode: true,
|
||||||
|
openCount: true,
|
||||||
|
downloadCount: true,
|
||||||
|
modifiedAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.getDailyActivity(7),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const statusStats = {
|
||||||
|
draft: 0,
|
||||||
|
published: 0,
|
||||||
|
archived: 0,
|
||||||
|
};
|
||||||
|
toolStatusBuckets.forEach((item) => {
|
||||||
|
statusStats[item.status] = item._count._all;
|
||||||
|
});
|
||||||
|
|
||||||
|
const modeStats = {
|
||||||
|
web: 0,
|
||||||
|
download: 0,
|
||||||
|
};
|
||||||
|
accessModeBuckets.forEach((item) => {
|
||||||
|
modeStats[item.accessMode] = item._count._all;
|
||||||
|
});
|
||||||
|
|
||||||
|
const openTotal = publishedTraffic._sum.openCount ?? 0;
|
||||||
|
const downloadTotal = publishedTraffic._sum.downloadCount ?? 0;
|
||||||
|
const interactionTotal = openTotal + downloadTotal;
|
||||||
|
|
||||||
|
const topTools = publishedTools
|
||||||
|
.map((tool) => ({
|
||||||
|
id: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
categoryName: tool.category.name,
|
||||||
|
accessMode: tool.accessMode,
|
||||||
|
openCount: tool.openCount,
|
||||||
|
downloadCount: tool.downloadCount,
|
||||||
|
interactionTotal: tool.openCount + tool.downloadCount,
|
||||||
|
updatedAt: tool.modifiedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.interactionTotal !== a.interactionTotal) {
|
||||||
|
return b.interactionTotal - a.interactionTotal;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
})
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const categoryStatMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
categoryId: string;
|
||||||
|
categoryName: string;
|
||||||
|
toolTotal: number;
|
||||||
|
openTotal: number;
|
||||||
|
downloadTotal: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
publishedTools.forEach((tool) => {
|
||||||
|
const existing = categoryStatMap.get(tool.categoryId) ?? {
|
||||||
|
categoryId: tool.categoryId,
|
||||||
|
categoryName: tool.category.name,
|
||||||
|
toolTotal: 0,
|
||||||
|
openTotal: 0,
|
||||||
|
downloadTotal: 0,
|
||||||
|
};
|
||||||
|
existing.toolTotal += 1;
|
||||||
|
existing.openTotal += tool.openCount;
|
||||||
|
existing.downloadTotal += tool.downloadCount;
|
||||||
|
categoryStatMap.set(tool.categoryId, existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
const topCategories = Array.from(categoryStatMap.values())
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
interactionTotal: item.openTotal + item.downloadTotal,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.interactionTotal !== a.interactionTotal) {
|
||||||
|
return b.interactionTotal - a.interactionTotal;
|
||||||
|
}
|
||||||
|
return b.toolTotal - a.toolTotal;
|
||||||
|
})
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const toolTotal = statusStats.draft + statusStats.published + statusStats.archived;
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
summary: {
|
||||||
|
toolTotal,
|
||||||
|
draftTotal: statusStats.draft,
|
||||||
|
publishedTotal: statusStats.published,
|
||||||
|
archivedTotal: statusStats.archived,
|
||||||
|
categoryTotal,
|
||||||
|
tagTotal,
|
||||||
|
webToolTotal: modeStats.web,
|
||||||
|
downloadToolTotal: modeStats.download,
|
||||||
|
downloadReadyToolTotal,
|
||||||
|
openTotal,
|
||||||
|
downloadTotal,
|
||||||
|
interactionTotal,
|
||||||
|
artifactTotal,
|
||||||
|
activeArtifactTotal,
|
||||||
|
auditLogTotal,
|
||||||
|
},
|
||||||
|
dailyActivity,
|
||||||
|
topCategories,
|
||||||
|
topTools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDailyActivity(days: number) {
|
||||||
|
const ranges = this.buildDailyRanges(days);
|
||||||
|
const queries = ranges.flatMap((range) => [
|
||||||
|
this.prisma.openRecord.count({
|
||||||
|
where: {
|
||||||
|
openedAt: {
|
||||||
|
gte: range.start,
|
||||||
|
lt: range.end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.downloadRecord.count({
|
||||||
|
where: {
|
||||||
|
downloadedAt: {
|
||||||
|
gte: range.start,
|
||||||
|
lt: range.end,
|
||||||
|
},
|
||||||
|
status: DownloadRecordStatus.success,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.adminAuditLog.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: range.start,
|
||||||
|
lt: range.end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const counts = await this.prisma.$transaction(queries);
|
||||||
|
|
||||||
|
return ranges.map((range, index) => {
|
||||||
|
const openCount = counts[index * 3] ?? 0;
|
||||||
|
const downloadCount = counts[index * 3 + 1] ?? 0;
|
||||||
|
const auditCount = counts[index * 3 + 2] ?? 0;
|
||||||
|
return {
|
||||||
|
date: range.date,
|
||||||
|
openCount,
|
||||||
|
downloadCount,
|
||||||
|
interactionTotal: openCount + downloadCount,
|
||||||
|
auditCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDailyRanges(days: number): DailyRange[] {
|
||||||
|
const safeDays = Math.max(1, days);
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = new Date(now);
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return Array.from({ length: safeDays }, (_, index) => {
|
||||||
|
const offset = safeDays - index - 1;
|
||||||
|
const start = new Date(todayStart);
|
||||||
|
start.setDate(todayStart.getDate() - offset);
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(start.getDate() + 1);
|
||||||
|
return {
|
||||||
|
date: start.toISOString().slice(0, 10),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
29
server/src/modules/admin-tags/admin-tags.controller.ts
Normal file
29
server/src/modules/admin-tags/admin-tags.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Audit } from '../../common/decorators/audit.decorator';
|
||||||
|
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||||
|
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
|
||||||
|
import { AdminTagsService } from './admin-tags.service';
|
||||||
|
import { CreateTagDto } from './dto/create-tag.dto';
|
||||||
|
|
||||||
|
@ApiTags('admin-tags')
|
||||||
|
@ApiBearerAuth('admin-access-token')
|
||||||
|
@UseGuards(AdminJwtGuard)
|
||||||
|
@UseInterceptors(AdminAuditInterceptor)
|
||||||
|
@Controller('admin/tags')
|
||||||
|
export class AdminTagsController {
|
||||||
|
constructor(private readonly adminTagsService: AdminTagsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Admin list tags' })
|
||||||
|
getTags() {
|
||||||
|
return this.adminTagsService.getTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Audit({ action: 'tag.create', resourceType: 'tag' })
|
||||||
|
@ApiOperation({ summary: 'Admin create tag' })
|
||||||
|
createTag(@Body() body: CreateTagDto) {
|
||||||
|
return this.adminTagsService.createTag(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
server/src/modules/admin-tags/admin-tags.module.ts
Normal file
9
server/src/modules/admin-tags/admin-tags.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AdminTagsController } from './admin-tags.controller';
|
||||||
|
import { AdminTagsService } from './admin-tags.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AdminTagsController],
|
||||||
|
providers: [AdminTagsService],
|
||||||
|
})
|
||||||
|
export class AdminTagsModule {}
|
||||||
65
server/src/modules/admin-tags/admin-tags.service.ts
Normal file
65
server/src/modules/admin-tags/admin-tags.service.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { CreateTagDto } from './dto/create-tag.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminTagsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getTags() {
|
||||||
|
const tags = await this.prisma.tag.findMany({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return tags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTag(body: CreateTagDto) {
|
||||||
|
const name = body.name.trim();
|
||||||
|
|
||||||
|
const existing = await this.prisma.tag.findUnique({
|
||||||
|
where: { name },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
if (existing.isDeleted) {
|
||||||
|
const restored = await this.prisma.tag.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { isDeleted: false },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: restored.id,
|
||||||
|
name: restored.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: existing.id,
|
||||||
|
name: existing.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.prisma.tag.create({
|
||||||
|
data: {
|
||||||
|
id: this.generateBusinessId('tag'),
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: created.id,
|
||||||
|
name: created.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateBusinessId(prefix: string): string {
|
||||||
|
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
server/src/modules/admin-tags/dto/create-tag.dto.ts
Normal file
12
server/src/modules/admin-tags/dto/create-tag.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTagDto {
|
||||||
|
@ApiProperty({ description: 'Tag display name' })
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(50)
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
@@ -77,9 +77,10 @@ export class AdminToolsService {
|
|||||||
async createTool(body: CreateToolDto) {
|
async createTool(body: CreateToolDto) {
|
||||||
await this.assertCategoryExists(body.categoryId);
|
await this.assertCategoryExists(body.categoryId);
|
||||||
await this.assertTagsExist(body.tags ?? []);
|
await this.assertTagsExist(body.tags ?? []);
|
||||||
|
const openUrl = this.normalizeOptionalUrl(body.openUrl);
|
||||||
|
|
||||||
if ((body.status ?? ToolStatus.draft) === ToolStatus.published) {
|
if ((body.status ?? ToolStatus.draft) === ToolStatus.published) {
|
||||||
this.assertPublishInput(body.accessMode, body.openUrl, undefined);
|
this.assertPublishInput(body.accessMode, openUrl, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolId = this.generateBusinessId('tool');
|
const toolId = this.generateBusinessId('tool');
|
||||||
@@ -95,7 +96,7 @@ export class AdminToolsService {
|
|||||||
description: body.description.trim(),
|
description: body.description.trim(),
|
||||||
rating: body.rating ?? 0,
|
rating: body.rating ?? 0,
|
||||||
accessMode: body.accessMode,
|
accessMode: body.accessMode,
|
||||||
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
|
openUrl,
|
||||||
openInNewTab: body.openInNewTab ?? true,
|
openInNewTab: body.openInNewTab ?? true,
|
||||||
status: body.status ?? ToolStatus.draft,
|
status: body.status ?? ToolStatus.draft,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
@@ -156,14 +157,12 @@ export class AdminToolsService {
|
|||||||
|
|
||||||
async updateTool(id: string, body: UpdateToolDto) {
|
async updateTool(id: string, body: UpdateToolDto) {
|
||||||
const existingTool = await this.getToolEntity(id);
|
const existingTool = await this.getToolEntity(id);
|
||||||
|
const normalizedOpenUrl =
|
||||||
|
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : undefined;
|
||||||
|
|
||||||
const nextAccessMode = body.accessMode ?? existingTool.accessMode;
|
const nextAccessMode = body.accessMode ?? existingTool.accessMode;
|
||||||
const nextOpenUrl =
|
const nextOpenUrl =
|
||||||
body.openUrl !== undefined
|
normalizedOpenUrl !== undefined ? normalizedOpenUrl : existingTool.openUrl ?? null;
|
||||||
? body.openUrl
|
|
||||||
: nextAccessMode === AccessMode.web
|
|
||||||
? existingTool.openUrl
|
|
||||||
: null;
|
|
||||||
const nextStatus = body.status ?? existingTool.status;
|
const nextStatus = body.status ?? existingTool.status;
|
||||||
|
|
||||||
if (body.categoryId) {
|
if (body.categoryId) {
|
||||||
@@ -189,7 +188,7 @@ export class AdminToolsService {
|
|||||||
description: body.description?.trim(),
|
description: body.description?.trim(),
|
||||||
rating: body.rating,
|
rating: body.rating,
|
||||||
accessMode: body.accessMode,
|
accessMode: body.accessMode,
|
||||||
openUrl: body.openUrl,
|
openUrl: normalizedOpenUrl,
|
||||||
openInNewTab: body.openInNewTab,
|
openInNewTab: body.openInNewTab,
|
||||||
status: body.status,
|
status: body.status,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
@@ -245,10 +244,12 @@ export class AdminToolsService {
|
|||||||
|
|
||||||
async updateAccessMode(id: string, body: UpdateAccessModeDto) {
|
async updateAccessMode(id: string, body: UpdateAccessModeDto) {
|
||||||
const tool = await this.getToolEntity(id);
|
const tool = await this.getToolEntity(id);
|
||||||
|
const nextOpenUrl =
|
||||||
|
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : tool.openUrl ?? null;
|
||||||
this.assertModeSwitchConstraint(
|
this.assertModeSwitchConstraint(
|
||||||
tool.status,
|
tool.status,
|
||||||
body.accessMode,
|
body.accessMode,
|
||||||
body.openUrl,
|
nextOpenUrl,
|
||||||
tool,
|
tool,
|
||||||
tool.accessMode !== body.accessMode,
|
tool.accessMode !== body.accessMode,
|
||||||
);
|
);
|
||||||
@@ -257,7 +258,7 @@ export class AdminToolsService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
accessMode: body.accessMode,
|
accessMode: body.accessMode,
|
||||||
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
|
openUrl: nextOpenUrl,
|
||||||
openInNewTab: body.openInNewTab ?? tool.openInNewTab,
|
openInNewTab: body.openInNewTab ?? tool.openInNewTab,
|
||||||
updatedAt: this.getDateOnlyString(),
|
updatedAt: this.getDateOnlyString(),
|
||||||
},
|
},
|
||||||
@@ -354,7 +355,7 @@ export class AdminToolsService {
|
|||||||
|
|
||||||
private assertPublishInput(
|
private assertPublishInput(
|
||||||
accessMode: AccessMode,
|
accessMode: AccessMode,
|
||||||
openUrl?: string,
|
openUrl?: string | null,
|
||||||
latestArtifact?: { status: ArtifactStatus } | null,
|
latestArtifact?: { status: ArtifactStatus } | null,
|
||||||
) {
|
) {
|
||||||
if (accessMode === AccessMode.web) {
|
if (accessMode === AccessMode.web) {
|
||||||
@@ -368,10 +369,14 @@ export class AdminToolsService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (openUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!latestArtifact || latestArtifact.status !== ArtifactStatus.active) {
|
if (!latestArtifact || latestArtifact.status !== ArtifactStatus.active) {
|
||||||
throw new AppException(
|
throw new AppException(
|
||||||
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
||||||
'download mode tool requires one active latest artifact before publish',
|
'download mode tool requires one active latest artifact or one download URL before publish',
|
||||||
HttpStatus.CONFLICT,
|
HttpStatus.CONFLICT,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -396,11 +401,12 @@ export class AdminToolsService {
|
|||||||
isSwitching &&
|
isSwitching &&
|
||||||
targetMode === AccessMode.download &&
|
targetMode === AccessMode.download &&
|
||||||
currentStatus === ToolStatus.published &&
|
currentStatus === ToolStatus.published &&
|
||||||
|
!openUrl &&
|
||||||
(!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active)
|
(!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active)
|
||||||
) {
|
) {
|
||||||
throw new AppException(
|
throw new AppException(
|
||||||
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
|
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
|
||||||
'published tool cannot switch to download mode without active artifact',
|
'published tool cannot switch to download mode without active artifact or download URL',
|
||||||
HttpStatus.CONFLICT,
|
HttpStatus.CONFLICT,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -481,4 +487,13 @@ export class AdminToolsService {
|
|||||||
|
|
||||||
return slug;
|
return slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeOptionalUrl(value: string | null | undefined): string | null | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = String(value ?? '').trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateToolDto {
|
export class CreateToolDto {
|
||||||
@@ -62,8 +61,10 @@ export class CreateToolDto {
|
|||||||
@IsEnum(AccessMode)
|
@IsEnum(AccessMode)
|
||||||
accessMode!: AccessMode;
|
accessMode!: AccessMode;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Required when accessMode=web' })
|
@ApiPropertyOptional({
|
||||||
@ValidateIf((obj: CreateToolDto) => obj.accessMode === AccessMode.web)
|
description: 'Required when accessMode=web; optional external download URL when accessMode=download',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsUrl({
|
@IsUrl({
|
||||||
require_protocol: true,
|
require_protocol: true,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { AccessMode } from '@prisma/client';
|
import { AccessMode } from '@prisma/client';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsBoolean, IsEnum, IsOptional, IsString, IsUrl, ValidateIf } from 'class-validator';
|
import { IsBoolean, IsEnum, IsOptional, IsString, IsUrl } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAccessModeDto {
|
export class UpdateAccessModeDto {
|
||||||
@ApiProperty({ enum: AccessMode })
|
@ApiProperty({ enum: AccessMode })
|
||||||
@IsEnum(AccessMode)
|
@IsEnum(AccessMode)
|
||||||
accessMode!: AccessMode;
|
accessMode!: AccessMode;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Required when accessMode=web' })
|
@ApiPropertyOptional({
|
||||||
@ValidateIf((obj: UpdateAccessModeDto) => obj.accessMode === AccessMode.web)
|
description: 'Required when accessMode=web; optional external download URL when accessMode=download',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsUrl({
|
@IsUrl({
|
||||||
require_protocol: true,
|
require_protocol: true,
|
||||||
|
|||||||
@@ -107,15 +107,6 @@ export class DownloadsService {
|
|||||||
status: DownloadRecordStatus.success,
|
status: DownloadRecordStatus.success,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.tool.update({
|
|
||||||
where: { id: ticketEntity.toolId },
|
|
||||||
data: {
|
|
||||||
downloadCount: {
|
|
||||||
increment: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
response.setHeader(
|
response.setHeader(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Transform, Type } from 'class-transformer';
|
|||||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export enum ToolSortBy {
|
export enum ToolSortBy {
|
||||||
|
created = 'created',
|
||||||
popular = 'popular',
|
popular = 'popular',
|
||||||
latest = 'latest',
|
latest = 'latest',
|
||||||
rating = 'rating',
|
rating = 'rating',
|
||||||
@@ -21,7 +22,7 @@ export class GetToolsQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
category?: string;
|
category?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ToolSortBy, default: ToolSortBy.latest })
|
@ApiPropertyOptional({ enum: ToolSortBy, default: ToolSortBy.created })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(ToolSortBy)
|
@IsEnum(ToolSortBy)
|
||||||
sortBy?: ToolSortBy;
|
sortBy?: ToolSortBy;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class ToolsService {
|
|||||||
async getTools(query: GetToolsQueryDto) {
|
async getTools(query: GetToolsQueryDto) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = Math.min(query.pageSize ?? 6, 50);
|
const pageSize = Math.min(query.pageSize ?? 6, 50);
|
||||||
const sortBy = query.sortBy ?? ToolSortBy.latest;
|
const sortBy = query.sortBy ?? ToolSortBy.created;
|
||||||
|
|
||||||
const where: Prisma.ToolWhereInput = {
|
const where: Prisma.ToolWhereInput = {
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
@@ -59,6 +59,8 @@ export class ToolsService {
|
|||||||
const hasArtifact = Boolean(
|
const hasArtifact = Boolean(
|
||||||
tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active,
|
tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active,
|
||||||
);
|
);
|
||||||
|
const hasExternalDownloadUrl =
|
||||||
|
tool.accessMode === 'download' && Boolean(tool.openUrl);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
@@ -75,8 +77,10 @@ export class ToolsService {
|
|||||||
latestVersion:
|
latestVersion:
|
||||||
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
|
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
|
||||||
accessMode: tool.accessMode,
|
accessMode: tool.accessMode,
|
||||||
openUrl: tool.accessMode === 'web' ? tool.openUrl : null,
|
openUrl: tool.openUrl,
|
||||||
hasArtifact: tool.accessMode === 'download' ? hasArtifact : false,
|
hasArtifact: tool.accessMode === 'download' ? hasArtifact : false,
|
||||||
|
downloadReady:
|
||||||
|
tool.accessMode === 'download' ? hasArtifact || hasExternalDownloadUrl : false,
|
||||||
tags: tool.tags.map((item) => item.tag.name),
|
tags: tool.tags.map((item) => item.tag.name),
|
||||||
features: tool.features.map((item) => item.featureText),
|
features: tool.features.map((item) => item.featureText),
|
||||||
updatedAt: tool.updatedAt,
|
updatedAt: tool.updatedAt,
|
||||||
@@ -134,7 +138,7 @@ export class ToolsService {
|
|||||||
tags: tool.tags.map((item) => item.tag.name),
|
tags: tool.tags.map((item) => item.tag.name),
|
||||||
features: tool.features.map((item) => item.featureText),
|
features: tool.features.map((item) => item.featureText),
|
||||||
updatedAt: tool.updatedAt,
|
updatedAt: tool.updatedAt,
|
||||||
openUrl: tool.accessMode === 'web' ? tool.openUrl : null,
|
openUrl: tool.openUrl,
|
||||||
latestVersion:
|
latestVersion:
|
||||||
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
|
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
|
||||||
fileSize:
|
fileSize:
|
||||||
@@ -143,7 +147,10 @@ export class ToolsService {
|
|||||||
: null,
|
: null,
|
||||||
downloadReady:
|
downloadReady:
|
||||||
tool.accessMode === 'download'
|
tool.accessMode === 'download'
|
||||||
? Boolean(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active)
|
? Boolean(
|
||||||
|
tool.openUrl ||
|
||||||
|
(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active),
|
||||||
|
)
|
||||||
: false,
|
: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,6 +159,8 @@ export class ToolsService {
|
|||||||
|
|
||||||
private buildOrderBy(sortBy: ToolSortBy): Prisma.ToolOrderByWithRelationInput[] {
|
private buildOrderBy(sortBy: ToolSortBy): Prisma.ToolOrderByWithRelationInput[] {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
|
case ToolSortBy.created:
|
||||||
|
return [{ createdAt: 'desc' }, { modifiedAt: 'desc' }];
|
||||||
case ToolSortBy.popular:
|
case ToolSortBy.popular:
|
||||||
return [{ downloadCount: 'desc' }, { openCount: 'desc' }, { modifiedAt: 'desc' }];
|
return [{ downloadCount: 'desc' }, { openCount: 'desc' }, { modifiedAt: 'desc' }];
|
||||||
case ToolSortBy.rating:
|
case ToolSortBy.rating:
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
|
import { INestApplication, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { AdminUserStatus, PrismaClient } from '@prisma/client';
|
||||||
|
import argon2 from 'argon2';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(PrismaService.name);
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
await this.$queryRawUnsafe('PRAGMA journal_mode = WAL;');
|
await this.$queryRawUnsafe('PRAGMA journal_mode = WAL;');
|
||||||
|
await this.ensureDefaultAdminUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
async enableShutdownHooks(app: INestApplication): Promise<void> {
|
async enableShutdownHooks(app: INestApplication): Promise<void> {
|
||||||
@@ -13,4 +18,31 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureDefaultAdminUser(): Promise<void> {
|
||||||
|
const username = process.env.DEFAULT_ADMIN_USERNAME?.trim() || 'admin';
|
||||||
|
const password = process.env.DEFAULT_ADMIN_PASSWORD || 'admin123456';
|
||||||
|
|
||||||
|
const existingAdmin = await this.adminUser.findUnique({
|
||||||
|
where: { username },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
||||||
|
await this.adminUser.create({
|
||||||
|
data: {
|
||||||
|
id: `u_admin_${randomUUID().replace(/-/g, '')}`,
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
displayName: 'System Admin',
|
||||||
|
status: AdminUserStatus.active,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Default admin user initialized: ${username}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
792
styles.css
792
styles.css
@@ -1,792 +0,0 @@
|
|||||||
:root {
|
|
||||||
--primary: #0a8fb5;
|
|
||||||
--primary-strong: #0d7697;
|
|
||||||
--secondary: #22d3ee;
|
|
||||||
--cta: #16a34a;
|
|
||||||
--bg: #f3f8fc;
|
|
||||||
--bg-mesh-a: rgba(34, 211, 238, 0.22);
|
|
||||||
--bg-mesh-b: rgba(14, 165, 233, 0.18);
|
|
||||||
--surface: rgba(255, 255, 255, 0.78);
|
|
||||||
--surface-strong: #ffffff;
|
|
||||||
--card: #ffffff;
|
|
||||||
--text: #0f2f3d;
|
|
||||||
--muted: #4b6674;
|
|
||||||
--line: rgba(18, 117, 150, 0.2);
|
|
||||||
--line-strong: rgba(18, 117, 150, 0.34);
|
|
||||||
--focus: #0ea5e9;
|
|
||||||
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
|
||||||
--duration-fast: 160ms;
|
|
||||||
--duration-normal: 240ms;
|
|
||||||
--shadow-soft: 0 12px 34px rgba(12, 66, 92, 0.12);
|
|
||||||
--shadow-lift: 0 16px 40px rgba(12, 66, 92, 0.18);
|
|
||||||
--glass-blur: 14px;
|
|
||||||
--radius-lg: 18px;
|
|
||||||
--radius-md: 12px;
|
|
||||||
--radius-sm: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: "Manrope", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
overflow-x: hidden;
|
|
||||||
background:
|
|
||||||
radial-gradient(460px 260px at 0% 8%, var(--bg-mesh-a), transparent 75%),
|
|
||||||
radial-gradient(380px 220px at 98% 4%, var(--bg-mesh-b), transparent 74%),
|
|
||||||
linear-gradient(180deg, #f7fcff 0%, var(--bg) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
width: min(1200px, calc(100% - 32px));
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-wrap {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
width: 100%;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
background: rgba(255, 255, 255, 0.72);
|
|
||||||
backdrop-filter: blur(var(--glass-blur));
|
|
||||||
transition: box-shadow var(--duration-normal) var(--ease-standard), border-color var(--duration-normal) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-wrap.is-scrolled {
|
|
||||||
border-bottom-color: var(--line-strong);
|
|
||||||
box-shadow: 0 8px 22px rgba(10, 72, 103, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
padding: 12px 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-family: "Sora", sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-mark {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a,
|
|
||||||
.nav-btn {
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0);
|
|
||||||
background: rgba(255, 255, 255, 0.42);
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color var(--duration-fast) var(--ease-standard),
|
|
||||||
color var(--duration-fast) var(--ease-standard),
|
|
||||||
transform var(--duration-fast) var(--ease-standard),
|
|
||||||
border-color var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a:hover,
|
|
||||||
.nav-btn:hover {
|
|
||||||
background: rgba(233, 249, 255, 0.86);
|
|
||||||
border-color: rgba(20, 143, 179, 0.2);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn.active {
|
|
||||||
background: rgba(217, 246, 255, 0.9);
|
|
||||||
color: var(--text);
|
|
||||||
border-color: rgba(20, 143, 179, 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
margin-top: 14px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-main {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.72));
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
animation: content-fade-in 420ms var(--ease-standard) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "Sora", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto auto;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box {
|
|
||||||
min-height: 44px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: rgba(255, 255, 255, 0.94);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box input {
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 42px;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select,
|
|
||||||
.btn {
|
|
||||||
min-height: 44px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
padding: 0 12px;
|
|
||||||
box-shadow: 0 2px 8px rgba(13, 88, 124, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color var(--duration-fast) var(--ease-standard),
|
|
||||||
border-color var(--duration-fast) var(--ease-standard),
|
|
||||||
transform var(--duration-fast) var(--ease-standard),
|
|
||||||
box-shadow var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background: rgba(241, 252, 255, 0.95);
|
|
||||||
border-color: var(--line-strong);
|
|
||||||
box-shadow: 0 8px 20px rgba(13, 88, 124, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(135deg, var(--primary), #0ea5c8);
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 12px 24px rgba(10, 143, 181, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: linear-gradient(135deg, #0e81a2, #1091b0);
|
|
||||||
border-color: #0e81a2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hot-keywords {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hot-keywords > span {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(255, 255, 255, 0.88);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color var(--duration-fast) var(--ease-standard),
|
|
||||||
border-color var(--duration-fast) var(--ease-standard),
|
|
||||||
transform var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip:hover,
|
|
||||||
.chip.active {
|
|
||||||
background: rgba(217, 246, 255, 0.92);
|
|
||||||
border-color: rgba(18, 117, 150, 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-grid {
|
|
||||||
margin-top: 10px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-grid > div {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: rgba(248, 253, 255, 0.92);
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-grid strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-grid span {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tips {
|
|
||||||
margin: 12px 0 0;
|
|
||||||
padding-left: 18px;
|
|
||||||
color: var(--muted);
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tools-layout {
|
|
||||||
margin-top: 14px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 248px minmax(0, 1fr);
|
|
||||||
gap: 14px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tools-main {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-sidebar {
|
|
||||||
position: sticky;
|
|
||||||
top: 74px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: linear-gradient(150deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.78));
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-title {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tip {
|
|
||||||
margin: 6px 0 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-sidebar-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-side-btn {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 42px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: rgba(255, 255, 255, 0.86);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 0 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition:
|
|
||||||
background-color var(--duration-fast) var(--ease-standard),
|
|
||||||
border-color var(--duration-fast) var(--ease-standard),
|
|
||||||
transform var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-side-btn:hover {
|
|
||||||
background: rgba(239, 251, 255, 0.96);
|
|
||||||
border-color: rgba(20, 143, 179, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-side-btn.active {
|
|
||||||
background: linear-gradient(135deg, rgba(214, 247, 255, 0.94), rgba(225, 252, 255, 0.9));
|
|
||||||
border-color: rgba(20, 143, 179, 0.34);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-side-btn .label {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-side-btn .count {
|
|
||||||
border: 1px solid rgba(18, 117, 150, 0.22);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(241, 252, 255, 0.9);
|
|
||||||
color: #0b6d8a;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 4px 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: linear-gradient(160deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.82));
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
padding: 14px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
min-height: 280px;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(8px);
|
|
||||||
will-change: transform, opacity;
|
|
||||||
animation: card-enter 380ms var(--ease-standard) both;
|
|
||||||
animation-delay: var(--stagger, 0ms);
|
|
||||||
transition:
|
|
||||||
border-color var(--duration-normal) var(--ease-standard),
|
|
||||||
background-color var(--duration-normal) var(--ease-standard),
|
|
||||||
transform var(--duration-normal) var(--ease-standard),
|
|
||||||
box-shadow var(--duration-normal) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
border-color: rgba(18, 117, 150, 0.34);
|
|
||||||
background: linear-gradient(160deg, rgba(255, 255, 255, 0.95), rgba(246, 252, 255, 0.88));
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-lift);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-top,
|
|
||||||
.card-foot {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category {
|
|
||||||
border: 1px solid rgba(21, 128, 110, 0.24);
|
|
||||||
background: rgba(236, 253, 245, 0.9);
|
|
||||||
color: #0f766e;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 2px 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h3 {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
min-height: 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
border: 1px solid rgba(18, 117, 150, 0.2);
|
|
||||||
background: rgba(236, 251, 255, 0.92);
|
|
||||||
color: #0c6f8d;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-list {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-list strong {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-foot {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-num {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-small {
|
|
||||||
min-height: 36px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color var(--duration-fast) var(--ease-standard),
|
|
||||||
border-color var(--duration-fast) var(--ease-standard),
|
|
||||||
transform var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-small:hover {
|
|
||||||
background: rgba(240, 251, 255, 0.95);
|
|
||||||
border-color: rgba(20, 143, 179, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-download {
|
|
||||||
border-color: rgba(22, 163, 74, 0.34);
|
|
||||||
background: linear-gradient(135deg, rgba(236, 253, 243, 0.95), rgba(220, 252, 231, 0.92));
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-download:hover {
|
|
||||||
border-color: rgba(22, 163, 74, 0.5);
|
|
||||||
background: linear-gradient(135deg, rgba(220, 252, 231, 0.95), rgba(199, 246, 212, 0.92));
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-open {
|
|
||||||
border-color: rgba(14, 165, 233, 0.36);
|
|
||||||
background: linear-gradient(135deg, rgba(224, 242, 254, 0.96), rgba(207, 234, 254, 0.92));
|
|
||||||
color: #0b5f87;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-open:hover {
|
|
||||||
border-color: rgba(14, 165, 233, 0.52);
|
|
||||||
background: linear-gradient(135deg, rgba(209, 233, 253, 0.96), rgba(188, 224, 252, 0.92));
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
border: 1px dashed rgba(18, 117, 150, 0.35);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: rgba(255, 255, 255, 0.8);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 18px 0 34px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(7, 31, 44, 0.36);
|
|
||||||
z-index: 20;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 16px;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity var(--duration-normal) var(--ease-standard), visibility var(--duration-normal) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop.open {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
width: min(680px, 100%);
|
|
||||||
max-height: 86vh;
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: linear-gradient(150deg, rgba(255, 255, 255, 0.95), rgba(248, 253, 255, 0.88));
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
box-shadow: var(--shadow-lift);
|
|
||||||
padding: 20px;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(14px) scale(0.985);
|
|
||||||
transition: transform var(--duration-normal) var(--ease-standard), opacity var(--duration-normal) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop.open .modal {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal p {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-list {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 18px;
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color var(--duration-fast) var(--ease-standard),
|
|
||||||
transform var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn:hover {
|
|
||||||
background: rgba(240, 251, 255, 0.95);
|
|
||||||
border-color: var(--line-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 16px;
|
|
||||||
right: 16px;
|
|
||||||
z-index: 30;
|
|
||||||
border: 1px solid rgba(14, 157, 127, 0.5);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: linear-gradient(135deg, #0d8f82, #0f766e);
|
|
||||||
box-shadow: 0 12px 28px rgba(6, 78, 73, 0.26);
|
|
||||||
color: #fff;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: 14px;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transform: translateY(8px);
|
|
||||||
transition: opacity var(--duration-normal) var(--ease-standard), transform var(--duration-normal) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.show {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:focus-visible,
|
|
||||||
a:focus-visible,
|
|
||||||
input:focus-visible,
|
|
||||||
select:focus-visible {
|
|
||||||
outline: 2px solid var(--focus);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
|
||||||
.btn:hover,
|
|
||||||
.btn-small:hover,
|
|
||||||
.icon-btn:hover,
|
|
||||||
.chip:hover,
|
|
||||||
.nav a:hover,
|
|
||||||
.nav-btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active,
|
|
||||||
.btn-small:active,
|
|
||||||
.icon-btn:active,
|
|
||||||
.chip:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
width: min(1200px, calc(100% - 24px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select,
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.tools-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-sidebar {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
animation: none !important;
|
|
||||||
transition: none !important;
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes content-fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(8px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes card-enter {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes badge-pop {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(6px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user