354 lines
11 KiB
JavaScript
354 lines
11 KiB
JavaScript
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"),
|
|
authLinks: [...document.querySelectorAll("[data-player-auth]")],
|
|
};
|
|
|
|
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 item.downloadUrl;
|
|
}
|
|
|
|
function getLoginHref(next = "/software.html") {
|
|
return `login.html?next=${encodeURIComponent(next)}`;
|
|
}
|
|
|
|
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="${escapeHtml(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();
|
|
}
|
|
return state.player;
|
|
}
|
|
|
|
async function ensurePlayer(next) {
|
|
if (!state.authLoaded) {
|
|
await loadAuth();
|
|
}
|
|
if (state.player) return true;
|
|
window.location.href = getLoginHref(next);
|
|
return false;
|
|
}
|
|
|
|
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.authLinks.forEach((link) => {
|
|
link.addEventListener("click", async (event) => {
|
|
const href = link.getAttribute("href") || "/software.html";
|
|
const next = link.dataset.next || href;
|
|
if (state.authLoaded && state.player) return;
|
|
|
|
event.preventDefault();
|
|
const isAuthenticated = await ensurePlayer(next);
|
|
if (!isAuthenticated) return;
|
|
|
|
if (href.startsWith("#")) {
|
|
document.querySelector(href)?.scrollIntoView({ behavior: "smooth" });
|
|
return;
|
|
}
|
|
window.location.href = href;
|
|
});
|
|
});
|
|
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();
|