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 cfgCacheSaveBtn = document.getElementById("cfgCacheSaveBtn"); const cfgCacheResetBtn = document.getElementById("cfgCacheResetBtn"); const cfgRedisUrl = document.getElementById("cfgRedisUrl"); const cfgClearRedisUrl = document.getElementById("cfgClearRedisUrl"); const cfgRedisTestBtn = document.getElementById("cfgRedisTestBtn"); const cfgCacheMsg = document.getElementById("cfgCacheMsg"); const cfgStorageSaveBtn = document.getElementById("cfgStorageSaveBtn"); const cfgStorageResetBtn = document.getElementById("cfgStorageResetBtn"); const cfgStorageProvider = document.getElementById("cfgStorageProvider"); const cfgOssEndpoint = document.getElementById("cfgOssEndpoint"); const cfgOssBucket = document.getElementById("cfgOssBucket"); const cfgOssAccessKeyId = document.getElementById("cfgOssAccessKeyId"); const cfgOssAccessKeySecret = document.getElementById("cfgOssAccessKeySecret"); const cfgClearOssAccessKeySecret = document.getElementById("cfgClearOssAccessKeySecret"); const cfgOssUploadPrefix = document.getElementById("cfgOssUploadPrefix"); const cfgOssPublicBaseUrl = document.getElementById("cfgOssPublicBaseUrl"); 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 = ""; if (cfgCacheMsg) cfgCacheMsg.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 (cfgRedisUrl) cfgRedisUrl.value = ""; if (cfgClearRedisUrl) cfgClearRedisUrl.checked = false; if (cfgStorageProvider) cfgStorageProvider.value = "AUTO"; if (cfgOssEndpoint) cfgOssEndpoint.value = ""; if (cfgOssBucket) cfgOssBucket.value = ""; if (cfgOssAccessKeyId) cfgOssAccessKeyId.value = ""; if (cfgOssAccessKeySecret) cfgOssAccessKeySecret.value = ""; if (cfgClearOssAccessKeySecret) cfgClearOssAccessKeySecret.checked = false; if (cfgOssUploadPrefix) cfgOssUploadPrefix.value = ""; if (cfgOssPublicBaseUrl) cfgOssPublicBaseUrl.value = ""; 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 (cfgRedisUrl) cfgRedisUrl.value = ""; if (cfgClearRedisUrl) cfgClearRedisUrl.checked = false; if (cfgRedisUrl) { const safe = String(resp.cache?.redisUrl || "").trim(); if (resp.cache?.hasRedisUrl) cfgRedisUrl.placeholder = safe ? `已配置,留空保持不变(${safe})` : "已配置,留空保持不变"; else cfgRedisUrl.placeholder = "例如:redis://127.0.0.1:6379/0(无密码)或 redis://:password@127.0.0.1:6379/0(有密码)"; } if (cfgStorageProvider) cfgStorageProvider.value = String(resp.storage?.provider || "AUTO").trim().toUpperCase() || "AUTO"; if (cfgOssEndpoint) cfgOssEndpoint.value = String(resp.storage?.oss?.endpoint || "").trim(); if (cfgOssBucket) cfgOssBucket.value = String(resp.storage?.oss?.bucket || "").trim(); if (cfgOssAccessKeyId) cfgOssAccessKeyId.value = String(resp.storage?.oss?.accessKeyId || "").trim(); if (cfgOssUploadPrefix) cfgOssUploadPrefix.value = String(resp.storage?.oss?.uploadPrefix || "").trim(); if (cfgOssPublicBaseUrl) cfgOssPublicBaseUrl.value = String(resp.storage?.oss?.publicBaseUrl || "").trim(); if (cfgOssAccessKeySecret) { if (resp.storage?.oss?.hasAccessKeySecret) cfgOssAccessKeySecret.placeholder = "已配置,留空保持不变"; else cfgOssAccessKeySecret.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 = "未配置,填写后保存"; } const configActiveDb = String(resp.db?.active || "").trim().toLowerCase(); let effectiveDb = configActiveDb; let connectOk = true; let connectErr = ""; try { const st = await apiFetch("/admin/db/status"); effectiveDb = String(st.probe?.effective || configActiveDb || "").trim().toLowerCase() || configActiveDb; connectOk = Boolean(st.probe?.connectOk); connectErr = String(st.probe?.error || "").trim(); } catch (e) {} if (cfgDbActive) { const suffix = connectOk ? "(OK)" : connectErr ? `(失败:${connectErr})` : "(失败)"; cfgDbActive.textContent = `当前连接:${effectiveDb || "-"}${effectiveDb ? suffix : ""}`; } const mysqlConfigured = Boolean( String(resp.db?.mysql?.host || "").trim() && String(resp.db?.mysql?.user || "").trim() && String(resp.db?.mysql?.database || "").trim() ); const hints = []; if (configActiveDb === "sqlite" && mysqlConfigured) { hints.push("提示:已配置 MySQL,但当前仍在 SQLite。点击“切换到 MySQL(迁移)”才会生效。"); } if (configActiveDb && effectiveDb && configActiveDb !== effectiveDb) { hints.push(`提示:配置显示为 ${configActiveDb},但实际连接为 ${effectiveDb},请检查 MySQL 连接是否正常。`); } if (resp.cache?.hasRedisUrl) hints.push("缓存:已配置 Redis(共享缓存已启用)"); else hints.push("缓存:未配置 Redis(使用本机进程内缓存)"); 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 ? "已配置" : "未配置"}`, ...hints, ] .filter(Boolean) .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, }, cache: { redisUrl: cfgRedisUrl ? cfgRedisUrl.value.trim() : "", clearRedisUrl: cfgClearRedisUrl ? cfgClearRedisUrl.checked : false, }, storage: { provider: cfgStorageProvider ? cfgStorageProvider.value : "AUTO", oss: { endpoint: cfgOssEndpoint ? cfgOssEndpoint.value.trim() : "", bucket: cfgOssBucket ? cfgOssBucket.value.trim() : "", accessKeyId: cfgOssAccessKeyId ? cfgOssAccessKeyId.value.trim() : "", accessKeySecret: cfgOssAccessKeySecret ? cfgOssAccessKeySecret.value.trim() : "", clearAccessKeySecret: cfgClearOssAccessKeySecret ? cfgClearOssAccessKeySecret.checked : false, uploadPrefix: cfgOssUploadPrefix ? cfgOssUploadPrefix.value.trim() : "", publicBaseUrl: cfgOssPublicBaseUrl ? cfgOssPublicBaseUrl.value.trim() : "", }, }, }, 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 testRedisConnection() { if (cfgCacheMsg) cfgCacheMsg.textContent = ""; try { const url = cfgRedisUrl ? cfgRedisUrl.value.trim() : ""; const resp = await apiFetch("/admin/redis/test", { method: "POST", body: url ? { url } : {} }); if (resp.ok) { if (cfgCacheMsg) cfgCacheMsg.textContent = "Redis:连接成功"; showToastSuccess("Redis:连接成功"); } else { if (cfgCacheMsg) cfgCacheMsg.textContent = "Redis:连接失败"; showToastError("Redis:连接失败"); } } catch (e) { const err = e.detail?.error || e.status || "unknown"; if (cfgCacheMsg) cfgCacheMsg.textContent = `Redis:连接失败(${err})`; showToastError(`Redis:连接失败(${err})`); 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); }); } async function updateMdPreview() { if (typeof renderMarkdown !== "function") { await loadScriptOnce("/static/app_markdown.js"); } 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 cacheCell = el("td", { id: `res-cache-${r.id}` }, badge("加载中", "badge")); const repoRefTitle = `${r.repoOwner}/${r.repoName} @ ${r.defaultRef}`; const repoRefCell = el( "td", { title: repoRefTitle }, el("div", {}, `${r.repoOwner}/${r.repoName}`), el("div", { class: "muted" }, String(r.defaultRef || "")) ); 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)), repoRefCell, el("td", { title: formatDateTime(r.updatedAt) }, formatDateTime(r.updatedAt)), cacheCell, el( "td", { class: "td-actions" }, btnGroup( el("a", { class: "btn", href: `/ui/resources/${r.id}` }, "查看"), el("button", { class: "btn", "data-action": "cache-res", "data-id": String(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, "暂无数据"); await loadResourceCacheSummaries(resources); 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; } function cacheSummaryBadge(summary) { if (!summary || !summary.ok) return badge("-", "badge"); const jobs = Array.isArray(summary.jobs) ? summary.jobs : []; if (jobs.some((j) => j && j.state === "building")) return badge("生成中", "badge-warning"); const count = Number(summary.count || 0); if (count <= 0) return badge("无", "badge"); return badge(`${count}份`, "badge-success"); } async function loadResourceCacheSummaries(resources) { const items = Array.isArray(resources) ? resources : []; const tasks = items.map((r) => async () => { const cell = document.getElementById(`res-cache-${r.id}`); if (!cell) return; try { const summary = await apiFetch(`/admin/resources/${r.id}/download-cache/summary`); cell.innerHTML = ""; cell.appendChild(cacheSummaryBadge(summary)); } catch (e) { cell.innerHTML = ""; cell.appendChild(badge("失败", "badge-danger")); if (e.status === 401) window.location.href = "/ui/admin/login"; } }); const limit = 4; let idx = 0; const workers = new Array(Math.min(limit, tasks.length)).fill(0).map(async () => { while (idx < tasks.length) { const i = idx; idx += 1; await tasks[i](); } }); await Promise.all(workers); } function openResourceDownloadCacheModal(res) { const refInput = el("input", { class: "input", value: String(res.defaultRef || "") }); const msg = el("div", { class: "muted" }, ""); const summaryBox = el("div", { class: "muted" }, "加载中…"); const statusBox = el("div", { class: "muted" }, ""); const listBox = el("div", {}, ""); async function refreshAll() { msg.textContent = ""; summaryBox.textContent = "加载中…"; statusBox.textContent = ""; listBox.innerHTML = ""; try { const [summary, status, list] = await Promise.all([ apiFetch(`/admin/resources/${res.id}/download-cache/summary`), apiFetch(`/admin/resources/${res.id}/download-cache/status?${new URLSearchParams({ ref: refInput.value.trim() || String(res.defaultRef || "") }).toString()}`), apiFetch(`/admin/resources/${res.id}/download-cache/list`), ]); const jobs = Array.isArray(summary.jobs) ? summary.jobs : []; const latest = summary.latest || null; summaryBox.textContent = [ `缓存文件:${Number(summary.count || 0)} 份`, latest && latest.mtime ? `最近更新:${formatDateTime(latest.mtime)}` : "最近更新:-", jobs.some((j) => j && j.state === "building") ? "生成中" : "", ] .filter(Boolean) .join(" / "); if (status && status.ready) { statusBox.textContent = `当前ref已缓存:${String(status.commit || "").slice(0, 12) || "-"}(${String(status.ref || "")})`; } else if (status && status.state) { statusBox.textContent = `当前ref状态:${status.state}${status.error ? ` / ${status.error}` : ""}`; } else { statusBox.textContent = "当前ref状态:-"; } const items = Array.isArray(list.items) ? list.items : []; if (!items.length) { listBox.appendChild(el("div", { class: "muted" }, "暂无缓存文件")); return; } const thead = el( "thead", {}, el( "tr", {}, el("th", {}, "commit"), el("th", {}, "ref"), el("th", {}, "大小"), el("th", {}, "更新时间"), el("th", {}, "TTL"), el("th", {}, "操作") ) ); const tbody = el("tbody", {}, ""); items.forEach((it) => { const commit = String(it.commit || ""); const ref = String(it.ref || ""); const metaText = it.meta ? JSON.stringify(it.meta, null, 2) : ""; const tr = el( "tr", {}, el("td", { title: commit }, commit ? commit.slice(0, 12) : "-"), el("td", { title: ref }, ref || "-"), el("td", {}, it.bytes != null ? formatBytes(it.bytes) : "-"), el("td", {}, it.mtime ? formatDateTime(it.mtime) : "-"), el("td", {}, it.ttlRemainingSeconds == null ? "-" : `${Math.max(0, Number(it.ttlRemainingSeconds || 0))}s`), el( "td", {}, btnGroup( el( "button", { class: "btn btn-sm", onclick: () => { if (!metaText) return; openModal("缓存元信息", [el("pre", { class: "pre" }, metaText)], [el("button", { class: "btn", onclick: closeModal }, "关闭")]); }, }, "查看" ), el( "a", { class: "btn btn-sm", href: commit ? `/admin/resources/${res.id}/download-cache/file?${new URLSearchParams({ commit }).toString()}` : "#", target: "_blank", rel: "noreferrer", onclick: (evt) => { if (!commit) evt.preventDefault(); }, }, "下载" ), el( "button", { class: "btn btn-sm btn-danger", onclick: async () => { if (!commit) return; const r = await Swal.fire({ title: "清理该缓存?", text: `commit:${commit.slice(0, 12)}`, icon: "warning", showCancelButton: true, confirmButtonText: "清理", cancelButtonText: "取消", confirmButtonColor: "var(--danger)", }); if (!r.isConfirmed) return; await apiFetch(`/admin/resources/${res.id}/download-cache?${new URLSearchParams({ commit }).toString()}`, { method: "DELETE" }); showToastSuccess("已清理"); await refreshAll(); await loadResources(); }, }, "清理" ) ) ) ); tbody.appendChild(tr); }); listBox.appendChild(el("table", { class: "table" }, thead, tbody)); } catch (e) { msg.textContent = `加载失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } } openModal( `下载缓存 #${res.id}`, [ el("div", { class: "muted" }, `仓库:${res.repoOwner}/${res.repoName}`), el("label", { class: "label" }, "ref(用于检查/刷新)"), refInput, summaryBox, statusBox, msg, listBox, ], [ el("button", { class: "btn", onclick: closeModal }, "关闭"), el( "button", { class: "btn", onclick: async () => { await refreshAll(); }, }, "刷新" ), el( "button", { class: "btn", onclick: async () => { msg.textContent = ""; try { const resp = await apiFetch(`/admin/resources/${res.id}/download-cache/refresh`, { method: "POST", body: { ref: refInput.value.trim() || String(res.defaultRef || "") } }); showToastSuccess(resp.ready ? "已生成" : "已开始生成"); await refreshAll(); await loadResources(); } catch (e) { msg.textContent = `刷新失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } }, }, "强制刷新" ), el( "button", { class: "btn btn-danger", onclick: async () => { const r = await Swal.fire({ title: "清理全部缓存?", text: "将删除该资源所有下载缓存文件。", icon: "warning", showCancelButton: true, confirmButtonText: "清理", cancelButtonText: "取消", confirmButtonColor: "var(--danger)", }); if (!r.isConfirmed) return; try { await apiFetch(`/admin/resources/${res.id}/download-cache?${new URLSearchParams({ all: "1" }).toString()}`, { method: "DELETE" }); showToastSuccess("已清理"); await refreshAll(); await loadResources(); } catch (e) { msg.textContent = `清理失败:${e.detail?.error || e.status || "unknown"}`; if (e.status === 401) window.location.href = "/ui/admin/login"; } }, }, "清理全部" ), ], "ri-archive-line" ); refreshAll(); } 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 (cfgCacheSaveBtn) { cfgCacheSaveBtn.addEventListener("click", async () => { await saveSettingsPartial({ cache: { redisUrl: cfgRedisUrl ? cfgRedisUrl.value.trim() : "", clearRedisUrl: cfgClearRedisUrl ? cfgClearRedisUrl.checked : false, }, }); }); } if (cfgCacheResetBtn) { cfgCacheResetBtn.addEventListener("click", async () => { await loadSettings(); const g = document.getElementById("cfgGroupCache"); if (g) g.setAttribute("data-open", "1"); }); } if (cfgRedisTestBtn) { cfgRedisTestBtn.addEventListener("click", async () => { await testRedisConnection(); }); } if (cfgStorageSaveBtn) { cfgStorageSaveBtn.addEventListener("click", async () => { await saveSettingsPartial({ storage: { provider: cfgStorageProvider ? cfgStorageProvider.value : "AUTO", oss: { endpoint: cfgOssEndpoint ? cfgOssEndpoint.value.trim() : "", bucket: cfgOssBucket ? cfgOssBucket.value.trim() : "", accessKeyId: cfgOssAccessKeyId ? cfgOssAccessKeyId.value.trim() : "", accessKeySecret: cfgOssAccessKeySecret ? cfgOssAccessKeySecret.value.trim() : "", clearAccessKeySecret: cfgClearOssAccessKeySecret ? cfgClearOssAccessKeySecret.checked : false, uploadPrefix: cfgOssUploadPrefix ? cfgOssUploadPrefix.value.trim() : "", publicBaseUrl: cfgOssPublicBaseUrl ? cfgOssPublicBaseUrl.value.trim() : "", }, }, }); }); } if (cfgStorageResetBtn) { cfgStorageResetBtn.addEventListener("click", async () => { await loadSettings(); const g = document.getElementById("cfgGroupStorage"); 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 === "cache-res") { const res = resourceMap.get(String(id)); if (!res) return; openResourceDownloadCacheModal(res); } 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", }); ensureImgFallback(coverPreview, defaultCoverUrl, "is-placeholder"); 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 === "admin_login") await pageAdminLogin(); if (page === "admin") await pageAdmin(); } catch (e) { showToastError(e?.detail?.error || e?.status || e?.message || "页面初始化失败"); } } main();