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