feat: add software download center

This commit is contained in:
lwt
2026-06-08 20:53:14 +08:00
parent aa29e4e462
commit c8cfe68361
18 changed files with 3738 additions and 0 deletions
+61
View File
@@ -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();
+214
View File
@@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 后读取列表。");
}
+321
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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();