feat: add software download center
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
server/uploads/packages/*
|
||||
server/uploads/icons/*
|
||||
!server/uploads/packages/.gitkeep
|
||||
!server/uploads/icons/.gitkeep
|
||||
.env
|
||||
npm-debug.log*
|
||||
server-test.*.log
|
||||
@@ -0,0 +1,137 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#edf5ff" />
|
||||
<title>sumi.work - 软件管理</title>
|
||||
<link rel="stylesheet" href="../assets/css/software.css?v=20260608-1" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="soft-topbar">
|
||||
<div class="soft-topbar-inner">
|
||||
<a class="soft-brand" href="../software.html" aria-label="返回软件下载">
|
||||
<img src="../assets/icons/software.svg" alt="" aria-hidden="true" />
|
||||
<span>软件管理</span>
|
||||
</a>
|
||||
<nav class="soft-nav" aria-label="软件管理导航">
|
||||
<a href="../index.html">首页</a>
|
||||
<a href="../software.html">软件下载</a>
|
||||
<a class="is-active" href="software.html">管理后台</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin-shell">
|
||||
<section class="admin-panel" aria-labelledby="token-title">
|
||||
<h1 id="token-title">管理令牌</h1>
|
||||
<p class="admin-note">令牌通过请求头发送到服务端,用于新增、上传和更新软件。</p>
|
||||
<div class="token-row">
|
||||
<input id="admin-token" type="password" autocomplete="current-password" placeholder="输入 ADMIN_TOKEN" />
|
||||
<button class="primary-action" type="button" id="save-token">保存</button>
|
||||
</div>
|
||||
<p class="status-message" id="token-status"></p>
|
||||
</section>
|
||||
|
||||
<div class="admin-layout">
|
||||
<section class="admin-panel" aria-labelledby="form-title">
|
||||
<h2 id="form-title">新增软件</h2>
|
||||
<form id="software-form">
|
||||
<input type="hidden" name="id" id="software-id" />
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="name">软件名称</label>
|
||||
<input id="name" name="name" required maxlength="80" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="version">版本号</label>
|
||||
<input id="version" name="version" required maxlength="40" placeholder="1.0.0" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="category">分类</label>
|
||||
<select id="category" name="category">
|
||||
<option value="office">办公效率</option>
|
||||
<option value="dev">开发工具</option>
|
||||
<option value="media">影音娱乐</option>
|
||||
<option value="security">安全系统</option>
|
||||
<option value="network">网络工具</option>
|
||||
<option value="tools">系统工具</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="platform">平台</label>
|
||||
<input id="platform" name="platform" maxlength="60" value="Windows" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="publisher">发布者</label>
|
||||
<input id="publisher" name="publisher" maxlength="80" value="sumi.work" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="homepage">官网</label>
|
||||
<input id="homepage" name="homepage" type="url" placeholder="https://example.com" />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="description">软件简介</label>
|
||||
<textarea id="description" name="description" maxlength="240" required></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="changelog">更新记录</label>
|
||||
<textarea id="changelog" name="changelog" maxlength="1000"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tags">标签</label>
|
||||
<input id="tags" name="tags" placeholder="官方, 推荐" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sort">排序</label>
|
||||
<input id="sort" name="sort" type="number" value="100" />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="download-url">外部下载地址</label>
|
||||
<input id="download-url" name="downloadUrl" type="url" placeholder="上传安装包时可留空" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="icon">软件图标</label>
|
||||
<input id="icon" name="icon" type="file" accept="image/*" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="package">安装包</label>
|
||||
<input id="package" name="package" type="file" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox-field"><input name="active" type="checkbox" checked /> 前台显示</label>
|
||||
<label class="checkbox-field"><input name="featured" type="checkbox" /> 精选推荐</label>
|
||||
</div>
|
||||
<p class="form-hint">更新软件时只选择新的安装包即可替换下载文件;不选择文件会保留原安装包。</p>
|
||||
<div class="form-actions">
|
||||
<button class="primary-action" type="submit" id="submit-button">保存软件</button>
|
||||
<button class="secondary-action" type="button" id="reset-form">清空</button>
|
||||
</div>
|
||||
<p class="status-message" id="form-status"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel" aria-labelledby="list-title">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2 id="list-title">软件列表</h2>
|
||||
<p id="admin-summary">正在加载...</p>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="reload-admin">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M20 12a8 8 0 1 1-2.3-5.7"></path>
|
||||
<path d="M20 4v6h-6"></path>
|
||||
</svg>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-list" id="admin-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="../assets/js/software-admin.js?v=20260608-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
+19
@@ -33,9 +33,21 @@
|
||||
</svg>
|
||||
连接状态
|
||||
</a>
|
||||
<a href="software.html">
|
||||
<svg class="nav-icon" 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>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<a class="quick-link software" href="software.html">
|
||||
<img src="assets/icons/software.svg" alt="" aria-hidden="true" />
|
||||
软件中心
|
||||
</a>
|
||||
<a class="quick-link gemini" href="https://gemini.google.com/app" target="_blank" rel="noreferrer">
|
||||
<img src="assets/icons/gemini.svg" alt="" aria-hidden="true" />
|
||||
Gemini ↗
|
||||
@@ -166,6 +178,13 @@
|
||||
<span class="service-desc">开发平台</span>
|
||||
<span class="arrow-button">→</span>
|
||||
</a>
|
||||
|
||||
<a class="service-card card-software" href="software.html" aria-label="进入软件下载中心">
|
||||
<span class="service-icon"><img src="assets/icons/software.svg" alt="" aria-hidden="true" /></span>
|
||||
<span class="service-title">软件下载</span>
|
||||
<span class="service-desc">上传、版本更新与下载分发</span>
|
||||
<span class="arrow-button">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#edf5ff" />
|
||||
<title>sumi.work - 玩家登录</title>
|
||||
<link rel="stylesheet" href="assets/css/software.css?v=20260608-2" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="soft-topbar">
|
||||
<div class="soft-topbar-inner login-topbar-inner">
|
||||
<a class="soft-brand" href="software.html" aria-label="返回软件下载">
|
||||
<img src="assets/icons/software.svg" alt="" aria-hidden="true" />
|
||||
<span>sumi.work 软件中心</span>
|
||||
</a>
|
||||
<nav class="soft-nav" aria-label="登录页导航">
|
||||
<a href="index.html">首页</a>
|
||||
<a href="software.html">软件下载</a>
|
||||
<a class="is-active" href="login.html">玩家登录</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="login-main">
|
||||
<section class="login-panel" aria-labelledby="login-title">
|
||||
<div>
|
||||
<p class="hero-kicker">Player Access</p>
|
||||
<h1 id="login-title">玩家登录</h1>
|
||||
<p class="login-copy">登录后即可下载软件中心里的安装包。</p>
|
||||
</div>
|
||||
<form class="login-form" id="login-form">
|
||||
<input type="hidden" name="next" id="next-url" />
|
||||
<div class="field">
|
||||
<label for="username">账号</label>
|
||||
<input id="username" name="username" autocomplete="username" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<button class="primary-action" type="submit">登录</button>
|
||||
<p class="status-message" id="login-status" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/login.js?v=20260608-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1007
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "sumi-work",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "sumi.work service gateway and software download center",
|
||||
"scripts": {
|
||||
"start": "node server/server.js",
|
||||
"dev": "node --watch server/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"players": [
|
||||
{
|
||||
"username": "player",
|
||||
"displayName": "玩家",
|
||||
"active": true,
|
||||
"passwordHash": "pbkdf2_sha256$120000$c586c0d064f172a52475a6f72401a17d$bbcfb3b6bf374edd93edcde984b33d5c548738df0dd8f95aa33530cdb1a8bbe9",
|
||||
"createdAt": "2026-06-08T00:00:00.000Z",
|
||||
"updatedAt": "2026-06-08T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "sumi-launcher",
|
||||
"name": "Sumi Launcher",
|
||||
"version": "1.0.0",
|
||||
"category": "office",
|
||||
"description": "常用工具快速启动入口,适合放置内部服务和日常软件。",
|
||||
"changelog": "初始化软件中心示例数据。",
|
||||
"publisher": "sumi.work",
|
||||
"platform": "Windows",
|
||||
"homepage": "",
|
||||
"tags": [
|
||||
"官方",
|
||||
"推荐"
|
||||
],
|
||||
"sort": 10,
|
||||
"active": true,
|
||||
"featured": true,
|
||||
"externalDownloadUrl": "",
|
||||
"downloadUrl": "",
|
||||
"size": "待上传",
|
||||
"sizeBytes": 0,
|
||||
"sha256": "",
|
||||
"iconUrl": "/assets/icons/software.svg",
|
||||
"createdAt": "2026-06-08T00:00:00.000Z",
|
||||
"updatedAt": "2026-06-08T00:00:00.000Z",
|
||||
"downloadCount": 0
|
||||
},
|
||||
{
|
||||
"id": "git-client",
|
||||
"name": "Git Client",
|
||||
"version": "2.0.0",
|
||||
"category": "dev",
|
||||
"description": "代码仓库和版本管理工具示例,可在后台替换成真实安装包。",
|
||||
"changelog": "初始化软件中心示例数据。",
|
||||
"publisher": "sumi.work",
|
||||
"platform": "Windows",
|
||||
"homepage": "",
|
||||
"tags": [
|
||||
"开发",
|
||||
"版本管理"
|
||||
],
|
||||
"sort": 20,
|
||||
"active": true,
|
||||
"featured": true,
|
||||
"externalDownloadUrl": "",
|
||||
"downloadUrl": "",
|
||||
"size": "待上传",
|
||||
"sizeBytes": 0,
|
||||
"sha256": "",
|
||||
"iconUrl": "/assets/icons/git.svg",
|
||||
"createdAt": "2026-06-08T00:00:00.000Z",
|
||||
"updatedAt": "2026-06-08T00:00:00.000Z",
|
||||
"downloadCount": 0
|
||||
},
|
||||
{
|
||||
"id": "sync-tool",
|
||||
"name": "Sync Tool",
|
||||
"version": "1.2.0",
|
||||
"category": "network",
|
||||
"description": "文件同步和分发工具示例,用于占位展示软件下载列表。",
|
||||
"changelog": "初始化软件中心示例数据。",
|
||||
"publisher": "sumi.work",
|
||||
"platform": "Windows",
|
||||
"homepage": "",
|
||||
"tags": [
|
||||
"同步",
|
||||
"网络"
|
||||
],
|
||||
"sort": 30,
|
||||
"active": true,
|
||||
"featured": false,
|
||||
"externalDownloadUrl": "",
|
||||
"downloadUrl": "",
|
||||
"size": "待上传",
|
||||
"sizeBytes": 0,
|
||||
"sha256": "",
|
||||
"iconUrl": "/assets/icons/syncthing.svg",
|
||||
"createdAt": "2026-06-08T00:00:00.000Z",
|
||||
"updatedAt": "2026-06-08T00:00:00.000Z",
|
||||
"downloadCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import express from "express";
|
||||
import multer from "multer";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const dataDir = path.join(__dirname, "data");
|
||||
const uploadDir = path.join(__dirname, "uploads");
|
||||
const packageDir = path.join(uploadDir, "packages");
|
||||
const iconDir = path.join(uploadDir, "icons");
|
||||
const dataFile = path.join(dataDir, "software.json");
|
||||
const playerFile = path.join(dataDir, "players.json");
|
||||
const port = Number(process.env.PORT || 3030);
|
||||
const adminToken = process.env.ADMIN_TOKEN || "sumi-admin";
|
||||
const playerSessionCookie = "sumi_player_session";
|
||||
const playerSessionTtlMs = Number(process.env.PLAYER_SESSION_TTL_MS || 7 * 24 * 60 * 60 * 1000);
|
||||
const passwordIterations = 120000;
|
||||
|
||||
const app = express();
|
||||
const playerSessions = new Map();
|
||||
|
||||
function slugify(value) {
|
||||
const slug = String(value || "")
|
||||
.normalize("NFKD")
|
||||
.replace(/[^\w\s.-]/g, "")
|
||||
.trim()
|
||||
.replace(/[\s_.]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.toLowerCase()
|
||||
.slice(0, 64);
|
||||
return slug || `software-${Date.now()}`;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback = false) {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
return ["1", "true", "on", "yes"].includes(String(value).toLowerCase());
|
||||
}
|
||||
|
||||
function parseTags(value) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value.flatMap(parseTags);
|
||||
return String(value)
|
||||
.split(/[,,\n]/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function formatSize(bytes = 0) {
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = Number(bytes);
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
async function ensureStorage() {
|
||||
await Promise.all([
|
||||
fsp.mkdir(dataDir, { recursive: true }),
|
||||
fsp.mkdir(packageDir, { recursive: true }),
|
||||
fsp.mkdir(iconDir, { recursive: true }),
|
||||
]);
|
||||
try {
|
||||
await fsp.access(dataFile);
|
||||
} catch {
|
||||
await writeStore({ items: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
await fsp.access(playerFile);
|
||||
} catch {
|
||||
await writePlayers({
|
||||
players: [
|
||||
createPlayerRecord(process.env.PLAYER_USERNAME || "player", process.env.PLAYER_PASSWORD || "sumi-player"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.PLAYER_USERNAME && process.env.PLAYER_PASSWORD) {
|
||||
await upsertEnvPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
async function readStore() {
|
||||
const raw = await fsp.readFile(dataFile, "utf8");
|
||||
const parsed = JSON.parse(raw || "{}");
|
||||
return { items: Array.isArray(parsed.items) ? parsed.items : [] };
|
||||
}
|
||||
|
||||
async function writeStore(store) {
|
||||
const tmpFile = `${dataFile}.${process.pid}.tmp`;
|
||||
await fsp.writeFile(tmpFile, `${JSON.stringify({ items: store.items }, null, 2)}\n`, "utf8");
|
||||
await fsp.rename(tmpFile, dataFile);
|
||||
}
|
||||
|
||||
async function readPlayers() {
|
||||
const raw = await fsp.readFile(playerFile, "utf8");
|
||||
const parsed = JSON.parse(raw || "{}");
|
||||
return { players: Array.isArray(parsed.players) ? parsed.players : [] };
|
||||
}
|
||||
|
||||
async function writePlayers(store) {
|
||||
const tmpFile = `${playerFile}.${process.pid}.tmp`;
|
||||
await fsp.writeFile(tmpFile, `${JSON.stringify({ players: store.players }, null, 2)}\n`, "utf8");
|
||||
await fsp.rename(tmpFile, playerFile);
|
||||
}
|
||||
|
||||
function createPlayerRecord(username, password, existing = {}) {
|
||||
return {
|
||||
...existing,
|
||||
username,
|
||||
displayName: existing.displayName || "玩家",
|
||||
active: true,
|
||||
passwordHash: hashPassword(password),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: existing.createdAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertEnvPlayer() {
|
||||
const username = process.env.PLAYER_USERNAME;
|
||||
const password = process.env.PLAYER_PASSWORD;
|
||||
const store = await readPlayers();
|
||||
const index = store.players.findIndex((player) => player.username === username);
|
||||
if (index === -1) {
|
||||
store.players.push(createPlayerRecord(username, password));
|
||||
} else {
|
||||
const existing = store.players[index];
|
||||
if (existing.active !== false && verifyPassword(password, existing.passwordHash)) {
|
||||
return;
|
||||
}
|
||||
store.players[index] = createPlayerRecord(username, password, store.players[index]);
|
||||
}
|
||||
await writePlayers(store);
|
||||
}
|
||||
|
||||
async function hashFile(filePath) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
await new Promise((resolve, reject) => {
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", resolve);
|
||||
});
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function hashPassword(password, salt = crypto.randomBytes(16).toString("hex")) {
|
||||
const hash = crypto.pbkdf2Sync(String(password), salt, passwordIterations, 32, "sha256").toString("hex");
|
||||
return `pbkdf2_sha256$${passwordIterations}$${salt}$${hash}`;
|
||||
}
|
||||
|
||||
function verifyPassword(password, encoded = "") {
|
||||
const [scheme, iterationText, salt, expectedHash] = String(encoded).split("$");
|
||||
if (scheme !== "pbkdf2_sha256" || !iterationText || !salt || !expectedHash) return false;
|
||||
const iterations = Number.parseInt(iterationText, 10);
|
||||
const actual = crypto.pbkdf2Sync(String(password), salt, iterations, 32, "sha256");
|
||||
const expected = Buffer.from(expectedHash, "hex");
|
||||
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
function getToken(req) {
|
||||
const auth = req.get("authorization") || "";
|
||||
if (auth.toLowerCase().startsWith("bearer ")) return auth.slice(7).trim();
|
||||
return req.get("x-admin-token") || req.query.token || "";
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
return Object.fromEntries(
|
||||
String(req.get("cookie") || "")
|
||||
.split(";")
|
||||
.map((pair) => pair.trim())
|
||||
.filter(Boolean)
|
||||
.map((pair) => {
|
||||
const index = pair.indexOf("=");
|
||||
if (index === -1) return [pair, ""];
|
||||
return [pair.slice(0, index), decodeURIComponent(pair.slice(index + 1))];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isHttpsRequest(req) {
|
||||
return req.secure || req.get("x-forwarded-proto") === "https";
|
||||
}
|
||||
|
||||
function setSessionCookie(req, res, token) {
|
||||
const secure = isHttpsRequest(req) ? "; Secure" : "";
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
`${playerSessionCookie}=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(playerSessionTtlMs / 1000)}${secure}`,
|
||||
);
|
||||
}
|
||||
|
||||
function clearSessionCookie(req, res) {
|
||||
const secure = isHttpsRequest(req) ? "; Secure" : "";
|
||||
res.setHeader("Set-Cookie", `${playerSessionCookie}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secure}`);
|
||||
}
|
||||
|
||||
function publicPlayer(player) {
|
||||
if (!player) return null;
|
||||
return {
|
||||
username: player.username,
|
||||
displayName: player.displayName || player.username,
|
||||
};
|
||||
}
|
||||
|
||||
function getPlayerSession(req) {
|
||||
const token = parseCookies(req)[playerSessionCookie];
|
||||
if (!token) return null;
|
||||
const session = playerSessions.get(token);
|
||||
if (!session) return null;
|
||||
if (session.expiresAt <= Date.now()) {
|
||||
playerSessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
session.expiresAt = Date.now() + playerSessionTtlMs;
|
||||
return { token, session };
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (getToken(req) !== adminToken) {
|
||||
res.status(401).json({ error: "Invalid admin token" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function requirePlayer(req, res, next) {
|
||||
const playerSession = getPlayerSession(req);
|
||||
if (!playerSession) {
|
||||
const loginUrl = `/login.html?next=${encodeURIComponent(req.originalUrl)}`;
|
||||
const accept = req.get("accept") || "";
|
||||
if (accept.includes("text/html") && !accept.includes("application/json")) {
|
||||
res.redirect(302, loginUrl);
|
||||
return;
|
||||
}
|
||||
res.status(401).json({ error: "Player login required", loginUrl });
|
||||
return;
|
||||
}
|
||||
req.player = playerSession.session.player;
|
||||
next();
|
||||
}
|
||||
|
||||
function publicItem(item, includePrivate = false) {
|
||||
const copy = { ...item };
|
||||
if (!includePrivate) {
|
||||
delete copy.storagePath;
|
||||
delete copy.iconStoragePath;
|
||||
delete copy.externalDownloadUrl;
|
||||
if (resolveDownloadUrl(item)) {
|
||||
copy.downloadUrl = `/api/software/${encodeURIComponent(item.id)}/download`;
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function sortSoftware(items) {
|
||||
return [...items].sort((a, b) => {
|
||||
if (Boolean(a.featured) !== Boolean(b.featured)) return a.featured ? -1 : 1;
|
||||
if ((a.sort ?? 100) !== (b.sort ?? 100)) return (a.sort ?? 100) - (b.sort ?? 100);
|
||||
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
|
||||
});
|
||||
}
|
||||
|
||||
async function makeFilePatch(files = {}) {
|
||||
const patch = {};
|
||||
const packageFile = files.package?.[0];
|
||||
const iconFile = files.icon?.[0];
|
||||
|
||||
if (packageFile) {
|
||||
patch.storagePath = packageFile.path;
|
||||
patch.originalFileName = packageFile.originalname;
|
||||
patch.fileName = packageFile.filename;
|
||||
patch.sizeBytes = packageFile.size;
|
||||
patch.size = formatSize(packageFile.size);
|
||||
patch.sha256 = await hashFile(packageFile.path);
|
||||
patch.downloadUrl = `/uploads/packages/${encodeURIComponent(packageFile.filename)}`;
|
||||
patch.externalDownloadUrl = "";
|
||||
}
|
||||
|
||||
if (iconFile) {
|
||||
patch.iconStoragePath = iconFile.path;
|
||||
patch.iconFileName = iconFile.filename;
|
||||
patch.iconUrl = `/uploads/icons/${encodeURIComponent(iconFile.filename)}`;
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
function getBodyPatch(body, existing = {}) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
name: String(body.name || existing.name || "").trim(),
|
||||
version: String(body.version || existing.version || "1.0.0").trim(),
|
||||
category: String(body.category || existing.category || "tools").trim(),
|
||||
description: String(body.description || existing.description || "").trim(),
|
||||
changelog: String(body.changelog || existing.changelog || "").trim(),
|
||||
publisher: String(body.publisher || existing.publisher || "sumi.work").trim(),
|
||||
platform: String(body.platform || existing.platform || "Windows").trim(),
|
||||
homepage: String(body.homepage || existing.homepage || "").trim(),
|
||||
tags: parseTags(body.tags ?? existing.tags),
|
||||
sort: Number.parseInt(body.sort ?? existing.sort ?? 100, 10),
|
||||
active: parseBoolean(body.active, existing.active !== false),
|
||||
featured: parseBoolean(body.featured, Boolean(existing.featured)),
|
||||
externalDownloadUrl: String(body.downloadUrl || existing.externalDownloadUrl || "").trim(),
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDownloadUrl(item) {
|
||||
return item.downloadUrl || item.externalDownloadUrl || "";
|
||||
}
|
||||
|
||||
function isPathInside(parent, child) {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function resolvePackagePath(item) {
|
||||
if (item.storagePath) return path.resolve(item.storagePath);
|
||||
if (item.downloadUrl?.startsWith("/uploads/packages/")) {
|
||||
return path.resolve(packageDir, path.basename(decodeURIComponent(item.downloadUrl)));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination(req, file, callback) {
|
||||
callback(null, file.fieldname === "icon" ? iconDir : packageDir);
|
||||
},
|
||||
filename(req, file, callback) {
|
||||
const ext = path.extname(file.originalname || "").slice(0, 24);
|
||||
const base = slugify(path.basename(file.originalname || "upload", ext));
|
||||
const suffix = crypto.randomBytes(6).toString("hex");
|
||||
callback(null, `${Date.now()}-${suffix}-${base}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024),
|
||||
files: 2,
|
||||
},
|
||||
fileFilter(req, file, callback) {
|
||||
if (file.fieldname === "icon" && !file.mimetype.startsWith("image/")) {
|
||||
callback(new Error("Icon must be an image file"));
|
||||
return;
|
||||
}
|
||||
callback(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use("/uploads/icons", express.static(iconDir, { fallthrough: false }));
|
||||
|
||||
app.get("/api/health", (req, res) => {
|
||||
res.json({ ok: true, service: "sumi-software-center" });
|
||||
});
|
||||
|
||||
app.get("/api/auth/me", (req, res) => {
|
||||
const playerSession = getPlayerSession(req);
|
||||
if (!playerSession) {
|
||||
res.json({ authenticated: false, player: null });
|
||||
return;
|
||||
}
|
||||
setSessionCookie(req, res, playerSession.token);
|
||||
res.json({ authenticated: true, player: publicPlayer(playerSession.session.player) });
|
||||
});
|
||||
|
||||
app.post("/api/auth/login", async (req, res, next) => {
|
||||
try {
|
||||
const username = String(req.body.username || "").trim();
|
||||
const password = String(req.body.password || "");
|
||||
const nextUrl = String(req.body.next || "/software.html");
|
||||
const { players } = await readPlayers();
|
||||
const player = players.find((entry) => entry.username === username && entry.active !== false);
|
||||
|
||||
if (!player || !verifyPassword(password, player.passwordHash)) {
|
||||
res.status(401).json({ error: "账号或密码错误" });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString("base64url");
|
||||
playerSessions.set(token, {
|
||||
player: publicPlayer(player),
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + playerSessionTtlMs,
|
||||
});
|
||||
setSessionCookie(req, res, token);
|
||||
res.json({ authenticated: true, player: publicPlayer(player), next: nextUrl });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/auth/logout", (req, res) => {
|
||||
const playerSession = getPlayerSession(req);
|
||||
if (playerSession) {
|
||||
playerSessions.delete(playerSession.token);
|
||||
}
|
||||
clearSessionCookie(req, res);
|
||||
res.json({ authenticated: false });
|
||||
});
|
||||
|
||||
app.get("/api/software", async (req, res, next) => {
|
||||
try {
|
||||
const { items } = await readStore();
|
||||
const category = String(req.query.category || "all");
|
||||
const keyword = String(req.query.keyword || "").trim().toLowerCase();
|
||||
const visibleItems = items
|
||||
.filter((item) => item.active !== false)
|
||||
.filter((item) => category === "all" || !category || item.category === category)
|
||||
.filter((item) => {
|
||||
if (!keyword) return true;
|
||||
return [item.name, item.description, item.publisher, ...(item.tags || [])]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
});
|
||||
res.json({ items: sortSoftware(visibleItems).map((item) => publicItem(item)) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/software/:id/latest", async (req, res, next) => {
|
||||
try {
|
||||
const { items } = await readStore();
|
||||
const item = items.find((entry) => entry.id === req.params.id && entry.active !== false);
|
||||
if (!item) {
|
||||
res.status(404).json({ error: "Software not found" });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
version: item.version,
|
||||
changelog: item.changelog,
|
||||
updatedAt: item.updatedAt,
|
||||
size: item.size,
|
||||
sizeBytes: item.sizeBytes,
|
||||
sha256: item.sha256,
|
||||
downloadUrl: `/api/software/${encodeURIComponent(item.id)}/download`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/software/:id/download", requirePlayer, async (req, res, next) => {
|
||||
try {
|
||||
const store = await readStore();
|
||||
const item = store.items.find((entry) => entry.id === req.params.id && entry.active !== false);
|
||||
if (!item) {
|
||||
res.status(404).json({ error: "Software not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadUrl = resolveDownloadUrl(item);
|
||||
if (!downloadUrl) {
|
||||
res.status(404).json({ error: "No package has been uploaded" });
|
||||
return;
|
||||
}
|
||||
|
||||
item.downloadCount = (item.downloadCount || 0) + 1;
|
||||
await writeStore(store);
|
||||
|
||||
const packagePath = resolvePackagePath(item);
|
||||
if (packagePath) {
|
||||
if (!isPathInside(packageDir, packagePath)) {
|
||||
res.status(403).json({ error: "Invalid package path" });
|
||||
return;
|
||||
}
|
||||
await fsp.access(packagePath);
|
||||
res.download(packagePath, item.originalFileName || item.fileName || path.basename(packagePath));
|
||||
return;
|
||||
}
|
||||
|
||||
res.redirect(downloadUrl);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/admin/software", requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { items } = await readStore();
|
||||
res.json({ items: sortSoftware(items).map((item) => publicItem(item, true)) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/admin/software", requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }]), async (req, res, next) => {
|
||||
try {
|
||||
const bodyPatch = getBodyPatch(req.body);
|
||||
if (!bodyPatch.name) {
|
||||
res.status(400).json({ error: "Software name is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const store = await readStore();
|
||||
const baseId = slugify(bodyPatch.name);
|
||||
let id = baseId;
|
||||
while (store.items.some((item) => item.id === id)) {
|
||||
id = `${baseId}-${crypto.randomBytes(3).toString("hex")}`;
|
||||
}
|
||||
|
||||
const filePatch = await makeFilePatch(req.files);
|
||||
const now = new Date().toISOString();
|
||||
const externalDownloadUrl = bodyPatch.externalDownloadUrl;
|
||||
const item = {
|
||||
id,
|
||||
createdAt: now,
|
||||
downloadCount: 0,
|
||||
...bodyPatch,
|
||||
...filePatch,
|
||||
downloadUrl: filePatch.downloadUrl || externalDownloadUrl,
|
||||
externalDownloadUrl: filePatch.downloadUrl ? "" : externalDownloadUrl,
|
||||
};
|
||||
|
||||
store.items.push(item);
|
||||
await writeStore(store);
|
||||
res.status(201).json({ item: publicItem(item, true) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/admin/software/:id", requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }]), async (req, res, next) => {
|
||||
try {
|
||||
const store = await readStore();
|
||||
const index = store.items.findIndex((item) => item.id === req.params.id);
|
||||
if (index === -1) {
|
||||
res.status(404).json({ error: "Software not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = store.items[index];
|
||||
const bodyPatch = getBodyPatch(req.body, existing);
|
||||
if (!bodyPatch.name) {
|
||||
res.status(400).json({ error: "Software name is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const filePatch = await makeFilePatch(req.files);
|
||||
const downloadUrl = filePatch.downloadUrl || bodyPatch.externalDownloadUrl || existing.downloadUrl || "";
|
||||
const item = {
|
||||
...existing,
|
||||
...bodyPatch,
|
||||
...filePatch,
|
||||
downloadUrl,
|
||||
externalDownloadUrl: filePatch.downloadUrl ? "" : bodyPatch.externalDownloadUrl,
|
||||
};
|
||||
|
||||
store.items[index] = item;
|
||||
await writeStore(store);
|
||||
res.json({ item: publicItem(item, true) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/admin/software/:id", requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const store = await readStore();
|
||||
const initialLength = store.items.length;
|
||||
store.items = store.items.filter((item) => item.id !== req.params.id);
|
||||
if (store.items.length === initialLength) {
|
||||
res.status(404).json({ error: "Software not found" });
|
||||
return;
|
||||
}
|
||||
await writeStore(store);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/software", (req, res) => res.redirect(301, "/software.html"));
|
||||
app.get("/software/", (req, res) => res.sendFile(path.join(projectRoot, "software.html")));
|
||||
app.get("/login", (req, res) => res.redirect(301, "/login.html"));
|
||||
app.get("/admin/software", (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html")));
|
||||
app.get("/", (req, res) => res.sendFile(path.join(projectRoot, "index.html")));
|
||||
app.get("/index.html", (req, res) => res.sendFile(path.join(projectRoot, "index.html")));
|
||||
app.get("/software.html", (req, res) => res.sendFile(path.join(projectRoot, "software.html")));
|
||||
app.get("/login.html", (req, res) => res.sendFile(path.join(projectRoot, "login.html")));
|
||||
app.get("/admin/software.html", (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html")));
|
||||
app.use("/assets", express.static(path.join(projectRoot, "assets"), { fallthrough: false }));
|
||||
|
||||
app.use((error, req, res, next) => {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: error.message || "Internal server error" });
|
||||
});
|
||||
|
||||
await ensureStorage();
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`sumi.work software center running at http://localhost:${port}`);
|
||||
if (!process.env.ADMIN_TOKEN) {
|
||||
console.log("ADMIN_TOKEN is not set. Using development token: sumi-admin");
|
||||
}
|
||||
if (!process.env.PLAYER_USERNAME || !process.env.PLAYER_PASSWORD) {
|
||||
console.log("PLAYER_USERNAME/PLAYER_PASSWORD is not set. Using development player: player / sumi-player");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="sumi.work 软件中心,提供常用软件的安全下载、版本信息和更新记录。" />
|
||||
<meta name="theme-color" content="#edf5ff" />
|
||||
<title>sumi.work - 软件下载</title>
|
||||
<link rel="stylesheet" href="assets/css/software.css?v=20260608-1" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="soft-topbar">
|
||||
<div class="soft-topbar-inner">
|
||||
<a class="soft-brand" href="index.html" aria-label="返回 sumi.work 首页">
|
||||
<img src="assets/icons/software.svg" alt="" aria-hidden="true" />
|
||||
<span>sumi.work 软件中心</span>
|
||||
</a>
|
||||
<nav class="soft-nav" aria-label="软件中心导航">
|
||||
<a href="index.html">首页</a>
|
||||
<a class="is-active" href="software.html">软件下载</a>
|
||||
<a href="admin/software.html">管理后台</a>
|
||||
</nav>
|
||||
<form class="soft-search" role="search">
|
||||
<label class="sr-only" for="software-keyword">搜索软件</label>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<path d="m16.5 16.5 4 4"></path>
|
||||
</svg>
|
||||
<input id="software-keyword" name="keyword" type="search" autocomplete="off" placeholder="搜索软件" />
|
||||
</form>
|
||||
<div class="player-status" id="player-status" aria-live="polite">
|
||||
<a class="player-login-link" href="login.html">玩家登录</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="download-hero" aria-labelledby="download-title">
|
||||
<div class="hero-content">
|
||||
<p class="hero-kicker">Official Download Center</p>
|
||||
<h1 id="download-title">海量软件下载,<span>管理无忧</span></h1>
|
||||
<p class="hero-copy">统一沉淀安装包、版本号、更新记录和下载入口,让常用软件找得到、下得准、更新快。</p>
|
||||
<div class="hero-actions">
|
||||
<a class="primary-action" href="#software-list">
|
||||
<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>
|
||||
<a class="secondary-action" href="admin/software.html">
|
||||
<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>
|
||||
</div>
|
||||
<dl class="hero-stats" aria-label="软件中心统计">
|
||||
<div>
|
||||
<dt id="stat-count">0</dt>
|
||||
<dd>软件收录</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt id="stat-featured">0</dt>
|
||||
<dd>精选推荐</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt id="stat-updated">--</dt>
|
||||
<dd>最近更新</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category-strip" aria-label="软件分类">
|
||||
<button class="category-button is-active" type="button" data-category="all">全部</button>
|
||||
<button class="category-button" type="button" data-category="office">办公效率</button>
|
||||
<button class="category-button" type="button" data-category="dev">开发工具</button>
|
||||
<button class="category-button" type="button" data-category="media">影音娱乐</button>
|
||||
<button class="category-button" type="button" data-category="security">安全系统</button>
|
||||
<button class="category-button" type="button" data-category="network">网络工具</button>
|
||||
</section>
|
||||
|
||||
<section class="software-section" id="software-list" aria-labelledby="software-title">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2 id="software-title">精选软件</h2>
|
||||
<p>常用安装包、版本和更新记录集中展示。</p>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="refresh-button">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M20 12a8 8 0 1 1-2.3-5.7"></path>
|
||||
<path d="M20 4v6h-6"></path>
|
||||
</svg>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div class="software-grid" id="featured-list" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="topic-grid" aria-label="装机专题">
|
||||
<article class="topic-panel topic-orange">
|
||||
<div class="topic-heading">
|
||||
<h2>办公必备</h2>
|
||||
<span>效率套件</span>
|
||||
</div>
|
||||
<div class="topic-list" id="topic-office"></div>
|
||||
</article>
|
||||
<article class="topic-panel topic-blue">
|
||||
<div class="topic-heading">
|
||||
<h2>开发常用</h2>
|
||||
<span>构建调试</span>
|
||||
</div>
|
||||
<div class="topic-list" id="topic-dev"></div>
|
||||
</article>
|
||||
<article class="topic-panel topic-green">
|
||||
<div class="topic-heading">
|
||||
<h2>安全维护</h2>
|
||||
<span>系统工具</span>
|
||||
</div>
|
||||
<div class="topic-list" id="topic-security"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="all-section" aria-labelledby="all-title">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2 id="all-title">全部软件</h2>
|
||||
<p id="result-summary">正在加载软件列表...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="all-list" id="all-list" aria-live="polite"></div>
|
||||
<p class="empty-state" id="empty-state" hidden>没有找到匹配的软件。</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="soft-footer">
|
||||
<span>© <span id="year"></span> sumi.work</span>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer">粤ICP备2026053646号</a>
|
||||
</footer>
|
||||
|
||||
<script src="assets/js/software.js?v=20260608-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user