Files
tools-show/app.js
dlandy 40be11adbf init
2026-03-27 10:18:26 +08:00

475 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const tools = [
{id: "tool_1", name: "ClipFlow", category: "开发效率", description: "轻量级代码片段管理工具,支持团队共享与快速插入。", tags: ["代码片段", "团队协作", "VSCode"], version: "2.4.1", size: "12.3 MB", downloads: 36850, updatedAt: "2026-03-12", features: ["多工作区同步", "标签归档", "快捷键模板插入"]},
{id: "tool_2", name: "TaskOrbit", category: "团队协作", description: "面向产品团队的任务看板与冲刺管理工具。", tags: ["看板", "项目管理", "甘特图"], version: "1.9.0", url: "https://example.com/tools/taskorbit", downloads: 29210, updatedAt: "2026-03-18", features: ["多视图任务管理", "冲刺模板", "成员工作负载分析"]},
{id: "tool_3", name: "PixelLint", category: "设计协作", description: "设计稿一致性检查工具,可自动扫描颜色与间距规范。", tags: ["设计规范", "Figma", "UI 质检"], version: "3.1.2", size: "7.8 MB", downloads: 21430, updatedAt: "2026-02-25", features: ["组件一致性对比", "批量标注问题", "导出质检报告"]},
{id: "tool_4", name: "DataSparrow", category: "数据分析", description: "可视化数据清洗与探索平台,适合中小团队快速上手。", tags: ["可视化", "数据清洗", "BI"], version: "4.0.0", url: "https://example.com/tools/datasparrow", downloads: 17680, updatedAt: "2026-03-21", features: ["拖拽式数据流", "字段质量检测", "图表模板库"]},
{id: "tool_5", name: "ShipMate", category: "自动化", description: "一键打包发布脚本管理器,统一多环境部署流程。", tags: ["CI/CD", "部署", "脚本"], version: "2.0.3", size: "10.1 MB", downloads: 25600, updatedAt: "2026-03-09", features: ["多环境变量模板", "发布回滚", "构建流水线监控"]},
{id: "tool_6", name: "InsightPanel", category: "数据分析", description: "业务指标仪表盘构建器,支持实时数据看板。", tags: ["仪表盘", "指标", "实时看板"], version: "5.2.1", url: "https://example.com/tools/insightpanel", downloads: 40980, updatedAt: "2026-03-20", features: ["实时刷新组件", "阈值告警", "多数据源连接"]},
{id: "tool_7", name: "CloudWatchdog", category: "运维监控", description: "基础设施健康监控工具,支持可视化告警规则。", tags: ["告警", "监控", "日志分析"], version: "1.6.8", size: "15.4 MB", downloads: 19870, updatedAt: "2026-03-15", features: ["阈值策略库", "异常聚合", "告警分级通知"]},
{id: "tool_8", name: "FormForge", category: "开发效率", description: "表单构建器,支持低代码生成校验规则与提交流程。", tags: ["低代码", "表单", "校验"], version: "3.7.5", size: "9.2 MB", downloads: 23200, updatedAt: "2026-03-11", features: ["可视化表单设计", "字段联动", "提交数据导出"]},
{id: "tool_9", name: "QuerySprint", category: "数据分析", description: "面向分析师的 SQL 协作平台,支持查询片段共享。", tags: ["SQL", "查询优化", "协作"], version: "2.8.4", url: "https://example.com/tools/querysprint", downloads: 27540, updatedAt: "2026-02-18", features: ["查询版本管理", "性能诊断建议", "团队模版库"]},
{id: "tool_10", name: "TestPilot", category: "自动化", description: "自动化测试流程编排工具,支持 API 与 UI 混合测试。", tags: ["自动化测试", "API", "回归测试"], version: "4.3.0", size: "22.4 MB", downloads: 31420, updatedAt: "2026-03-17", features: ["测试用例可视化", "失败重跑策略", "测试报告仪表盘"]},
{id: "tool_11", name: "BrandBoard", category: "设计协作", description: "品牌资产管理工具,统一素材规范与组件资产。", tags: ["品牌资产", "设计系统", "素材管理"], version: "1.4.2", url: "https://example.com/tools/brandboard", downloads: 16890, updatedAt: "2026-03-08", features: ["版本化素材库", "品牌规范手册", "跨团队共享链接"]},
{id: "tool_12", name: "DeployLens", category: "运维监控", description: "发布质量追踪平台,聚合版本、错误率与回滚记录。", tags: ["发布追踪", "SRE", "质量分析"], version: "2.2.6", url: "https://example.com/tools/deploylens", downloads: 22160, updatedAt: "2026-03-19", features: ["发布健康指标", "回滚影响分析", "问题根因视图"]}
];
const state = {query: "", category: "all", sortBy: "popular", page: 1, pageSize: 6};
const keywords = ["自动化", "设计系统", "仪表盘", "监控", "协作"];
let toastTimer = null;
const elements = {
headerWrap: document.querySelector(".header-wrap"),
overviewBtn: document.getElementById("overviewBtn"),
searchInput: document.getElementById("searchInput"),
categorySelect: document.getElementById("categorySelect"),
categorySidebarList: document.getElementById("categorySidebarList"),
sortSelect: document.getElementById("sortSelect"),
resetBtn: document.getElementById("resetBtn"),
hotKeywords: document.getElementById("hotKeywords"),
resultTip: document.getElementById("resultTip"),
toolGrid: document.getElementById("toolGrid"),
pagination: document.getElementById("pagination"),
prevBtn: document.getElementById("prevBtn"),
nextBtn: document.getElementById("nextBtn"),
pageText: document.getElementById("pageText"),
detailModal: document.getElementById("detailModal"),
closeModalBtn: document.getElementById("closeModalBtn"),
overviewModal: document.getElementById("overviewModal"),
closeOverviewModalBtn: document.getElementById("closeOverviewModalBtn"),
detailTitle: document.getElementById("detailTitle"),
detailDescription: document.getElementById("detailDescription"),
detailMeta: document.getElementById("detailMeta"),
detailFeatures: document.getElementById("detailFeatures"),
toast: document.getElementById("toast"),
kpiTotal: document.getElementById("kpiTotal"),
kpiCategories: document.getElementById("kpiCategories"),
kpiDownloads: document.getElementById("kpiDownloads"),
kpiFiltered: document.getElementById("kpiFiltered")
};
function escapeHtml(value) {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(value);
}
function formatDate(dateText) {
return new Intl.DateTimeFormat("zh-CN", {year: "numeric", month: "2-digit", day: "2-digit"})
.format(new Date(dateText));
}
function getCategories() {
return Array.from(new Set(tools.map((tool) => tool.category)));
}
function matchesQuery(tool, query) {
if (!query) {
return true;
}
const pool = [tool.name, tool.description, tool.category, ...tool.tags].join(" ").toLowerCase();
return pool.includes(query);
}
function buildOptions() {
const categories = getCategories();
categories.forEach((category) => {
const option = document.createElement("option");
option.value = category;
option.textContent = category;
elements.categorySelect.append(option);
});
keywords.forEach((keyword) => {
const button = document.createElement("button");
button.type = "button";
button.className = "chip";
button.dataset.keyword = keyword;
button.textContent = keyword;
elements.hotKeywords.append(button);
});
}
function renderCategorySidebar() {
if (!elements.categorySidebarList) {
return;
}
const query = state.query.trim().toLowerCase();
const queryMatchedTools = tools.filter((tool) => matchesQuery(tool, query));
const countMap = new Map();
queryMatchedTools.forEach((tool) => {
countMap.set(tool.category, (countMap.get(tool.category) || 0) + 1);
});
const items = [
{value: "all", label: "全部分类", count: queryMatchedTools.length},
...getCategories().map((category) => ({
value: category,
label: category,
count: countMap.get(category) || 0
}))
];
elements.categorySidebarList.textContent = "";
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.className = "category-side-btn";
button.dataset.category = item.value;
button.setAttribute("aria-pressed", item.value === state.category ? "true" : "false");
if (item.value === state.category) {
button.classList.add("active");
}
const label = document.createElement("span");
label.className = "label";
label.textContent = item.label;
const count = document.createElement("span");
count.className = "count";
count.textContent = formatNumber(item.count);
button.append(label, count);
fragment.append(button);
});
elements.categorySidebarList.append(fragment);
}
function filterTools() {
const query = state.query.trim().toLowerCase();
const filtered = tools.filter((tool) => {
if (state.category !== "all" && state.category !== tool.category) {
return false;
}
return matchesQuery(tool, query);
});
const sorted = [...filtered];
if (state.sortBy === "popular") {
sorted.sort((a, b) => b.downloads - a.downloads);
} else if (state.sortBy === "latest") {
sorted.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
} else {
sorted.sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN"));
}
return sorted;
}
function paginate(items) {
const totalPages = Math.max(1, Math.ceil(items.length / state.pageSize));
if (state.page > totalPages) {
state.page = totalPages;
}
const start = (state.page - 1) * state.pageSize;
return {items: items.slice(start, start + state.pageSize), totalPages, start};
}
function renderKpi(filteredCount) {
elements.kpiTotal.textContent = formatNumber(tools.length);
elements.kpiCategories.textContent = formatNumber(new Set(tools.map((tool) => tool.category)).size);
elements.kpiDownloads.textContent = formatNumber(tools.reduce((sum, tool) => sum + tool.downloads, 0));
elements.kpiFiltered.textContent = formatNumber(filteredCount);
}
function render() {
const filtered = filterTools();
const page = paginate(filtered);
const displayStart = filtered.length ? page.start + 1 : 0;
const displayEnd = Math.min(page.start + state.pageSize, filtered.length);
renderKpi(filtered.length);
renderCategorySidebar();
elements.resultTip.textContent = `共找到 ${formatNumber(filtered.length)} 个工具,当前显示 ${displayStart}-${displayEnd}`;
if (filtered.length === 0) {
elements.toolGrid.innerHTML = `
<div class="empty">
<p>没有匹配结果,请尝试更换关键词或分类。</p>
<button id="clearEmptyBtn" type="button" class="btn">清空筛选条件</button>
</div>
`;
elements.pagination.style.display = "none";
return;
}
elements.toolGrid.innerHTML = page.items.map((tool, index) => `
<article class="card" style="--stagger:${index * 45}ms;">
<div class="card-top">
<span class="category">${escapeHtml(tool.category)}</span>
</div>
<h3>${escapeHtml(tool.name)}</h3>
<p class="desc">${escapeHtml(tool.description)}</p>
<div class="tags">${tool.tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("")}</div>
<ul class="meta-list">
<li>版本:<strong>${escapeHtml(tool.version)}</strong></li>
${tool.size ? `<li>大小:<strong>${escapeHtml(tool.size)}</strong></li>` : ""}
<li>更新:<strong>${formatDate(tool.updatedAt)}</strong></li>
</ul>
<div class="card-foot">
<span class="download-num">${tool.url ? "访问" : "下载"} ${formatNumber(tool.downloads)}</span>
<div class="actions">
<button type="button" class="btn-small js-detail" data-id="${tool.id}">详情</button>
${tool.url
? `<button type="button" class="btn-small btn-open js-open" data-id="${tool.id}">打开网页</button>`
: `<button type="button" class="btn-small btn-download js-download" data-id="${tool.id}">下载</button>`}
</div>
</div>
</article>
`).join("");
elements.pagination.style.display = "flex";
elements.pageText.textContent = `${state.page} / ${page.totalPages}`;
elements.prevBtn.disabled = state.page === 1;
elements.nextBtn.disabled = state.page === page.totalPages;
}
function showToast(message) {
elements.toast.textContent = message;
elements.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => elements.toast.classList.remove("show"), 2200);
}
function downloadTool(tool) {
if (tool.url) {
openWebTool(tool);
return;
}
const payload = {
toolId: tool.id,
toolName: tool.name,
version: tool.version,
category: tool.category,
downloadedAt: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {type: "application/json;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tool.name.replace(/\s+/g, "-").toLowerCase()}-manifest.json`;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
tool.downloads += 1;
render();
showToast(`${tool.name} 下载已开始Mock`);
}
function openWebTool(tool) {
if (!tool.url) {
return;
}
const nextWindow = window.open(tool.url, "_blank", "noopener,noreferrer");
if (!nextWindow) {
showToast("浏览器阻止了新窗口,请允许弹窗后重试");
return;
}
tool.downloads += 1;
render();
showToast(`${tool.name} 已在新标签页打开`);
}
function openModal(tool) {
elements.detailTitle.textContent = tool.name;
elements.detailDescription.textContent = tool.description;
elements.detailMeta.innerHTML = `
<li>分类:<strong>${escapeHtml(tool.category)}</strong></li>
<li>版本:<strong>${escapeHtml(tool.version)}</strong></li>
${tool.size ? `<li>大小:<strong>${escapeHtml(tool.size)}</strong></li>` : ""}
<li>访问方式:<strong>${tool.url ? "网页打开" : "下载安装"}</strong></li>
<li>${tool.url ? "访问" : "下载"}<strong>${formatNumber(tool.downloads)}</strong></li>
<li>更新时间:<strong>${formatDate(tool.updatedAt)}</strong></li>
`;
elements.detailFeatures.innerHTML = tool.features.map((feature) => `<li>${escapeHtml(feature)}</li>`).join("");
elements.detailModal.classList.add("open");
}
function closeModal() {
elements.detailModal.classList.remove("open");
}
function openOverviewModal() {
elements.overviewBtn.classList.add("active");
elements.overviewBtn.setAttribute("aria-expanded", "true");
elements.overviewModal.classList.add("open");
}
function closeOverviewModal() {
elements.overviewBtn.classList.remove("active");
elements.overviewBtn.setAttribute("aria-expanded", "false");
elements.overviewModal.classList.remove("open");
}
function updateHeaderScrollState() {
if (!elements.headerWrap) {
return;
}
const isScrolled = window.scrollY > 8;
elements.headerWrap.classList.toggle("is-scrolled", isScrolled);
}
function clearChipActive() {
elements.hotKeywords.querySelectorAll(".chip").forEach((chip) => chip.classList.remove("active"));
}
function resetFilters() {
state.query = "";
state.category = "all";
state.page = 1;
elements.searchInput.value = "";
elements.categorySelect.value = "all";
clearChipActive();
render();
}
function bindEvents() {
elements.searchInput.addEventListener("input", (event) => {
state.query = event.target.value;
state.page = 1;
if (!state.query.trim()) {
clearChipActive();
}
render();
});
elements.categorySelect.addEventListener("change", (event) => {
state.category = event.target.value;
state.page = 1;
render();
});
elements.sortSelect.addEventListener("change", (event) => {
state.sortBy = event.target.value;
state.page = 1;
render();
});
elements.resetBtn.addEventListener("click", () => {
resetFilters();
});
elements.hotKeywords.addEventListener("click", (event) => {
const chip = event.target.closest(".chip");
if (!chip) {
return;
}
clearChipActive();
chip.classList.add("active");
state.query = chip.dataset.keyword || "";
state.page = 1;
elements.searchInput.value = state.query;
render();
});
if (elements.categorySidebarList) {
elements.categorySidebarList.addEventListener("click", (event) => {
const categoryButton = event.target.closest(".category-side-btn");
if (!categoryButton) {
return;
}
state.category = categoryButton.dataset.category || "all";
state.page = 1;
elements.categorySelect.value = state.category;
render();
});
}
elements.prevBtn.addEventListener("click", () => {
if (state.page > 1) {
state.page -= 1;
render();
}
});
elements.nextBtn.addEventListener("click", () => {
const totalPages = Math.max(1, Math.ceil(filterTools().length / state.pageSize));
if (state.page < totalPages) {
state.page += 1;
render();
}
});
elements.toolGrid.addEventListener("click", (event) => {
const detailBtn = event.target.closest(".js-detail");
if (detailBtn) {
const tool = tools.find((item) => item.id === detailBtn.dataset.id);
if (tool) {
openModal(tool);
}
return;
}
const downloadBtn = event.target.closest(".js-download");
if (downloadBtn) {
const tool = tools.find((item) => item.id === downloadBtn.dataset.id);
if (tool) {
downloadTool(tool);
}
return;
}
const openBtn = event.target.closest(".js-open");
if (openBtn) {
const tool = tools.find((item) => item.id === openBtn.dataset.id);
if (tool) {
openWebTool(tool);
}
return;
}
const clearBtn = event.target.closest("#clearEmptyBtn");
if (clearBtn) {
resetFilters();
}
});
elements.closeModalBtn.addEventListener("click", closeModal);
elements.detailModal.addEventListener("click", (event) => {
if (event.target === elements.detailModal) {
closeModal();
}
});
elements.overviewBtn.addEventListener("click", openOverviewModal);
elements.closeOverviewModalBtn.addEventListener("click", closeOverviewModal);
elements.overviewModal.addEventListener("click", (event) => {
if (event.target === elements.overviewModal) {
closeOverviewModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (elements.detailModal.classList.contains("open")) {
closeModal();
}
if (elements.overviewModal.classList.contains("open")) {
closeOverviewModal();
}
}
});
window.addEventListener("scroll", updateHeaderScrollState, {passive: true});
}
function init() {
buildOptions();
bindEvents();
updateHeaderScrollState();
render();
}
init();