update
This commit is contained in:
@@ -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`)
|
||||
- 结果:构建通过。
|
||||
@@ -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"]
|
||||
|
||||
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();
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
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