function getCookie(name) { const raw = document.cookie || ""; const parts = raw.split(";"); for (const p of parts) { const s = p.trim(); if (!s) continue; const idx = s.indexOf("="); if (idx <= 0) continue; const k = s.slice(0, idx).trim(); if (k !== name) continue; return decodeURIComponent(s.slice(idx + 1)); } return ""; } async function apiFetch(url, { method = "GET", body } = {}) { const init = { method, headers: {} }; if (!["GET", "HEAD", "OPTIONS", "TRACE"].includes(String(method || "GET").toUpperCase())) { const csrf = getCookie("csrf_token"); if (csrf) init.headers["X-CSRF-Token"] = csrf; } if (body !== undefined) { init.headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } const resp = await fetch(url, init); const contentType = resp.headers.get("content-type") || ""; const isJson = contentType.includes("application/json"); if (!resp.ok) { let detail = null; if (isJson) { try { detail = await resp.json(); } catch (e) { detail = null; } } const err = new Error("request_failed"); err.status = resp.status; err.detail = detail; throw err; } if (isJson) return await resp.json(); return resp; } const Toast = Swal.mixin({ toast: true, position: 'top-end', showConfirmButton: false, timer: 3000, timerProgressBar: true, didOpen: (toast) => { toast.addEventListener('mouseenter', Swal.stopTimer) toast.addEventListener('mouseleave', Swal.resumeTimer) } }); function showToastError(text) { Toast.fire({ icon: 'error', title: String(text || "未知错误") }); } function showToastSuccess(text) { Toast.fire({ icon: 'success', title: String(text || "操作成功") }); } function currentNextParam() { return encodeURIComponent(window.location.pathname + window.location.search); } function nextFromQuery() { const p = new URLSearchParams(window.location.search || ""); const next = (p.get("next") || "").trim(); if (!next) return ""; if (!next.startsWith("/")) return ""; if (next.startsWith("//")) return ""; return next; } async function initTopbar() { const navAuth = document.getElementById("navAuth"); const navLogin = document.getElementById("navLogin"); const navRegister = document.getElementById("navRegister"); if (navLogin) navLogin.setAttribute("href", `/ui/login?next=${currentNextParam()}`); if (navRegister) navRegister.setAttribute("href", `/ui/register?next=${currentNextParam()}`); if (!navAuth) return; let msgBadge = null; async function refreshMessageBadge() { if (!msgBadge) return; try { const data = await apiFetch("/me/messages?page=1&pageSize=1"); const cnt = parseInt(data?.unreadCount || 0, 10) || 0; if (cnt > 0) { msgBadge.style.display = "inline-flex"; msgBadge.textContent = cnt > 99 ? "99+" : String(cnt); } else { msgBadge.style.display = "none"; msgBadge.textContent = ""; } } catch (e) { msgBadge.style.display = "none"; msgBadge.textContent = ""; } } let me = null; try { me = await apiFetch("/me"); } catch (e) { me = { user: null }; } const user = me && me.user ? me.user : null; if (!user) return; navAuth.innerHTML = ""; const userEl = el( "a", { class: "nav-user", href: "/ui/me", style: "display:flex; align-items:center; gap:4px;" }, el("i", { class: "ri-user-smile-line" }), el("span", {}, String(user.phone || "我的")) ); if (user.vipActive) userEl.appendChild(el("span", { class: "nav-badge nav-badge-vip" }, "VIP")); msgBadge = el("span", { class: "nav-msg-badge" }, ""); const msgLink = el("a", { href: "/ui/messages", class: "btn-ghost nav-msg", title: "消息通知" }, el("i", { class: "ri-notification-3-line" }), msgBadge); const logout = el("a", { href: "#", class: "btn-ghost", style: "padding:6px 10px; border-radius: 8px; display:flex; align-items:center; gap:4px;" }, el("i", {class: "ri-logout-box-r-line"}), "退出"); logout.addEventListener("click", async (evt) => { evt.preventDefault(); try { await apiFetch("/auth/logout", { method: "POST" }); } catch (e) {} window.location.href = "/"; }); navAuth.appendChild(userEl); navAuth.appendChild(msgLink); navAuth.appendChild(logout); window.__refreshMessageBadge = refreshMessageBadge; await refreshMessageBadge(); } window.addEventListener("error", (evt) => { const msg = evt?.error?.message || evt?.message || "页面脚本错误"; showToastError(msg); }); window.addEventListener("unhandledrejection", (evt) => { const msg = evt?.reason?.message || String(evt?.reason || "异步错误"); showToastError(msg); }); function el(tag, attrs = {}, ...children) { const node = document.createElement(tag); Object.entries(attrs).forEach(([k, v]) => { if (k === "class") node.className = v; else if (k === "html") node.innerHTML = v; else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v); else node.setAttribute(k, v); }); children.forEach((c) => { if (c === null || c === undefined) return; if (typeof c === "string") node.appendChild(document.createTextNode(c)); else node.appendChild(c); }); return node; } function btnGroup(...children) { return el("div", { class: "btn-group" }, ...children); } function renderEmptyRow(tbody, colCount, text) { tbody.appendChild(el("tr", {}, el("td", { colspan: String(colCount), class: "table-empty muted" }, text))); } function badge(text, variantClass = "") { const cls = ["badge", variantClass].filter(Boolean).join(" "); return el("span", { class: cls }, text); } function resourceTypeBadge(type) { if (type === "VIP") return badge("VIP", "badge-vip"); return badge("免费", "badge-free"); } function resourceStatusBadge(status) { if (status === "ONLINE") return badge("上架", "badge-success"); if (status === "OFFLINE") return badge("下架", "badge-danger"); return badge("草稿", "badge"); } function orderStatusBadge(status) { if (status === "PAID") return badge("已支付", "badge-success"); if (status === "PENDING") return badge("待支付", "badge-warning"); if (status === "FAILED") return badge("失败", "badge-danger"); return badge("已关闭", "badge"); } function userStatusBadge(status) { if (status === "ACTIVE") return badge("启用", "badge-success"); return badge("禁用", "badge-danger"); } function formatCents(cents) { return `¥${(cents / 100).toFixed(2)}`; } function formatDateTime(value) { if (value === null || value === undefined) return "-"; if (value === "-") return "-"; if (typeof value === "number") { const ms = value < 1e12 ? value * 1000 : value; const d = new Date(ms); if (Number.isNaN(d.getTime())) return String(value); const pad2 = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; } const s = String(value).trim(); if (!s) return "-"; if (/^\d+$/.test(s)) return formatDateTime(Number(s)); const d = new Date(s); if (Number.isNaN(d.getTime())) return s; const pad2 = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; } function formatMessageText(text) { const s = String(text || ""); const isoRe = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/g; return s.replace(isoRe, (m) => formatDateTime(m)); } function escapeHtml(text) { return String(text || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function sanitizeMarkdownUrl(url) { const s = String(url || "").trim(); if (!s) return ""; if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s)) { const scheme = s.split(":", 1)[0].toLowerCase(); if (!["http", "https", "mailto"].includes(scheme)) return ""; } return s; } function renderMarkdown(md) { const raw = String(md || ""); const escaped = escapeHtml(raw); const blocks = []; const placeholder = (i) => `@@BLOCK_${i}@@`; const fenced = escaped.replace(/```([\s\S]*?)```/g, (_m, code) => { const html = `
${code.replace(/^\n+|\n+$/g, "")}
`; blocks.push(html); return placeholder(blocks.length - 1); }); let html = fenced .replace(/^### (.*)$/gm, "

$1

") .replace(/^## (.*)$/gm, "

$1

") .replace(/^# (.*)$/gm, "

$1

") .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => { const safeUrl = sanitizeMarkdownUrl(url); if (!safeUrl) return `[图片已拦截]`; return `${alt}`; }) .replace(/@\[(video)\]\(([^)]+)\)/g, (_m, _t, url) => { const safeUrl = sanitizeMarkdownUrl(url); if (!safeUrl) return `[视频已拦截]`; return ``; }) .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, url) => { const safeUrl = sanitizeMarkdownUrl(url); if (!safeUrl) return `${text}`; return `${text}`; }) .replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*\n]+)\*/g, "$1"); html = html.replace(/\n{2,}/g, "\n\n"); html = html .split("\n\n") .map((p) => { if (p.startsWith("@@BLOCK_")) return p; if (/^/.test(p.trim()) || /^
");
      return `

${lines}

`; }) .join("\n"); blocks.forEach((b, i) => { html = html.replaceAll(placeholder(i), b); }); return html; } async function pageIndex() { const pageMode = document.body.getAttribute("data-page") || ""; const isHome = pageMode === "index"; const qInput = document.getElementById("q"); const typeSelect = document.getElementById("type"); const sortSelect = document.getElementById("sort"); const list = document.getElementById("resourceList"); const prevBtn = document.getElementById("prevPage"); const nextBtn = document.getElementById("nextPage"); const pageInfo = document.getElementById("pageInfo"); const searchBtn = document.getElementById("searchBtn"); const pager = document.getElementById("pager"); const homeMore = document.getElementById("homeMore"); let page = 1; function setLoading(loading) { if (searchBtn) searchBtn.disabled = loading; if (prevBtn) prevBtn.disabled = loading || page <= 1; if (nextBtn) nextBtn.disabled = loading; if (qInput) qInput.disabled = loading; if (typeSelect) typeSelect.disabled = loading; if (sortSelect) sortSelect.disabled = loading; } function skeletonCard() { const cover = el("div", { class: "resource-card-cover skeleton" }); const line1 = el("div", { class: "skeleton skeleton-line" }); const line2 = el("div", { class: "skeleton skeleton-line" }); line1.style.width = "70%"; line2.style.width = "90%"; const stats = el("div", { class: "toolbar" }, el("div", { class: "skeleton skeleton-pill" }), el("div", { class: "skeleton skeleton-pill" })); return el("div", { class: "card" }, cover, line1, line2, stats); } function renderSkeleton(count) { list.innerHTML = ""; for (let i = 0; i < count; i += 1) list.appendChild(skeletonCard()); } function renderEmpty(text) { list.innerHTML = ""; list.appendChild( el( "div", { class: "card", style: "grid-column: 1 / -1; text-align:center" }, el("div", {}, text || "暂无数据"), el( "div", { class: "toolbar", style: "justify-content:center" }, el( "button", { class: "btn", onclick: async () => { if (qInput) qInput.value = ""; if (typeSelect) typeSelect.value = ""; if (sortSelect) sortSelect.value = "latest"; page = 1; await load(); }, }, "清空筛选" ) ) ) ); } async function load() { if (!list) return; renderSkeleton(isHome ? 6 : 9); setLoading(true); const q = (qInput ? qInput.value : "").trim(); const type = (typeSelect ? typeSelect.value : "").trim(); const sort = (sortSelect ? sortSelect.value : "").trim() || "latest"; const params = new URLSearchParams(); if (q) params.set("q", q); if (type) params.set("type", type); if (!isHome) params.set("sort", sort); params.set("page", String(page)); params.set("pageSize", isHome ? "9" : "12"); let data = null; try { data = await apiFetch(`/resources?${params.toString()}`); } finally { setLoading(false); } list.innerHTML = ""; const items = (data && data.items) || []; if (!items.length) { renderEmpty(q || type ? "没有找到匹配的资源" : "暂无资源"); if (pageInfo) pageInfo.textContent = ""; if (pager) pager.style.display = isHome ? "none" : ""; if (homeMore) homeMore.style.display = isHome ? "" : "none"; return; } items.forEach((r) => { const badgeClass = r.type === "VIP" ? "badge badge-vip" : "badge badge-free"; const badgeIcon = r.type === "VIP" ? "ri-vip-crown-line" : "ri-price-tag-3-line"; const cover = r.coverUrl ? el("div", { class: "resource-card-cover" }, el("img", { class: "resource-card-cover-img", src: r.coverUrl, alt: r.title })) : null; const tags = Array.isArray(r.tags) ? r.tags.slice(0, 4) : []; const tagRow = tags.length ? el("div", { class: "resource-card-tags" }, ...tags.map((t) => badge(t, "badge"))) : null; list.appendChild( el( "div", { class: "card", style: "display:flex; flex-direction:column; height:100%;" }, cover, el( "div", { style: "flex:1;" }, el("span", { class: badgeClass, style: "float:right; display:flex; align-items:center; gap:4px;" }, el("i", {class: badgeIcon}), r.type === "VIP" ? "VIP" : "FREE"), el("h3", { style: "margin-top:0;" }, r.title) ), tagRow, el("div", { class: "muted", style: "margin-bottom:16px; flex:1;" }, (r.summary || "").slice(0, 80)), el( "div", { class: "toolbar", style: "margin-top:auto;" }, el("a", { class: "btn btn-primary", style: "display:flex; align-items:center; gap:4px;", href: `/ui/resources/${r.id}` }, el("i", {class: "ri-eye-line"}), "查看详情"), el("span", { class: "muted", style: "display:flex; align-items:center; gap:8px; font-size:0.9rem;" }, el("span", {style:"display:flex; align-items:center; gap:2px;"}, el("i", {class: "ri-bar-chart-box-line"}), String(r.viewCount)), el("span", {style:"display:flex; align-items:center; gap:2px;"}, el("i", {class: "ri-download-cloud-2-line"}), String(r.downloadCount)) ) ) ) ); }); const totalPages = Math.max(Math.ceil((data.total || 0) / (data.pageSize || 12)), 1); if (pageInfo) pageInfo.textContent = isHome ? "" : `第 ${data.page} / ${totalPages} 页,共 ${data.total} 条`; if (prevBtn) prevBtn.disabled = isHome || page <= 1; if (nextBtn) nextBtn.disabled = isHome || page >= totalPages; if (pager) pager.style.display = isHome ? "none" : ""; if (homeMore) homeMore.style.display = isHome ? "" : "none"; } if (prevBtn) prevBtn.addEventListener("click", async () => { page = Math.max(page - 1, 1); await load(); }); if (nextBtn) nextBtn.addEventListener("click", async () => { page += 1; await load(); }); if (searchBtn) searchBtn.addEventListener("click", async () => { page = 1; await load(); }); if (sortSelect) sortSelect.addEventListener("change", async () => { page = 1; await load(); }); if (qInput) qInput.addEventListener("keydown", async (e) => { if (e.key !== "Enter") return; page = 1; await load(); }); await load(); } async function pageLogin() { const phone = document.getElementById("phone"); const password = document.getElementById("password"); const btn = document.getElementById("loginBtn"); const msg = document.getElementById("msg"); const toRegister = document.getElementById("toRegister"); const next = nextFromQuery(); if (toRegister) toRegister.setAttribute("href", `/ui/register?next=${next ? encodeURIComponent(next) : currentNextParam()}`); function setMsg(text) { if (!msg) return; if (!text) { msg.style.display = "none"; msg.textContent = ""; return; } msg.style.display = ""; msg.textContent = String(text); } async function submit() { setMsg(""); const phoneVal = String(phone.value || "").trim(); const passwordVal = String(password.value || ""); if (!/^\d{6,20}$/.test(phoneVal)) { setMsg("请输入正确的手机号"); phone.focus(); return; } if (!passwordVal) { setMsg("请输入密码"); password.focus(); return; } const original = btn.textContent; btn.disabled = true; btn.textContent = "登录中…"; try { await apiFetch("/auth/login", { method: "POST", body: { phone: phoneVal, password: passwordVal }, }); showToastSuccess("登录成功"); window.location.href = next || "/ui/me"; } catch (e) { setMsg(`登录失败:${e.detail?.error || e.status || "unknown"}`); } finally { btn.disabled = false; btn.textContent = original; } } try { const me = await apiFetch("/me"); if (me && me.user) { window.location.href = next || "/ui/me"; return; } } catch (e) {} btn.addEventListener("click", submit); phone.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); password.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); } async function pageRegister() { const phone = document.getElementById("phone"); const password = document.getElementById("password"); const btn = document.getElementById("registerBtn"); const msg = document.getElementById("msg"); const toLogin = document.getElementById("toLogin"); const next = nextFromQuery(); if (toLogin) toLogin.setAttribute("href", `/ui/login?next=${next ? encodeURIComponent(next) : currentNextParam()}`); function setMsg(text) { if (!msg) return; if (!text) { msg.style.display = "none"; msg.textContent = ""; return; } msg.style.display = ""; msg.textContent = String(text); } async function submit() { setMsg(""); const phoneVal = String(phone.value || "").trim(); const passwordVal = String(password.value || ""); if (!/^\d{6,20}$/.test(phoneVal)) { setMsg("请输入正确的手机号"); phone.focus(); return; } if (String(passwordVal).length < 6) { setMsg("密码至少 6 位"); password.focus(); return; } const original = btn.textContent; btn.disabled = true; btn.textContent = "注册中…"; try { await apiFetch("/auth/register", { method: "POST", body: { phone: phoneVal, password: passwordVal }, }); showToastSuccess("注册成功"); window.location.href = next || "/ui/me"; } catch (e) { setMsg(`注册失败:${e.detail?.error || e.status || "unknown"}`); } finally { btn.disabled = false; btn.textContent = original; } } btn.addEventListener("click", submit); phone.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); password.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); } async function pageMe() { const meInfo = document.getElementById("meInfo"); const orderList = document.getElementById("orderList"); const logoutBtn = document.getElementById("logoutBtn"); const orderSection = document.getElementById("orderSection"); const downloadSection = document.getElementById("downloadSection"); const downloadList = document.getElementById("downloadList"); const downloadPager = document.getElementById("downloadPager"); const meMsg = document.getElementById("meMsg"); let downloadPage = 1; const downloadPageSize = 10; async function loadDownloads(page) { if (!downloadList) return; downloadPage = Math.max(1, parseInt(page || 1, 10) || 1); downloadList.innerHTML = ""; downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" })); downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" })); if (downloadPager) downloadPager.innerHTML = ""; const q = new URLSearchParams(); q.set("page", String(downloadPage)); q.set("pageSize", String(downloadPageSize)); const data = await apiFetch(`/me/downloads?${q.toString()}`); const items = (data && data.items) || []; const total = parseInt(data.total || 0, 10) || 0; const totalPages = Math.max(1, Math.ceil(total / downloadPageSize)); downloadList.innerHTML = ""; if (!items.length) { downloadList.appendChild( el( "div", { style: "text-align: center; padding: 28px 16px; color: var(--muted);" }, el("i", { class: "ri-inbox-line", style: "font-size: 2.2rem; margin-bottom: 12px; opacity: 0.5;" }), el("div", { style: "font-size: 1.05rem; margin-bottom: 6px;" }, "暂无下载记录"), el("div", { style: "font-size: 0.9rem;" }, "下载过资源后会显示在这里。") ) ); } else { items.forEach((it) => { let stateBadge = el("span", { class: "badge badge-success" }, "可用"); if (it.resourceState === "DELETED") stateBadge = el("span", { class: "badge badge-danger" }, "资源已删除"); else if (it.resourceState === "OFFLINE") stateBadge = el("span", { class: "badge badge-warning" }, "资源已下架"); const typeBadge = el( "span", { class: `badge ${it.resourceType === "VIP" ? "badge-vip" : "badge-free"}` }, `下载时:${it.resourceType === "VIP" ? "VIP" : "免费"}` ); const currentType = it.currentResourceType || ""; const currentTypeBadge = currentType && it.resourceState !== "DELETED" ? el( "span", { class: `badge ${currentType === "VIP" ? "badge-vip" : "badge-free"}` }, `当前:${currentType === "VIP" ? "VIP" : "免费"}` ) : null; const driftBadge = currentType && it.resourceType && currentType !== it.resourceType && it.resourceState === "ONLINE" ? el("span", { class: "badge badge-warning" }, "类型已变更") : null; const canOpenDetail = !!it.resourceId && it.resourceState === "ONLINE"; const titleNode = canOpenDetail ? el( "a", { href: `/ui/resources/${it.resourceId}`, style: "color: inherit; text-decoration: none;" }, it.resourceTitle || "" ) : el("span", { class: "muted" }, it.resourceTitle || ""); downloadList.appendChild( el( "div", { style: "border: 1px solid var(--border); border-radius: 12px; padding: 16px; background: var(--bg); display: flex; flex-direction: column; gap: 10px;" }, el( "div", { style: "display: flex; justify-content: space-between; align-items: center; gap: 12px;" }, el("div", { style: "font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" }, titleNode), stateBadge ), el( "div", { class: "muted", style: "display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 0.9rem;" }, typeBadge, currentTypeBadge, driftBadge, el("span", {}, `Ref:${it.ref || ""}`), el("span", {}, `下载:${formatDateTime(it.downloadedAt)}`) ) ) ); }); } if (downloadPager) { const prevBtn = el("button", { class: "btn", disabled: downloadPage <= 1 }, "上一页"); const nextBtn = el("button", { class: "btn", disabled: downloadPage >= totalPages }, "下一页"); prevBtn.addEventListener("click", async () => loadDownloads(downloadPage - 1)); nextBtn.addEventListener("click", async () => loadDownloads(downloadPage + 1)); downloadPager.appendChild(el("div", { class: "muted" }, `第 ${downloadPage} / ${totalPages} 页 · 共 ${total} 条`)); downloadPager.appendChild(el("div", { style: "display: flex; gap: 8px;" }, prevBtn, nextBtn)); } } async function load() { if (meMsg) { meMsg.style.display = "none"; meMsg.textContent = ""; } meInfo.innerHTML = ""; meInfo.appendChild(el("div", { class: "skeleton skeleton-line", style: "width: 60%;" })); meInfo.appendChild(el("div", { class: "skeleton skeleton-line", style: "width: 45%;" })); if (orderList) { orderList.innerHTML = ""; orderList.appendChild(el("div", { class: "card skeleton", style: "height: 96px;" })); orderList.appendChild(el("div", { class: "card skeleton", style: "height: 96px;" })); } if (downloadList) { downloadList.innerHTML = ""; downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" })); downloadList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" })); } if (downloadPager) downloadPager.innerHTML = ""; const data = await apiFetch("/me"); if (!data.user) { meInfo.innerHTML = ""; meInfo.appendChild(el("div", {}, "未登录")); meInfo.appendChild(el("div", { class: "muted" }, "登录后可查看会员状态与订单记录。")); meInfo.appendChild(el("div", { class: "toolbar" }, el("a", { class: "btn btn-primary", href: `/ui/login?next=${currentNextParam()}` }, "去登录"), el("a", { class: "btn", href: `/ui/register?next=${currentNextParam()}` }, "去注册"))); if (orderSection) orderSection.style.display = "none"; if (logoutBtn) logoutBtn.style.display = "none"; if (downloadSection) downloadSection.style.display = "none"; if (meMsg) { meMsg.style.display = ""; meMsg.textContent = "提示:未登录"; } if (orderList) orderList.innerHTML = ""; if (downloadList) downloadList.innerHTML = ""; if (downloadPager) downloadPager.innerHTML = ""; return; } if (orderSection) orderSection.style.display = ""; if (logoutBtn) logoutBtn.style.display = ""; if (downloadSection) downloadSection.style.display = ""; meInfo.innerHTML = ""; meInfo.appendChild(el("div", { style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;" }, el("i", { class: "ri-smartphone-line", style: "color: var(--muted);" }), `手机号:${data.user.phone}`)); meInfo.appendChild(el("div", { style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;" }, el("i", { class: "ri-vip-crown-line", style: "color: var(--muted);" }), `状态:`, el("span", { class: data.user.vipActive ? "badge badge-success" : "badge badge-danger", style: "margin-left: -4px;" }, data.user.vipActive ? "VIP 有效" : "无/已过期"))); meInfo.appendChild(el("div", { class: "muted", style: "display: flex; align-items: center; gap: 8px;" }, el("i", { class: "ri-calendar-event-line" }), `到期时间:${formatDateTime(data.user.vipExpireAt)}`)); const orders = await apiFetch("/orders"); orderList.innerHTML = ""; const items = (orders && orders.items) || []; if (!items.length) { orderList.appendChild( el("div", { style: "text-align: center; padding: 40px 20px; color: var(--muted);" }, el("i", { class: "ri-inbox-line", style: "font-size: 3rem; margin-bottom: 16px; opacity: 0.5;" }), el("div", { style: "font-size: 1.1rem; margin-bottom: 8px;" }, "暂无订单"), el("div", { style: "font-size: 0.9rem;" }, "购买会员后将显示订单记录。") ) ); } if (items.length) { items.forEach((o) => { let statusBadge; if (o.status === "PAID") statusBadge = el("span", { class: "badge badge-success" }, "已支付"); else if (o.status === "CLOSED") statusBadge = el("span", { class: "badge badge-danger" }, "已关闭"); else statusBadge = el("span", { class: "badge badge-info" }, o.status); orderList.appendChild( el( "div", { style: "border: 1px solid var(--border); border-radius: 12px; padding: 20px; display: flex; flex-direction: column; gap: 12px; background: var(--bg); transition: transform 0.2s, box-shadow 0.2s;" }, el("div", { style: "display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 12px;" }, el("div", { style: "font-weight: 500;" }, `订单号:${o.id}`), statusBadge ), el("div", { style: "display: flex; justify-content: space-between; align-items: center;" }, el("div", { style: "display: flex; align-items: center; gap: 8px;" }, el("i", { class: "ri-vip-crown-fill", style: "color: var(--brand);" }), el("span", { style: "font-weight: 500;" }, o.planSnapshot.name), el("span", { class: "muted", style: "font-size: 0.9rem;" }, `(${o.planSnapshot.durationDays} 天)`)), el("div", { style: "font-weight: bold; color: var(--brand); font-size: 1.1rem;" }, formatCents(o.amountCents)) ), el("div", { class: "muted", style: "font-size: 0.9rem; display: flex; justify-content: space-between; margin-top: 4px;" }, el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-time-line" }), `创建:${formatDateTime(o.createdAt)}`), el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-check-double-line" }), `支付:${formatDateTime(o.paidAt)}`) ) ) ); }); } await loadDownloads(1); } logoutBtn.addEventListener("click", async () => { await apiFetch("/auth/logout", { method: "POST" }); window.location.reload(); }); await load(); } async function pageMessages() { const messageList = document.getElementById("messageList"); const messagePager = document.getElementById("messagePager"); const messageUnreadBadge = document.getElementById("messageUnreadBadge"); const messageUnreadOnlyBtn = document.getElementById("messageUnreadOnlyBtn"); const messageAllBtn = document.getElementById("messageAllBtn"); const messageMsg = document.getElementById("messageMsg"); let page = 1; const pageSize = 10; let unreadOnly = false; function setMsg(text, isError = true) { if (!messageMsg) return; messageMsg.style.display = ""; messageMsg.className = `form-msg ${isError ? "form-msg-error" : "form-msg-success"}`; messageMsg.textContent = String(text || ""); } function clearMsg() { if (!messageMsg) return; messageMsg.style.display = "none"; messageMsg.textContent = ""; } function updateUnreadBadge(cnt) { if (!messageUnreadBadge) return; const n = parseInt(cnt || 0, 10) || 0; if (n > 0) { messageUnreadBadge.style.display = ""; messageUnreadBadge.textContent = `${n} 未读`; } else { messageUnreadBadge.style.display = "none"; messageUnreadBadge.textContent = ""; } } function updateToggleButtons() { if (messageUnreadOnlyBtn) messageUnreadOnlyBtn.className = `btn btn-sm ${unreadOnly ? "btn-primary" : ""}`.trim(); if (messageAllBtn) messageAllBtn.className = `btn btn-sm ${!unreadOnly ? "btn-primary" : ""}`.trim(); } async function loadMessages(targetPage) { if (!messageList) return; page = Math.max(1, parseInt(targetPage || 1, 10) || 1); clearMsg(); messageList.innerHTML = ""; messageList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" })); messageList.appendChild(el("div", { class: "card skeleton", style: "height: 72px;" })); if (messagePager) messagePager.innerHTML = ""; const q = new URLSearchParams(); q.set("page", String(page)); q.set("pageSize", String(pageSize)); if (unreadOnly) q.set("unread", "1"); let data; try { data = await apiFetch(`/me/messages?${q.toString()}`); } catch (e) { if (e.status === 401) { setMsg("未登录,无法查看消息。"); messageList.innerHTML = ""; messageList.appendChild(el("div", { class: "toolbar" }, el("a", { class: "btn btn-primary", href: `/ui/login?next=${currentNextParam()}` }, "去登录"))); return; } setMsg(e.detail?.error || e.status || "消息加载失败"); messageList.innerHTML = ""; return; } const items = (data && data.items) || []; const total = parseInt(data.total || 0, 10) || 0; const unreadCount = parseInt(data.unreadCount || 0, 10) || 0; const totalPages = Math.max(1, Math.ceil(total / pageSize)); updateUnreadBadge(unreadCount); updateToggleButtons(); if (typeof window.__refreshMessageBadge === "function") { try { await window.__refreshMessageBadge(); } catch (e) {} } messageList.innerHTML = ""; if (!items.length) { messageList.appendChild( el( "div", { style: "text-align: center; padding: 28px 16px; color: var(--muted);" }, el("i", { class: "ri-inbox-line", style: "font-size: 2.2rem; margin-bottom: 12px; opacity: 0.5;" }), el("div", { style: "font-size: 1.05rem; margin-bottom: 6px;" }, unreadOnly ? "暂无未读消息" : "暂无消息"), el("div", { style: "font-size: 0.9rem;" }, "系统通知会显示在这里。") ) ); } else { items.forEach((m) => { const read = !!m.read; const statusBadge = read ? el("span", { class: "badge" }, "已读") : el("span", { class: "badge badge-warning" }, "未读"); const actions = !read ? el( "button", { class: "btn btn-sm", onclick: async () => { try { await apiFetch(`/me/messages/${m.id}/read`, { method: "PUT" }); await loadMessages(page); } catch (e) { showToastError(e?.detail?.error || e?.status || "标记失败"); } }, }, "标记已读" ) : null; messageList.appendChild( el( "div", { style: "border: 1px solid var(--border); border-radius: 12px; padding: 16px; background: var(--bg); display: flex; flex-direction: column; gap: 10px;" }, el( "div", { style: "display: flex; justify-content: space-between; align-items: center; gap: 12px;" }, el("div", { style: "font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" }, m.title || "通知"), el("div", { style: "display: flex; align-items: center; gap: 8px; flex: 0 0 auto;" }, statusBadge, actions) ), el("div", { class: "muted", style: "white-space: pre-wrap;" }, formatMessageText(m.content || "")), el("div", { class: "muted", style: "font-size: 0.9rem;" }, `发送时间:${formatDateTime(m.createdAt)}`) ) ); }); } if (messagePager) { const prevBtn = el("button", { class: "btn", disabled: page <= 1 }, "上一页"); const nextBtn = el("button", { class: "btn", disabled: page >= totalPages }, "下一页"); prevBtn.addEventListener("click", async () => loadMessages(page - 1)); nextBtn.addEventListener("click", async () => loadMessages(page + 1)); messagePager.appendChild(el("div", { class: "muted" }, `第 ${page} / ${totalPages} 页 · 共 ${total} 条`)); messagePager.appendChild(el("div", { style: "display: flex; gap: 8px;" }, prevBtn, nextBtn)); } } if (messageUnreadOnlyBtn) { messageUnreadOnlyBtn.addEventListener("click", async () => { unreadOnly = true; await loadMessages(1); }); } if (messageAllBtn) { messageAllBtn.addEventListener("click", async () => { unreadOnly = false; await loadMessages(1); }); } updateToggleButtons(); await loadMessages(1); } async function pageVip() { const planList = document.getElementById("planList"); const vipMsg = document.getElementById("vipMsg"); const vipStatus = document.getElementById("vipStatus"); let me = null; try { me = await apiFetch("/me"); } catch (e) { me = { user: null }; } const user = me && me.user ? me.user : null; if (vipStatus) { vipStatus.style.display = ""; vipStatus.innerHTML = ""; if (!user) { vipStatus.appendChild( el("div", { style: "display: flex; flex-direction: column; align-items: center; padding: 16px;" }, el("div", { style: "margin-bottom: 16px; font-size: 1.1rem; color: var(--brand);" }, "未登录。登录后可购买/续费会员,并查看权益状态。"), el("a", { class: "btn btn-primary", href: `/ui/login?next=${currentNextParam()}`, style: "display: inline-flex; align-items: center; gap: 8px; border-radius: 20px; padding: 8px 24px;" }, el("i", { class: "ri-login-box-line" }), "去登录" ) ) ); } else { vipStatus.appendChild( el("div", { style: "display: flex; justify-content: space-around; flex-wrap: wrap; gap: 16px; padding: 16px;" }, el("div", { style: "display: flex; flex-direction: column; align-items: center; gap: 8px;" }, el("div", { class: "muted", style: "font-size: 0.9rem;" }, "当前账号"), el("div", { style: "font-weight: 500; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-smartphone-line", style: "color: var(--brand);" }), user.phone) ), el("div", { style: "display: flex; flex-direction: column; align-items: center; gap: 8px;" }, el("div", { class: "muted", style: "font-size: 0.9rem;" }, "会员状态"), el("div", { style: "font-weight: 500; display: flex; align-items: center; gap: 4px;" }, el("i", { class: user.vipActive ? "ri-vip-crown-fill" : "ri-vip-crown-line", style: user.vipActive ? "color: #ffd700;" : "color: var(--muted);" }), el("span", { class: user.vipActive ? "badge badge-success" : "badge badge-danger", style: "margin-left: 4px;" }, user.vipActive ? "VIP 有效" : "无/已过期") ) ), el("div", { style: "display: flex; flex-direction: column; align-items: center; gap: 8px;" }, el("div", { class: "muted", style: "font-size: 0.9rem;" }, "到期时间"), el("div", { style: "font-weight: 500; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-calendar-event-line", style: "color: var(--brand);" }), formatDateTime(user.vipExpireAt)) ) ) ); } } planList.innerHTML = ""; planList.appendChild(el("div", { class: "card skeleton", style: "grid-column: 1 / -1; height: 120px;" })); const plans = await apiFetch("/plans"); planList.innerHTML = ""; plans.forEach((p) => { const isRecommended = plans[0] && p.id === plans[0].id; planList.appendChild( el( "div", { class: "card", style: `position: relative; overflow: hidden; padding: 32px 24px; border: 2px solid ${isRecommended ? 'var(--brand)' : 'transparent'}; box-shadow: var(--shadow-md); transition: transform 0.3s, box-shadow 0.3s; display: flex; flex-direction: column; height: 100%;` }, isRecommended ? el("div", { style: "position: absolute; top: 16px; right: -32px; background: var(--brand); color: white; padding: 4px 40px; transform: rotate(45deg); font-size: 0.85rem; font-weight: bold; box-shadow: var(--shadow);" }, "推荐") : null, el( "div", { style: "text-align: center; margin-bottom: 24px;" }, el("h3", { style: "margin: 0 0 8px 0; font-size: 1.5rem; color: var(--text);" }, p.name), el("div", { class: "muted" }, `有效期 ${p.durationDays} 天`) ), el( "div", { style: "text-align: center; margin-bottom: 32px;" }, el("span", { style: "font-size: 1.25rem; color: var(--brand); font-weight: bold;" }, "¥ "), el("span", { style: "font-size: 2.5rem; color: var(--brand); font-weight: bold; line-height: 1;" }, (p.priceCents / 100).toFixed(2)) ), el( "div", { style: "margin-top: auto;" }, el( "button", { class: isRecommended ? "btn btn-primary" : "btn", style: "width: 100%; height: 48px; font-size: 1.1rem; border-radius: 24px; display: flex; justify-content: center; align-items: center; gap: 8px;", "data-plan-id": String(p.id), }, el("i", { class: "ri-shopping-cart-2-line" }), "立即开通" ) ) ) ); }); planList.addEventListener("click", async (evt) => { const btn = evt.target.closest("button[data-plan-id]"); if (!btn) return; vipMsg.textContent = ""; const originalText = btn.textContent; btn.disabled = true; btn.textContent = "处理中…"; const planId = Number(btn.getAttribute("data-plan-id")); try { const order = await apiFetch("/orders", { method: "POST", body: { planId } }); const payResp = await apiFetch(`/orders/${order.id}/pay`, { method: "POST" }); if (payResp && payResp.payUrl) { vipMsg.textContent = "已发起支付宝支付,正在跳转…"; window.location.href = payResp.payUrl; return; } vipMsg.textContent = "支付成功(模拟),已发放会员权益。"; showToastSuccess("支付成功,会员权益已生效"); setTimeout(() => { window.location.href = "/ui/me"; }, 300); } catch (e) { if (e.status === 401) window.location.href = `/ui/login?next=${currentNextParam()}`; vipMsg.textContent = `下单/支付失败:${e.detail?.error || e.status || "unknown"}`; } finally { btn.disabled = false; btn.textContent = originalText; } }); } async function pageResourceDetail() { const root = document.getElementById("resourceDetail"); const resourceId = Number(root.getAttribute("data-resource-id")); const descRoot = document.getElementById("resourceDescription"); const refSelect = document.getElementById("refSelect"); const reloadRepo = document.getElementById("reloadRepo"); const treeEl = document.getElementById("tree"); const fileContent = document.getElementById("fileContent"); const breadcrumb = document.getElementById("breadcrumb"); const downloadBtn = document.getElementById("downloadBtn"); const repoModalBackdrop = document.getElementById("repoModalBackdrop"); const repoModalTitle = document.getElementById("repoModalTitle"); const repoModalClose = document.getElementById("repoModalClose"); const repoModalBody = document.getElementById("repoModalBody"); const repoModalFooter = document.getElementById("repoModalFooter"); let currentRef = ""; let currentPath = ""; let canEditRepo = false; let refKinds = { branches: new Set(), tags: new Set() }; let selectedFilePath = ""; let selectedFileContent = ""; let detail = null; let me = null; let inlineDownloadBtn = null; const repoWriteActionsEnabled = false; function closeRepoModal() { repoModalBackdrop.style.display = "none"; repoModalTitle.textContent = ""; repoModalBody.innerHTML = ""; repoModalFooter.innerHTML = ""; } function openRepoModal(title, bodyNodes, footerNodes, icon = "ri-code-line") { repoModalTitle.innerHTML = ""; repoModalTitle.appendChild(el("i", { class: icon })); repoModalTitle.appendChild(document.createTextNode(title)); repoModalBody.innerHTML = ""; repoModalFooter.innerHTML = ""; bodyNodes.forEach((n) => repoModalBody.appendChild(n)); footerNodes.forEach((n) => repoModalFooter.appendChild(n)); repoModalBackdrop.style.display = ""; } repoModalClose.addEventListener("click", closeRepoModal); repoModalBackdrop.addEventListener("click", (evt) => { if (evt.target === repoModalBackdrop) closeRepoModal(); }); function isBranchRef(ref) { return refKinds.branches.has(ref); } function setDownloadButtonLabel(btn, label) { if (!btn) return; const text = String(label || "").trim() || "下载 ZIP"; const iconClass = text.includes("开通会员") ? "ri-vip-crown-line" : "ri-download-cloud-2-line"; btn.innerHTML = ""; btn.appendChild(el("i", { class: iconClass })); btn.appendChild(document.createTextNode(text)); } function updateDownloadButton() { const user = me && me.user ? me.user : null; if (!user) { setDownloadButtonLabel(downloadBtn, "下载 ZIP"); setDownloadButtonLabel(inlineDownloadBtn, "下载 ZIP"); return; } if (detail && detail.type === "VIP" && !user.vipActive) { setDownloadButtonLabel(downloadBtn, "开通会员下载"); setDownloadButtonLabel(inlineDownloadBtn, "开通会员下载"); return; } setDownloadButtonLabel(downloadBtn, "下载 ZIP"); setDownloadButtonLabel(inlineDownloadBtn, "下载 ZIP"); } async function loadMe() { try { me = await apiFetch("/me"); } catch (e) { me = null; } updateDownloadButton(); } function setBreadcrumb(path) { breadcrumb.innerHTML = ""; const parts = path ? path.split("/") : []; const items = [{ name: "根目录", path: "" }]; let acc = ""; parts.forEach((p) => { acc = acc ? `${acc}/${p}` : p; items.push({ name: p, path: acc }); }); items.forEach((it, idx) => { const a = el("a", { href: "#" }, it.name); a.addEventListener("click", async (e) => { e.preventDefault(); currentPath = it.path; await loadTree(); }); breadcrumb.appendChild(a); if (idx < items.length - 1) breadcrumb.appendChild(el("span", { class: "muted" }, "/")); }); } async function loadDetail() { const r = await apiFetch(`/resources/${resourceId}`); detail = r; inlineDownloadBtn = null; root.innerHTML = ""; if (descRoot) descRoot.innerHTML = ""; const coverCol = r.coverUrl ? el("img", { src: r.coverUrl, alt: r.title, style: "width: 100%; max-width: 320px; height: 240px; object-fit: cover; border-radius: 12px; box-shadow: var(--shadow);" }) : null; inlineDownloadBtn = el( "button", { class: "btn btn-primary", style: "margin-top: 14px; border-radius: 10px; display: inline-flex; align-items: center; gap: 6px; width: fit-content;" }, el("i", { class: "ri-download-cloud-2-line" }), "下载 ZIP" ); inlineDownloadBtn.addEventListener("click", downloadZip); const metaCol = el( "div", { style: "flex: 1; display: flex; flex-direction: column;" }, el("h1", { style: "margin: 0 0 16px 0; font-size: 2rem; color: var(--text);" }, r.title), el( "div", { style: "display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap;" }, r.type === "VIP" ? el("span", { class: "badge badge-danger", style: "padding: 6px 12px; font-size: 0.9rem;" }, el("i", { class: "ri-vip-crown-fill", style: "margin-right: 4px;" }), "VIP 专享") : el("span", { class: "badge badge-success", style: "padding: 6px 12px; font-size: 0.9rem;" }, el("i", { class: "ri-check-line", style: "margin-right: 4px;" }), "免费资源"), el("span", { class: "badge badge-info", style: "padding: 6px 12px; font-size: 0.9rem;" }, el("i", { class: "ri-hashtag", style: "margin-right: 4px;" }), `资源 ID: ${r.id}`), ...((r.tags || []).slice(0, 8).map((t) => el("span", { class: "badge", style: "padding: 6px 12px; font-size: 0.9rem; background: var(--bg); border: 1px solid var(--border);" }, t))) ), el("div", { class: "muted", style: "display: flex; gap: 16px; margin-bottom: 8px;" }, el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-eye-line" }), `浏览 ${r.viewCount}`), el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-download-cloud-2-line" }), `下载 ${r.downloadCount}`) ), el("div", { class: "muted", style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-github-line" }), `仓库: ${r.repo.owner}/${r.repo.name}${r.repo.private ? " (私有)" : ""}`), inlineDownloadBtn ); const headerRow = el("div", { style: "display: flex; gap: 32px; flex-wrap: wrap; margin-bottom: 32px;" }, coverCol, metaCol); root.appendChild( el( "div", { class: "card", style: "padding: 32px; border-radius: 16px; box-shadow: var(--shadow-md);" }, headerRow ) ); if (descRoot) { const summary = el("div", { class: "md", style: "line-height: 1.8; color: var(--text-light);", html: renderMarkdown(r.summary || "暂无描述") }); descRoot.appendChild( el( "div", { class: "card", style: "margin-top:24px; padding: 32px; border-radius: 16px; box-shadow: var(--shadow-md);" }, el("h3", { style: "display: flex; align-items: center; gap: 8px; margin-bottom: 16px;" }, el("i", { class: "ri-file-text-line", style: "color: var(--brand);" }), "资源描述"), summary ) ); } updateDownloadButton(); } async function loadRefs() { const refs = await apiFetch(`/resources/${resourceId}/repo/refs`); refSelect.innerHTML = ""; refKinds = { branches: new Set(), tags: new Set() }; const branchGroup = document.createElement("optgroup"); branchGroup.label = "分支"; (refs.branches || []).forEach((b) => { const name = (b.name || "").trim(); if (!name) return; refKinds.branches.add(name); branchGroup.appendChild(el("option", { value: name }, name)); }); const tagGroup = document.createElement("optgroup"); tagGroup.label = "标签"; (refs.tags || []).forEach((t) => { const name = (t.name || "").trim(); if (!name) return; refKinds.tags.add(name); tagGroup.appendChild(el("option", { value: name }, name)); }); if (branchGroup.children.length) refSelect.appendChild(branchGroup); if (tagGroup.children.length) refSelect.appendChild(tagGroup); currentRef = refSelect.value; } async function loadTree() { fileContent.textContent = ""; selectedFilePath = ""; selectedFileContent = ""; setBreadcrumb(currentPath); treeEl.innerHTML = ""; const params = new URLSearchParams(); params.set("ref", currentRef); params.set("path", currentPath); const data = await apiFetch(`/resources/${resourceId}/repo/tree?${params.toString()}`); data.items.forEach((it) => { const rightText = String(it.path || ""); const isLocked = it.type !== "dir" && it.guestAllowed === false; const rightNode = isLocked ? el( "div", { class: "muted tree-locked", style: "font-size: 0.85rem; display: flex; align-items: center; gap: 6px;" }, el("i", { class: "ri-lock-2-line" }), "需登录" ) : rightText && rightText !== it.name ? el("div", { class: "muted", style: "font-size: 0.85rem;" }, rightText) : null; const row = el( "div", { class: `card${isLocked ? " is-locked" : ""}` }, el("div", { style: "display: flex; align-items: center; gap: 8px; font-weight: 500;" }, el("i", { class: it.type === "dir" ? "ri-folder-3-fill" : "ri-file-text-line", style: `font-size: 1.2rem; color: ${it.type === "dir" ? "#fbbf24" : "var(--muted)"};` }), it.name ), rightNode ); row.addEventListener("click", async () => { if (it.type === "dir") { currentPath = it.path; await loadTree(); return; } if (it.guestAllowed === false) { Swal.fire({ title: '需要登录', text: '未登录仅可预览文档/配置等普通文本文件', icon: 'info', showCancelButton: true, confirmButtonText: '去登录', cancelButtonText: '取消', confirmButtonColor: 'var(--brand)' }).then((result) => { if (result.isConfirmed) { window.location.href = `/ui/login?next=${currentNextParam()}`; } }); return; } const p = new URLSearchParams(); p.set("ref", currentRef); p.set("path", it.path); try { const f = await apiFetch(`/resources/${resourceId}/repo/file?${p.toString()}`); fileContent.textContent = f.content; selectedFilePath = it.path; selectedFileContent = f.content; } catch (e) { if (e.status === 401 && e.detail?.error === "login_required") { Swal.fire({ title: '需要登录', text: '未登录仅可预览文档/配置等普通文本文件', icon: 'info', showCancelButton: true, confirmButtonText: '去登录', cancelButtonText: '取消', confirmButtonColor: 'var(--brand)' }).then((result) => { if (result.isConfirmed) { window.location.href = `/ui/login?next=${currentNextParam()}`; } }); return; } fileContent.textContent = `无法预览:${e.detail?.error || e.status || "unknown"}`; } }); treeEl.appendChild(row); }); } async function downloadZip() { const user = me && me.user ? me.user : null; if (!user) { Swal.fire({ title: '未登录', text: '下载资源需要先登录账户', icon: 'info', showCancelButton: true, confirmButtonText: '去登录', cancelButtonText: '取消', confirmButtonColor: 'var(--brand)' }).then((result) => { if (result.isConfirmed) { window.location.href = `/ui/login?next=${currentNextParam()}`; } }); return; } if (detail && detail.type === "VIP" && !user.vipActive) { Swal.fire({ title: 'VIP 专享资源', text: '该资源为 VIP 专属,需要开通会员后才能下载。', icon: 'warning', showCancelButton: true, confirmButtonText: '去开通会员', cancelButtonText: '取消', confirmButtonColor: '#ffd700', iconColor: '#ffd700' }).then((result) => { if (result.isConfirmed) { window.location.href = "/ui/vip"; } }); return; } if (downloadBtn) downloadBtn.disabled = true; if (inlineDownloadBtn) inlineDownloadBtn.disabled = true; Swal.fire({ title: "正在准备下载", text: "请稍候…", allowOutsideClick: false, allowEscapeKey: false, showConfirmButton: false, didOpen: () => { Swal.showLoading(); }, }); try { const resp = await apiFetch(`/resources/${resourceId}/download`, { method: "POST", body: { ref: currentRef }, }); const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `resource-${resourceId}-${currentRef}.zip`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); Swal.close(); } catch (e) { Swal.close(); if (e.status === 401) window.location.href = `/ui/login?next=${currentNextParam()}`; if (e.status === 403 && e.detail?.error === "vip_required") { Swal.fire({ title: 'VIP 专享资源', text: '该资源为 VIP 专属,需要开通会员后才能下载。', icon: 'warning', showCancelButton: true, confirmButtonText: '去开通会员', cancelButtonText: '取消', confirmButtonColor: '#ffd700', iconColor: '#ffd700' }).then((result) => { if (result.isConfirmed) { window.location.href = "/ui/vip"; } }); return; } Swal.fire({ icon: 'error', title: '下载失败', text: e.detail?.error || e.status || "未知错误" }); } finally { if (downloadBtn) downloadBtn.disabled = false; if (inlineDownloadBtn) inlineDownloadBtn.disabled = false; } } refSelect.addEventListener("change", async () => { currentRef = refSelect.value; currentPath = ""; await loadTree(); }); reloadRepo.addEventListener("click", async () => { await loadRefs(); currentPath = ""; await loadTree(); }); downloadBtn.addEventListener("click", downloadZip); const toolbar = downloadBtn.closest(".toolbar"); const commitsBtn = el("button", { class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-history-line" }), "提交历史"); toolbar.insertBefore(commitsBtn, downloadBtn); async function showCommits() { const p = new URLSearchParams(); p.set("ref", currentRef); const focusPath = selectedFilePath || currentPath || ""; if (focusPath) p.set("path", focusPath); p.set("limit", "20"); const msg = el("div", { class: "muted" }, "加载中…"); openRepoModal("提交历史", [msg], [el("button", { class: "btn", onclick: closeRepoModal }, "关闭")], "ri-history-line"); try { const data = await apiFetch(`/resources/${resourceId}/repo/commits?${p.toString()}`); const items = data.items || []; if (!items.length) { msg.textContent = "没有找到提交记录"; return; } msg.remove(); const list = el("div", {}); items.forEach((it) => { const sha = String(it.sha || ""); list.appendChild( el( "div", { class: "card", style: "margin-bottom:12px; padding: 16px; border-left: 3px solid var(--brand); border-radius: 8px;" }, el("div", { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;" }, el("div", { style: "font-weight: 500; font-size: 1.05rem;" }, String(it.subject || "")), el("span", { class: "badge", style: "font-family: monospace; font-size: 0.85rem;" }, sha.slice(0, 7)) ), el("div", { class: "muted", style: "display: flex; align-items: center; gap: 8px; font-size: 0.9rem;" }, el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-user-line" }), it.authorName || ""), el("span", { style: "display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-time-line" }), formatDateTime(it.authorDate)) ) ) ); }); repoModalBody.appendChild(list); } catch (e) { msg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`; } } commitsBtn.addEventListener("click", showCommits); try { await apiFetch("/admin/settings"); canEditRepo = true; } catch (e) { canEditRepo = false; } if (canEditRepo && repoWriteActionsEnabled) { const createBtn = el("button", { class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-file-add-line" }), "新建文件"); const editBtn = el("button", { class: "btn", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-edit-line" }), "在线编辑"); const delBtn = el("button", { class: "btn btn-danger", style: "border-radius: 8px; display: flex; align-items: center; gap: 4px;" }, el("i", { class: "ri-delete-bin-line" }), "删除"); function requireBranchOrToast() { if (isBranchRef(currentRef)) return true; Swal.fire({ icon: 'warning', title: '操作受限', text: '仅支持在分支上进行编辑或提交操作' }); return false; } function requireSelectedFileOrToast() { if (selectedFilePath) return true; Swal.fire({ icon: 'info', title: '未选择文件', text: '请先在左侧目录结构中选择一个文件' }); return false; } async function createFile() { if (!requireBranchOrToast()) return; const pathInput = el("input", { class: "input", placeholder: "例如:README.md 或 docs/intro.md" }); const defaultPath = currentPath ? `${currentPath.replace(/\\/g, "/").replace(/\/+$/, "")}/new-file.txt` : "new-file.txt"; pathInput.value = defaultPath; const msgInput = el("input", { class: "input", placeholder: "提交信息,例如:Add new file" }); const ta = el("textarea", { class: "input", style: "min-height:260px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;" }); const msg = el("div", { class: "muted" }); const saveBtn = el("button", { class: "btn btn-primary" }, "提交"); saveBtn.addEventListener("click", async () => { msg.textContent = ""; saveBtn.disabled = true; try { const res = await apiFetch(`/resources/${resourceId}/repo/file`, { method: "POST", body: { ref: currentRef, path: pathInput.value.trim(), content: ta.value, message: msgInput.value.trim() }, }); msg.textContent = `提交成功:${String(res.commit || "").slice(0, 10)}`; await loadTree(); setTimeout(closeRepoModal, 600); } catch (e) { msg.textContent = `提交失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`; } finally { saveBtn.disabled = false; } }); openRepoModal( "新建文件", [ el("div", { style: "display: flex; flex-direction: column; gap: 16px;" }, el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件路径"), pathInput), el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "提交信息"), msgInput), el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件内容"), ta), msg ) ], [el("button", { class: "btn", onclick: closeRepoModal }, "取消"), saveBtn], "ri-file-add-line" ); } async function editFile() { if (!requireBranchOrToast()) return; if (!requireSelectedFileOrToast()) return; const pathText = el("input", { class: "input", value: selectedFilePath, disabled: true, style: "background: #f1f5f9; color: var(--muted); cursor: not-allowed;" }); const msgInput = el("input", { class: "input", placeholder: "提交信息,例如:Update README" }); const ta = el("textarea", { class: "input", style: "min-height:320px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;" }, ""); ta.value = selectedFileContent || ""; const msg = el("div", { class: "muted" }); const saveBtn = el("button", { class: "btn btn-primary" }, "提交"); saveBtn.addEventListener("click", async () => { msg.textContent = ""; saveBtn.disabled = true; try { const res = await apiFetch(`/resources/${resourceId}/repo/file`, { method: "PUT", body: { ref: currentRef, path: selectedFilePath, content: ta.value, message: msgInput.value.trim() }, }); selectedFileContent = ta.value; msg.textContent = `提交成功:${String(res.commit || "").slice(0, 10)}`; await loadTree(); setTimeout(closeRepoModal, 600); } catch (e) { msg.textContent = `提交失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `\n${e.detail.message}` : ""}`; } finally { saveBtn.disabled = false; } }); openRepoModal( "在线编辑", [ el("div", { style: "display: flex; flex-direction: column; gap: 16px;" }, el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件路径"), pathText), el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "提交信息"), msgInput), el("div", { style: "display: flex; flex-direction: column; gap: 8px;" }, el("div", { class: "label", style: "font-weight: 600;" }, "文件内容"), ta), msg ) ], [el("button", { class: "btn", onclick: closeRepoModal }, "取消"), saveBtn], "ri-edit-line" ); } async function deleteFile() { if (!requireBranchOrToast()) return; if (!requireSelectedFileOrToast()) return; Swal.fire({ title: '确认删除?', text: `您即将删除文件:${selectedFilePath}`, icon: 'warning', input: 'text', inputPlaceholder: '提交信息,例如:Delete file', showCancelButton: true, confirmButtonColor: '#d33', cancelButtonColor: 'var(--border)', confirmButtonText: ' 确认删除', cancelButtonText: '取消', showLoaderOnConfirm: true, customClass: { cancelButton: 'btn', confirmButton: 'btn btn-danger' }, preConfirm: async (message) => { try { const res = await apiFetch(`/resources/${resourceId}/repo/file`, { method: "DELETE", body: { ref: currentRef, path: selectedFilePath, message: (message || "").trim() }, }); return res; } catch (e) { Swal.showValidationMessage(`删除失败:${e.detail?.error || e.status || "unknown"}${e.detail?.message ? `
${e.detail.message}` : ""}`); } }, allowOutsideClick: () => !Swal.isLoading() }).then(async (result) => { if (result.isConfirmed) { selectedFilePath = ""; selectedFileContent = ""; Swal.fire({ title: '删除成功!', text: `提交 ID:${String(result.value.commit || "").slice(0, 10)}`, icon: 'success', timer: 1500, showConfirmButton: false }); await loadTree(); } }); } createBtn.addEventListener("click", createFile); editBtn.addEventListener("click", editFile); delBtn.addEventListener("click", deleteFile); toolbar.insertBefore(btnGroup(createBtn, editBtn, delBtn), commitsBtn); } try { await loadDetail(); } catch (e) { root.innerHTML = ""; root.appendChild(el("div", { class: "card" }, `加载失败:${e.detail?.error || e.status || "unknown"}`)); return; } await loadMe(); try { await loadRefs(); await loadTree(); } catch (e) { breadcrumb.textContent = "仓库加载失败"; treeEl.innerHTML = ""; treeEl.appendChild(el("div", { class: "card", style: "margin: 8px; padding: 16px; border-radius: 12px;" }, `加载失败:${e.detail?.error || e.status || "unknown"}`)); fileContent.textContent = ""; } } async function pageAdminLogin() { const username = document.getElementById("username"); const password = document.getElementById("password"); const btn = document.getElementById("adminLoginBtn"); const msg = document.getElementById("msg"); btn.addEventListener("click", async () => { msg.textContent = ""; try { await apiFetch("/admin/auth/login", { method: "POST", body: { username: username.value.trim(), password: password.value }, }); window.location.href = "/ui/admin"; } catch (e) { msg.textContent = `登录失败:${e.detail?.error || e.status || "unknown"}`; } }); } function renderJsonCard(title, obj) { return el("div", { class: "card" }, el("div", {}, title), el("pre", { class: "code" }, JSON.stringify(obj, null, 2))); } async function pageAdmin() { const overviewRefreshBtn = document.getElementById("overviewRefreshBtn"); const overviewUpdatedAt = document.getElementById("overviewUpdatedAt"); const ovUsersTotal = document.getElementById("ovUsersTotal"); const ovUsersSub = document.getElementById("ovUsersSub"); const ovResourcesTotal = document.getElementById("ovResourcesTotal"); const ovResourcesSub = document.getElementById("ovResourcesSub"); const ovOrdersTotal = document.getElementById("ovOrdersTotal"); const ovOrdersSub = document.getElementById("ovOrdersSub"); const ovRevenueTotal = document.getElementById("ovRevenueTotal"); const ovRevenueSub = document.getElementById("ovRevenueSub"); const ovDownloadsTotal = document.getElementById("ovDownloadsTotal"); const ovDownloadsSub = document.getElementById("ovDownloadsSub"); const ovMessagesTotal = document.getElementById("ovMessagesTotal"); const ovMessagesSub = document.getElementById("ovMessagesSub"); const ovSystemInfo = document.getElementById("ovSystemInfo"); const createPlanOpenBtn = document.getElementById("createPlanOpenBtn"); const createResOpenBtn = document.getElementById("createResOpenBtn"); const resQ = document.getElementById("resQ"); const resTypeFilter = document.getElementById("resTypeFilter"); const resStatusFilter = document.getElementById("resStatusFilter"); const resSearchBtn = document.getElementById("resSearchBtn"); const resPrevPage = document.getElementById("resPrevPage"); const resNextPage = document.getElementById("resNextPage"); const resPageInfo = document.getElementById("resPageInfo"); const uploadsQ = document.getElementById("uploadsQ"); const uploadsFilterAll = document.getElementById("uploadsFilterAll"); const uploadsFilterUnused = document.getElementById("uploadsFilterUnused"); const uploadsFilterUsed = document.getElementById("uploadsFilterUsed"); const uploadsRefreshBtn = document.getElementById("uploadsRefreshBtn"); const uploadsUploadBtn = document.getElementById("uploadsUploadBtn"); const uploadsFile = document.getElementById("uploadsFile"); const uploadsCleanupBtn = document.getElementById("uploadsCleanupBtn"); const uploadsStats = document.getElementById("uploadsStats"); const uploadTbody = document.querySelector("#uploadTable tbody"); const orderQ = document.getElementById("orderQ"); const orderStatusFilter = document.getElementById("orderStatusFilter"); const orderCreateBtn = document.getElementById("orderCreateBtn"); const orderRefreshBtn = document.getElementById("orderRefreshBtn"); const orderPrevPage = document.getElementById("orderPrevPage"); const orderNextPage = document.getElementById("orderNextPage"); const orderPageInfo = document.getElementById("orderPageInfo"); const userQ = document.getElementById("userQ"); const userStatusFilter = document.getElementById("userStatusFilter"); const userVipFilter = document.getElementById("userVipFilter"); const userSearchBtn = document.getElementById("userSearchBtn"); const userPrevPage = document.getElementById("userPrevPage"); const userNextPage = document.getElementById("userNextPage"); const userPageInfo = document.getElementById("userPageInfo"); const dlQ = document.getElementById("dlQ"); const dlTypeFilter = document.getElementById("dlTypeFilter"); const dlStateFilter = document.getElementById("dlStateFilter"); const dlSearchBtn = document.getElementById("dlSearchBtn"); const dlPrevPage = document.getElementById("dlPrevPage"); const dlNextPage = document.getElementById("dlNextPage"); const dlPageInfo = document.getElementById("dlPageInfo"); const msgQ = document.getElementById("msgQ"); const msgReadFilter = document.getElementById("msgReadFilter"); const msgSenderFilter = document.getElementById("msgSenderFilter"); const msgSearchBtn = document.getElementById("msgSearchBtn"); const msgSendBtn = document.getElementById("msgSendBtn"); const msgBroadcastBtn = document.getElementById("msgBroadcastBtn"); const msgPrevPage = document.getElementById("msgPrevPage"); const msgNextPage = document.getElementById("msgNextPage"); const msgPageInfo = document.getElementById("msgPageInfo"); const msgTbody = document.querySelector("#msgTable tbody"); const settingsRefreshBtn = document.getElementById("settingsRefreshBtn"); const settingsSaveBtn = document.getElementById("settingsSaveBtn"); const cfgGogsSaveBtn = document.getElementById("cfgGogsSaveBtn"); const cfgGogsResetBtn = document.getElementById("cfgGogsResetBtn"); const cfgGogsBaseUrl = document.getElementById("cfgGogsBaseUrl"); const cfgGogsToken = document.getElementById("cfgGogsToken"); const cfgClearGogsToken = document.getElementById("cfgClearGogsToken"); const cfgPaySaveBtn = document.getElementById("cfgPaySaveBtn"); const cfgPayResetBtn = document.getElementById("cfgPayResetBtn"); const cfgPayProvider = document.getElementById("cfgPayProvider"); const cfgEnableMockPay = document.getElementById("cfgEnableMockPay"); const cfgPayApiKey = document.getElementById("cfgPayApiKey"); const cfgClearPayApiKey = document.getElementById("cfgClearPayApiKey"); const cfgAlipayFields = document.getElementById("cfgAlipayFields"); const cfgAlipayAppId = document.getElementById("cfgAlipayAppId"); const cfgAlipayGateway = document.getElementById("cfgAlipayGateway"); const cfgAlipayNotifyUrl = document.getElementById("cfgAlipayNotifyUrl"); const cfgAlipayReturnUrl = document.getElementById("cfgAlipayReturnUrl"); const cfgAlipayUseCurrentNotify = document.getElementById("cfgAlipayUseCurrentNotify"); const cfgAlipayUseCurrentReturn = document.getElementById("cfgAlipayUseCurrentReturn"); const cfgAlipayPrivateKey = document.getElementById("cfgAlipayPrivateKey"); const cfgClearAlipayPrivateKey = document.getElementById("cfgClearAlipayPrivateKey"); const cfgAlipayPublicKey = document.getElementById("cfgAlipayPublicKey"); const cfgClearAlipayPublicKey = document.getElementById("cfgClearAlipayPublicKey"); const cfgShowAlipayPrivateKey = document.getElementById("cfgShowAlipayPrivateKey"); const cfgShowAlipayPublicKey = document.getElementById("cfgShowAlipayPublicKey"); const cfgLlmSaveBtn = document.getElementById("cfgLlmSaveBtn"); const cfgLlmResetBtn = document.getElementById("cfgLlmResetBtn"); const cfgLlmProvider = document.getElementById("cfgLlmProvider"); const cfgLlmBaseUrl = document.getElementById("cfgLlmBaseUrl"); const cfgLlmModel = document.getElementById("cfgLlmModel"); const cfgLlmApiKey = document.getElementById("cfgLlmApiKey"); const cfgClearLlmApiKey = document.getElementById("cfgClearLlmApiKey"); const cfgDbActive = document.getElementById("cfgDbActive"); const cfgDbSaveBtn = document.getElementById("cfgDbSaveBtn"); const cfgDbResetBtn = document.getElementById("cfgDbResetBtn"); const cfgMysqlHost = document.getElementById("cfgMysqlHost"); const cfgMysqlPort = document.getElementById("cfgMysqlPort"); const cfgMysqlUser = document.getElementById("cfgMysqlUser"); const cfgMysqlPassword = document.getElementById("cfgMysqlPassword"); const cfgClearMysqlPassword = document.getElementById("cfgClearMysqlPassword"); const cfgMysqlDatabase = document.getElementById("cfgMysqlDatabase"); const cfgMysqlTestBtn = document.getElementById("cfgMysqlTestBtn"); const cfgDbSwitchMysqlBtn = document.getElementById("cfgDbSwitchMysqlBtn"); const cfgDbSwitchSqliteBtn = document.getElementById("cfgDbSwitchSqliteBtn"); const settingsMsg = document.getElementById("settingsMsg"); const cfgSearch = document.getElementById("cfgSearch"); const cfgGroupNav = document.getElementById("cfgGroupNav"); const settingsGroupsWrap = document.getElementById("settingsGroups"); const adminLogoutBtn = document.getElementById("adminLogoutBtn"); const menu = document.getElementById("adminMenu"); const contentTitle = document.getElementById("contentTitle"); const modalBackdrop = document.getElementById("adminModalBackdrop"); const modalTitle = document.getElementById("adminModalTitle"); const modalHeaderActions = document.getElementById("adminModalHeaderActions"); const modalClose = document.getElementById("adminModalClose"); const modalBody = document.getElementById("adminModalBody"); const modalFooter = document.getElementById("adminModalFooter"); const modalEl = modalBackdrop ? modalBackdrop.querySelector(".modal") : null; let currentModalOnResize = null; let currentModalBeforeClose = null; let currentModalOnKeydown = null; const planMap = new Map(); const resourceMap = new Map(); const userMap = new Map(); const orderMap = new Map(); const downloadLogMap = new Map(); const messageMap = new Map(); const resState = { page: 1, pageSize: 20, total: 0 }; const userState = { page: 1, pageSize: 20, total: 0 }; const orderState = { page: 1, pageSize: 20, total: 0 }; const downloadLogState = { page: 1, pageSize: 20, total: 0 }; const messageState = { page: 1, pageSize: 20, total: 0 }; const uploadsState = { filter: "all" }; let lastSettingsSnapshot = null; function formatBytes(bytes) { const n = Number(bytes || 0); if (!Number.isFinite(n) || n <= 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let v = n; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } const fixed = i === 0 ? 0 : v >= 10 ? 1 : 2; return `${v.toFixed(fixed)} ${units[i]}`; } async function copyText(text) { const s = String(text || ""); if (!s) return; try { await navigator.clipboard.writeText(s); showToastSuccess("已复制链接"); return; } catch (e) {} const ta = el("textarea", { style: "position:fixed; left:-9999px; top:-9999px;" }, s); document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); showToastSuccess("已复制链接"); } catch (e) { showToastError("复制失败"); } finally { ta.remove(); } } function setUploadsFilter(next) { uploadsState.filter = next; [uploadsFilterAll, uploadsFilterUnused, uploadsFilterUsed].forEach((b) => b.classList.remove("active")); if (next === "unused") uploadsFilterUnused.classList.add("active"); else if (next === "used") uploadsFilterUsed.classList.add("active"); else uploadsFilterAll.classList.add("active"); } async function loadUploads() { uploadsStats.textContent = ""; uploadTbody.innerHTML = ""; try { const params = new URLSearchParams(); const q = (uploadsQ.value || "").trim(); if (q) params.set("q", q); if (uploadsState.filter === "unused") params.set("used", "unused"); if (uploadsState.filter === "used") params.set("used", "used"); const resp = await apiFetch(`/admin/uploads?${params.toString()}`); const s = resp.stats || {}; uploadsStats.textContent = [ `共 ${s.totalCount ?? 0} 个文件(${formatBytes(s.totalBytes ?? 0)})`, `已引用 ${s.usedCount ?? 0} 个(${formatBytes(s.usedBytes ?? 0)})`, `未引用 ${s.unusedCount ?? 0} 个(${formatBytes(s.unusedBytes ?? 0)})`, ].join(" / "); const items = Array.isArray(resp.items) ? resp.items : []; items.forEach((it) => { const name = String(it.name || ""); const url = String(it.url || ""); const used = Boolean(it.used); const kind = String(it.kind || "file"); const preview = kind === "image" ? el("img", { class: "upload-thumb", src: url, alt: name, loading: "lazy" }) : kind === "video" ? badge("视频", "badge-warning") : badge("文件"); const usedBadge = used ? badge("已引用", "badge-success") : badge("未引用", "badge"); const tr = el( "tr", {}, el("td", {}, preview), el("td", {}, el("div", { class: "upload-name" }, name), el("div", { class: "muted upload-url" }, url)), el("td", {}, formatBytes(it.bytes || 0)), el("td", {}, formatDateTime(it.mtime || 0)), el("td", {}, usedBadge), el( "td", {}, btnGroup( el("button", { class: "btn btn-sm", onclick: () => copyText(url) }, "复制链接"), el( "button", { class: "btn btn-sm btn-danger", onclick: async () => { const r = await Swal.fire({ title: "删除文件?", text: `将删除:${name}`, icon: "warning", showCancelButton: true, confirmButtonText: "删除", cancelButtonText: "取消", confirmButtonColor: "var(--danger)", }); if (!r.isConfirmed) return; await apiFetch(`/admin/uploads/${encodeURIComponent(name)}`, { method: "DELETE" }); showToastSuccess("已删除"); await loadUploads(); }, }, "删除" ) ) ) ); uploadTbody.appendChild(tr); }); if (!items.length) renderEmptyRow(uploadTbody, 6, "暂无数据"); } catch (e) { uploadsStats.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } } async function loadSettings() { settingsMsg.textContent = ""; cfgGogsToken.value = ""; cfgClearGogsToken.checked = false; cfgPayApiKey.value = ""; cfgClearPayApiKey.checked = false; if (cfgAlipayPrivateKey) cfgAlipayPrivateKey.value = ""; if (cfgClearAlipayPrivateKey) cfgClearAlipayPrivateKey.checked = false; if (cfgShowAlipayPrivateKey) cfgShowAlipayPrivateKey.checked = false; if (cfgAlipayPrivateKey) cfgAlipayPrivateKey.classList.remove("is-revealed"); if (cfgAlipayPublicKey) cfgAlipayPublicKey.value = ""; if (cfgClearAlipayPublicKey) cfgClearAlipayPublicKey.checked = false; if (cfgShowAlipayPublicKey) cfgShowAlipayPublicKey.checked = false; if (cfgAlipayPublicKey) cfgAlipayPublicKey.classList.remove("is-revealed"); cfgLlmApiKey.value = ""; cfgClearLlmApiKey.checked = false; if (cfgMysqlPassword) cfgMysqlPassword.value = ""; if (cfgClearMysqlPassword) cfgClearMysqlPassword.checked = false; try { const resp = await apiFetch("/admin/settings"); lastSettingsSnapshot = resp; cfgGogsBaseUrl.value = (resp.gogsBaseUrl || "").trim(); if (resp.hasGogsToken) cfgGogsToken.placeholder = "已配置,留空保持不变"; else cfgGogsToken.placeholder = "未配置,填写后保存"; cfgPayProvider.value = (resp.payment?.provider || "MOCK").toUpperCase(); cfgEnableMockPay.checked = Boolean(resp.payment?.enableMockPay); if (resp.payment?.hasApiKey) cfgPayApiKey.placeholder = "已配置,留空保持不变"; else cfgPayApiKey.placeholder = "未配置,填写后保存"; if (cfgAlipayAppId) cfgAlipayAppId.value = (resp.payment?.alipay?.appId || "").trim(); if (cfgAlipayGateway) cfgAlipayGateway.value = (resp.payment?.alipay?.gateway || "").trim(); if (cfgAlipayNotifyUrl) cfgAlipayNotifyUrl.value = (resp.payment?.alipay?.notifyUrl || "").trim(); if (cfgAlipayReturnUrl) cfgAlipayReturnUrl.value = (resp.payment?.alipay?.returnUrl || "").trim(); if (cfgAlipayPrivateKey) { if (resp.payment?.alipay?.hasPrivateKey) cfgAlipayPrivateKey.placeholder = "已配置,留空保持不变"; else cfgAlipayPrivateKey.placeholder = "未配置,填写后保存"; } if (cfgAlipayPublicKey) { if (resp.payment?.alipay?.hasPublicKey) cfgAlipayPublicKey.placeholder = "已配置,留空保持不变"; else cfgAlipayPublicKey.placeholder = "未配置,填写后保存"; } cfgLlmProvider.value = (resp.llm?.provider || "").trim(); cfgLlmBaseUrl.value = (resp.llm?.baseUrl || "").trim(); cfgLlmModel.value = (resp.llm?.model || "").trim(); if (resp.llm?.hasApiKey) cfgLlmApiKey.placeholder = "已配置,留空保持不变"; else cfgLlmApiKey.placeholder = "未配置,填写后保存"; if (cfgMysqlHost) cfgMysqlHost.value = (resp.db?.mysql?.host || "").trim(); if (cfgMysqlPort) cfgMysqlPort.value = String(resp.db?.mysql?.port ?? "").trim(); if (cfgMysqlUser) cfgMysqlUser.value = (resp.db?.mysql?.user || "").trim(); if (cfgMysqlDatabase) cfgMysqlDatabase.value = (resp.db?.mysql?.database || "").trim(); if (cfgMysqlPassword) { if (resp.db?.mysql?.hasPassword) cfgMysqlPassword.placeholder = "已配置,留空保持不变"; else cfgMysqlPassword.placeholder = "未配置,填写后保存"; } if (cfgDbActive) cfgDbActive.textContent = `当前连接:${resp.db?.active || "-"}`; settingsMsg.textContent = [ `Gogs Token:${resp.hasGogsToken ? "已配置" : "未配置"}`, `支付 Key:${resp.payment?.hasApiKey ? "已配置" : "未配置"}`, `支付宝私钥:${resp.payment?.alipay?.hasPrivateKey ? "已配置" : "未配置"}`, `支付宝公钥:${resp.payment?.alipay?.hasPublicKey ? "已配置" : "未配置"}`, `大模型 Key:${resp.llm?.hasApiKey ? "已配置" : "未配置"}`, `MySQL Password:${resp.db?.mysql?.hasPassword ? "已配置" : "未配置"}`, ].join(" / "); updatePayProviderVisibility(); applySettingsFilter(); } catch (e) { settingsMsg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } } function updatePayProviderVisibility() { if (!cfgAlipayFields || !cfgPayProvider) return; const p = String(cfgPayProvider.value || "").trim().toUpperCase(); cfgAlipayFields.style.display = p === "ALIPAY" ? "" : "none"; } function listSettingGroups() { if (!settingsGroupsWrap) return []; return Array.from(settingsGroupsWrap.querySelectorAll(".collapse.settings-group")); } function listVisibleSettingGroups() { const groups = listSettingGroups(); if (!settingsGroupsWrap) return groups; if (settingsGroupsWrap.classList.contains("is-tabs")) { const active = groups.find((g) => g.classList.contains("is-active")); return active ? [active] : []; } return groups.filter((g) => g.style.display !== "none"); } function setSettingGroupOpen(groupEl, open) { if (!groupEl) return; groupEl.setAttribute("data-open", open ? "1" : "0"); } function setActiveSettingsGroup(targetSel, opts) { if (!settingsGroupsWrap) return; const target = String(targetSel || "").trim(); if (!target) return; const groups = listSettingGroups(); groups.forEach((g) => { g.classList.remove("is-active"); g.style.display = ""; }); const el = document.querySelector(target); if (!el) return; settingsGroupsWrap.classList.add("is-tabs"); settingsGroupsWrap.classList.remove("is-searching"); el.classList.add("is-active"); setSettingNavActive(target); try { localStorage.setItem("adminSettingsActiveGroup", target); } catch (e) {} if (opts && opts.open) setSettingGroupOpen(el, true); if (opts && opts.scroll) el.scrollIntoView({ block: "start", behavior: "smooth" }); } function getActiveSettingsGroupSel() { try { const v = localStorage.getItem("adminSettingsActiveGroup"); if (v && document.querySelector(v)) return v; } catch (e) {} const first = listSettingGroups().find((g) => g && g.id); return first ? `#${first.id}` : "#cfgGroupGogs"; } function setSettingNavActive(targetSel) { if (!cfgGroupNav) return; cfgGroupNav.querySelectorAll(".btn").forEach((b) => b.classList.remove("active")); const btn = cfgGroupNav.querySelector(`.btn[data-target="${targetSel}"]`); if (btn) btn.classList.add("active"); } function applySettingsFilter() { const q = (cfgSearch && cfgSearch.value ? cfgSearch.value : "").trim().toLowerCase(); const groups = listSettingGroups(); if (!settingsGroupsWrap) return; if (!q) { settingsGroupsWrap.classList.remove("is-searching"); setActiveSettingsGroup(getActiveSettingsGroupSel(), { open: true, scroll: false }); return; } settingsGroupsWrap.classList.remove("is-tabs"); settingsGroupsWrap.classList.add("is-searching"); groups.forEach((g) => { g.classList.remove("is-active"); const text = (g.textContent || "").toLowerCase(); const show = text.includes(q); g.style.display = show ? "" : "none"; if (show) setSettingGroupOpen(g, true); }); } async function saveSettings() { settingsMsg.textContent = ""; try { const mysqlPayload = cfgMysqlHost || cfgMysqlPort || cfgMysqlUser || cfgMysqlPassword || cfgMysqlDatabase || cfgClearMysqlPassword ? { host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "", port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "", user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "", password: cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "", clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false, database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "", } : null; await apiFetch("/admin/settings", { method: "PUT", body: Object.assign( { gogsBaseUrl: cfgGogsBaseUrl.value.trim(), gogsToken: cfgGogsToken.value.trim(), clearGogsToken: cfgClearGogsToken.checked, payment: { provider: cfgPayProvider.value, enableMockPay: cfgEnableMockPay.checked, apiKey: cfgPayApiKey.value.trim(), clearApiKey: cfgClearPayApiKey.checked, alipay: { appId: cfgAlipayAppId ? cfgAlipayAppId.value.trim() : "", gateway: cfgAlipayGateway ? cfgAlipayGateway.value.trim() : "", notifyUrl: cfgAlipayNotifyUrl ? cfgAlipayNotifyUrl.value.trim() : "", returnUrl: cfgAlipayReturnUrl ? cfgAlipayReturnUrl.value.trim() : "", privateKey: cfgAlipayPrivateKey ? cfgAlipayPrivateKey.value.trim() : "", clearPrivateKey: cfgClearAlipayPrivateKey ? cfgClearAlipayPrivateKey.checked : false, publicKey: cfgAlipayPublicKey ? cfgAlipayPublicKey.value.trim() : "", clearPublicKey: cfgClearAlipayPublicKey ? cfgClearAlipayPublicKey.checked : false, }, }, llm: { provider: cfgLlmProvider.value.trim(), baseUrl: cfgLlmBaseUrl.value.trim(), model: cfgLlmModel.value.trim(), apiKey: cfgLlmApiKey.value.trim(), clearApiKey: cfgClearLlmApiKey.checked, }, }, mysqlPayload ? { mysql: mysqlPayload } : {} ), }); await loadSettings(); settingsMsg.textContent = "保存成功"; } catch (e) { settingsMsg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } } async function saveSettingsPartial(body) { settingsMsg.textContent = ""; try { await apiFetch("/admin/settings", { method: "PUT", body }); await loadSettings(); settingsMsg.textContent = "保存成功"; } catch (e) { settingsMsg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } } async function testMysqlConnection() { settingsMsg.textContent = ""; try { const body = { host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "", port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "", user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "", database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "", }; const pwd = cfgMysqlPassword ? cfgMysqlPassword.value.trim() : ""; if (pwd) body.password = pwd; const resp = await apiFetch("/admin/mysql/test", { method: "POST", body }); if (resp.ok && resp.createdDatabase) { settingsMsg.textContent = "MySQL:连接成功(已自动创建库)"; } else { settingsMsg.textContent = resp.ok ? "MySQL:连接成功" : "MySQL:连接失败"; } } catch (e) { const errno = e.detail?.errno ? ` errno=${e.detail.errno}` : ""; settingsMsg.textContent = `MySQL:连接失败(${e.detail?.error || e.status || "unknown"}${errno})`; if (e.status === 401) window.location.href = "/ui/admin/login"; } } async function switchDatabase(target, force) { settingsMsg.textContent = ""; if (target === "mysql") { const host = cfgMysqlHost ? cfgMysqlHost.value.trim() : ""; const user = cfgMysqlUser ? cfgMysqlUser.value.trim() : ""; const database = cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : ""; if (!host || !user || !database) { await Swal.fire({ title: "MySQL 参数不完整", text: "请先填写 Host / User / Database(可选填写 Port / Password),再切换", icon: "error", }); return; } } const r = await Swal.fire({ title: "切换数据库?", text: target === "mysql" ? "将迁移数据到 MySQL,并切换读写到 MySQL" : "将迁移数据到 SQLite,并切换读写到 SQLite", icon: "warning", showCancelButton: true, confirmButtonText: "继续", cancelButtonText: "取消", confirmButtonColor: "var(--danger)", }); if (!r.isConfirmed) return; try { const body = { target, force: Boolean(force) }; if (target === "mysql") { const mysql = { host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "", port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "", user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "", database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "", clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false, }; const pwd = cfgMysqlPassword ? cfgMysqlPassword.value.trim() : ""; if (pwd) mysql.password = pwd; body.mysql = mysql; } const resp = await apiFetch("/admin/db/switch", { method: "POST", body }); settingsMsg.textContent = `切换成功:${resp.from} → ${resp.to}`; await loadSettings(); } catch (e) { if (e.detail?.error === "target_not_empty") { const r2 = await Swal.fire({ title: "目标库非空,是否覆盖?", text: "继续将清空目标库的表数据,然后迁移并切换(不可逆)", icon: "warning", showCancelButton: true, confirmButtonText: "覆盖并切换", cancelButtonText: "取消", confirmButtonColor: "var(--danger)", }); if (!r2.isConfirmed) return; await switchDatabase(target, true); return; } settingsMsg.textContent = `切换失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } } async function fillRefSelect(owner, repo, selectEl, prefer) { selectEl.innerHTML = ""; selectEl.appendChild(el("option", { value: "AUTO" }, "AUTO(默认分支)")); const [branches, tags] = await Promise.all([ apiFetch(`/admin/gogs/branches?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`), apiFetch(`/admin/gogs/tags?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`), ]); const branchGroup = document.createElement("optgroup"); branchGroup.label = "分支"; (branches.items || []).forEach((b) => { branchGroup.appendChild(el("option", { value: b.name }, b.name)); }); selectEl.appendChild(branchGroup); const tagGroup = document.createElement("optgroup"); tagGroup.label = "标签"; (tags.items || []).forEach((t) => { tagGroup.appendChild(el("option", { value: t.name }, t.name)); }); selectEl.appendChild(tagGroup); if (prefer) selectEl.value = prefer; } async function closeModal(force) { const isForce = force === true; if (!isForce && currentModalBeforeClose) { try { const ok = await currentModalBeforeClose(); if (!ok) return; } catch (e) {} } if (currentModalOnKeydown) { try { document.removeEventListener("keydown", currentModalOnKeydown, true); } catch (e) {} } modalBackdrop.style.display = "none"; modalTitle.textContent = ""; modalBody.innerHTML = ""; modalFooter.innerHTML = ""; if (modalHeaderActions) modalHeaderActions.innerHTML = ""; if (modalEl) modalEl.removeAttribute("data-size"); currentModalOnResize = null; currentModalBeforeClose = null; currentModalOnKeydown = null; } function openModal(title, bodyNodes, footerNodes, icon = "ri-settings-4-line", opts = {}) { modalTitle.innerHTML = ""; modalTitle.appendChild(el("i", { class: icon })); modalTitle.appendChild(document.createTextNode(title)); modalBody.innerHTML = ""; modalFooter.innerHTML = ""; if (modalHeaderActions) modalHeaderActions.innerHTML = ""; if (modalEl) modalEl.removeAttribute("data-size"); currentModalOnResize = typeof opts.onResize === "function" ? opts.onResize : null; currentModalBeforeClose = typeof opts.beforeClose === "function" ? opts.beforeClose : null; if (currentModalOnKeydown) { try { document.removeEventListener("keydown", currentModalOnKeydown, true); } catch (e) {} currentModalOnKeydown = null; } if (typeof opts.onKeydown === "function") { currentModalOnKeydown = (evt) => opts.onKeydown(evt); document.addEventListener("keydown", currentModalOnKeydown, true); } bodyNodes.forEach((n) => modalBody.appendChild(n)); footerNodes.forEach((n) => modalFooter.appendChild(n)); modalBackdrop.style.display = ""; if (modalEl && opts.resizable && modalHeaderActions) { let preferredSize = (opts.size || "").toString().trim(); if (!preferredSize) { try { preferredSize = (localStorage.getItem("adminModalSize") || "").toString().trim(); } catch (e) {} } if (!preferredSize) preferredSize = "sm"; const btnSm = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "小"); const btnLg = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "大"); function applySize(size) { const s = size === "sm" ? "sm" : "lg"; modalEl.setAttribute("data-size", s); btnSm.classList.toggle("active", s === "sm"); btnLg.classList.toggle("active", s === "lg"); try { localStorage.setItem("adminModalSize", s); } catch (e) {} if (currentModalOnResize) currentModalOnResize(s); } btnSm.addEventListener("click", () => applySize("sm")); btnLg.addEventListener("click", () => applySize("lg")); modalHeaderActions.appendChild(el("div", { class: "btn-group" }, btnSm, btnLg)); applySize(preferredSize); } else if (modalEl && currentModalOnResize) { const s = (modalEl.getAttribute("data-size") || "sm").toString(); currentModalOnResize(s); } else if (modalEl && opts.size) { modalEl.setAttribute("data-size", String(opts.size)); } } modalClose.addEventListener("click", () => closeModal()); modalBackdrop.addEventListener("click", (evt) => { if (evt.target === modalBackdrop) closeModal(); }); function insertAtCursor(textarea, text) { const start = textarea.selectionStart || 0; const end = textarea.selectionEnd || 0; const before = textarea.value.slice(0, start); const after = textarea.value.slice(end); textarea.value = `${before}${text}${after}`; const pos = start + text.length; textarea.setSelectionRange(pos, pos); textarea.focus(); try { textarea.dispatchEvent(new Event("input", { bubbles: true })); } catch (e) {} } function parseRepoInput(raw) { let s = (raw || "").trim(); if (!s) return null; s = s.replace(/\.git$/i, ""); if (s.includes("://")) { try { const u = new URL(s); s = (u.pathname || "").replace(/^\/+/, ""); } catch (e) { return null; } } const sshIdx = s.indexOf(":"); if (s.startsWith("git@") && sshIdx !== -1) { s = s.slice(sshIdx + 1); } s = s.replace(/^\/+/, ""); const parts = s.split("/").filter(Boolean); if (parts.length < 2) return null; return { owner: parts[0], repo: parts[1] }; } function buildMarkdownEditor({ initialValue, msgEl }) { const summaryInput = el("textarea", { class: "input md-editor-input", style: "min-height:260px; resize:vertical", placeholder: "简介(Markdown,支持粘贴/拖拽上传图片/视频)", value: initialValue || "", }); const syncReadme = el("input", { type: "checkbox" }); syncReadme.checked = true; attachPasteUpload(summaryInput, msgEl); const tocWrap = el("div", { class: "md-toc", style: "display:none" }); const tocTitle = el("div", { class: "md-toc-title" }, el("span", {}, "大纲"), el("span", { class: "muted", style: "font-weight:650" }, "点击跳转")); const tocItems = el("div", { class: "md-toc-items" }); tocWrap.appendChild(tocTitle); tocWrap.appendChild(tocItems); const mdContent = el("div", { html: "" }); const mdPreview = el("div", { class: "md md-editor-preview", html: "" }, tocWrap, mdContent); let showToc = false; function slugify(text) { const raw = (text || "").toString().trim().toLowerCase(); const s = raw .replace(/[\s]+/g, "-") .replace(/[^\u4e00-\u9fa5a-z0-9\-_]/g, "") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); return s || "h"; } function buildToc() { tocItems.innerHTML = ""; const headings = Array.from(mdContent.querySelectorAll("h1,h2,h3,h4,h5,h6")); if (!showToc || !headings.length) { tocWrap.style.display = "none"; return; } tocWrap.style.display = ""; const used = new Map(); headings.forEach((h) => { const level = Number(String(h.tagName || "H2").replace("H", "")) || 2; const base = slugify(h.textContent || ""); const n = (used.get(base) || 0) + 1; used.set(base, n); const id = n === 1 ? base : `${base}-${n}`; if (!h.id) h.id = id; const btn = el("button", { type: "button", class: "md-toc-item", style: `padding-left:${Math.max(0, (level - 1) * 12)}px` }, h.textContent || ""); btn.addEventListener("click", (evt) => { evt.preventDefault(); try { h.scrollIntoView({ behavior: "smooth", block: "start" }); } catch (e) { h.scrollIntoView(); } }); tocItems.appendChild(btn); }); } function updateMdPreview() { mdContent.innerHTML = renderMarkdown(summaryInput.value); buildToc(); } updateMdPreview(); summaryInput.addEventListener("input", updateMdPreview); function wrapSelection(textarea, left, right) { const start = textarea.selectionStart || 0; const end = textarea.selectionEnd || 0; const value = textarea.value || ""; const selected = value.slice(start, end); const next = `${value.slice(0, start)}${left}${selected}${right}${value.slice(end)}`; textarea.value = next; const nextStart = start + left.length; const nextEnd = nextStart + selected.length; textarea.setSelectionRange(nextStart, nextEnd); textarea.focus(); try { textarea.dispatchEvent(new Event("input", { bubbles: true })); } catch (e) {} } function prefixLines(textarea, prefix) { const start = textarea.selectionStart || 0; const end = textarea.selectionEnd || 0; const value = textarea.value || ""; const selected = value.slice(start, end); const text = selected || ""; const nextBlock = text .split("\n") .map((line) => (line ? `${prefix}${line}` : prefix.trimEnd())) .join("\n"); const insertText = selected ? nextBlock : `\n${prefix}`; insertAtCursor(textarea, insertText); } function mdBtn(label, title, onClick) { const b = el("button", { type: "button", class: "btn btn-sm", title }, label); b.addEventListener("click", (evt) => { evt.preventDefault(); onClick(); }); return b; } function insertSnippet(text, cursorRelStart, cursorRelEnd) { const start = summaryInput.selectionStart || 0; const end = summaryInput.selectionEnd || 0; const before = summaryInput.value.slice(0, start); const after = summaryInput.value.slice(end); summaryInput.value = `${before}${text}${after}`; const s = start + (cursorRelStart == null ? text.length : cursorRelStart); const e = start + (cursorRelEnd == null ? (cursorRelStart == null ? text.length : cursorRelStart) : cursorRelEnd); summaryInput.setSelectionRange(s, e); summaryInput.focus(); try { summaryInput.dispatchEvent(new Event("input", { bubbles: true })); } catch (e2) {} } const viewEditBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle active" }, "编辑"); const viewPreviewBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "预览"); const viewSplitBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle" }, "分屏"); const tocBtn = el("button", { type: "button", class: "btn btn-sm btn-toggle", title: "按标题生成大纲" }, "大纲"); const boldBtn = mdBtn("B", "加粗", () => wrapSelection(summaryInput, "**", "**")); const italicBtn = mdBtn("I", "斜体", () => wrapSelection(summaryInput, "*", "*")); const codeBtn = mdBtn("", "行内代码", () => wrapSelection(summaryInput, "`", "`")); const h2Btn = mdBtn("H2", "二级标题", () => insertAtCursor(summaryInput, "\n## ")); const blockCodeBtn = mdBtn("代码块", "代码块", () => insertSnippet("\n```text\n\n```\n", "\n```text\n".length)); const tableBtn = mdBtn("表格", "表格", () => { const t = "\n| 标题 | 内容 |\n| --- | --- |\n| | |\n"; const cursor = t.lastIndexOf("| |") + 2; insertSnippet(t, cursor, cursor); }); const imgLinkBtn = mdBtn("图片链接", "图片链接", () => { const t = "\n![]()\n"; insertSnippet(t, t.indexOf("()") + 1, t.indexOf(")") ); }); const quoteBtn = mdBtn("引用", "引用", () => prefixLines(summaryInput, "> ")); const ulBtn = mdBtn("•", "无序列表", () => insertAtCursor(summaryInput, "\n- ")); const olBtn = mdBtn("1.", "有序列表", () => insertAtCursor(summaryInput, "\n1. ")); const linkBtn = mdBtn("链接", "链接", () => { const start = summaryInput.selectionStart || 0; const end = summaryInput.selectionEnd || 0; const selected = (summaryInput.value || "").slice(start, end); if (selected) wrapSelection(summaryInput, "[", "](https://)"); else insertAtCursor(summaryInput, "[](" + "https://)"); }); const imgFile = el("input", { type: "file", accept: "image/*", style: "display:none" }); const videoFile = el("input", { type: "file", accept: "video/*", style: "display:none" }); const uploadImgBtn = el("button", { class: "btn btn-sm" }, "上传图片"); const uploadVideoBtn = el("button", { class: "btn btn-sm" }, "上传视频"); uploadImgBtn.addEventListener("click", () => imgFile.click()); uploadVideoBtn.addEventListener("click", () => videoFile.click()); const onPickFile = async (f) => { msgEl.textContent = "上传中..."; try { const url = await adminUploadFile(f); const syntax = (f.type || "").startsWith("video/") ? `\n@[video](${url})\n` : `\n![](${url})\n`; insertAtCursor(summaryInput, syntax); msgEl.textContent = "已插入上传内容"; } catch (e) { msgEl.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } }; summaryInput.addEventListener("dragover", (evt) => { const files = evt.dataTransfer?.files ? Array.from(evt.dataTransfer.files) : []; const file = files.find((it) => (it.type || "").startsWith("image/") || (it.type || "").startsWith("video/")); if (file) evt.preventDefault(); }); summaryInput.addEventListener("drop", async (evt) => { const files = evt.dataTransfer?.files ? Array.from(evt.dataTransfer.files) : []; const file = files.find((it) => (it.type || "").startsWith("image/") || (it.type || "").startsWith("video/")); if (!file) return; evt.preventDefault(); await onPickFile(file); }); imgFile.addEventListener("change", async () => { const f = imgFile.files && imgFile.files[0]; if (f) await onPickFile(f); imgFile.value = ""; }); videoFile.addEventListener("change", async () => { const f = videoFile.files && videoFile.files[0]; if (f) await onPickFile(f); videoFile.value = ""; }); const mdToolbar = el( "div", { class: "toolbar md-editor-toolbar", style: "margin:0" }, viewEditBtn, viewPreviewBtn, viewSplitBtn, tocBtn, boldBtn, italicBtn, codeBtn, h2Btn, blockCodeBtn, tableBtn, imgLinkBtn, quoteBtn, ulBtn, olBtn, linkBtn, el("div", { style: "flex:1" }), uploadImgBtn, uploadVideoBtn, imgFile, videoFile, el("label", { class: "checkbox-row", style: "margin:0" }, syncReadme, el("span", { class: "muted" }, "同步 README.md")) ); const mdEditor = el( "div", { class: "md-editor", "data-view": "edit" }, mdToolbar, el("div", { class: "md-editor-body" }, summaryInput, mdPreview) ); function setMdView(view) { mdEditor.setAttribute("data-view", view); [viewEditBtn, viewPreviewBtn, viewSplitBtn].forEach((b) => b.classList.remove("active")); if (view === "preview") viewPreviewBtn.classList.add("active"); else if (view === "split") viewSplitBtn.classList.add("active"); else viewEditBtn.classList.add("active"); updateMdPreview(); } function setViewByModalSize(size) { const s = size === "lg" ? "lg" : "sm"; const cur = (mdEditor.getAttribute("data-view") || "edit").toString(); if (s === "lg" && cur === "edit") setMdView("split"); if (s === "sm" && cur === "split") setMdView("edit"); updateMdPreview(); } viewEditBtn.addEventListener("click", () => setMdView("edit")); viewPreviewBtn.addEventListener("click", () => setMdView("preview")); viewSplitBtn.addEventListener("click", () => setMdView("split")); tocBtn.addEventListener("click", () => { showToc = !showToc; tocBtn.classList.toggle("active", showToc); updateMdPreview(); }); function setText(text) { summaryInput.value = String(text || ""); try { summaryInput.dispatchEvent(new Event("input", { bubbles: true })); } catch (e) {} } return { root: mdEditor, textarea: summaryInput, syncReadme, toolbarEl: mdToolbar, setText, setMdView, setViewByModalSize }; } async function adminUploadFileMeta(file) { const fd = new FormData(); fd.append("file", file); const headers = {}; const csrf = getCookie("csrf_token"); if (csrf) headers["X-CSRF-Token"] = csrf; const resp = await fetch("/admin/uploads", { method: "POST", body: fd, headers }); const contentType = resp.headers.get("content-type") || ""; const isJson = contentType.includes("application/json"); const detail = isJson ? await resp.json() : null; if (!resp.ok) { const err = new Error("upload_failed"); err.status = resp.status; err.detail = detail; throw err; } return detail; } async function adminUploadFile(file) { const detail = await adminUploadFileMeta(file); return detail.url; } function attachPasteUpload(textarea, msgEl) { textarea.addEventListener("paste", async (evt) => { const items = evt.clipboardData?.items ? Array.from(evt.clipboardData.items) : []; const fileItem = items.find((it) => it.kind === "file" && (it.type || "").startsWith("image/")) || items.find((it) => it.kind === "file" && (it.type || "").startsWith("video/")); if (!fileItem) return; evt.preventDefault(); const file = fileItem.getAsFile(); if (!file) return; msgEl.textContent = "上传中..."; try { const url = await adminUploadFile(file); const syntax = (file.type || "").startsWith("video/") ? `\n@[video](${url})\n` : `\n![](${url})\n`; insertAtCursor(textarea, syntax); msgEl.textContent = "已插入上传内容"; } catch (e) { msgEl.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } }); } function openRepoPicker(initialOwner, onPick) { const ownerInput = el("input", { class: "input", placeholder: "Owner(可选:留空则列出 Token 可见仓库)", value: initialOwner || "" }); const qInput = el("input", { class: "input", placeholder: "仓库关键词(可选)" }); const searchBtn = el("button", { class: "btn" }, "搜索"); const msg = el("div", { class: "muted" }, ""); const table = el("table", { class: "table" }, el("thead", {}, el("tr", {}, el("th", {}, "仓库"), el("th", {}, "默认分支"), el("th", {}, "操作"))), el("tbody", {})); const tbody = table.querySelector("tbody"); const tableWrap = el("div", { class: "table-wrap" }, table); async function refresh() { tbody.innerHTML = ""; msg.textContent = ""; try { const params = new URLSearchParams(); if (ownerInput.value.trim()) params.set("owner", ownerInput.value.trim()); if (qInput.value.trim()) params.set("q", qInput.value.trim()); const resp = await apiFetch(`/admin/gogs/repos?${params.toString()}`); const items = resp.items || []; if (!items.length) { renderEmptyRow(tbody, 3, "未找到仓库"); return; } items.forEach((r) => { const ownerName = (r.owner || (r.fullName || "").split("/")[0] || "").trim(); const tr = el( "tr", {}, el("td", {}, r.fullName || r.name), el("td", {}, r.defaultBranch || "-"), el( "td", {}, btnGroup( el( "button", { class: "btn", onclick: () => { onPick({ owner: ownerName, name: r.name, fullName: r.fullName || "", defaultBranch: r.defaultBranch || "" }); closeModal(); }, }, "选择" ) ) ) ); tbody.appendChild(tr); }); } catch (e) { const errCode = e.detail?.error || e.status || "unknown"; const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : ""; if (e.detail?.error === "gogs_token_required") { msg.textContent = "查询失败:未配置 GOGS_TOKEN,请填写 Owner 后再搜索"; return; } if (e.detail?.error === "gogs_unreachable" || (e.detail?.error === "gogs_failed" && Number(e.detail?.status || 0) === 599)) { const url = (e.detail?.url || "").toString().trim(); msg.textContent = `查询失败:无法连接 Gogs,请检查 GOGS_BASE_URL/网络${url ? `(${url})` : ""}。若不配置 Token,请填写 Owner 后再搜索。`; showToastError("无法连接 Gogs"); return; } msg.textContent = `查询失败:${errCode}${upstream}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } } searchBtn.addEventListener("click", refresh); openModal( "选择仓库", [el("div", { class: "toolbar toolbar-tight" }, ownerInput, qInput, searchBtn), msg, tableWrap], [el("button", { class: "btn", onclick: closeModal }, "关闭")], "ri-git-repository-line" ); refresh(); } async function loadPlans() { const planTbody = document.querySelector("#planTable tbody"); planTbody.innerHTML = ""; const plans = await apiFetch("/admin/plans"); planMap.clear(); plans.forEach((p) => { planMap.set(String(p.id), p); const tr = el( "tr", {}, el("td", { title: String(p.id) }, String(p.id)), el("td", { title: p.name }, p.name), el("td", {}, String(p.durationDays)), el("td", {}, formatCents(p.priceCents)), el("td", {}, p.enabled ? badge("启用", "badge-success") : badge("禁用", "badge-danger")), el("td", {}, String(p.sort)), el( "td", { class: "td-actions" }, btnGroup( el("button", { class: "btn", "data-action": "edit-plan", "data-id": String(p.id) }, "编辑"), el("button", { class: "btn", "data-action": "del-plan", "data-id": String(p.id) }, "删除") ) ) ); planTbody.appendChild(tr); }); if (!plans.length) renderEmptyRow(planTbody, 7, "暂无数据"); } if (settingsGroupsWrap) { settingsGroupsWrap.addEventListener("click", (evt) => { const head = evt.target.closest(".collapse-head"); if (!head) return; const wrap = head.closest(".collapse"); if (!wrap) return; evt.preventDefault(); const cur = wrap.getAttribute("data-open") === "1"; setSettingGroupOpen(wrap, !cur); if (wrap.id) setSettingNavActive(`#${wrap.id}`); }); } if (cfgGroupNav) { cfgGroupNav.addEventListener("click", (evt) => { const btn = evt.target.closest(".btn"); if (!btn) return; const targetSel = btn.getAttribute("data-target"); if (!targetSel) return; evt.preventDefault(); if (cfgSearch && cfgSearch.value.trim()) { cfgSearch.value = ""; applySettingsFilter(); } setActiveSettingsGroup(targetSel, { open: true, scroll: false }); }); } if (cfgSearch) { cfgSearch.addEventListener("input", () => { applySettingsFilter(); }); cfgSearch.addEventListener("keydown", (evt) => { if (evt.key !== "Enter") return; evt.preventDefault(); applySettingsFilter(); const first = listSettingGroups().find((g) => g.style.display !== "none"); if (first) { setSettingGroupOpen(first, true); if (first.id) setSettingNavActive(`#${first.id}`); first.scrollIntoView({ block: "start", behavior: "smooth" }); } }); } async function loadResources() { const resTbody = document.querySelector("#resourceTable tbody"); resTbody.innerHTML = ""; const query = new URLSearchParams(); if (resQ.value.trim()) query.set("q", resQ.value.trim()); if (resTypeFilter.value) query.set("type", resTypeFilter.value); if (resStatusFilter.value) query.set("status", resStatusFilter.value); query.set("page", String(resState.page)); query.set("pageSize", String(resState.pageSize)); const resp = await apiFetch(`/admin/resources?${query.toString()}`); const resources = resp.items || []; resState.total = Number(resp.total || 0); resourceMap.clear(); resources.forEach((r) => { resourceMap.set(String(r.id), r); const tr = el( "tr", {}, el("td", { title: String(r.id) }, String(r.id)), el("td", { title: r.title }, r.title), el("td", {}, resourceTypeBadge(r.type)), el("td", {}, resourceStatusBadge(r.status)), el("td", { title: r.defaultRef }, r.defaultRef), el("td", { title: `${r.repoOwner}/${r.repoName}` }, `${r.repoOwner}/${r.repoName}`), el("td", { title: formatDateTime(r.updatedAt) }, formatDateTime(r.updatedAt)), el( "td", { class: "td-actions" }, btnGroup( el("a", { class: "btn", href: `/ui/resources/${r.id}` }, "查看"), el("button", { class: "btn", "data-action": "edit-res", "data-id": String(r.id) }, "编辑"), el("button", { class: "btn", "data-action": "del-res", "data-id": String(r.id) }, "删除") ) ) ); resTbody.appendChild(tr); }); if (!resources.length) renderEmptyRow(resTbody, 8, "暂无数据"); const pageCount = Math.max(1, Math.ceil(resState.total / resState.pageSize)); resPageInfo.textContent = `第 ${resState.page} / ${pageCount} 页,共 ${resState.total} 条`; resPrevPage.disabled = resState.page <= 1; resNextPage.disabled = resState.page >= pageCount; } async function loadOrders() { const orderTbody = document.querySelector("#orderTable tbody"); orderTbody.innerHTML = ""; const query = new URLSearchParams(); if (orderQ.value.trim()) query.set("q", orderQ.value.trim()); if (orderStatusFilter.value) query.set("status", orderStatusFilter.value); query.set("page", String(orderState.page)); query.set("pageSize", String(orderState.pageSize)); const orders = await apiFetch(`/admin/orders?${query.toString()}`); orderState.total = Number(orders.total || 0); orderMap.clear(); (orders.items || []).forEach((o) => { orderMap.set(String(o.id), o); const isLocked = o.status === "PAID"; const delBtn = el("button", { class: "btn", "data-action": "del-order", "data-id": String(o.id) }, "删除"); if (isLocked) { delBtn.disabled = true; } const tr = el( "tr", {}, el("td", { title: o.id }, o.id), el("td", {}, orderStatusBadge(o.status)), el("td", {}, formatCents(o.amountCents)), el("td", { title: `${o.userId} / ${o.userPhone}` }, `${o.userId} / ${o.userPhone}`), el( "td", { title: `${o.planSnapshot.name}(${o.planSnapshot.durationDays}天 / ${formatCents(o.planSnapshot.priceCents)})` }, o.planSnapshot.name ), el("td", { title: formatDateTime(o.createdAt) }, formatDateTime(o.createdAt)), el("td", { title: formatDateTime(o.paidAt) }, formatDateTime(o.paidAt)), el( "td", { class: "td-actions" }, btnGroup( el("button", { class: "btn", "data-action": "view-order", "data-id": String(o.id) }, "查看"), delBtn ) ) ); orderTbody.appendChild(tr); }); if (!(orders.items || []).length) renderEmptyRow(orderTbody, 8, "暂无数据"); const pageCount = Math.max(1, Math.ceil(orderState.total / orderState.pageSize)); orderPageInfo.textContent = `第 ${orderState.page} / ${pageCount} 页,共 ${orderState.total} 条`; orderPrevPage.disabled = orderState.page <= 1; orderNextPage.disabled = orderState.page >= pageCount; } async function loadUsers() { const userTbody = document.querySelector("#userTable tbody"); userTbody.innerHTML = ""; const query = new URLSearchParams(); if (userQ.value.trim()) query.set("q", userQ.value.trim()); if (userStatusFilter.value) query.set("status", userStatusFilter.value); if (userVipFilter && userVipFilter.value) query.set("vip", userVipFilter.value); query.set("page", String(userState.page)); query.set("pageSize", String(userState.pageSize)); const resp = await apiFetch(`/admin/users?${query.toString()}`); const users = resp.items || []; userState.total = Number(resp.total || 0); userMap.clear(); users.forEach((u) => { userMap.set(String(u.id), u); const vipBadge = u.vipActive ? badge("VIP", "badge-vip") : badge("非VIP", "badge"); const vipDays = u.vipActive && Number.isFinite(Number(u.vipRemainingDays)) ? `剩余 ${Number(u.vipRemainingDays)} 天` : ""; const vipInfo = el( "div", { style: "display:flex; align-items:center; gap:8px; white-space:nowrap;" }, vipBadge, vipDays ? el("span", { class: "muted", style: "font-size: inherit;" }, vipDays) : null ); const tr = el( "tr", {}, el("td", { title: String(u.id) }, String(u.id)), el("td", { title: u.phone }, u.phone), el("td", {}, userStatusBadge(u.status)), el("td", {}, vipInfo), el("td", { title: formatDateTime(u.vipExpireAt) }, formatDateTime(u.vipExpireAt)), el("td", { title: formatDateTime(u.createdAt) }, formatDateTime(u.createdAt)), el( "td", { class: "td-actions" }, btnGroup( el("button", { class: "btn", "data-action": "user-actions", "data-id": String(u.id) }, "操作") ) ) ); userTbody.appendChild(tr); }); if (!users.length) renderEmptyRow(userTbody, 7, "暂无数据"); const pageCount = Math.max(1, Math.ceil(userState.total / userState.pageSize)); userPageInfo.textContent = `第 ${userState.page} / ${pageCount} 页,共 ${userState.total} 条`; userPrevPage.disabled = userState.page <= 1; userNextPage.disabled = userState.page >= pageCount; } async function loadDownloadLogs() { const tbody = document.querySelector("#downloadLogTable tbody"); tbody.innerHTML = ""; const query = new URLSearchParams(); if (dlQ && dlQ.value.trim()) query.set("q", dlQ.value.trim()); if (dlTypeFilter && dlTypeFilter.value) query.set("type", dlTypeFilter.value); if (dlStateFilter && dlStateFilter.value) query.set("state", dlStateFilter.value); query.set("page", String(downloadLogState.page)); query.set("pageSize", String(downloadLogState.pageSize)); const resp = await apiFetch(`/admin/download-logs?${query.toString()}`); downloadLogState.total = Number(resp.total || 0); downloadLogMap.clear(); (resp.items || []).forEach((it) => { downloadLogMap.set(String(it.id), it); const stateBadge = it.resourceState === "DELETED" ? badge("已删除", "badge-danger") : it.resourceState === "OFFLINE" ? badge("已下架", "badge-warning") : badge("在线", "badge-success"); const typeBadge = it.resourceType === "VIP" ? badge("VIP", "badge-vip") : badge("免费", "badge-free"); const currentTypeBadge = it.currentResourceType === "VIP" ? badge("VIP", "badge-vip") : it.currentResourceType === "FREE" ? badge("免费", "badge-free") : badge("-", "badge"); const userCell = `${it.userId} / ${it.userPhone || "-"}`; const titleText = String(it.resourceTitle || ""); const titleNode = it.resourceId && it.resourceState === "ONLINE" ? el("a", { href: `/ui/resources/${it.resourceId}`, style: "color: inherit; text-decoration: none;" }, titleText) : el("span", { class: "muted" }, titleText); const tr = el( "tr", {}, el("td", { title: String(it.id) }, String(it.id)), el("td", { title: formatDateTime(it.downloadedAt) }, formatDateTime(it.downloadedAt)), el("td", { title: userCell }, userCell), el("td", { title: titleText }, titleNode), el("td", {}, typeBadge), el("td", {}, currentTypeBadge), el("td", {}, stateBadge), el("td", { title: String(it.ip || "") }, String(it.ip || "-")), el( "td", { class: "td-actions" }, btnGroup(el("button", { class: "btn", "data-action": "view-download-log", "data-id": String(it.id) }, "查看")) ) ); tbody.appendChild(tr); }); if (!(resp.items || []).length) renderEmptyRow(tbody, 9, "暂无数据"); const pageCount = Math.max(1, Math.ceil(downloadLogState.total / downloadLogState.pageSize)); if (dlPageInfo) dlPageInfo.textContent = `第 ${downloadLogState.page} / ${pageCount} 页,共 ${downloadLogState.total} 条`; if (dlPrevPage) dlPrevPage.disabled = downloadLogState.page <= 1; if (dlNextPage) dlNextPage.disabled = downloadLogState.page >= pageCount; } async function loadAdminMessages() { if (!msgTbody) return; msgTbody.innerHTML = ""; const params = new URLSearchParams(); params.set("page", String(messageState.page)); params.set("pageSize", String(messageState.pageSize)); const q = (msgQ?.value || "").trim(); if (q) params.set("q", q); const read = (msgReadFilter?.value || "").trim(); if (read) params.set("read", read); const senderType = (msgSenderFilter?.value || "").trim(); if (senderType) params.set("senderType", senderType); try { const resp = await apiFetch(`/admin/messages?${params.toString()}`); messageState.total = parseInt(resp.total || 0, 10) || 0; messageMap.clear(); const items = Array.isArray(resp.items) ? resp.items : []; items.forEach((m) => { messageMap.set(String(m.id), m); const userCell = `${m.userId} / ${m.userPhone || "-"}`; const readBadge = m.read ? badge("已读", "badge-success") : badge("未读", "badge-warning"); const senderBadge = m.senderType === "ADMIN" ? badge("管理员", "badge-info") : badge("系统", "badge"); const titleText = String(m.title || ""); const tr = el( "tr", {}, el("td", { title: String(m.id) }, String(m.id)), el("td", { title: userCell }, userCell), el("td", { title: titleText }, titleText), el("td", { title: formatDateTime(m.createdAt) }, formatDateTime(m.createdAt)), el("td", {}, readBadge), el("td", {}, senderBadge), el( "td", { class: "td-actions" }, btnGroup( el("button", { class: "btn", "data-action": "view-message", "data-id": String(m.id) }, "查看"), el("button", { class: "btn btn-danger", "data-action": "del-message", "data-id": String(m.id) }, "删除") ) ) ); msgTbody.appendChild(tr); }); if (!items.length) renderEmptyRow(msgTbody, 7, "暂无数据"); const pageCount = Math.max(1, Math.ceil(messageState.total / messageState.pageSize)); if (msgPageInfo) msgPageInfo.textContent = `第 ${messageState.page} / ${pageCount} 页,共 ${messageState.total} 条`; if (msgPrevPage) msgPrevPage.disabled = messageState.page <= 1; if (msgNextPage) msgNextPage.disabled = messageState.page >= pageCount; } catch (e) { if (e.status === 401) window.location.href = "/ui/admin/login"; renderEmptyRow(msgTbody, 7, `加载失败:${e.detail?.error || e.status || "unknown"}`); } } async function loadAdminOverview() { if (!ovUsersTotal || !ovSystemInfo) return; try { if (overviewUpdatedAt) overviewUpdatedAt.textContent = "加载中…"; const stats = await apiFetch("/admin/stats"); if (ovUsersTotal) ovUsersTotal.textContent = String(stats?.users?.total ?? 0); if (ovUsersSub) ovUsersSub.textContent = `活跃 ${stats?.users?.active ?? 0},VIP ${stats?.users?.vipActive ?? 0}`; if (ovResourcesTotal) ovResourcesTotal.textContent = String(stats?.resources?.total ?? 0); if (ovResourcesSub) ovResourcesSub.textContent = `上架 ${stats?.resources?.online ?? 0}`; if (ovOrdersTotal) ovOrdersTotal.textContent = String(stats?.orders?.total ?? 0); if (ovOrdersSub) ovOrdersSub.textContent = `已付 ${stats?.orders?.paid ?? 0},待付 ${stats?.orders?.pending ?? 0}`; if (ovRevenueTotal) ovRevenueTotal.textContent = formatCents(stats?.revenue?.totalCents ?? 0); if (ovRevenueSub) ovRevenueSub.textContent = `24h ${formatCents(stats?.revenue?.last24hCents ?? 0)}`; if (ovDownloadsTotal) ovDownloadsTotal.textContent = String(stats?.downloads?.total ?? 0); if (ovDownloadsSub) ovDownloadsSub.textContent = `24h ${stats?.downloads?.last24h ?? 0}`; if (ovMessagesTotal) ovMessagesTotal.textContent = String(stats?.messages?.total ?? 0); if (ovMessagesSub) ovMessagesSub.textContent = `24h ${stats?.messages?.last24h ?? 0}`; if (ovSystemInfo) ovSystemInfo.textContent = `当前数据库:${stats?.backend || "-"},统计时间:${formatDateTime(stats?.now)}`; if (overviewUpdatedAt) overviewUpdatedAt.textContent = `更新时间:${formatDateTime(stats?.now)}`; } catch (e) { if (e.status === 401) window.location.href = "/ui/admin/login"; if (overviewUpdatedAt) overviewUpdatedAt.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`; } } async function activate(section) { const effectiveSection = section; document.querySelectorAll(".menu-item").forEach((a) => a.classList.remove("active")); const link = document.querySelector(`.menu-item[data-section='${section}']`); if (link) link.classList.add("active"); document.querySelectorAll(".content-section").forEach((s) => (s.style.display = "none")); const sec = document.getElementById(`sec-${effectiveSection}`); if (sec) sec.style.display = ""; if (effectiveSection === "overview") { contentTitle.textContent = "概览"; await loadAdminOverview(); } else if (effectiveSection === "plans") { contentTitle.textContent = "会员方案"; await loadPlans(); } else if (effectiveSection === "resources") { contentTitle.textContent = "资源管理"; await loadResources(); } else if (effectiveSection === "uploads") { contentTitle.textContent = "上传管理"; await loadUploads(); } else if (effectiveSection === "orders") { contentTitle.textContent = "订单管理"; orderState.page = 1; await loadOrders(); } else if (effectiveSection === "users") { contentTitle.textContent = "用户管理"; await loadUsers(); } else if (effectiveSection === "download-logs") { contentTitle.textContent = "下载记录"; downloadLogState.page = 1; await loadDownloadLogs(); } else if (effectiveSection === "messages") { contentTitle.textContent = "消息管理"; messageState.page = 1; await loadAdminMessages(); } else if (effectiveSection === "settings") { contentTitle.textContent = "第三方配置"; await loadSettings(); } } menu.addEventListener("click", async (evt) => { const a = evt.target.closest(".menu-item"); if (!a) return; evt.preventDefault(); const sec = a.getAttribute("data-section"); await activate(sec); }); if (overviewRefreshBtn) { overviewRefreshBtn.addEventListener("click", async () => { await loadAdminOverview(); }); } settingsRefreshBtn.addEventListener("click", async () => { await loadSettings(); }); settingsSaveBtn.addEventListener("click", async () => { await saveSettings(); }); if (cfgPayProvider) { cfgPayProvider.addEventListener("change", () => { updatePayProviderVisibility(); }); } if (cfgAlipayUseCurrentNotify && cfgAlipayNotifyUrl) { cfgAlipayUseCurrentNotify.addEventListener("click", () => { cfgAlipayNotifyUrl.value = `${window.location.origin}/pay/callback`; }); } if (cfgAlipayUseCurrentReturn && cfgAlipayReturnUrl) { cfgAlipayUseCurrentReturn.addEventListener("click", () => { cfgAlipayReturnUrl.value = `${window.location.origin}/ui/me`; }); } if (cfgShowAlipayPrivateKey && cfgAlipayPrivateKey) { cfgShowAlipayPrivateKey.addEventListener("change", () => { cfgAlipayPrivateKey.classList.toggle("is-revealed", Boolean(cfgShowAlipayPrivateKey.checked)); }); } if (cfgShowAlipayPublicKey && cfgAlipayPublicKey) { cfgShowAlipayPublicKey.addEventListener("change", () => { cfgAlipayPublicKey.classList.toggle("is-revealed", Boolean(cfgShowAlipayPublicKey.checked)); }); } if (cfgGogsSaveBtn) { cfgGogsSaveBtn.addEventListener("click", async () => { await saveSettingsPartial({ gogsBaseUrl: cfgGogsBaseUrl.value.trim(), gogsToken: cfgGogsToken.value.trim(), clearGogsToken: cfgClearGogsToken.checked, }); }); } if (cfgGogsResetBtn) { cfgGogsResetBtn.addEventListener("click", async () => { await loadSettings(); const g = document.getElementById("cfgGroupGogs"); if (g) g.setAttribute("data-open", "1"); }); } if (cfgPaySaveBtn) { cfgPaySaveBtn.addEventListener("click", async () => { await saveSettingsPartial({ payment: { provider: cfgPayProvider.value, enableMockPay: cfgEnableMockPay.checked, apiKey: cfgPayApiKey.value.trim(), clearApiKey: cfgClearPayApiKey.checked, alipay: { appId: cfgAlipayAppId ? cfgAlipayAppId.value.trim() : "", gateway: cfgAlipayGateway ? cfgAlipayGateway.value.trim() : "", notifyUrl: cfgAlipayNotifyUrl ? cfgAlipayNotifyUrl.value.trim() : "", returnUrl: cfgAlipayReturnUrl ? cfgAlipayReturnUrl.value.trim() : "", privateKey: cfgAlipayPrivateKey ? cfgAlipayPrivateKey.value.trim() : "", clearPrivateKey: cfgClearAlipayPrivateKey ? cfgClearAlipayPrivateKey.checked : false, publicKey: cfgAlipayPublicKey ? cfgAlipayPublicKey.value.trim() : "", clearPublicKey: cfgClearAlipayPublicKey ? cfgClearAlipayPublicKey.checked : false, }, }, }); }); } if (cfgPayResetBtn) { cfgPayResetBtn.addEventListener("click", async () => { await loadSettings(); const g = document.getElementById("cfgGroupPay"); if (g) g.setAttribute("data-open", "1"); }); } if (cfgLlmSaveBtn) { cfgLlmSaveBtn.addEventListener("click", async () => { await saveSettingsPartial({ llm: { provider: cfgLlmProvider.value.trim(), baseUrl: cfgLlmBaseUrl.value.trim(), model: cfgLlmModel.value.trim(), apiKey: cfgLlmApiKey.value.trim(), clearApiKey: cfgClearLlmApiKey.checked, }, }); }); } if (cfgLlmResetBtn) { cfgLlmResetBtn.addEventListener("click", async () => { await loadSettings(); const g = document.getElementById("cfgGroupLlm"); if (g) g.setAttribute("data-open", "1"); }); } if (cfgDbSaveBtn) { cfgDbSaveBtn.addEventListener("click", async () => { await saveSettingsPartial({ mysql: { host: cfgMysqlHost ? cfgMysqlHost.value.trim() : "", port: cfgMysqlPort ? cfgMysqlPort.value.trim() : "", user: cfgMysqlUser ? cfgMysqlUser.value.trim() : "", password: cfgMysqlPassword ? cfgMysqlPassword.value.trim() : "", clearPassword: cfgClearMysqlPassword ? cfgClearMysqlPassword.checked : false, database: cfgMysqlDatabase ? cfgMysqlDatabase.value.trim() : "", }, }); }); } if (cfgDbResetBtn) { cfgDbResetBtn.addEventListener("click", async () => { await loadSettings(); const g = document.getElementById("cfgGroupDb"); if (g) g.setAttribute("data-open", "1"); }); } if (cfgMysqlTestBtn) { cfgMysqlTestBtn.addEventListener("click", async () => { await testMysqlConnection(); }); } if (cfgDbSwitchMysqlBtn) { cfgDbSwitchMysqlBtn.addEventListener("click", async () => { await switchDatabase("mysql", false); }); } if (cfgDbSwitchSqliteBtn) { cfgDbSwitchSqliteBtn.addEventListener("click", async () => { await switchDatabase("sqlite", false); }); } uploadsFilterAll.addEventListener("click", async () => { setUploadsFilter("all"); await loadUploads(); }); uploadsFilterUnused.addEventListener("click", async () => { setUploadsFilter("unused"); await loadUploads(); }); uploadsFilterUsed.addEventListener("click", async () => { setUploadsFilter("used"); await loadUploads(); }); uploadsRefreshBtn.addEventListener("click", async () => { await loadUploads(); }); uploadsQ.addEventListener("keydown", async (evt) => { if (evt.key !== "Enter") return; evt.preventDefault(); await loadUploads(); }); uploadsUploadBtn.addEventListener("click", () => { uploadsFile.value = ""; uploadsFile.click(); }); uploadsFile.addEventListener("change", async () => { const files = uploadsFile.files ? Array.from(uploadsFile.files) : []; if (!files.length) return; uploadsUploadBtn.disabled = true; uploadsCleanupBtn.disabled = true; uploadsRefreshBtn.disabled = true; try { for (const f of files) { await adminUploadFileMeta(f); } showToastSuccess("上传成功"); await loadUploads(); } catch (e) { showToastError(e.detail?.error || e.status || "上传失败"); if (e.status === 401) window.location.href = "/ui/admin/login"; } finally { uploadsUploadBtn.disabled = false; uploadsCleanupBtn.disabled = false; uploadsRefreshBtn.disabled = false; } }); uploadsCleanupBtn.addEventListener("click", async () => { const r = await Swal.fire({ title: "一键清理未使用文件?", text: "将删除 uploads 目录中所有未被资源引用的文件。", icon: "warning", showCancelButton: true, confirmButtonText: "开始清理", cancelButtonText: "取消", confirmButtonColor: "var(--danger)", }); if (!r.isConfirmed) return; try { const resp = await apiFetch("/admin/uploads/cleanup-unused", { method: "POST" }); showToastSuccess(`已清理 ${resp.deletedCount || 0} 个文件`); await loadUploads(); } catch (e) { showToastError(e.detail?.error || e.status || "清理失败"); if (e.status === 401) window.location.href = "/ui/admin/login"; } }); createPlanOpenBtn.addEventListener("click", () => { const nameInput = el("input", { class: "input", placeholder: "名称" }); const daysInput = el("input", { class: "input", placeholder: "时长(天)" }); const priceInput = el("input", { class: "input", placeholder: "价格(分)" }); const enabledSelect = el("select", { class: "input" }, el("option", { value: "1" }, "启用"), el("option", { value: "0" }, "禁用")); const sortInput = el("input", { class: "input", placeholder: "排序,默认 0", value: "0" }); const msg = el("div", { class: "muted" }, ""); openModal( "新增方案", [ el("label", { class: "label" }, "名称"), nameInput, el("label", { class: "label" }, "时长(天)"), daysInput, el("label", { class: "label" }, "价格(分)"), priceInput, el("label", { class: "label" }, "启用"), enabledSelect, el("label", { class: "label" }, "排序"), sortInput, msg, ], [ el("button", { class: "btn", onclick: closeModal }, "取消"), el( "button", { class: "btn btn-primary", onclick: async () => { msg.textContent = ""; try { await apiFetch("/admin/plans", { method: "POST", body: { name: nameInput.value.trim(), durationDays: Number(daysInput.value), priceCents: Number(priceInput.value), enabled: enabledSelect.value === "1", sort: Number(sortInput.value || "0"), }, }); closeModal(); await loadPlans(); } catch (e) { msg.textContent = `创建失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } }, }, "创建" ), ], "ri-add-circle-line" ); }); document.addEventListener("click", async (evt) => { const btn = evt.target.closest("button[data-action]"); if (!btn) return; const action = btn.getAttribute("data-action"); const id = btn.getAttribute("data-id"); const openVipAdjustModal = (u) => { const daysInput = el("input", { class: "input", value: "30" }); const msg = el("div", { class: "muted" }, ""); openModal( `调整会员 #${u.id}`, [ el("div", { class: "muted" }, `手机号:${u.phone},当前到期:${formatDateTime(u.vipExpireAt)}`), el("label", { class: "label" }, "增加天数(可为负数)"), daysInput, msg, ], [ el("button", { class: "btn", onclick: closeModal }, "取消"), el( "button", { class: "btn btn-primary", onclick: async () => { msg.textContent = ""; try { await apiFetch(`/admin/users/${u.id}/vip-adjust`, { method: "POST", body: { addDays: Number(daysInput.value) } }); closeModal(); await loadUsers(); } catch (e) { msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } }, }, "保存" ), ], "ri-vip-crown-line" ); }; const openResetUserPasswordModal = (u) => { const msg = el("div", { class: "muted" }, ""); const passwordInput = el("input", { class: "input", type: "password" }); const confirmInput = el("input", { class: "input", type: "password" }); const generate = () => `${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`.slice(0, 12); passwordInput.value = generate(); confirmInput.value = passwordInput.value; openModal( `重置密码 #${u.id}`, [ el("div", { class: "muted" }, `手机号:${u.phone}`), el("label", { class: "label" }, "新密码(至少 6 位)"), passwordInput, el("label", { class: "label" }, "确认新密码"), confirmInput, msg, ], [ el("button", { class: "btn", onclick: closeModal }, "取消"), el( "button", { class: "btn", onclick: () => { passwordInput.value = generate(); confirmInput.value = passwordInput.value; }, }, "随机生成" ), el("button", { class: "btn", onclick: () => copyText(passwordInput.value) }, "复制密码"), el( "button", { class: "btn btn-primary", onclick: async () => { msg.textContent = ""; const p1 = passwordInput.value || ""; const p2 = confirmInput.value || ""; if (p1.length < 6) { msg.textContent = "新密码至少 6 位"; return; } if (p1 !== p2) { msg.textContent = "两次输入不一致"; return; } try { await apiFetch(`/admin/users/${u.id}/password-reset`, { method: "POST", body: { password: p1 } }); closeModal(); showToastSuccess("已重置密码"); } catch (e) { msg.textContent = `重置失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } }, }, "确认重置" ), ], "ri-key-2-line" ); }; if (action === "del-plan") { try { await apiFetch(`/admin/plans/${id}`, { method: "DELETE" }); await loadPlans(); } catch (e) { if (e.status === 401) window.location.href = "/ui/admin/login"; } } if (action === "edit-plan") { const plan = planMap.get(String(id)); if (!plan) return; const nameInput = el("input", { class: "input", value: plan.name }); const daysInput = el("input", { class: "input", value: String(plan.durationDays) }); const priceInput = el("input", { class: "input", value: String(plan.priceCents) }); const enabledSelect = el( "select", { class: "input" }, el("option", { value: "1" }, "启用"), el("option", { value: "0" }, "禁用") ); enabledSelect.value = plan.enabled ? "1" : "0"; const sortInput = el("input", { class: "input", value: String(plan.sort) }); const msg = el("div", { class: "muted" }, ""); openModal( `编辑方案 #${plan.id}`, [ el("label", { class: "label" }, "名称"), nameInput, el("label", { class: "label" }, "时长(天)"), daysInput, el("label", { class: "label" }, "价格(分)"), priceInput, el("label", { class: "label" }, "启用"), enabledSelect, el("label", { class: "label" }, "排序"), sortInput, msg, ], [ el("button", { class: "btn", onclick: closeModal }, "取消"), el( "button", { class: "btn btn-primary", onclick: async () => { msg.textContent = ""; try { await apiFetch(`/admin/plans/${plan.id}`, { method: "PUT", body: { name: nameInput.value.trim(), durationDays: Number(daysInput.value), priceCents: Number(priceInput.value), enabled: enabledSelect.value === "1", sort: Number(sortInput.value), }, }); closeModal(); await loadPlans(); } catch (e) { msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } }, }, "保存" ), ], "ri-edit-circle-line" ); } if (action === "del-res") { try { await apiFetch(`/admin/resources/${id}`, { method: "DELETE" }); await loadResources(); } catch (e) { if (e.status === 401) window.location.href = "/ui/admin/login"; } } if (action === "edit-res") { const res = resourceMap.get(String(id)); if (!res) return; openResourceEditorModal({ mode: "edit", res }); } if (action === "toggle-user") { const next = btn.getAttribute("data-next"); try { await apiFetch(`/admin/users/${id}`, { method: "PUT", body: { status: next } }); await loadUsers(); } catch (e) { if (e.status === 401) window.location.href = "/ui/admin/login"; } } if (action === "vip-user") { const u = userMap.get(String(id)); if (!u) return; openVipAdjustModal(u); } if (action === "reset-user-pass") { const u = userMap.get(String(id)); if (!u) return; openResetUserPasswordModal(u); } if (action === "user-actions") { const u = userMap.get(String(id)); if (!u) return; const nextStatus = u.status === "ACTIVE" ? "DISABLED" : "ACTIVE"; openModal( `用户操作 #${u.id}`, [el("div", { class: "muted" }, `手机号:${u.phone}`)], [ el("button", { class: "btn", onclick: closeModal }, "关闭"), el( "button", { class: "btn", onclick: async () => { try { await apiFetch(`/admin/users/${u.id}`, { method: "PUT", body: { status: nextStatus } }); closeModal(); await loadUsers(); } catch (e) { if (e.status === 401) window.location.href = "/ui/admin/login"; } }, }, nextStatus === "DISABLED" ? "禁用" : "启用" ), el( "button", { class: "btn", onclick: () => { openResetUserPasswordModal(u); }, }, "重置密码" ), el( "button", { class: "btn", onclick: () => { openVipAdjustModal(u); }, }, "调整会员" ), ], "ri-settings-3-line" ); } if (action === "view-order") { const box = el("div", {}); const msg = el("div", { class: "muted" }, "加载中…"); box.appendChild(msg); openModal("订单详情", [box], [el("button", { class: "btn", onclick: closeModal }, "关闭")], "ri-file-list-3-line"); try { const o = await apiFetch(`/admin/orders/${id}`); box.innerHTML = ""; box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "订单号"), el("div", {}, String(o.id)))); box.appendChild( el( "div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "状态"), el("div", {}, orderStatusBadge(o.status)) ) ); box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "金额"), el("div", {}, formatCents(o.amountCents)))); box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "用户"), el("div", {}, `${o.userId} / ${o.userPhone}`))); box.appendChild( el( "div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "方案"), el("div", {}, `${o.planSnapshot?.name || "-"}(${o.planSnapshot?.durationDays || "-"}天 / ${formatCents(o.planSnapshot?.priceCents || 0)})`) ) ); box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "创建时间"), el("div", {}, formatDateTime(o.createdAt)))); box.appendChild(el("div", { class: "card", style: "padding:14px; border-radius: 10px;" }, el("div", { class: "muted" }, "支付时间"), el("div", {}, formatDateTime(o.paidAt)))); } catch (e) { msg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } } if (action === "view-download-log") { const it = downloadLogMap.get(String(id)); if (!it) return; const userText = `${it.userId} / ${it.userPhone || "-"}`; const resText = `${it.resourceId || "-"} / ${it.resourceTitle || "-"}`; const stateText = it.resourceState === "DELETED" ? "资源已删除" : it.resourceState === "OFFLINE" ? "资源已下架" : "资源在线"; const typeText = it.resourceType === "VIP" ? "VIP" : "免费"; const currentTypeText = it.currentResourceType === "VIP" ? "VIP" : it.currentResourceType === "FREE" ? "免费" : "-"; const driftText = it.currentResourceType && it.currentResourceType !== it.resourceType ? "(类型已变更)" : ""; openModal( "下载记录详情", [ el( "div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "下载时间"), el("div", {}, formatDateTime(it.downloadedAt)) ), el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "用户"), el("div", {}, userText)), el( "div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "资源"), el("div", {}, resText) ), el( "div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "类型"), el("div", {}, `下载时:${typeText} / 当前:${currentTypeText}${driftText}`) ), el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "资源状态"), el("div", {}, stateText)), el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "Ref"), el("div", {}, String(it.ref || "-"))), el("div", { class: "card", style: "padding:14px; border-radius: 10px; margin-bottom: 10px;" }, el("div", { class: "muted" }, "IP"), el("div", {}, String(it.ip || "-"))), el( "div", { class: "card", style: "padding:14px; border-radius: 10px;" }, el("div", { class: "muted" }, "User-Agent"), el("div", {}, String(it.userAgent || "-")) ), ], [el("button", { class: "btn", onclick: closeModal }, "关闭")], "ri-download-cloud-line" ); } if (action === "view-message") { const m = messageMap.get(String(id)); if (!m) return; const header = el( "div", { class: "muted" }, `用户:${m.userId} / ${m.userPhone || "-"} · 来源:${m.senderType === "ADMIN" ? "管理员" : "系统"} · 发送:${formatDateTime(m.createdAt)} · 已读:${m.read ? formatDateTime(m.readAt) : "未读"}` ); const titleEl = el("div", { style: "font-weight: 650; margin-top: 6px;" }, String(m.title || "")); const contentEl = el("pre", { class: "code", style: "white-space: pre-wrap;" }, formatMessageText(m.content || "")); openModal( `消息 #${m.id}`, [header, titleEl, contentEl], [ el("button", { class: "btn", onclick: () => copyText(m.content || "") }, "复制内容"), el("button", { class: "btn", onclick: closeModal }, "关闭"), ], "ri-mail-open-line", { resizable: true, size: "lg" } ); } if (action === "del-message") { const m = messageMap.get(String(id)); if (!m) return; const r = await Swal.fire({ title: "删除消息?", text: `将删除消息 #${m.id}(用户:${m.userPhone || m.userId})`, icon: "warning", showCancelButton: true, confirmButtonText: "删除", cancelButtonText: "取消", confirmButtonColor: "var(--danger)", }); if (!r.isConfirmed) return; try { await apiFetch(`/admin/messages/${m.id}`, { method: "DELETE" }); showToastSuccess("已删除"); await loadAdminMessages(); } catch (e) { showToastError(e.detail?.error || e.status || "删除失败"); if (e.status === 401) window.location.href = "/ui/admin/login"; } } if (action === "del-order") { Swal.fire({ title: "删除订单?", text: `订单号:${id}`, icon: "warning", showCancelButton: true, confirmButtonText: "删除", cancelButtonText: "取消", confirmButtonColor: "var(--danger)", }).then(async (r) => { if (!r.isConfirmed) return; try { await apiFetch(`/admin/orders/${id}`, { method: "DELETE" }); await loadOrders(); } catch (e) { if (e.status === 401) window.location.href = "/ui/admin/login"; Swal.fire({ icon: "error", title: "删除失败", text: e.detail?.error || e.status || "未知错误" }); } }); } }); function openResourceEditorModal({ mode, res }) { const isEdit = mode === "edit"; const field = (labelText, inputEl) => el("div", {}, el("div", { class: "label" }, labelText), inputEl); const msg = el("div", { class: "form-msg muted" }, ""); const titleInput = el("input", { class: "input", placeholder: "标题", value: isEdit ? res.title : "" }); const keywordsInput = el("input", { class: "input", placeholder: "关键字(逗号分隔,可选)", value: isEdit && Array.isArray(res.tags) ? res.tags.join(",") : "" }); function makeSegmented(items, initialValue, { disabled, onChange } = {}) { let value = String(initialValue || items[0]?.value || ""); const wrap = el("div", { class: "segmented", role: "group" }); function apply(v) { value = String(v); Array.from(wrap.querySelectorAll("button")).forEach((b) => b.classList.toggle("active", b.getAttribute("data-value") === value)); if (typeof onChange === "function") onChange(value); } items.forEach((it) => { const b = el("button", { type: "button", class: "btn btn-sm", "data-value": String(it.value) }, String(it.label)); if (disabled) b.disabled = true; b.addEventListener("click", (evt) => { evt.preventDefault(); if (disabled) return; apply(it.value); }); wrap.appendChild(b); }); apply(value); return { root: wrap, getValue: () => value, setValue: (v) => apply(v), setInvalid: (bad) => wrap.classList.toggle("is-invalid", Boolean(bad)), }; } const typeHelp = el("div", { class: "help" }, ""); function refreshTypeHelp(v) { const val = String(v || ""); typeHelp.textContent = val === "VIP" ? "VIP:仅会员可访问。" : "FREE:所有用户可访问。"; } const typeSeg = makeSegmented( [ { value: "FREE", label: "FREE" }, { value: "VIP", label: "VIP" }, ], isEdit ? res.type : "FREE", { onChange: refreshTypeHelp } ); refreshTypeHelp(typeSeg.getValue()); const statusHelp = el("div", { class: "help" }, ""); function refreshStatusHelp(v) { const val = String(v || ""); statusHelp.textContent = val === "ONLINE" ? "上线:前台可见。" : val === "OFFLINE" ? "下线:前台不可见。" : "草稿:用于编辑中,前台不可见。"; } const statusSeg = makeSegmented( [ { value: "ONLINE", label: "上线" }, { value: "OFFLINE", label: "下线" }, { value: "DRAFT", label: "草稿" }, ], isEdit ? res.status : "ONLINE", { onChange: refreshStatusHelp } ); statusSeg.root.classList.add("nowrap"); refreshStatusHelp(statusSeg.getValue()); const defaultCoverUrl = "/static/images/resources/default.png"; const tempCoverUploads = new Set(); function extractUploadNameFromUrl(value) { const m = String(value || "").match(/\/static\/uploads\/([0-9a-f]{32}(?:\.[a-z0-9]+)?)$/i); return m ? String(m[1] || "") : ""; } async function deleteUploadByName(name) { const n = String(name || "").trim(); if (!n) return; try { await apiFetch(`/admin/uploads/${encodeURIComponent(n)}`, { method: "DELETE" }); } catch (e) {} } async function cleanupTempCoverUploads(keepUrl) { const keepName = extractUploadNameFromUrl(keepUrl); const tasks = []; for (const name of Array.from(tempCoverUploads)) { if (keepName && name.toLowerCase() === keepName.toLowerCase()) continue; tasks.push(deleteUploadByName(name)); } tempCoverUploads.clear(); if (tasks.length) await Promise.allSettled(tasks); } const coverUrlInput = el("input", { class: "input", placeholder: "封面图 URL(可选)", value: isEdit ? res.coverUrl || "" : "" }); const coverPreview = el("img", { class: "resource-detail-cover cover-picker-img", src: (coverUrlInput.value || "").trim() ? coverUrlInput.value : defaultCoverUrl, alt: "cover", role: "button", tabindex: "0", }); const coverFile = el("input", { type: "file", accept: "image/*", style: "display:none" }); coverPreview.addEventListener("click", () => coverFile.click()); coverPreview.addEventListener("keydown", (evt) => { if (evt.key === "Enter" || evt.key === " ") { evt.preventDefault(); coverFile.click(); } }); coverFile.addEventListener("change", async () => { const f = coverFile.files && coverFile.files[0]; if (!f) return; msg.textContent = "上传中..."; try { const detail = await adminUploadFileMeta(f); const url = detail?.url || ""; if (detail?.name) tempCoverUploads.add(String(detail.name)); coverUrlInput.value = url; coverPreview.src = url; coverPreview.classList.remove("is-placeholder"); msg.textContent = "封面已更新"; } catch (e) { msg.textContent = `上传失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } finally { coverFile.value = ""; } }); let coverPreviewTimer = null; function refreshCoverPreview() { const url = coverUrlInput.value.trim(); if (!url) { coverPreview.src = defaultCoverUrl; coverPreview.classList.add("is-placeholder"); return; } coverPreview.src = url; coverPreview.classList.remove("is-placeholder"); } coverUrlInput.addEventListener("input", () => { if (coverPreviewTimer) clearTimeout(coverPreviewTimer); coverPreviewTimer = setTimeout(refreshCoverPreview, 250); }); coverUrlInput.addEventListener("blur", refreshCoverPreview); refreshCoverPreview(); function normalizeKeywordsValue(raw) { const text = (raw || "").toString(); const parts = text .split(/[,,\n\r\t]+/g) .map((s) => s.trim()) .filter(Boolean); const uniq = []; const seen = new Set(); parts.forEach((p) => { const key = p.toLowerCase(); if (seen.has(key)) return; seen.add(key); uniq.push(p); }); return uniq.join(","); } keywordsInput.addEventListener("blur", () => { keywordsInput.value = normalizeKeywordsValue(keywordsInput.value); }); const md = buildMarkdownEditor({ initialValue: isEdit ? res.summary || "" : "", msgEl: msg }); const modeSeg = makeSegmented( [ { value: "CREATE", label: "创建仓库" }, { value: "BIND", label: "绑定仓库" }, ], isEdit ? "BIND" : "CREATE", { disabled: isEdit } ); const modeHelp = el("div", { class: "help" }, isEdit ? "编辑模式下仓库模式固定为绑定仓库。" : "创建仓库会初始化 README.md;绑定仓库可选择分支/标签。"); const createOwnerInput = el("input", { class: "input", placeholder: "仓库 Owner(可选,留空则创建到 Token 用户)" }); const createRepoInput = el("input", { class: "input", placeholder: "仓库名称(可选,留空则自动生成)" }); const createPrivateSeg = makeSegmented( [ { value: "0", label: "公开" }, { value: "1", label: "私有" }, ], "0" ); const repoFullInput = el("input", { class: "input", placeholder: "仓库(owner/repo 或 URL/SSH 地址)" }); const refInput = el("input", { class: "input", placeholder: "默认引用(AUTO/分支/标签)", value: "AUTO" }); const refPickSelect = el("select", { class: "input" }, el("option", { value: "" }, "选择分支/标签(可选)")); refPickSelect.addEventListener("change", () => { if (refPickSelect.value) refInput.value = refPickSelect.value; }); const pickRepoBtn = el("button", { class: "btn" }, "选择仓库"); const refreshRefBtn = el("button", { class: "btn btn-ghost" }, "刷新分支/标签"); const repoHint = el("div", { class: "help" }, ""); async function loadRepoAndRefs(prefer) { const parsed = parseRepoInput(repoFullInput.value); if (!parsed) { repoHint.textContent = "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)"; return; } try { const info = await apiFetch(`/admin/gogs/repo?owner=${encodeURIComponent(parsed.owner)}&repo=${encodeURIComponent(parsed.repo)}`); const wanted = (prefer || refInput.value || "AUTO").toString().trim() || "AUTO"; await fillRefSelect(parsed.owner, parsed.repo, refPickSelect, wanted); if (!refInput.value.trim()) refInput.value = wanted; repoHint.textContent = `仓库已识别:${info.fullName || `${parsed.owner}/${parsed.repo}`};默认分支:${(info.defaultBranch || "master").trim()}`; } catch (e) { const errCode = e.detail?.error || e.status || "unknown"; const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : ""; repoHint.textContent = `仓库加载失败:${errCode}${upstream}`; showToastError(`仓库加载失败:${errCode}`); if (e.status === 401) window.location.href = "/ui/admin/login"; } } pickRepoBtn.addEventListener("click", () => { const parsed = parseRepoInput(repoFullInput.value); const initialOwner = parsed ? parsed.owner : ""; openRepoPicker(initialOwner, async ({ owner, name, fullName }) => { if (fullName) repoFullInput.value = fullName; else if (owner && name) repoFullInput.value = `${owner}/${name}`; await loadRepoAndRefs("AUTO"); }); }); refreshRefBtn.addEventListener("click", async () => { await loadRepoAndRefs(refInput.value.trim() || "AUTO"); }); repoFullInput.addEventListener("blur", async () => { if (!repoFullInput.value.trim()) return; await loadRepoAndRefs(refInput.value.trim() || "AUTO"); }); const createOwnerWrap = field("仓库 Owner(可选)", createOwnerInput); const createRepoWrap = field("仓库名称(可选)", createRepoInput); const createPrivateWrap = el("div", {}, el("div", { class: "label" }, "公开/私有"), createPrivateSeg.root, el("div", { class: "help" }, "创建仓库时生效。")); const repoFullWrap = el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "仓库(owner/repo 或 URL/SSH)"), repoFullInput); const refWrap = field("默认引用", refInput); const refPickWrap = field("选择分支/标签", refPickSelect); const repoActionsWrap = el( "div", { style: "grid-column: 1 / -1" }, el("div", { class: "toolbar", style: "margin:0" }, pickRepoBtn, refreshRefBtn) ); function refreshMode() { const isCreate = modeSeg.getValue() === "CREATE"; [createOwnerWrap, createRepoWrap, createPrivateWrap].forEach((n) => (n.style.display = isCreate ? "" : "none")); [repoFullWrap, refWrap, refPickWrap, repoActionsWrap].forEach((n) => (n.style.display = isCreate ? "none" : "")); repoHint.style.display = isCreate ? "none" : ""; } modeSeg.root.addEventListener("click", refreshMode); if (isEdit) { repoFullInput.value = `${res.repoOwner}/${res.repoName}`; refInput.value = (res.defaultRef || "AUTO").toString().trim() || "AUTO"; refreshMode(); setTimeout(() => loadRepoAndRefs(refInput.value.trim() || "AUTO"), 0); } else { refreshMode(); } function makeCollapse(title, bodyNodes, open) { const icon = el("i", { class: "ri-arrow-down-s-line collapse-icon" }); const head = el("button", { type: "button", class: "collapse-head" }, el("span", {}, title), icon); const body = el("div", { class: "collapse-body" }, ...bodyNodes); const wrap = el("div", { class: "collapse", "data-open": open ? "1" : "0" }, head, body); function setOpen(next) { wrap.setAttribute("data-open", next ? "1" : "0"); } head.addEventListener("click", (evt) => { evt.preventDefault(); const cur = wrap.getAttribute("data-open") === "1"; setOpen(!cur); }); return { root: wrap, setOpen }; } const baseSection = makeCollapse( "基础属性", [ el( "div", { class: "form-grid" }, el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "标题"), titleInput), el("div", {}, el("div", { class: "label" }, "类型"), typeSeg.root, typeHelp), el("div", {}, el("div", { class: "label" }, "状态"), statusSeg.root, statusHelp), el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "关键字(逗号分隔,可选)"), keywordsInput, el("div", { class: "help" }, "支持中英文逗号/换行分隔,失焦时自动去重规范化。")) ), ], true ); const clearCoverBtn = el("button", { type: "button", class: "btn btn-ghost" }, "清空"); clearCoverBtn.addEventListener("click", (evt) => { evt.preventDefault(); coverUrlInput.value = ""; try { coverUrlInput.dispatchEvent(new Event("input", { bubbles: true })); } catch (e) {} refreshCoverPreview(); }); const coverSection = makeCollapse( "封面", [ el( "div", { class: "form-grid" }, el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "help" }, "点击图片选择并上传封面")), el("div", { style: "grid-column: 1 / -1" }, coverPreview), el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "封面图 URL(可选)"), coverUrlInput), el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "toolbar", style: "margin:0" }, clearCoverBtn, coverFile)) ), ], false ); const repoModeSection = makeCollapse( "仓库", [ el( "div", { class: "form-grid" }, el("div", { style: "grid-column: 1 / -1" }, el("div", { class: "label" }, "仓库模式"), modeSeg.root, modeHelp), el("div", { style: "grid-column: 1 / -1" }, repoHint), createOwnerWrap, createRepoWrap, createPrivateWrap, repoFullWrap, refWrap, refPickWrap, repoActionsWrap ), ], false ); const contentSection = el("div", { class: "form-section" }, el("div", { class: "form-section-title" }, "内容编辑"), md.root); const side = el("div", { class: "res-form-side" }, baseSection.root, coverSection.root, repoModeSection.root); const main = el("div", { class: "res-form-main" }, contentSection, msg); const layout = el("div", { class: "res-form-layout" }, side, main); function clearInvalid() { [titleInput, keywordsInput, coverUrlInput, repoFullInput, refInput].forEach((x) => x.classList.remove("is-invalid")); typeSeg.setInvalid(false); statusSeg.setInvalid(false); modeSeg.setInvalid(false); createPrivateSeg.setInvalid(false); } function invalid(elOrSeg, text) { if (elOrSeg === titleInput || elOrSeg === keywordsInput || elOrSeg === typeSeg || elOrSeg === statusSeg) baseSection.setOpen(true); if (elOrSeg === coverUrlInput) coverSection.setOpen(true); if (elOrSeg === repoFullInput || elOrSeg === refInput || elOrSeg === refPickSelect || elOrSeg === modeSeg || elOrSeg === createPrivateSeg) repoModeSection.setOpen(true); if (elOrSeg && typeof elOrSeg.setInvalid === "function") elOrSeg.setInvalid(true); else if (elOrSeg && elOrSeg.classList) elOrSeg.classList.add("is-invalid"); msg.textContent = String(text || ""); showToastError(text || "请检查填写内容"); try { if (elOrSeg && elOrSeg.focus) elOrSeg.focus(); } catch (e) {} } async function fetchReadmeText() { const parsed = parseRepoInput(repoFullInput.value); if (!parsed) throw Object.assign(new Error("invalid_repo"), { detail: { error: "invalid_repo" } }); const ref = refInput.value.trim() || "AUTO"; const url = `/admin/gogs/file-text?owner=${encodeURIComponent(parsed.owner)}&repo=${encodeURIComponent(parsed.repo)}&ref=${encodeURIComponent(ref)}&path=${encodeURIComponent("README.md")}`; const resp = await apiFetch(url); return (resp.text || "").toString(); } async function loadReadmeIntoEditor() { msg.textContent = ""; const cur = (md.textarea.value || "").toString(); if (cur.trim()) { try { const r = await Swal.fire({ title: "用 README.md 覆盖当前内容?", text: "覆盖后当前未保存内容将丢失。", icon: "warning", showCancelButton: true, confirmButtonText: "覆盖", cancelButtonText: "取消", confirmButtonColor: "var(--danger)", }); if (!r.isConfirmed) return; } catch (e) {} } try { msg.textContent = "加载 README.md 中..."; const text = await fetchReadmeText(); md.setText(text); md.syncReadme.checked = true; msg.textContent = "已从 README.md 导入"; showToastSuccess("README.md 已导入"); } catch (e) { const code = e.detail?.error || e.status || e.message || "unknown"; msg.textContent = `README.md 加载失败:${code}`; showToastError(`README.md 加载失败:${code}`); if (e.status === 401) window.location.href = "/ui/admin/login"; } } const loadReadmeBtn = el("button", { type: "button", class: "btn btn-sm" }, "加载 README.md"); loadReadmeBtn.addEventListener("click", async (evt) => { evt.preventDefault(); await loadReadmeIntoEditor(); }); function refreshReadmeBtn() { const allow = isEdit || modeSeg.getValue() === "BIND"; const hasRepo = Boolean(parseRepoInput(repoFullInput.value)); loadReadmeBtn.disabled = !(allow && hasRepo); } repoFullInput.addEventListener("input", refreshReadmeBtn); modeSeg.root.addEventListener("click", refreshReadmeBtn); refreshReadmeBtn(); const spacer = Array.from(md.toolbarEl.children).find((n) => n && n.style && n.style.flex === "1"); if (spacer) md.toolbarEl.insertBefore(loadReadmeBtn, spacer); else md.toolbarEl.appendChild(loadReadmeBtn); if (isEdit && !(md.textarea.value || "").trim()) { setTimeout(() => loadReadmeIntoEditor(), 0); } async function submitAndMaybeView(openAfter) { msg.textContent = ""; clearInvalid(); const title = titleInput.value.trim(); if (!title) { invalid(titleInput, "请填写标题"); return; } if (isEdit) { const parsed = parseRepoInput(repoFullInput.value); if (!parsed) { invalid(repoFullInput, "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)"); return; } msg.textContent = "保存中,请稍候..."; try { await apiFetch(`/admin/resources/${res.id}`, { method: "PUT", body: { title, summary: md.textarea.value.trim(), keywords: normalizeKeywordsValue(keywordsInput.value), coverUrl: coverUrlInput.value.trim(), type: typeSeg.getValue(), status: statusSeg.getValue(), repoOwner: parsed.owner, repoName: parsed.repo, defaultRef: refInput.value.trim() || "AUTO", syncReadme: md.syncReadme.checked, }, }); await cleanupTempCoverUploads(coverUrlInput.value.trim()); await closeModal(true); await loadResources(); if (openAfter) window.open(`/ui/resources/${res.id}`, "_blank"); showToastSuccess("已保存"); } catch (e) { msg.textContent = `保存失败:${e.detail?.error || e.status || "unknown"}`; showToastError(e.detail?.error || e.status || "保存失败"); if (e.status === 401) window.location.href = "/ui/admin/login"; } return; } msg.textContent = "创建中,请稍候..."; const body = { title, summary: md.textarea.value.trim(), keywords: normalizeKeywordsValue(keywordsInput.value), coverUrl: coverUrlInput.value.trim(), type: typeSeg.getValue(), status: statusSeg.getValue(), syncReadme: md.syncReadme.checked, }; if (modeSeg.getValue() === "CREATE") { body.createRepo = true; body.repoOwner = createOwnerInput.value.trim(); body.repoName = createRepoInput.value.trim(); body.repoPrivate = createPrivateSeg.getValue() === "1"; } else { const parsed = parseRepoInput(repoFullInput.value); if (!parsed) { invalid(repoFullInput, "请填写正确的仓库格式:owner/repo(或直接粘贴仓库地址)"); return; } body.createRepo = false; body.repoOwner = parsed.owner; body.repoName = parsed.repo; body.defaultRef = refInput.value.trim() || "AUTO"; } try { const resp = await apiFetch("/admin/resources", { method: "POST", body }); await cleanupTempCoverUploads(coverUrlInput.value.trim()); await closeModal(true); await loadResources(); if (openAfter) window.open(`/ui/resources/${resp.id}`, "_blank"); showToastSuccess("已创建"); } catch (e) { const code = e.detail?.error || e.status || "unknown"; const upstream = e.detail?.status ? `(Gogs: ${e.detail.status})` : ""; const detailMsg = e.detail?.message ? `\n${e.detail.message}` : ""; const detailUrl = e.detail?.url ? `\n${e.detail.url}` : ""; msg.textContent = `${isEdit ? "保存" : "创建"}失败:${code}${upstream}${detailMsg}${detailUrl}`; showToastError(`${isEdit ? "保存" : "创建"}失败:${code}`); if (e.status === 401) window.location.href = "/ui/admin/login"; } } const primaryBtn = el("button", { class: "btn btn-primary" }, isEdit ? "保存" : "创建"); const viewBtn = el("button", { class: "btn" }, isEdit ? "保存并查看" : "创建并查看"); primaryBtn.addEventListener("click", async () => { primaryBtn.disabled = true; viewBtn.disabled = true; try { await submitAndMaybeView(false); } finally { primaryBtn.disabled = false; viewBtn.disabled = false; } }); viewBtn.addEventListener("click", async () => { primaryBtn.disabled = true; viewBtn.disabled = true; try { await submitAndMaybeView(true); } finally { primaryBtn.disabled = false; viewBtn.disabled = false; } }); const initialDraft = JSON.stringify({ title: titleInput.value, keywords: keywordsInput.value, type: typeSeg.getValue(), status: statusSeg.getValue(), coverUrl: coverUrlInput.value, summary: md.textarea.value, syncReadme: md.syncReadme.checked, repoMode: modeSeg.getValue(), createOwner: createOwnerInput.value, createRepo: createRepoInput.value, createPrivate: createPrivateSeg.getValue(), repoFull: repoFullInput.value, ref: refInput.value, }); function isDirty() { const now = JSON.stringify({ title: titleInput.value, keywords: keywordsInput.value, type: typeSeg.getValue(), status: statusSeg.getValue(), coverUrl: coverUrlInput.value, summary: md.textarea.value, syncReadme: md.syncReadme.checked, repoMode: modeSeg.getValue(), createOwner: createOwnerInput.value, createRepo: createRepoInput.value, createPrivate: createPrivateSeg.getValue(), repoFull: repoFullInput.value, ref: refInput.value, }); return now !== initialDraft; } openModal(isEdit ? `编辑资源 #${res.id}` : "新增资源", [layout], [el("button", { class: "btn", onclick: closeModal }, "取消"), viewBtn, primaryBtn], isEdit ? "ri-edit-circle-line" : "ri-add-box-line", { resizable: true, size: "lg", onResize: (size) => md.setViewByModalSize(size), onKeydown: (evt) => { const key = String(evt.key || ""); if ((evt.ctrlKey || evt.metaKey) && key.toLowerCase() === "s") { evt.preventDefault(); primaryBtn.click(); return; } if ((evt.ctrlKey || evt.metaKey) && key === "Enter") { evt.preventDefault(); primaryBtn.click(); return; } if (key === "Escape") { evt.preventDefault(); closeModal(); } }, beforeClose: async () => { if (!isDirty()) { await cleanupTempCoverUploads(""); return true; } try { const r = await Swal.fire({ title: "放弃未保存的修改?", text: "当前内容尚未保存,关闭后将丢失。", icon: "warning", showCancelButton: true, confirmButtonText: "放弃修改", cancelButtonText: "继续编辑", confirmButtonColor: "var(--danger)", }); if (!r.isConfirmed) return false; await cleanupTempCoverUploads(""); return true; } catch (e) { await cleanupTempCoverUploads(""); return true; } }, }); } createResOpenBtn.addEventListener("click", () => { openResourceEditorModal({ mode: "create" }); }); resSearchBtn.addEventListener("click", async () => { resState.page = 1; await loadResources(); }); resPrevPage.addEventListener("click", async () => { resState.page = Math.max(1, resState.page - 1); await loadResources(); }); resNextPage.addEventListener("click", async () => { resState.page = resState.page + 1; await loadResources(); }); userSearchBtn.addEventListener("click", async () => { userState.page = 1; await loadUsers(); }); if (dlSearchBtn) { dlSearchBtn.addEventListener("click", async () => { downloadLogState.page = 1; await loadDownloadLogs(); }); } if (dlQ) { dlQ.addEventListener("keydown", async (evt) => { if (evt.key !== "Enter") return; evt.preventDefault(); downloadLogState.page = 1; await loadDownloadLogs(); }); } if (dlPrevPage) { dlPrevPage.addEventListener("click", async () => { downloadLogState.page = Math.max(1, downloadLogState.page - 1); await loadDownloadLogs(); }); } if (dlNextPage) { dlNextPage.addEventListener("click", async () => { downloadLogState.page += 1; await loadDownloadLogs(); }); } if (msgSearchBtn) { msgSearchBtn.addEventListener("click", async () => { messageState.page = 1; await loadAdminMessages(); }); } if (msgQ) { msgQ.addEventListener("keydown", async (evt) => { if (evt.key !== "Enter") return; evt.preventDefault(); messageState.page = 1; await loadAdminMessages(); }); } if (msgPrevPage) { msgPrevPage.addEventListener("click", async () => { messageState.page = Math.max(1, messageState.page - 1); await loadAdminMessages(); }); } if (msgNextPage) { msgNextPage.addEventListener("click", async () => { messageState.page += 1; await loadAdminMessages(); }); } if (msgSendBtn) { msgSendBtn.addEventListener("click", async () => { const phoneInput = el("input", { class: "input", placeholder: "用户手机号(已注册)" }); const userIdInput = el("input", { class: "input", placeholder: "用户ID(可选,优先于手机号)" }); const titleInput = el("input", { class: "input", placeholder: "标题(必填)" }); const contentInput = el("textarea", { class: "input", style: "min-height: 180px; resize: vertical;", placeholder: "内容(必填)" }); const msg = el("div", { class: "muted" }, ""); openModal( "发送消息", [ el("div", { class: "muted" }, "发送给单个用户。填写用户ID或手机号即可。"), el("label", { class: "label" }, "用户ID"), userIdInput, el("label", { class: "label" }, "手机号"), phoneInput, el("label", { class: "label" }, "标题"), titleInput, el("label", { class: "label" }, "内容"), contentInput, msg, ], [ el("button", { class: "btn", onclick: closeModal }, "取消"), el( "button", { class: "btn btn-primary", onclick: async () => { msg.textContent = ""; try { const userId = Number((userIdInput.value || "").trim() || 0) || 0; await apiFetch("/admin/messages/send", { method: "POST", body: { userId, phone: phoneInput.value.trim(), title: titleInput.value.trim(), content: contentInput.value }, }); closeModal(); showToastSuccess("发送成功"); await loadAdminMessages(); } catch (e) { msg.textContent = `发送失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } }, }, "发送" ), ], "ri-notification-3-line" ); }); } if (msgBroadcastBtn) { msgBroadcastBtn.addEventListener("click", async () => { const audienceSelect = el( "select", { class: "input" }, el("option", { value: "ALL" }, "全部用户"), el("option", { value: "VIP" }, "仅 VIP 用户"), el("option", { value: "NONVIP" }, "仅非 VIP 用户") ); const titleInput = el("input", { class: "input", placeholder: "标题(必填)" }); const contentInput = el("textarea", { class: "input", style: "min-height: 200px; resize: vertical;", placeholder: "内容(必填)" }); const msg = el("div", { class: "muted" }, ""); openModal( "群发消息", [ el("div", { class: "muted" }, "将为符合条件的每个用户生成一条站内消息。"), el("label", { class: "label" }, "发送范围"), audienceSelect, el("label", { class: "label" }, "标题"), titleInput, el("label", { class: "label" }, "内容"), contentInput, msg, ], [ el("button", { class: "btn", onclick: closeModal }, "取消"), el( "button", { class: "btn btn-primary", onclick: async () => { msg.textContent = ""; try { const r = await Swal.fire({ title: "确认群发?", text: "将立即发送站内消息给符合条件的用户。", icon: "warning", showCancelButton: true, confirmButtonText: "确认发送", cancelButtonText: "取消", confirmButtonColor: "var(--brand)", }); if (!r.isConfirmed) return; const resp = await apiFetch("/admin/messages/broadcast", { method: "POST", body: { audience: audienceSelect.value, title: titleInput.value.trim(), content: contentInput.value }, }); closeModal(); showToastSuccess(`已发送 ${resp.count || 0} 条`); await loadAdminMessages(); } catch (e) { msg.textContent = `发送失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } }, }, "发送" ), ], "ri-megaphone-line" ); }); } userPrevPage.addEventListener("click", async () => { userState.page = Math.max(1, userState.page - 1); await loadUsers(); }); userNextPage.addEventListener("click", async () => { userState.page = userState.page + 1; await loadUsers(); }); orderRefreshBtn.addEventListener("click", async () => { orderState.page = 1; await loadOrders(); }); orderCreateBtn.addEventListener("click", async () => { const phoneInput = el("input", { class: "input", placeholder: "用户手机号(必须已注册)" }); const planSelect = el("select", { class: "input" }, el("option", { value: "" }, "加载中...")); const statusSelect = el( "select", { class: "input" }, el("option", { value: "PENDING" }, "待支付"), el("option", { value: "PAID" }, "已支付"), el("option", { value: "CLOSED" }, "已关闭"), el("option", { value: "FAILED" }, "失败") ); const msg = el("div", { class: "muted" }, ""); openModal( "新建订单", [el("label", { class: "label" }, "用户手机号"), phoneInput, el("label", { class: "label" }, "方案"), planSelect, el("label", { class: "label" }, "状态"), statusSelect, el("div", { class: "muted" }, "设置为“已支付”会自动延长该用户会员。"), msg], [ el("button", { class: "btn", onclick: closeModal }, "取消"), el( "button", { class: "btn btn-primary", onclick: async () => { msg.textContent = ""; const phone = phoneInput.value.trim(); const planId = Number(planSelect.value || "0"); if (!phone || planId <= 0) { msg.textContent = "请填写手机号并选择方案"; return; } try { await apiFetch("/admin/orders", { method: "POST", body: { userPhone: phone, planId, status: statusSelect.value } }); closeModal(); orderState.page = 1; await loadOrders(); } catch (e) { msg.textContent = `创建失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } }, }, "创建" ), ], "ri-add-circle-line" ); try { const plans = await apiFetch("/admin/plans"); planSelect.innerHTML = ""; (plans || []).filter((p) => p && p.enabled).forEach((p) => { planSelect.appendChild(el("option", { value: String(p.id) }, `${p.name}(${p.durationDays}天 / ${formatCents(p.priceCents)})`)); }); if (!planSelect.children.length) planSelect.appendChild(el("option", { value: "" }, "暂无可用方案")); } catch (e) { planSelect.innerHTML = ""; planSelect.appendChild(el("option", { value: "" }, "方案加载失败")); if (e.status === 401) window.location.href = "/ui/admin/login"; } }); orderStatusFilter.addEventListener("change", async () => { orderState.page = 1; await loadOrders(); }); orderPrevPage.addEventListener("click", async () => { orderState.page = Math.max(1, orderState.page - 1); await loadOrders(); }); orderNextPage.addEventListener("click", async () => { orderState.page = orderState.page + 1; await loadOrders(); }); /* inline creation handlers removed; now using modal-based creation */ adminLogoutBtn.addEventListener("click", async () => { await apiFetch("/admin/auth/logout", { method: "POST" }); window.location.href = "/ui/admin/login"; }); try { await activate("overview"); } catch (e) { if (e.status === 401) window.location.href = "/ui/admin/login"; } } async function main() { const page = document.body.getAttribute("data-page") || ""; try { await initTopbar(); if (page === "index") await pageIndex(); if (page === "resources") await pageIndex(); if (page === "login") await pageLogin(); if (page === "register") await pageRegister(); if (page === "me") await pageMe(); if (page === "messages") await pageMessages(); if (page === "vip") await pageVip(); if (page === "resource_detail") await pageResourceDetail(); if (page === "admin_login") await pageAdminLogin(); if (page === "admin") await pageAdmin(); } catch (e) { showToastError(e?.detail?.error || e?.status || e?.message || "页面初始化失败"); } } main();