diff --git a/AI-CHANGELOG.md b/AI-CHANGELOG.md
deleted file mode 100644
index 888582d..0000000
--- a/AI-CHANGELOG.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# AI Change Log
-
-## 2026-03-27 11:25:24 +08:00
-
-### 目标
-将管理端从“单一 `AdminApp.vue` 内按路由显示区块”的结构,拆分为 `admin/pages` 下的独立路由页面组件,并通过 `vue-router` 实现页面切换。
-
-### 实现结果
-- 管理端路由改为嵌套路由:`/admin` 作为布局壳,子路由分别对应 `overview/tools/categories/auditlogs`。
-- 管理端页面拆分到 `client/src/admin/pages`:
- - `AdminOverviewPage.vue`
- - `AdminToolsPage.vue`
- - `AdminCategoriesPage.vue`
- - `AdminAuditLogsPage.vue`
-- `AdminApp.vue` 不再使用 `v-show` 在同文件切换四个区块,改为 `router-view` 渲染当前路由页面。
-- `AdminApp.vue` 保留登录态、侧边栏、顶栏、数据加载与通用弹窗逻辑,通过 `currentPageProps` 和 `currentPageEvents` 向不同路由页面分发数据与事件。
-- 路由元信息 `meta`(`menuKey/sectionTitle/withKpi`)用于驱动菜单高亮、标题和布局样式,不再使用路径字符串硬编码判断。
-
-### 变更文件
-- `client/src/admin/router.js`
-- `client/src/admin/AdminApp.vue`
-- `client/src/admin/pages/AdminOverviewPage.vue`
-- `client/src/admin/pages/AdminToolsPage.vue`
-- `client/src/admin/pages/AdminCategoriesPage.vue`
-- `client/src/admin/pages/AdminAuditLogsPage.vue`
-- `AI-CHANGELOG.md`
-
-### 验证
-- 执行:`npm run build`(`client`)
-- 结果:构建通过。
diff --git a/Dockerfile b/Dockerfile
index 93cfe80..6dffd53 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,14 +19,16 @@ FROM ${NODE_IMAGE} AS runtime
WORKDIR /app/server
ENV NODE_ENV=production
ENV PORT=3000
+ENV CLIENT_DIST_PATH=/app/server/public
COPY server/package*.json ./
RUN npm ci --omit=dev
COPY --from=server-builder /build/server/dist ./dist
COPY --from=server-builder /build/server/prisma ./prisma
+RUN npx prisma generate
COPY --from=client-builder /build/client/dist ./public
EXPOSE 3000
-CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]
+CMD ["sh", "-c", "npx prisma migrate deploy && node dist/src/main.js"]
diff --git a/app.js b/app.js
deleted file mode 100644
index 126ecfa..0000000
--- a/app.js
+++ /dev/null
@@ -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, "'");
-}
-
-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 = `
-
-
没有匹配结果,请尝试更换关键词或分类。
-
-
- `;
- elements.pagination.style.display = "none";
- return;
- }
-
- elements.toolGrid.innerHTML = page.items.map((tool, index) => `
-
-
- ${escapeHtml(tool.category)}
-
- ${escapeHtml(tool.name)}
- ${escapeHtml(tool.description)}
- ${tool.tags.map((tag) => `${escapeHtml(tag)}`).join("")}
-
-
-
- `).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 = `
- 分类:${escapeHtml(tool.category)}
- 版本:${escapeHtml(tool.version)}
- ${tool.size ? `大小:${escapeHtml(tool.size)}` : ""}
- 访问方式:${tool.url ? "网页打开" : "下载安装"}
- ${tool.url ? "访问" : "下载"}:${formatNumber(tool.downloads)}
- 更新时间:${formatDate(tool.updatedAt)}
- `;
- elements.detailFeatures.innerHTML = tool.features.map((feature) => `${escapeHtml(feature)}`).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();
diff --git a/docs/DOCKER_RUN_TOOLSSHOW.md b/docs/DOCKER_RUN_TOOLSSHOW.md
index d53bc73..77f997c 100644
--- a/docs/DOCKER_RUN_TOOLSSHOW.md
+++ b/docs/DOCKER_RUN_TOOLSSHOW.md
@@ -76,7 +76,7 @@ docker run -d \
说明:
-- 容器启动命令已在 `Dockerfile` 中定义:`npx prisma migrate deploy && node dist/main`。
+- 容器启动命令已在 `Dockerfile` 中定义:`npx prisma migrate deploy && node dist/src/main.js`。
- 首次启动会自动执行数据库迁移。
## 5. 常用运维命令
@@ -138,4 +138,3 @@ docker build -t toolsshow:latest .
docker rm -f toolsshow-app 2>$null
docker run -d --name toolsshow-app -p 3000:3000 --env-file ./server/.env -v toolsshow_prisma:/app/server/prisma toolsshow:latest
```
-
diff --git a/index.html b/index.html
deleted file mode 100644
index 975186a..0000000
--- a/index.html
+++ /dev/null
@@ -1,144 +0,0 @@
-
-
-
-
-
- ToolsShow - 工具展示站
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 热门搜索:
-
-
-
-
-
-
-
-
-
-
-
-
-
展示站当前统计信息与核心能力说明。
-
-
0工具总数
-
0分类数量
-
0累计使用
-
0当前结果
-
-
- - 浏览:分页展示工具卡片
- - 搜索:实时匹配关键词
- - 获取:支持模拟下载与网页直达
-
-
-
-
-
-
-
-
diff --git a/package.json b/package.json
deleted file mode 100644
index 97015a8..0000000
--- a/package.json
+++ /dev/null
@@ -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
-}
diff --git a/server/package.json b/server/package.json
index a299680..5f1564b 100644
--- a/server/package.json
+++ b/server/package.json
@@ -11,7 +11,7 @@
"start": "nest start",
"start:dev": "nest start --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",
"test": "jest",
"test:watch": "jest --watch",
diff --git a/server/src/main.ts b/server/src/main.ts
index 3736572..cdc09d4 100644
--- a/server/src/main.ts
+++ b/server/src/main.ts
@@ -51,7 +51,7 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, swaggerConfig);
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)) {
app.useStaticAssets(clientDistPath);
diff --git a/styles.css b/styles.css
deleted file mode 100644
index 6a12921..0000000
--- a/styles.css
+++ /dev/null
@@ -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);
- }
-}