feat: add software download center
This commit is contained in:
@@ -123,6 +123,12 @@
|
||||
border: 1px solid rgba(35, 136, 255, 0.24);
|
||||
}
|
||||
|
||||
.quick-link.software {
|
||||
color: #2049ee;
|
||||
background: rgba(32, 73, 238, 0.08);
|
||||
border: 1px solid rgba(32, 73, 238, 0.22);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: var(--blue);
|
||||
@@ -656,6 +662,12 @@
|
||||
background: linear-gradient(145deg, #f7fbff, #eef7ff);
|
||||
}
|
||||
|
||||
.card-software {
|
||||
color: #2049ee;
|
||||
border-color: rgba(32, 73, 238, 0.28);
|
||||
background: linear-gradient(145deg, #f7fbff, #edf5ff);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.brand::after {
|
||||
display: none;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title>Software download</title>
|
||||
<defs>
|
||||
<linearGradient id="box" x1="12" x2="52" y1="8" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5db2ff"/>
|
||||
<stop offset="1" stop-color="#2049ee"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="22" x2="42" y1="14" y2="38" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#fff"/>
|
||||
<stop offset="1" stop-color="#dff0ff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="#edf5ff"/>
|
||||
<path fill="url(#box)" d="M12 18.5 32 8l20 10.5v27L32 56 12 45.5v-27Z"/>
|
||||
<path fill="#8cc8ff" d="m12 18.5 20 10.4 20-10.4L32 8 12 18.5Z"/>
|
||||
<path fill="#1b37ba" opacity=".28" d="M32 28.9 52 18.5v27L32 56V28.9Z"/>
|
||||
<path fill="url(#accent)" d="M30 17h4v18.2l6.1-6.1 2.8 2.8L32 42.8 21.1 31.9l2.8-2.8 6.1 6.1V17Z"/>
|
||||
<rect x="19" y="45" width="26" height="4" rx="2" fill="#dff0ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 992 B |
@@ -0,0 +1,61 @@
|
||||
const form = document.querySelector("#login-form");
|
||||
const nextInput = document.querySelector("#next-url");
|
||||
const statusEl = document.querySelector("#login-status");
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const requestedNext = params.get("next") || "/software.html";
|
||||
|
||||
function safeNext(value) {
|
||||
if (!value) return "/software.html";
|
||||
if (value.startsWith("/") && !value.startsWith("//")) return value;
|
||||
if (/^[a-z0-9._/-]+$/i.test(value)) return value;
|
||||
return "/software.html";
|
||||
}
|
||||
|
||||
function setStatus(message, type = "") {
|
||||
statusEl.textContent = message;
|
||||
statusEl.classList.toggle("is-error", type === "error");
|
||||
statusEl.classList.toggle("is-ok", type === "ok");
|
||||
}
|
||||
|
||||
async function checkExistingLogin() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/me", { headers: { Accept: "application/json" } });
|
||||
const payload = await response.json();
|
||||
if (payload.authenticated) {
|
||||
window.location.href = safeNext(requestedNext);
|
||||
}
|
||||
} catch {
|
||||
setStatus("");
|
||||
}
|
||||
}
|
||||
|
||||
nextInput.value = safeNext(requestedNext);
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const payload = Object.fromEntries(formData.entries());
|
||||
|
||||
try {
|
||||
setStatus("正在登录...");
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "登录失败");
|
||||
}
|
||||
setStatus("登录成功,正在跳转。", "ok");
|
||||
window.location.href = safeNext(data.next || payload.next);
|
||||
} catch (error) {
|
||||
setStatus(error.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
checkExistingLogin();
|
||||
@@ -0,0 +1,214 @@
|
||||
const CATEGORY_NAMES = {
|
||||
office: "办公效率",
|
||||
dev: "开发工具",
|
||||
media: "影音娱乐",
|
||||
security: "安全系统",
|
||||
network: "网络工具",
|
||||
tools: "系统工具",
|
||||
};
|
||||
|
||||
const els = {
|
||||
token: document.querySelector("#admin-token"),
|
||||
saveToken: document.querySelector("#save-token"),
|
||||
tokenStatus: document.querySelector("#token-status"),
|
||||
form: document.querySelector("#software-form"),
|
||||
formTitle: document.querySelector("#form-title"),
|
||||
formStatus: document.querySelector("#form-status"),
|
||||
submitButton: document.querySelector("#submit-button"),
|
||||
resetForm: document.querySelector("#reset-form"),
|
||||
reload: document.querySelector("#reload-admin"),
|
||||
list: document.querySelector("#admin-list"),
|
||||
summary: document.querySelector("#admin-summary"),
|
||||
};
|
||||
|
||||
let softwareItems = [];
|
||||
|
||||
function escapeHtml(value = "") {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem("sumiSoftwareAdminToken") || "";
|
||||
}
|
||||
|
||||
function setStatus(element, message, type = "") {
|
||||
element.textContent = message;
|
||||
element.classList.toggle("is-error", type === "error");
|
||||
element.classList.toggle("is-ok", type === "ok");
|
||||
}
|
||||
|
||||
function getInitial(name = "") {
|
||||
return Array.from(name.trim())[0]?.toUpperCase() || "S";
|
||||
}
|
||||
|
||||
function iconMarkup(item) {
|
||||
if (item.iconUrl) {
|
||||
return `<span class="app-icon"><img src="${escapeHtml(item.iconUrl)}" alt="${escapeHtml(item.name)}" onerror="this.remove(); this.parentElement.textContent='${escapeHtml(getInitial(item.name))}'" /></span>`;
|
||||
}
|
||||
return `<span class="app-icon" aria-hidden="true">${escapeHtml(getInitial(item.name))}</span>`;
|
||||
}
|
||||
|
||||
function normalizeItems(payload) {
|
||||
const items = Array.isArray(payload) ? payload : payload?.items;
|
||||
return (items || []).map((item) => ({
|
||||
...item,
|
||||
tags: Array.isArray(item.tags) ? item.tags : [],
|
||||
}));
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
els.summary.textContent = `共 ${softwareItems.length} 款软件`;
|
||||
els.list.innerHTML = softwareItems.map((item) => `<article class="admin-item">
|
||||
${iconMarkup(item)}
|
||||
<div>
|
||||
<div class="app-name-line">
|
||||
<h3 class="app-name">${escapeHtml(item.name)}</h3>
|
||||
<span class="version-pill">v${escapeHtml(item.version || "-")}</span>
|
||||
<span class="category-pill">${escapeHtml(CATEGORY_NAMES[item.category] || "其他")}</span>
|
||||
</div>
|
||||
<p class="app-desc">${escapeHtml(item.description || "暂无简介")}</p>
|
||||
<div class="app-meta">
|
||||
<span>${item.active === false ? "已隐藏" : "前台显示"}</span>
|
||||
<span>${item.featured ? "精选" : "普通"}</span>
|
||||
<span>${escapeHtml(item.size || "未知大小")}</span>
|
||||
<span>${formatDate(item.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-item-actions">
|
||||
<button class="edit-button" type="button" data-action="edit" data-id="${escapeHtml(item.id)}">编辑</button>
|
||||
<button class="danger-button" type="button" data-action="delete" data-id="${escapeHtml(item.id)}">删除</button>
|
||||
</div>
|
||||
</article>`).join("");
|
||||
}
|
||||
|
||||
async function apiFetch(url, options = {}) {
|
||||
const token = getToken();
|
||||
const headers = {
|
||||
...(options.headers || {}),
|
||||
"X-Admin-Token": token,
|
||||
};
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `HTTP ${response.status}`);
|
||||
}
|
||||
return response.headers.get("content-type")?.includes("application/json") ? response.json() : response.text();
|
||||
}
|
||||
|
||||
async function loadSoftware() {
|
||||
try {
|
||||
setStatus(els.tokenStatus, "正在读取软件列表...");
|
||||
const payload = await apiFetch("/api/admin/software");
|
||||
softwareItems = normalizeItems(payload).sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
|
||||
renderList();
|
||||
setStatus(els.tokenStatus, "令牌可用。", "ok");
|
||||
} catch (error) {
|
||||
softwareItems = [];
|
||||
renderList();
|
||||
setStatus(els.tokenStatus, `读取失败:${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
els.form.reset();
|
||||
els.form.elements.id.value = "";
|
||||
els.form.elements.platform.value = "Windows";
|
||||
els.form.elements.publisher.value = "sumi.work";
|
||||
els.form.elements.sort.value = "100";
|
||||
els.form.elements.active.checked = true;
|
||||
els.formTitle.textContent = "新增软件";
|
||||
els.submitButton.textContent = "保存软件";
|
||||
setStatus(els.formStatus, "");
|
||||
}
|
||||
|
||||
function fillForm(item) {
|
||||
els.form.elements.id.value = item.id;
|
||||
els.form.elements.name.value = item.name || "";
|
||||
els.form.elements.version.value = item.version || "";
|
||||
els.form.elements.category.value = item.category || "tools";
|
||||
els.form.elements.platform.value = item.platform || "Windows";
|
||||
els.form.elements.publisher.value = item.publisher || "sumi.work";
|
||||
els.form.elements.homepage.value = item.homepage || "";
|
||||
els.form.elements.description.value = item.description || "";
|
||||
els.form.elements.changelog.value = item.changelog || "";
|
||||
els.form.elements.tags.value = (item.tags || []).join(", ");
|
||||
els.form.elements.sort.value = item.sort ?? 100;
|
||||
els.form.elements.downloadUrl.value = item.externalDownloadUrl || "";
|
||||
els.form.elements.active.checked = item.active !== false;
|
||||
els.form.elements.featured.checked = Boolean(item.featured);
|
||||
els.form.elements.icon.value = "";
|
||||
els.form.elements.package.value = "";
|
||||
els.formTitle.textContent = `更新 ${item.name}`;
|
||||
els.submitButton.textContent = "更新软件";
|
||||
setStatus(els.formStatus, "已载入当前软件,选择新安装包即可发布新版本。", "ok");
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
async function saveSoftware(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(els.form);
|
||||
const id = formData.get("id");
|
||||
formData.delete("id");
|
||||
|
||||
try {
|
||||
setStatus(els.formStatus, "正在保存...");
|
||||
const url = id ? `/api/admin/software/${encodeURIComponent(id)}` : "/api/admin/software";
|
||||
const method = id ? "PUT" : "POST";
|
||||
await apiFetch(url, { method, body: formData });
|
||||
setStatus(els.formStatus, id ? "软件已更新。" : "软件已新增。", "ok");
|
||||
resetForm();
|
||||
await loadSoftware();
|
||||
} catch (error) {
|
||||
setStatus(els.formStatus, `保存失败:${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSoftware(id) {
|
||||
const item = softwareItems.find((entry) => entry.id === id);
|
||||
if (!item || !window.confirm(`确定删除 ${item.name} 吗?`)) return;
|
||||
|
||||
try {
|
||||
await apiFetch(`/api/admin/software/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
setStatus(els.tokenStatus, "软件已删除。", "ok");
|
||||
await loadSoftware();
|
||||
} catch (error) {
|
||||
setStatus(els.tokenStatus, `删除失败:${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
els.token.value = getToken();
|
||||
els.saveToken.addEventListener("click", () => {
|
||||
localStorage.setItem("sumiSoftwareAdminToken", els.token.value.trim());
|
||||
setStatus(els.tokenStatus, "令牌已保存。", "ok");
|
||||
loadSoftware();
|
||||
});
|
||||
|
||||
els.reload.addEventListener("click", loadSoftware);
|
||||
els.resetForm.addEventListener("click", resetForm);
|
||||
els.form.addEventListener("submit", saveSoftware);
|
||||
els.list.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-action]");
|
||||
if (!button) return;
|
||||
const item = softwareItems.find((entry) => entry.id === button.dataset.id);
|
||||
if (button.dataset.action === "edit" && item) fillForm(item);
|
||||
if (button.dataset.action === "delete") deleteSoftware(button.dataset.id);
|
||||
});
|
||||
|
||||
if (getToken()) {
|
||||
loadSoftware();
|
||||
} else {
|
||||
renderList();
|
||||
setStatus(els.tokenStatus, "请输入服务端 ADMIN_TOKEN 后读取列表。");
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
const CATEGORY_NAMES = {
|
||||
office: "办公效率",
|
||||
dev: "开发工具",
|
||||
media: "影音娱乐",
|
||||
security: "安全系统",
|
||||
network: "网络工具",
|
||||
tools: "系统工具",
|
||||
};
|
||||
|
||||
const FALLBACK_SOFTWARE = [
|
||||
{
|
||||
id: "sumi-launcher",
|
||||
name: "Sumi Launcher",
|
||||
version: "1.0.0",
|
||||
category: "office",
|
||||
description: "常用工具快速启动入口",
|
||||
publisher: "sumi.work",
|
||||
platform: "Windows",
|
||||
size: "待上传",
|
||||
updatedAt: "2026-06-08T00:00:00.000Z",
|
||||
iconUrl: "assets/icons/software.svg",
|
||||
downloadUrl: "",
|
||||
tags: ["推荐"],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: "git-client",
|
||||
name: "Git Client",
|
||||
version: "2.0.0",
|
||||
category: "dev",
|
||||
description: "代码仓库和版本管理工具",
|
||||
publisher: "sumi.work",
|
||||
platform: "Windows",
|
||||
size: "待上传",
|
||||
updatedAt: "2026-06-08T00:00:00.000Z",
|
||||
iconUrl: "assets/icons/git.svg",
|
||||
downloadUrl: "",
|
||||
tags: ["开发"],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: "sync-tool",
|
||||
name: "Sync Tool",
|
||||
version: "1.2.0",
|
||||
category: "network",
|
||||
description: "文件同步和分发工具",
|
||||
publisher: "sumi.work",
|
||||
platform: "Windows",
|
||||
size: "待上传",
|
||||
updatedAt: "2026-06-08T00:00:00.000Z",
|
||||
iconUrl: "assets/icons/syncthing.svg",
|
||||
downloadUrl: "",
|
||||
tags: ["同步"],
|
||||
featured: false,
|
||||
},
|
||||
];
|
||||
|
||||
const state = {
|
||||
items: [],
|
||||
category: "all",
|
||||
keyword: "",
|
||||
player: null,
|
||||
authLoaded: false,
|
||||
};
|
||||
|
||||
const els = {
|
||||
year: document.querySelector("#year"),
|
||||
keyword: document.querySelector("#software-keyword"),
|
||||
categoryButtons: [...document.querySelectorAll(".category-button")],
|
||||
featuredList: document.querySelector("#featured-list"),
|
||||
allList: document.querySelector("#all-list"),
|
||||
emptyState: document.querySelector("#empty-state"),
|
||||
resultSummary: document.querySelector("#result-summary"),
|
||||
refreshButton: document.querySelector("#refresh-button"),
|
||||
statCount: document.querySelector("#stat-count"),
|
||||
statFeatured: document.querySelector("#stat-featured"),
|
||||
statUpdated: document.querySelector("#stat-updated"),
|
||||
topicOffice: document.querySelector("#topic-office"),
|
||||
topicDev: document.querySelector("#topic-dev"),
|
||||
topicSecurity: document.querySelector("#topic-security"),
|
||||
playerStatus: document.querySelector("#player-status"),
|
||||
};
|
||||
|
||||
function escapeHtml(value = "") {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function getCategoryName(category) {
|
||||
return CATEGORY_NAMES[category] || "其他";
|
||||
}
|
||||
|
||||
function getInitial(name = "") {
|
||||
return Array.from(name.trim())[0]?.toUpperCase() || "S";
|
||||
}
|
||||
|
||||
function getDownloadHref(item) {
|
||||
if (!item.downloadUrl) return "";
|
||||
return `/api/software/${encodeURIComponent(item.id)}/download`;
|
||||
}
|
||||
|
||||
function iconMarkup(item) {
|
||||
const safeName = escapeHtml(item.name);
|
||||
if (item.iconUrl) {
|
||||
return `<span class="app-icon"><img src="${escapeHtml(item.iconUrl)}" alt="${safeName}" onerror="this.remove(); this.parentElement.textContent='${escapeHtml(getInitial(item.name))}'" /></span>`;
|
||||
}
|
||||
return `<span class="app-icon" aria-hidden="true">${escapeHtml(getInitial(item.name))}</span>`;
|
||||
}
|
||||
|
||||
function downloadButton(item) {
|
||||
const href = getDownloadHref(item);
|
||||
if (!href) {
|
||||
return `<span class="download-button is-disabled">待上传</span>`;
|
||||
}
|
||||
if (!state.player) {
|
||||
return `<a class="download-button login-required" href="login.html?next=${encodeURIComponent(href)}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M12 3 5 6v5c0 4.6 2.8 8.5 7 10 4.2-1.5 7-5.4 7-10V6l-7-3Z"></path>
|
||||
<path d="m9.5 12 1.7 1.7 3.6-4"></path>
|
||||
</svg>
|
||||
登录下载
|
||||
</a>`;
|
||||
}
|
||||
return `<a class="download-button" href="${href}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M12 3v12"></path>
|
||||
<path d="m7 10 5 5 5-5"></path>
|
||||
<path d="M5 21h14"></path>
|
||||
</svg>
|
||||
下载
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function renderPlayerStatus() {
|
||||
if (!els.playerStatus) return;
|
||||
if (!state.authLoaded) {
|
||||
els.playerStatus.innerHTML = `<span class="player-name">检查登录中</span>`;
|
||||
return;
|
||||
}
|
||||
if (!state.player) {
|
||||
els.playerStatus.innerHTML = `<a class="player-login-link" href="login.html">玩家登录</a>`;
|
||||
return;
|
||||
}
|
||||
els.playerStatus.innerHTML = `<span class="player-name">${escapeHtml(state.player.displayName || state.player.username)}</span>
|
||||
<button class="player-logout" type="button" id="player-logout">退出</button>`;
|
||||
}
|
||||
|
||||
function softwareCard(item) {
|
||||
return `<article class="software-card">
|
||||
${iconMarkup(item)}
|
||||
<div>
|
||||
<div class="app-name-line">
|
||||
<h3 class="app-name">${escapeHtml(item.name)}</h3>
|
||||
<span class="version-pill">v${escapeHtml(item.version || "-")}</span>
|
||||
</div>
|
||||
<p class="app-desc">${escapeHtml(item.description || "暂无简介")}</p>
|
||||
<div class="app-meta">
|
||||
<span>${escapeHtml(getCategoryName(item.category))}</span>
|
||||
<span>${escapeHtml(item.size || "未知大小")}</span>
|
||||
<span>${formatDate(item.updatedAt)}</span>
|
||||
</div>
|
||||
${downloadButton(item)}
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function topicItem(item) {
|
||||
return `<article class="topic-item">
|
||||
${iconMarkup(item)}
|
||||
<div>
|
||||
<div class="app-name-line">
|
||||
<h3 class="app-name">${escapeHtml(item.name)}</h3>
|
||||
</div>
|
||||
<p class="app-desc">${escapeHtml(item.description || "暂无简介")}</p>
|
||||
</div>
|
||||
${downloadButton(item)}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function softwareRow(item) {
|
||||
const tags = (item.tags || []).slice(0, 2).map((tag) => `<span class="category-pill">${escapeHtml(tag)}</span>`).join("");
|
||||
return `<article class="software-row">
|
||||
${iconMarkup(item)}
|
||||
<div>
|
||||
<div class="app-name-line">
|
||||
<h3 class="app-name">${escapeHtml(item.name)}</h3>
|
||||
<span class="version-pill">v${escapeHtml(item.version || "-")}</span>
|
||||
<span class="category-pill">${escapeHtml(getCategoryName(item.category))}</span>
|
||||
${tags}
|
||||
</div>
|
||||
<p class="app-desc">${escapeHtml(item.description || "暂无简介")}</p>
|
||||
<div class="app-meta">
|
||||
<span>${escapeHtml(item.publisher || "sumi.work")}</span>
|
||||
<span>${escapeHtml(item.platform || "Windows")}</span>
|
||||
<span>${escapeHtml(item.size || "未知大小")}</span>
|
||||
<span>更新 ${formatDate(item.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-actions">${downloadButton(item)}</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function normalizeItems(payload) {
|
||||
const items = Array.isArray(payload) ? payload : payload?.items;
|
||||
return (items || []).map((item) => ({
|
||||
...item,
|
||||
tags: Array.isArray(item.tags) ? item.tags : [],
|
||||
}));
|
||||
}
|
||||
|
||||
function sortItems(items) {
|
||||
return [...items].sort((a, b) => {
|
||||
if (Boolean(a.featured) !== Boolean(b.featured)) return a.featured ? -1 : 1;
|
||||
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function getFilteredItems() {
|
||||
const keyword = state.keyword.trim().toLowerCase();
|
||||
return sortItems(state.items).filter((item) => {
|
||||
const matchesCategory = state.category === "all" || item.category === state.category;
|
||||
const matchesKeyword = !keyword || [item.name, item.description, item.publisher, ...(item.tags || [])]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
return matchesCategory && matchesKeyword;
|
||||
});
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const featured = state.items.filter((item) => item.featured).length;
|
||||
const latest = sortItems(state.items)[0]?.updatedAt;
|
||||
els.statCount.textContent = String(state.items.length);
|
||||
els.statFeatured.textContent = String(featured);
|
||||
els.statUpdated.textContent = formatDate(latest);
|
||||
}
|
||||
|
||||
function renderTopics() {
|
||||
const byCategory = (category) => sortItems(state.items).filter((item) => item.category === category).slice(0, 5);
|
||||
els.topicOffice.innerHTML = byCategory("office").map(topicItem).join("") || `<p class="empty-state">暂无办公软件。</p>`;
|
||||
els.topicDev.innerHTML = byCategory("dev").map(topicItem).join("") || `<p class="empty-state">暂无开发工具。</p>`;
|
||||
els.topicSecurity.innerHTML = byCategory("security").map(topicItem).join("") || `<p class="empty-state">暂无安全工具。</p>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const filtered = getFilteredItems();
|
||||
const featured = filtered.filter((item) => item.featured).slice(0, 8);
|
||||
|
||||
els.featuredList.innerHTML = (featured.length ? featured : filtered.slice(0, 8)).map(softwareCard).join("");
|
||||
els.allList.innerHTML = filtered.map(softwareRow).join("");
|
||||
els.emptyState.hidden = filtered.length > 0;
|
||||
els.resultSummary.textContent = `共找到 ${filtered.length} 款软件`;
|
||||
renderStats();
|
||||
renderTopics();
|
||||
renderPlayerStatus();
|
||||
}
|
||||
|
||||
async function loadAuth() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/me", { headers: { Accept: "application/json" } });
|
||||
const payload = await response.json();
|
||||
state.player = payload.authenticated ? payload.player : null;
|
||||
} catch {
|
||||
state.player = null;
|
||||
} finally {
|
||||
state.authLoaded = true;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSoftware() {
|
||||
try {
|
||||
const response = await fetch("/api/software", { headers: { Accept: "application/json" } });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
state.items = normalizeItems(await response.json());
|
||||
} catch (error) {
|
||||
state.items = FALLBACK_SOFTWARE;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
els.year.textContent = new Date().getFullYear();
|
||||
els.keyword.addEventListener("input", (event) => {
|
||||
state.keyword = event.target.value;
|
||||
render();
|
||||
});
|
||||
|
||||
els.categoryButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.category = button.dataset.category;
|
||||
els.categoryButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
els.refreshButton.addEventListener("click", loadSoftware);
|
||||
els.playerStatus.addEventListener("click", async (event) => {
|
||||
if (!event.target.closest("#player-logout")) return;
|
||||
await fetch("/api/auth/logout", { method: "POST", headers: { Accept: "application/json" } });
|
||||
state.player = null;
|
||||
state.authLoaded = true;
|
||||
render();
|
||||
});
|
||||
|
||||
loadAuth();
|
||||
loadSoftware();
|
||||
Reference in New Issue
Block a user