fix:优化当前的项目
This commit is contained in:
@@ -18,6 +18,8 @@ const imBtn = $("imBtn");
|
||||
const coverUploadBtn = $("coverUploadBtn");
|
||||
const coverUrlUploadBtn = $("coverUrlUploadBtn");
|
||||
const coverGenerateBtn = $("coverGenerateBtn");
|
||||
const saveCoverImageModelBtn = $("saveCoverImageModelBtn");
|
||||
const coverImageModelInput = $("coverImageModel");
|
||||
const coverModeManualBtn = $("coverModeManualBtn");
|
||||
const coverModeAiBtn = $("coverModeAiBtn");
|
||||
const coverManualSection = $("coverManualSection");
|
||||
@@ -26,11 +28,13 @@ const coverAutoAfterRewrite = $("coverAutoAfterRewrite");
|
||||
const coverPreview = $("coverPreview");
|
||||
const coverPreviewWrap = $("coverPreviewWrap");
|
||||
const logoutBtn = $("logoutBtn");
|
||||
const clearDraftBtn = $("clearDraftBtn");
|
||||
const targetBodyCharsInput = $("targetBodyChars");
|
||||
const posterGenerateBtn = $("posterGenerateBtn");
|
||||
const posterPreviewList = $("posterPreviewList");
|
||||
const posterHint = $("posterHint");
|
||||
const posterAutoInclude = $("posterAutoInclude");
|
||||
const DRAFT_STORAGE_KEY = "aifagao:index:draft:v1";
|
||||
|
||||
let posterState = {
|
||||
signature: "",
|
||||
@@ -125,6 +129,123 @@ function setStatus(msg, danger = false) {
|
||||
statusEl.textContent = msg;
|
||||
}
|
||||
|
||||
function saveDraftState() {
|
||||
try {
|
||||
const data = {
|
||||
sourceText: ($("sourceText") && $("sourceText").value) || "",
|
||||
titleHint: ($("titleHint") && $("titleHint").value) || "",
|
||||
audienceExtra: ($("audienceExtra") && $("audienceExtra").value) || "",
|
||||
toneExtra: ($("toneExtra") && $("toneExtra").value) || "",
|
||||
avoidWords: ($("avoidWords") && $("avoidWords").value) || "",
|
||||
keepPoints: ($("keepPoints") && $("keepPoints").value) || "",
|
||||
targetBodyChars: ($("targetBodyChars") && $("targetBodyChars").value) || "500",
|
||||
title: ($("title") && $("title").value) || "",
|
||||
summary: ($("summary") && $("summary").value) || "",
|
||||
body: ($("body") && $("body").value) || "",
|
||||
thumbMediaId: ($("thumbMediaId") && $("thumbMediaId").value) || "",
|
||||
coverStyleHint: ($("coverStyleHint") && $("coverStyleHint").value) || "",
|
||||
coverImageModel: (coverImageModelInput && coverImageModelInput.value) || "",
|
||||
coverAutoAfterRewrite: Boolean(coverAutoAfterRewrite && coverAutoAfterRewrite.checked),
|
||||
posterAutoInclude: Boolean(posterAutoInclude && posterAutoInclude.checked),
|
||||
audienceChipValues: Array.from(document.querySelectorAll('input[name="audienceChip"]:checked')).map((n) => n.value),
|
||||
toneChipValues: Array.from(document.querySelectorAll('input[name="toneChip"]:checked')).map((n) => n.value),
|
||||
};
|
||||
window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDraftState() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const data = JSON.parse(raw);
|
||||
if (!data || typeof data !== "object") return;
|
||||
const setVal = (id, val) => {
|
||||
const el = $(id);
|
||||
if (!el || typeof val !== "string") return;
|
||||
el.value = val;
|
||||
};
|
||||
setVal("sourceText", data.sourceText || "");
|
||||
setVal("titleHint", data.titleHint || "");
|
||||
setVal("audienceExtra", data.audienceExtra || "");
|
||||
setVal("toneExtra", data.toneExtra || "");
|
||||
setVal("avoidWords", data.avoidWords || "");
|
||||
setVal("keepPoints", data.keepPoints || "");
|
||||
setVal("targetBodyChars", String(data.targetBodyChars || "500"));
|
||||
setVal("title", data.title || "");
|
||||
setVal("summary", data.summary || "");
|
||||
setVal("body", data.body || "");
|
||||
setVal("thumbMediaId", data.thumbMediaId || "");
|
||||
setVal("coverStyleHint", data.coverStyleHint || "");
|
||||
if (coverImageModelInput && typeof data.coverImageModel === "string" && data.coverImageModel.trim()) {
|
||||
coverImageModelInput.value = data.coverImageModel;
|
||||
}
|
||||
if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = Boolean(data.coverAutoAfterRewrite);
|
||||
if (posterAutoInclude) posterAutoInclude.checked = Boolean(data.posterAutoInclude);
|
||||
const audienceSet = new Set(Array.isArray(data.audienceChipValues) ? data.audienceChipValues : []);
|
||||
document.querySelectorAll('input[name="audienceChip"]').forEach((el) => {
|
||||
el.checked = audienceSet.size ? audienceSet.has(el.value) : el.checked;
|
||||
});
|
||||
const toneSet = new Set(Array.isArray(data.toneChipValues) ? data.toneChipValues : []);
|
||||
document.querySelectorAll('input[name="toneChip"]').forEach((el) => {
|
||||
el.checked = toneSet.size ? toneSet.has(el.value) : el.checked;
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraftState() {
|
||||
try {
|
||||
window.localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const clearIds = [
|
||||
"sourceText",
|
||||
"titleHint",
|
||||
"audienceExtra",
|
||||
"toneExtra",
|
||||
"avoidWords",
|
||||
"keepPoints",
|
||||
"title",
|
||||
"summary",
|
||||
"body",
|
||||
"thumbMediaId",
|
||||
"coverStyleHint",
|
||||
"coverUrl",
|
||||
];
|
||||
clearIds.forEach((id) => {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.value = "";
|
||||
});
|
||||
if ($("targetBodyChars")) $("targetBodyChars").value = "500";
|
||||
if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = false;
|
||||
if (posterAutoInclude) posterAutoInclude.checked = false;
|
||||
if (coverImageModelInput) coverImageModelInput.value = "";
|
||||
document.querySelectorAll('input[name="audienceChip"]').forEach((el) => {
|
||||
el.checked = false;
|
||||
});
|
||||
document.querySelectorAll('input[name="toneChip"]').forEach((el) => {
|
||||
el.checked = false;
|
||||
});
|
||||
if (coverPreviewWrap) coverPreviewWrap.hidden = true;
|
||||
if (coverPreview) coverPreview.src = "";
|
||||
if ($("coverFile")) $("coverFile").value = "";
|
||||
|
||||
posterState = { signature: "", bodyMarkdownWithPosters: "", posters: [] };
|
||||
renderPosterPreview([]);
|
||||
updateCounters();
|
||||
syncTargetCharChips();
|
||||
initMultiDropdowns();
|
||||
initImageModelStatus();
|
||||
if (posterHint) posterHint.textContent = "默认不生成海报,点击“生成段落海报”后再插入发布。";
|
||||
setStatus("草稿已清除。");
|
||||
}
|
||||
|
||||
function buildPosterSignature() {
|
||||
const title = ($("title") && $("title").value.trim()) || "";
|
||||
const summary = ($("summary") && $("summary").value.trim()) || "";
|
||||
@@ -183,7 +304,7 @@ function renderPosterPreview(posters) {
|
||||
function markPosterStaleIfNeeded() {
|
||||
if (!posterState.signature || !posterHint) return;
|
||||
if (posterState.signature !== buildPosterSignature()) {
|
||||
posterHint.textContent = "正文已修改,发布前会自动重建段落海报。";
|
||||
posterHint.textContent = "正文已修改,如需海报请手动点击“生成段落海报”。";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,11 +315,26 @@ async function postJSON(url, body) {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || "请求失败");
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.detail || "请求失败");
|
||||
err.payload = data;
|
||||
throw err;
|
||||
}
|
||||
data._requestId = res.headers.get("X-Request-ID") || "";
|
||||
return data;
|
||||
}
|
||||
|
||||
function handleUpgradeRequired(err) {
|
||||
const data = (err && err.payload) || {};
|
||||
const msg = (err && err.message) || data.detail || "";
|
||||
if (!data.upgrade_required && !msg.includes("免费额度已用完") && !msg.includes("余额不足")) return false;
|
||||
setStatus("免费额度已用完,请前往升级页充值或升级 VIP 用户。", true);
|
||||
window.setTimeout(() => {
|
||||
window.location.href = "/upgrade";
|
||||
}, 800);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function generatePosterMaterials({ silent = false } = {}) {
|
||||
const bodyMarkdown = (($("body") && $("body").value) || "").trim();
|
||||
if (bodyMarkdown.length < 20) {
|
||||
@@ -212,9 +348,14 @@ async function generatePosterMaterials({ silent = false } = {}) {
|
||||
title: $("title").value,
|
||||
summary: $("summary").value,
|
||||
body_markdown: $("body").value,
|
||||
image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "",
|
||||
upload_to_wechat: true,
|
||||
});
|
||||
if (!data.ok) throw new Error(data.detail || "海报生成失败");
|
||||
if (!data.ok) {
|
||||
const err = new Error(data.detail || "海报生成失败");
|
||||
err.payload = data;
|
||||
throw err;
|
||||
}
|
||||
|
||||
posterState = {
|
||||
signature: buildPosterSignature(),
|
||||
@@ -252,9 +393,14 @@ async function generateWechatCover({ silent = false } = {}) {
|
||||
title,
|
||||
summary: (($("summary") && $("summary").value) || "").trim(),
|
||||
style_hint: (($("coverStyleHint") && $("coverStyleHint").value) || "").trim(),
|
||||
image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "",
|
||||
upload_to_wechat: true,
|
||||
});
|
||||
if (!data.ok) throw new Error(data.detail || "封面生成失败");
|
||||
if (!data.ok) {
|
||||
const err = new Error(data.detail || "封面生成失败");
|
||||
err.payload = data;
|
||||
throw err;
|
||||
}
|
||||
const mid = data.thumb_media_id || "";
|
||||
if (mid && $("thumbMediaId")) $("thumbMediaId").value = mid;
|
||||
if (data.preview_data_url && coverPreview && coverPreviewWrap) {
|
||||
@@ -353,6 +499,20 @@ async function initWechatAccountSwitch() {
|
||||
if (me) renderWechatAccountSelect(me);
|
||||
}
|
||||
|
||||
async function initImageModelStatus() {
|
||||
try {
|
||||
const me = await fetch("/api/auth/me").then((r) => r.json());
|
||||
const active = me && me.active_ai_model ? me.active_ai_model : null;
|
||||
const imageModel = active && active.image_model ? String(active.image_model).trim() : "";
|
||||
if (coverImageModelInput) {
|
||||
const current = (coverImageModelInput.value || "").trim();
|
||||
if (!current) coverImageModelInput.value = imageModel || "wanx2.0-t2i-turbo";
|
||||
}
|
||||
} catch {
|
||||
if (coverImageModelInput && !(coverImageModelInput.value || "").trim()) coverImageModelInput.value = "wanx2.0-t2i-turbo";
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutAndGoAuth() {
|
||||
try {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
@@ -369,6 +529,12 @@ if (logoutBtn) {
|
||||
});
|
||||
}
|
||||
|
||||
if (clearDraftBtn) {
|
||||
clearDraftBtn.addEventListener("click", () => {
|
||||
clearDraftState();
|
||||
});
|
||||
}
|
||||
|
||||
if (coverModeManualBtn) {
|
||||
coverModeManualBtn.addEventListener("click", () => setCoverMode("manual"));
|
||||
}
|
||||
@@ -419,6 +585,7 @@ $("rewriteBtn").addEventListener("click", async () => {
|
||||
$("summary").value = data.summary || "";
|
||||
$("body").value = data.body_markdown || "";
|
||||
updateCounters();
|
||||
saveDraftState();
|
||||
const tr = data.trace || {};
|
||||
if (data.mode === "fallback") {
|
||||
const note = (data.quality_notes || [])[0] || "当前为保底改写稿";
|
||||
@@ -429,23 +596,19 @@ $("rewriteBtn").addEventListener("click", async () => {
|
||||
} else {
|
||||
setStatus("改写完成。");
|
||||
}
|
||||
try {
|
||||
setStatus("改写完成,正在生成段落海报...");
|
||||
await generatePosterMaterials({ silent: true });
|
||||
setStatus("改写与段落海报生成完成。");
|
||||
} catch (posterErr) {
|
||||
setStatus(`改写完成,段落海报未生成:${posterErr.message}`, true);
|
||||
}
|
||||
if (posterHint) posterHint.textContent = "改写完成。默认不自动生成海报,可手动点击“生成段落海报”。";
|
||||
if (coverAutoAfterRewrite && coverAutoAfterRewrite.checked) {
|
||||
try {
|
||||
setStatus("改写完成,正在按输出标题生成封面...");
|
||||
await generateWechatCover({ silent: true });
|
||||
setStatus("改写、封面与段落海报生成完成。");
|
||||
} catch (coverErr) {
|
||||
if (handleUpgradeRequired(coverErr)) return;
|
||||
setStatus(`改写完成,封面未生成:${coverErr.message}`, true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (handleUpgradeRequired(e)) return;
|
||||
setStatus(`改写失败: ${e.message}`, true);
|
||||
} finally {
|
||||
setLoading(rewriteBtn, false, "改写并排版", "改写中...");
|
||||
@@ -460,15 +623,10 @@ $("wechatBtn").addEventListener("click", async () => {
|
||||
const autoInclude = Boolean(posterAutoInclude && posterAutoInclude.checked);
|
||||
if (autoInclude) {
|
||||
const stale = posterState.signature !== buildPosterSignature() || !posterState.bodyMarkdownWithPosters;
|
||||
if (stale) {
|
||||
try {
|
||||
await generatePosterMaterials({ silent: true });
|
||||
} catch (posterErr) {
|
||||
setStatus(`海报生成失败,本次仅发布文字:${posterErr.message}`, true);
|
||||
}
|
||||
}
|
||||
if (posterState.bodyMarkdownWithPosters) {
|
||||
if (!stale && posterState.bodyMarkdownWithPosters) {
|
||||
bodyForPublish = posterState.bodyMarkdownWithPosters;
|
||||
} else {
|
||||
setStatus("未检测到可用海报,本次仅发布文字;如需海报请先手动生成。", true);
|
||||
}
|
||||
}
|
||||
const data = await postJSON("/api/publish/wechat", {
|
||||
@@ -495,7 +653,9 @@ if (coverGenerateBtn) {
|
||||
coverGenerateBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await generateWechatCover({ silent: false });
|
||||
saveDraftState();
|
||||
} catch (e) {
|
||||
if (handleUpgradeRequired(e)) return;
|
||||
const hint = $("coverHint");
|
||||
if (hint) hint.textContent = "AI 封面生成失败,请检查标题、模型或公众号配置。";
|
||||
setStatus(`封面生成失败: ${e.message}`, true);
|
||||
@@ -508,6 +668,30 @@ if (coverGenerateBtn) {
|
||||
});
|
||||
}
|
||||
|
||||
if (saveCoverImageModelBtn) {
|
||||
saveCoverImageModelBtn.addEventListener("click", async () => {
|
||||
const value = (coverImageModelInput && coverImageModelInput.value.trim()) || "";
|
||||
if (!value) {
|
||||
setStatus("请先填写文生图模型", true);
|
||||
return;
|
||||
}
|
||||
setLoading(saveCoverImageModelBtn, true, "保存模型", "保存中...");
|
||||
try {
|
||||
const out = await postJSON("/api/auth/ai-models/image-model/update", { image_model: value });
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "保存失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("文生图模型已保存。");
|
||||
saveDraftState();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "保存失败", true);
|
||||
} finally {
|
||||
setLoading(saveCoverImageModelBtn, false, "保存模型", "保存中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (coverUploadBtn) {
|
||||
coverUploadBtn.addEventListener("click", async () => {
|
||||
const fileInput = $("coverFile");
|
||||
@@ -529,6 +713,7 @@ if (coverUploadBtn) {
|
||||
if ($("thumbMediaId")) $("thumbMediaId").value = mid;
|
||||
if (hint) hint.textContent = `封面上传成功,已绑定 media_id:${mid}`;
|
||||
setStatus("封面上传成功,发布时将优先使用该封面。");
|
||||
saveDraftState();
|
||||
} catch (e) {
|
||||
if (hint) hint.textContent = "封面上传失败,请看状态提示。";
|
||||
setStatus(`封面上传失败: ${e.message}`, true);
|
||||
@@ -561,6 +746,7 @@ if (coverUrlUploadBtn) {
|
||||
if (hint) hint.textContent = `URL 封面上传成功,已绑定 media_id:${mid}`;
|
||||
setStatus("URL 封面上传成功,发布时将优先使用该封面。");
|
||||
if ($("coverUrl")) $("coverUrl").value = "";
|
||||
saveDraftState();
|
||||
} catch (e) {
|
||||
if (hint) hint.textContent = "URL 封面上传失败,请看状态提示。";
|
||||
setStatus(`URL 封面上传失败: ${e.message}`, true);
|
||||
@@ -579,7 +765,9 @@ if (posterGenerateBtn) {
|
||||
posterGenerateBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await generatePosterMaterials({ silent: false });
|
||||
saveDraftState();
|
||||
} catch (e) {
|
||||
if (handleUpgradeRequired(e)) return;
|
||||
setStatus(`海报生成失败: ${e.message}`, true);
|
||||
if (posterHint) posterHint.textContent = "海报生成失败,请检查配置后重试。";
|
||||
if ((e.message || "").includes("请先登录")) {
|
||||
@@ -610,13 +798,30 @@ $("imBtn").addEventListener("click", async () => {
|
||||
|
||||
["sourceText", "title", "summary", "body"].forEach((id) => {
|
||||
$(id).addEventListener("input", updateCounters);
|
||||
$(id).addEventListener("input", saveDraftState);
|
||||
if (id !== "sourceText") $(id).addEventListener("input", markPosterStaleIfNeeded);
|
||||
});
|
||||
|
||||
["titleHint", "audienceExtra", "toneExtra", "avoidWords", "keepPoints", "targetBodyChars", "thumbMediaId", "coverStyleHint"].forEach(
|
||||
(id) => {
|
||||
const el = $(id);
|
||||
if (el) el.addEventListener("input", saveDraftState);
|
||||
},
|
||||
);
|
||||
document.querySelectorAll('input[name="audienceChip"],input[name="toneChip"]').forEach((el) => {
|
||||
el.addEventListener("change", saveDraftState);
|
||||
});
|
||||
if (coverAutoAfterRewrite) coverAutoAfterRewrite.addEventListener("change", saveDraftState);
|
||||
if (posterAutoInclude) posterAutoInclude.addEventListener("change", saveDraftState);
|
||||
if (coverImageModelInput) coverImageModelInput.addEventListener("input", saveDraftState);
|
||||
|
||||
restoreDraftState();
|
||||
updateCounters();
|
||||
initMultiDropdowns();
|
||||
initWechatAccountSwitch();
|
||||
syncTargetCharChips();
|
||||
renderPosterPreview([]);
|
||||
setCoverMode("manual");
|
||||
initImageModelStatus();
|
||||
window.addEventListener("beforeunload", saveDraftState);
|
||||
window.addEventListener("load", () => setCoverMode("manual"));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let challengeId = "";
|
||||
|
||||
function setStatus(msg, danger = false) {
|
||||
const el = $("status");
|
||||
@@ -24,6 +25,21 @@ async function postJSON(url, body) {
|
||||
return data;
|
||||
}
|
||||
|
||||
async function refreshChallenge() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/challenge");
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.detail || "校验题加载失败");
|
||||
challengeId = (data.challenge_id || "").trim();
|
||||
const label = $("challengeLabel");
|
||||
if (label) label.textContent = `人机校验:${data.question || ""}`;
|
||||
const ans = $("challengeAnswer");
|
||||
if (ans) ans.value = "";
|
||||
} catch {
|
||||
setStatus("校验题加载失败,请刷新页面重试", true);
|
||||
}
|
||||
}
|
||||
|
||||
function nextPath() {
|
||||
const nxt = (window.__NEXT_PATH__ || "/").trim();
|
||||
if (!nxt.startsWith("/")) return "/";
|
||||
@@ -35,6 +51,9 @@ function fields() {
|
||||
username: ($("username") && $("username").value.trim()) || "",
|
||||
password: ($("password") && $("password").value) || "",
|
||||
remember_me: Boolean($("rememberMe") && $("rememberMe").checked),
|
||||
challenge_id: challengeId,
|
||||
challenge_answer: ($("challengeAnswer") && $("challengeAnswer").value.trim()) || "",
|
||||
honeypot: ($("botTrap") && $("botTrap").value) || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,6 +76,7 @@ async function authAction(url, button, idleText, loadingText, okMessage) {
|
||||
|
||||
const loginBtn = $("loginBtn");
|
||||
const registerBtn = $("registerBtn");
|
||||
const refreshChallengeBtn = $("refreshChallengeBtn");
|
||||
|
||||
if (loginBtn) {
|
||||
loginBtn.addEventListener("click", async () => {
|
||||
@@ -78,7 +98,7 @@ if (registerBtn) {
|
||||
const msg =
|
||||
`注册成功!请务必保存你的重置码(找回密码唯一凭证):\n\n${code}\n\n` +
|
||||
"请立即复制并妥善保管,点击“确定”后继续进入系统。";
|
||||
window.alert(msg);
|
||||
await window.uiAlert(msg, "注册成功");
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
@@ -91,8 +111,19 @@ if (registerBtn) {
|
||||
window.location.href = nextPath();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "请求异常", true);
|
||||
await refreshChallenge();
|
||||
} finally {
|
||||
setLoading(registerBtn, false, "注册", "注册中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshChallengeBtn) {
|
||||
refreshChallengeBtn.addEventListener("click", async () => {
|
||||
setLoading(refreshChallengeBtn, true, "刷新题目", "刷新中...");
|
||||
await refreshChallenge();
|
||||
setLoading(refreshChallengeBtn, false, "刷新题目", "刷新中...");
|
||||
});
|
||||
}
|
||||
|
||||
refreshChallenge();
|
||||
|
||||
340
app/static/billing.js
Normal file
340
app/static/billing.js
Normal file
@@ -0,0 +1,340 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function setStatus(msg, danger = false) {
|
||||
const el = $("status");
|
||||
if (!el) return;
|
||||
el.style.color = danger ? "#b42318" : "#0f5f3d";
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
function setLoading(button, loading, idleText, loadingText) {
|
||||
if (!button) return;
|
||||
button.disabled = loading;
|
||||
button.textContent = loading ? loadingText : idleText;
|
||||
}
|
||||
|
||||
function importantNotice(message, title = "提示") {
|
||||
if (typeof window.uiAlert === "function") {
|
||||
void window.uiAlert(message, title);
|
||||
return;
|
||||
}
|
||||
setStatus(message, true);
|
||||
}
|
||||
|
||||
function openPayLink(url) {
|
||||
const payUrl = String(url || "").trim();
|
||||
if (!payUrl) return false;
|
||||
const tab = window.open(payUrl, "_blank", "noopener");
|
||||
if (tab) return true;
|
||||
window.location.href = payUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function postJSON(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || "请求失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function authMe() {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
if (!data.logged_in) {
|
||||
window.location.href = "/auth?next=/billing";
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
const n = Number(ts || 0);
|
||||
if (!n) return "-";
|
||||
return new Date(n * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function mapOrderStatus(status) {
|
||||
const s = String(status || "").toLowerCase();
|
||||
if (s === "paid" || s === "success") return "已支付";
|
||||
if (s === "pending") return "待支付";
|
||||
if (s === "failed") return "支付失败";
|
||||
if (s === "cancelled") return "已取消";
|
||||
if (s === "closed") return "已关闭";
|
||||
return status || "-";
|
||||
}
|
||||
|
||||
function mapChannel(channel) {
|
||||
const c = String(channel || "").toLowerCase();
|
||||
if (c === "wechat") return "微信支付";
|
||||
if (c === "alipay") return "支付宝";
|
||||
if (c === "stripe") return "Stripe";
|
||||
return channel || "-";
|
||||
}
|
||||
|
||||
function formatDetail(detail) {
|
||||
if (!detail || typeof detail !== "object") return "-";
|
||||
const rows = [];
|
||||
if (detail.source_chars) rows.push(`原文${detail.source_chars}字`);
|
||||
if (detail.body_chars) rows.push(`正文${detail.body_chars}字`);
|
||||
if (detail.title_chars) rows.push(`标题${detail.title_chars}字`);
|
||||
if (detail.summary_chars) rows.push(`摘要${detail.summary_chars}字`);
|
||||
if (detail.image_count) rows.push(`图片${detail.image_count}张`);
|
||||
if (detail.prompt_tokens) rows.push(`输入Token:${detail.prompt_tokens}`);
|
||||
if (detail.completion_tokens) rows.push(`输出Token:${detail.completion_tokens}`);
|
||||
if (detail.total_tokens) rows.push(`总Token:${detail.total_tokens}`);
|
||||
if (detail.model) rows.push(`模型:${detail.model}`);
|
||||
if (detail.image_model) rows.push(`生图模型:${detail.image_model}`);
|
||||
if (detail.image_price_package_images && detail.image_price_package_cny) {
|
||||
rows.push(`图片计价:${detail.image_price_package_images}张=${Number(detail.image_price_package_cny).toFixed(2)}元`);
|
||||
}
|
||||
if (detail.credits_rule) rows.push(`规则:${detail.credits_rule}`);
|
||||
if (detail.billed_basis === "usage_tokens") rows.push("按真实Token计费");
|
||||
if (detail.billed_basis === "char_estimate") rows.push("按字符估算计费");
|
||||
if (detail.paid_amount_cny) rows.push(`实付¥${Number(detail.paid_amount_cny).toFixed(2)}`);
|
||||
if (detail.external_txn_id) rows.push(`交易号:${detail.external_txn_id}`);
|
||||
if (detail.credit_source && typeof detail.credit_source === "object") {
|
||||
const seat = Number(detail.credit_source.seat || 0);
|
||||
const shared = Number(detail.credit_source.shared || 0);
|
||||
rows.push(`抵扣来源:席位${seat}/共享${shared}`);
|
||||
}
|
||||
return rows.length ? rows.join(" | ") : "-";
|
||||
}
|
||||
|
||||
function renderRechargeRecords(records) {
|
||||
const el = $("rechargeRecords");
|
||||
if (!el) return;
|
||||
const list = Array.isArray(records) ? records : [];
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<p class="muted small">暂无充值记录</p>';
|
||||
return;
|
||||
}
|
||||
const rows = list
|
||||
.map((r) => {
|
||||
const statusText = mapOrderStatus(r.status);
|
||||
const statusClass =
|
||||
statusText === "已支付"
|
||||
? "paid"
|
||||
: statusText === "待支付"
|
||||
? "pending"
|
||||
: statusText === "支付失败"
|
||||
? "failed"
|
||||
: "closed";
|
||||
return `<tr>
|
||||
<td class="mono">${r.order_no || "-"}</td>
|
||||
<td><span class="billing-badge ${statusClass}">${statusText}</span></td>
|
||||
<td>${mapChannel(r.channel)}</td>
|
||||
<td>${Number(r.token_amount || 0)}</td>
|
||||
<td>¥${Number(r.amount_cny || 0).toFixed(2)}</td>
|
||||
<td>${fmtTime(r.created_at)}</td>
|
||||
<td>${r.paid_at ? fmtTime(r.paid_at) : "-"}</td>
|
||||
<td>${
|
||||
statusText === "待支付"
|
||||
? `<button class="primary pay-now-btn" data-order-no="${r.order_no || ""}" type="button">立即支付</button>`
|
||||
: "-"
|
||||
}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
el.innerHTML = `<table class="billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>状态</th>
|
||||
<th>渠道</th>
|
||||
<th>Credits</th>
|
||||
<th>金额</th>
|
||||
<th>创建时间</th>
|
||||
<th>支付时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function renderConsumeRecords(records) {
|
||||
const el = $("consumeRecords");
|
||||
if (!el) return;
|
||||
const list = Array.isArray(records) ? records : [];
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<p class="muted small">暂无消费记录</p>';
|
||||
return;
|
||||
}
|
||||
const kindTextMap = {
|
||||
trial_grant: "试用赠送",
|
||||
paid_recharge: "充值到账",
|
||||
manual_recharge: "手动充值",
|
||||
rewrite: "AI改写",
|
||||
cover_generate: "封面生成",
|
||||
poster_generate: "段落海报",
|
||||
usage: "模型调用",
|
||||
};
|
||||
const rows = list
|
||||
.map((r) => {
|
||||
const kindText = kindTextMap[r.kind] || r.kind || "-";
|
||||
const delta = `${r.direction === "out" ? "-" : "+"}${Number(r.token_change || 0)}`;
|
||||
const ref = `${r.ref_type || "-"} ${r.ref_id || ""}`.trim();
|
||||
const detail = formatDetail(r.detail);
|
||||
return `<tr>
|
||||
<td>${kindText}</td>
|
||||
<td class="${r.direction === "out" ? "out" : "in"}">${delta}</td>
|
||||
<td>${Number(r.balance_after || 0)}</td>
|
||||
<td>${ref}</td>
|
||||
<td>${detail}</td>
|
||||
<td>${fmtTime(r.created_at)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
el.innerHTML = `<table class="billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>Credits变动</th>
|
||||
<th>余额</th>
|
||||
<th>关联</th>
|
||||
<th>明细</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function refreshBilling() {
|
||||
const data = await fetch("/api/billing/overview").then((r) => r.json());
|
||||
if (!data.ok) throw new Error(data.detail || "账单加载失败");
|
||||
renderRechargeRecords(data.recharge_records || []);
|
||||
renderConsumeRecords(data.consume_records || []);
|
||||
}
|
||||
|
||||
const createRechargeOrderBtn = $("createRechargeOrderBtn");
|
||||
const refreshBillingBtn = $("refreshBillingBtn");
|
||||
const logoutBtn = $("logoutBtn");
|
||||
const billingRechargeTokensInput = $("billingRechargeTokens");
|
||||
const billingRechargeAmountInput = $("billingRechargeAmount");
|
||||
|
||||
function packageRate() {
|
||||
const credits = Number((billingRechargeTokensInput && billingRechargeTokensInput.dataset.packageCredits) || "1500");
|
||||
const amount = Number((billingRechargeAmountInput && billingRechargeAmountInput.dataset.packageAmount) || "19.9");
|
||||
return {
|
||||
packageCredits: Number.isFinite(credits) && credits > 0 ? credits : 1500,
|
||||
packageAmount: Number.isFinite(amount) && amount > 0 ? amount : 19.9,
|
||||
};
|
||||
}
|
||||
|
||||
function syncBillingAmount() {
|
||||
if (!billingRechargeTokensInput || !billingRechargeAmountInput) return;
|
||||
const credits = Number((billingRechargeTokensInput.value || "0").trim());
|
||||
if (!Number.isFinite(credits) || credits <= 0) return;
|
||||
const { packageCredits, packageAmount } = packageRate();
|
||||
const amount = (credits / packageCredits) * packageAmount;
|
||||
billingRechargeAmountInput.value = amount.toFixed(2);
|
||||
}
|
||||
|
||||
if (billingRechargeTokensInput) billingRechargeTokensInput.addEventListener("input", syncBillingAmount);
|
||||
if (billingRechargeAmountInput) billingRechargeAmountInput.readOnly = true;
|
||||
|
||||
if (createRechargeOrderBtn) {
|
||||
createRechargeOrderBtn.addEventListener("click", async () => {
|
||||
setLoading(createRechargeOrderBtn, true, "创建充值订单", "创建中...");
|
||||
try {
|
||||
const tokens = Number((($("billingRechargeTokens") && $("billingRechargeTokens").value) || "0").trim());
|
||||
const amount = Number((($("billingRechargeAmount") && $("billingRechargeAmount").value) || "0").trim());
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) {
|
||||
setStatus("请输入正确的充值 Credits 数量", true);
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setStatus("请输入正确的支付金额", true);
|
||||
return;
|
||||
}
|
||||
const out = await postJSON("/api/pay/wechat/", {
|
||||
tokens: Math.round(tokens),
|
||||
amount_cny: Number(amount.toFixed(2)),
|
||||
channel: "wechat",
|
||||
});
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "创建订单失败", true);
|
||||
return;
|
||||
}
|
||||
const orderNo = out.order && out.order.order_no ? out.order.order_no : "";
|
||||
if (out.pay_url) {
|
||||
openPayLink(out.pay_url);
|
||||
} else {
|
||||
importantNotice("订单已创建,但未获取到支付链接,请检查支付网关配置。", "支付链接缺失");
|
||||
}
|
||||
setStatus(`订单已创建:${orderNo}`);
|
||||
await refreshBilling();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "创建订单失败", true);
|
||||
} finally {
|
||||
setLoading(createRechargeOrderBtn, false, "创建充值订单", "创建中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const rechargeRecordsWrap = $("rechargeRecords");
|
||||
if (rechargeRecordsWrap) {
|
||||
rechargeRecordsWrap.addEventListener("click", async (evt) => {
|
||||
const btn = evt.target && evt.target.closest ? evt.target.closest(".pay-now-btn") : null;
|
||||
if (!btn) return;
|
||||
const orderNo = (btn.getAttribute("data-order-no") || "").trim();
|
||||
if (!orderNo) return;
|
||||
setLoading(btn, true, "立即支付", "拉起中...");
|
||||
try {
|
||||
const out = await postJSON("/api/billing/recharge/pay-now", { order_no: orderNo });
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "拉起支付失败", true);
|
||||
await refreshBilling();
|
||||
return;
|
||||
}
|
||||
if (out.pay_url) {
|
||||
openPayLink(out.pay_url);
|
||||
} else {
|
||||
importantNotice("未获取到支付链接,请检查支付网关配置。", "立即支付失败");
|
||||
}
|
||||
setStatus(`订单 ${orderNo} 已拉起支付。`);
|
||||
await refreshBilling();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "拉起支付失败", true);
|
||||
await refreshBilling();
|
||||
} finally {
|
||||
setLoading(btn, false, "立即支付", "拉起中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshBillingBtn) {
|
||||
refreshBillingBtn.addEventListener("click", async () => {
|
||||
setLoading(refreshBillingBtn, true, "刷新账单记录", "刷新中...");
|
||||
try {
|
||||
await refreshBilling();
|
||||
setStatus("账单已刷新。");
|
||||
} catch (e) {
|
||||
setStatus(e.message || "账单刷新失败", true);
|
||||
} finally {
|
||||
setLoading(refreshBillingBtn, false, "刷新账单记录", "刷新中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
} finally {
|
||||
window.location.href = "/auth?next=/";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const me = await authMe();
|
||||
if (!me) return;
|
||||
syncBillingAmount();
|
||||
await refreshBilling();
|
||||
})();
|
||||
97
app/static/profile.js
Normal file
97
app/static/profile.js
Normal file
@@ -0,0 +1,97 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function setStatus(msg, danger = false) {
|
||||
const el = $("status");
|
||||
if (!el) return;
|
||||
el.style.color = danger ? "#b42318" : "#0f5f3d";
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
function setLoading(button, loading, idleText, loadingText) {
|
||||
if (!button) return;
|
||||
button.disabled = loading;
|
||||
button.textContent = loading ? loadingText : idleText;
|
||||
}
|
||||
|
||||
async function postJSON(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || "请求失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function authMe() {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
if (!data.logged_in) {
|
||||
window.location.href = "/auth?next=/profile";
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
const res = await fetch("/api/profile");
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.detail || "个人信息加载失败");
|
||||
const profile = data.profile || {};
|
||||
if ($("profileSubscriberName")) $("profileSubscriberName").value = profile.subscriber_name || "";
|
||||
if ($("profileSubscriberPhone")) $("profileSubscriberPhone").value = profile.subscriber_phone || "";
|
||||
if ($("profileShippingAddress")) $("profileShippingAddress").value = profile.shipping_address || "";
|
||||
}
|
||||
|
||||
const saveProfileBtn = $("saveProfileBtn");
|
||||
const logoutBtn = $("logoutBtn");
|
||||
|
||||
if (saveProfileBtn) {
|
||||
saveProfileBtn.addEventListener("click", async () => {
|
||||
setLoading(saveProfileBtn, true, "保存个人信息", "保存中...");
|
||||
try {
|
||||
const subscriberName = (($("profileSubscriberName") && $("profileSubscriberName").value) || "").trim();
|
||||
const subscriberPhone = (($("profileSubscriberPhone") && $("profileSubscriberPhone").value) || "").trim();
|
||||
const shippingAddress = (($("profileShippingAddress") && $("profileShippingAddress").value) || "").trim();
|
||||
if (!subscriberName) {
|
||||
setStatus("请填写订阅人姓名", true);
|
||||
return;
|
||||
}
|
||||
if (!shippingAddress) {
|
||||
setStatus("请填写收货地址", true);
|
||||
return;
|
||||
}
|
||||
const out = await postJSON("/api/profile", {
|
||||
subscriber_name: subscriberName,
|
||||
subscriber_phone: subscriberPhone,
|
||||
shipping_address: shippingAddress,
|
||||
});
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "保存失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("个人信息已保存。");
|
||||
} catch (e) {
|
||||
setStatus(e.message || "保存失败", true);
|
||||
} finally {
|
||||
setLoading(saveProfileBtn, false, "保存个人信息", "保存中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
} finally {
|
||||
window.location.href = "/auth?next=/";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const me = await authMe();
|
||||
if (!me) return;
|
||||
await loadProfile();
|
||||
})();
|
||||
@@ -72,18 +72,28 @@ function renderModels(me) {
|
||||
list.forEach((m) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(m.id);
|
||||
const imageModel = (m.image_model || "").trim();
|
||||
opt.textContent = imageModel ? `${m.model_name} (${m.model} / 图:${imageModel})` : `${m.model_name} (${m.model})`;
|
||||
opt.textContent = `${m.model_name} (${m.model})`;
|
||||
if ((active && m.id === active) || m.active) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function renderVip(me) {
|
||||
const vip = me && me.vip ? me.vip : {};
|
||||
const enabledSelect = $("vipEnabledSelect");
|
||||
const tokenBalance = $("vipTokenBalance");
|
||||
const totalConsumed = $("vipTotalConsumed");
|
||||
if (enabledSelect) enabledSelect.value = vip.vip_enabled ? "1" : "0";
|
||||
if (tokenBalance) tokenBalance.value = String(Number(vip.token_balance || 0));
|
||||
if (totalConsumed) totalConsumed.value = String(Number(vip.total_consumed_tokens || 0));
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const me = await authMe();
|
||||
if (!me) return;
|
||||
renderAccounts(me);
|
||||
renderModels(me);
|
||||
renderVip(me);
|
||||
}
|
||||
|
||||
const accountSelect = $("accountSelect");
|
||||
@@ -95,6 +105,8 @@ const deleteAccountBtn = $("deleteAccountBtn");
|
||||
const modelSelect = $("modelSelect");
|
||||
const saveModelBtn = $("saveModelBtn");
|
||||
const deleteModelBtn = $("deleteModelBtn");
|
||||
const saveVipBtn = $("saveVipBtn");
|
||||
const vipRechargeBtn = $("vipRechargeBtn");
|
||||
|
||||
if (accountSelect) {
|
||||
accountSelect.addEventListener("change", async () => {
|
||||
@@ -121,7 +133,7 @@ if (deleteWechatBtn) {
|
||||
setStatus("请先选择要删除的公众号", true);
|
||||
return;
|
||||
}
|
||||
const sure = window.confirm("确定删除当前公众号绑定吗?删除后不可恢复。");
|
||||
const sure = await window.uiConfirm("确定删除当前公众号绑定吗?删除后不可恢复。", "删除公众号");
|
||||
if (!sure) return;
|
||||
setLoading(deleteWechatBtn, true, "删除当前公众号", "删除中...");
|
||||
try {
|
||||
@@ -195,7 +207,7 @@ if (saveModelBtn) {
|
||||
api_key: ($("apiKey") && $("apiKey").value.trim()) || "",
|
||||
base_url: ($("baseUrl") && $("baseUrl").value.trim()) || "",
|
||||
model: ($("modelValue") && $("modelValue").value.trim()) || "",
|
||||
image_model: ($("imageModelValue") && $("imageModelValue").value.trim()) || "",
|
||||
image_model: "",
|
||||
timeout_sec: Number((($("timeoutSec") && $("timeoutSec").value) || "120").trim()),
|
||||
max_output_tokens: Number((($("maxOutputTokens") && $("maxOutputTokens").value) || "8192").trim()),
|
||||
max_retries: Number((($("maxRetries") && $("maxRetries").value) || "0").trim()),
|
||||
@@ -207,7 +219,6 @@ if (saveModelBtn) {
|
||||
setStatus("模型配置已保存并设为当前。");
|
||||
if ($("apiKey")) $("apiKey").value = "";
|
||||
if ($("modelName")) $("modelName").value = "";
|
||||
if ($("imageModelValue")) $("imageModelValue").value = "";
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "模型保存失败", true);
|
||||
@@ -224,7 +235,7 @@ if (deleteModelBtn) {
|
||||
setStatus("请先选择要删除的模型", true);
|
||||
return;
|
||||
}
|
||||
const sure = window.confirm("确定删除当前模型配置吗?删除后不可恢复。");
|
||||
const sure = await window.uiConfirm("确定删除当前模型配置吗?删除后不可恢复。", "删除模型");
|
||||
if (!sure) return;
|
||||
setLoading(deleteModelBtn, true, "删除当前模型", "删除中...");
|
||||
try {
|
||||
@@ -243,6 +254,61 @@ if (deleteModelBtn) {
|
||||
});
|
||||
}
|
||||
|
||||
if (saveVipBtn) {
|
||||
saveVipBtn.addEventListener("click", async () => {
|
||||
setLoading(saveVipBtn, true, "保存 VIP 设置", "保存中...");
|
||||
try {
|
||||
const enabled = (($("vipEnabledSelect") && $("vipEnabledSelect").value) || "0") === "1";
|
||||
const out = await postJSON("/api/auth/vip/toggle", { enabled });
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "VIP 设置保存失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("VIP 设置已更新。");
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "VIP 设置保存失败", true);
|
||||
} finally {
|
||||
setLoading(saveVipBtn, false, "保存 VIP 设置", "保存中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (vipRechargeBtn) {
|
||||
vipRechargeBtn.addEventListener("click", async () => {
|
||||
setLoading(vipRechargeBtn, true, "充值 Token", "创建订单中...");
|
||||
try {
|
||||
const tokens = Number((($("vipRechargeTokens") && $("vipRechargeTokens").value) || "0").trim());
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) {
|
||||
setStatus("请输入正确的充值数量", true);
|
||||
return;
|
||||
}
|
||||
const out = await postJSON("/api/pay/wechat/", {
|
||||
tokens: Math.round(tokens),
|
||||
amount_cny: Number((((Number(tokens) / 10000) * 9.9) || 9.9).toFixed(2)),
|
||||
channel: "wechat",
|
||||
});
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "充值失败", true);
|
||||
return;
|
||||
}
|
||||
if (out.pay_url) {
|
||||
window.open(out.pay_url, "_blank", "noopener");
|
||||
setStatus("订单已创建,请在新窗口完成支付。");
|
||||
} else {
|
||||
setStatus("订单已创建,但未获取到支付链接,请联系管理员配置购物系统。", true);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
window.location.href = "/billing";
|
||||
}, 400);
|
||||
} catch (e) {
|
||||
setStatus(e.message || "充值失败", true);
|
||||
} finally {
|
||||
setLoading(vipRechargeBtn, false, "充值 Token", "创建订单中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
setLoading(logoutBtn, true, "退出登录", "退出中...");
|
||||
@@ -292,9 +358,14 @@ if (deleteAccountBtn) {
|
||||
setStatus("请输入注销校验重置码", true);
|
||||
return;
|
||||
}
|
||||
const sure = window.confirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。");
|
||||
const sure = await window.uiConfirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。", "注销账户");
|
||||
if (!sure) return;
|
||||
const confirmText = window.prompt("为防止误删,请输入「注销账户」后确认:", "");
|
||||
const confirmText = await window.uiPrompt(
|
||||
"为防止误删,请输入「注销账户」后确认:",
|
||||
"二次确认",
|
||||
"",
|
||||
"请输入:注销账户",
|
||||
);
|
||||
if ((confirmText || "").trim() !== "注销账户") {
|
||||
setStatus("二次确认未通过,已取消注销。", true);
|
||||
return;
|
||||
|
||||
@@ -257,6 +257,17 @@ a {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
padding-right: 20px;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #8a6f58 50%),
|
||||
linear-gradient(135deg, #8a6f58 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 12px) calc(50% - 2px),
|
||||
calc(100% - 7px) calc(50% - 2px);
|
||||
background-size:
|
||||
5px 5px,
|
||||
5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.topbar-select:focus {
|
||||
@@ -441,6 +452,45 @@ button {
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
padding-right: 34px;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #8a6f58 50%),
|
||||
linear-gradient(135deg, #8a6f58 50%, transparent 50%),
|
||||
linear-gradient(180deg, #ffffff, #ffffff);
|
||||
background-position:
|
||||
calc(100% - 16px) calc(50% - 2px),
|
||||
calc(100% - 11px) calc(50% - 2px),
|
||||
0 0;
|
||||
background-size:
|
||||
6px 6px,
|
||||
6px 6px,
|
||||
100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
select:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
color: var(--faint);
|
||||
border-color: var(--line);
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #c4af98 50%),
|
||||
linear-gradient(135deg, #c4af98 50%, transparent 50%),
|
||||
linear-gradient(180deg, #f8f3ec, #f8f3ec);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-select {
|
||||
background-color: #fffdf8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
@@ -770,6 +820,49 @@ button.target-char-chip {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-model-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 122, 26, 0.24);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, #fff8ed, #fff0dc);
|
||||
}
|
||||
|
||||
.image-model-banner div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.image-model-banner strong {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.image-model-banner span {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.image-model-banner a {
|
||||
flex: 0 0 auto;
|
||||
color: var(--accent-2);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-model-banner a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cover-mode-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -929,6 +1022,92 @@ button.target-char-chip {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.billing-list {
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.billing-table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.billing-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 760px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.billing-table th,
|
||||
.billing-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.billing-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #fff8ed;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.billing-table tbody tr:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.billing-table .mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.billing-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 64px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.billing-badge.pending {
|
||||
color: #b54708;
|
||||
background: #fffaeb;
|
||||
}
|
||||
|
||||
.billing-badge.paid {
|
||||
color: #027a48;
|
||||
background: #ecfdf3;
|
||||
}
|
||||
|
||||
.billing-badge.failed {
|
||||
color: #b42318;
|
||||
background: #fef3f2;
|
||||
}
|
||||
|
||||
.billing-badge.closed {
|
||||
color: #475467;
|
||||
background: #f2f4f7;
|
||||
}
|
||||
|
||||
.billing-table td.in {
|
||||
color: #027a48;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.billing-table td.out {
|
||||
color: #b42318;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.poster-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
@@ -1093,6 +1272,33 @@ button.target-char-chip {
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.model-image-config {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 122, 26, 0.28);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, #fffaf3, #fff0dc);
|
||||
}
|
||||
|
||||
.model-image-config strong,
|
||||
.model-image-config span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.model-image-config strong {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.model-image-config span {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
@@ -1318,6 +1524,525 @@ button.target-char-chip {
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.upgrade-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.upgrade-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.upgrade-scroll {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.upgrade-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, #fff, #fff0dc);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.upgrade-hero h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.upgrade-hero p {
|
||||
max-width: 620px;
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.55;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upgrade-balance-card {
|
||||
min-width: 160px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #ffb23f, #ff6b16);
|
||||
color: #fff;
|
||||
box-shadow: 0 18px 40px rgba(255, 107, 22, 0.22);
|
||||
}
|
||||
|
||||
.upgrade-balance-card span,
|
||||
.upgrade-balance-card small {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upgrade-balance-card strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.upgrade-grid {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.8fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upgrade-plans-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upgrade-plan-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.upgrade-tabbar {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fffdf9;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.upgrade-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
min-height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upgrade-tab.is-active {
|
||||
border-color: #ffca97;
|
||||
background: #fff0de;
|
||||
color: #9b3f00;
|
||||
}
|
||||
|
||||
.upgrade-plan {
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 122, 26, 0.42);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(180deg, #fff, #fff8ed);
|
||||
box-shadow: 0 10px 24px rgba(255, 107, 22, 0.18);
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted {
|
||||
border-color: #4a2f1f;
|
||||
background: linear-gradient(180deg, #3c2619, #2b1a12);
|
||||
box-shadow: 0 14px 30px rgba(38, 20, 12, 0.3);
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted .plan-head h3,
|
||||
.upgrade-plan.is-highlighted p,
|
||||
.upgrade-plan.is-highlighted ul {
|
||||
color: #f8e9da;
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted .plan-head span {
|
||||
background: rgba(255, 173, 66, 0.2);
|
||||
color: #ffd39f;
|
||||
}
|
||||
|
||||
.plan-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-head h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.plan-head span {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-2);
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upgrade-plan p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
justify-self: start;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-items: baseline;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #fff2de, #ffe2bf);
|
||||
color: #b43f00;
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2px;
|
||||
box-shadow: 0 10px 24px rgba(255, 107, 22, 0.18);
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted .plan-price {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #ffcf90, #ffb766);
|
||||
color: #5a2a05;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0.2px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted .upgrade-toggle-row label {
|
||||
color: #f8e9da;
|
||||
}
|
||||
|
||||
.upgrade-plan.is-highlighted .ui-select {
|
||||
border-color: #7a553c;
|
||||
background-color: #f8e9da;
|
||||
color: #2d1f17;
|
||||
}
|
||||
|
||||
.upgrade-plan ul {
|
||||
margin: 0 0 0 16px;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upgrade-toggle-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.upgrade-toggle-row label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upgrade-wallet {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.upgrade-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upgrade-stats div {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.upgrade-stats span,
|
||||
.upgrade-stats strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upgrade-stats span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upgrade-stats strong {
|
||||
margin-top: 4px;
|
||||
color: var(--text);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.upgrade-recharge {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.upgrade-recharge button {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
white-space: nowrap;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.upgrade-plan button.secondary,
|
||||
.upgrade-recharge button.secondary {
|
||||
border-color: #ffb57a;
|
||||
background: #fff6ec;
|
||||
color: #9b3f00;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.upgrade-plan button.secondary:hover,
|
||||
.upgrade-recharge button.secondary:hover {
|
||||
background: #ffe9d3;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card {
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-soft);
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card > *:not(.purchase-head) {
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.purchase-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.purchase-head {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card .muted.small {
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.purchase-row {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.purchase-row span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.purchase-row strong {
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.purchase-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.purchase-qty {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.purchase-qty > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.purchase-qty .tiny {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.purchase-info {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.purchase-static-text {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.purchase-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr) 42px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.purchase-stepper button {
|
||||
margin-top: 0;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.purchase-stepper input {
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pay-channel-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pay-channel-option {
|
||||
margin: 0;
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pay-channel-option[data-channel="wechat"] {
|
||||
border-color: #b7e8c9;
|
||||
color: #1f9d55;
|
||||
background: #f4fdf7;
|
||||
}
|
||||
|
||||
.pay-channel-option[data-channel="alipay"] {
|
||||
border-color: #b8d8ff;
|
||||
color: #1677ff;
|
||||
background: #f3f8ff;
|
||||
}
|
||||
|
||||
.pay-channel-option[data-channel="wechat"].is-active {
|
||||
border-color: #07c160;
|
||||
color: #056a35;
|
||||
background: #eafaf1;
|
||||
box-shadow: 0 8px 18px rgba(7, 193, 96, 0.2);
|
||||
}
|
||||
|
||||
.pay-channel-option[data-channel="alipay"].is-active {
|
||||
border-color: #1677ff;
|
||||
color: #0d4fb8;
|
||||
background: #ebf3ff;
|
||||
box-shadow: 0 8px 18px rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
|
||||
.purchase-summary {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.purchase-summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.purchase-summary span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.purchase-summary strong {
|
||||
color: var(--accent-2);
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.purchase-action {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card #vipRechargeBtn {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
min-height: 40px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.upgrade-hero {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.upgrade-balance-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upgrade-grid,
|
||||
.upgrade-stats,
|
||||
.upgrade-recharge {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.upgrade-plan-grid,
|
||||
.pay-channel-group {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.purchase-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.upgrade-purchase-card {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.upgrade-recharge button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
@@ -1804,3 +2529,60 @@ button.target-char-chip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-dialog-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.ui-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ui-dialog {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.ui-dialog-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ui-dialog-message {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ui-dialog-input {
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-dialog-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ui-dialog-btn {
|
||||
width: auto;
|
||||
min-width: 92px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
101
app/static/ui-dialog.js
Normal file
101
app/static/ui-dialog.js
Normal file
@@ -0,0 +1,101 @@
|
||||
(() => {
|
||||
function ensureRoot() {
|
||||
let root = document.getElementById("uiDialogRoot");
|
||||
if (root) return root;
|
||||
root = document.createElement("div");
|
||||
root.id = "uiDialogRoot";
|
||||
root.className = "ui-dialog-root";
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function closeDialog(resolve, value, overlay) {
|
||||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
resolve(value);
|
||||
}
|
||||
|
||||
function createDialog({ title, message, mode = "alert", confirmText = "确定", cancelText = "取消", placeholder = "", defaultValue = "" }) {
|
||||
return new Promise((resolve) => {
|
||||
const root = ensureRoot();
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "ui-dialog-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="ui-dialog" role="dialog" aria-modal="true">
|
||||
<h3 class="ui-dialog-title"></h3>
|
||||
<div class="ui-dialog-message"></div>
|
||||
<input class="ui-dialog-input" type="text" />
|
||||
<div class="ui-dialog-actions">
|
||||
<button class="ui-dialog-btn secondary ui-dialog-cancel" type="button">${cancelText}</button>
|
||||
<button class="ui-dialog-btn primary ui-dialog-confirm" type="button">${confirmText}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.appendChild(overlay);
|
||||
|
||||
const titleEl = overlay.querySelector(".ui-dialog-title");
|
||||
const msgEl = overlay.querySelector(".ui-dialog-message");
|
||||
const inputEl = overlay.querySelector(".ui-dialog-input");
|
||||
const cancelBtn = overlay.querySelector(".ui-dialog-cancel");
|
||||
const okBtn = overlay.querySelector(".ui-dialog-confirm");
|
||||
if (titleEl) titleEl.textContent = title || "提示";
|
||||
if (msgEl) msgEl.textContent = message || "";
|
||||
if (inputEl) {
|
||||
inputEl.placeholder = placeholder || "";
|
||||
inputEl.value = defaultValue || "";
|
||||
}
|
||||
|
||||
if (mode === "alert") {
|
||||
if (cancelBtn) cancelBtn.style.display = "none";
|
||||
} else if (mode === "confirm") {
|
||||
if (inputEl) inputEl.style.display = "none";
|
||||
} else if (mode === "prompt") {
|
||||
if (inputEl) inputEl.style.display = "block";
|
||||
}
|
||||
|
||||
const onEsc = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
document.removeEventListener("keydown", onEsc);
|
||||
if (mode === "alert") closeDialog(resolve, true, overlay);
|
||||
else closeDialog(resolve, null, overlay);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onEsc);
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
document.removeEventListener("keydown", onEsc);
|
||||
if (mode === "confirm") closeDialog(resolve, false, overlay);
|
||||
else closeDialog(resolve, null, overlay);
|
||||
});
|
||||
}
|
||||
if (okBtn) {
|
||||
okBtn.addEventListener("click", () => {
|
||||
document.removeEventListener("keydown", onEsc);
|
||||
if (mode === "prompt") closeDialog(resolve, inputEl ? inputEl.value : "", overlay);
|
||||
else if (mode === "confirm") closeDialog(resolve, true, overlay);
|
||||
else closeDialog(resolve, true, overlay);
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "prompt" && inputEl) {
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
} else if (okBtn) {
|
||||
window.setTimeout(() => okBtn.focus(), 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.uiAlert = async (message, title = "提示") => createDialog({ title, message, mode: "alert", confirmText: "我知道了" });
|
||||
window.uiConfirm = async (message, title = "请确认") =>
|
||||
createDialog({ title, message, mode: "confirm", confirmText: "确认", cancelText: "取消" });
|
||||
window.uiPrompt = async (message, title = "请输入", defaultValue = "", placeholder = "") =>
|
||||
createDialog({
|
||||
title,
|
||||
message,
|
||||
mode: "prompt",
|
||||
confirmText: "确认",
|
||||
cancelText: "取消",
|
||||
defaultValue,
|
||||
placeholder,
|
||||
});
|
||||
})();
|
||||
292
app/static/upgrade.js
Normal file
292
app/static/upgrade.js
Normal file
@@ -0,0 +1,292 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let pendingOrderNo = "";
|
||||
let pendingPollTimer = null;
|
||||
let pendingPollCount = 0;
|
||||
|
||||
function setStatus(msg, danger = false) {
|
||||
const el = $("status");
|
||||
if (!el) return;
|
||||
el.style.color = danger ? "#b42318" : "#0f5f3d";
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
function setLoading(button, loading, idleText, loadingText) {
|
||||
if (!button) return;
|
||||
button.disabled = loading;
|
||||
button.textContent = loading ? loadingText : idleText;
|
||||
}
|
||||
|
||||
function importantNotice(message, title = "提示") {
|
||||
if (typeof window.uiAlert === "function") {
|
||||
void window.uiAlert(message, title);
|
||||
return;
|
||||
}
|
||||
setStatus(message);
|
||||
}
|
||||
|
||||
function openPayLink(url) {
|
||||
const payUrl = String(url || "").trim();
|
||||
if (!payUrl) return false;
|
||||
const tab = window.open(payUrl, "_blank", "noopener");
|
||||
if (tab) return true;
|
||||
window.location.href = payUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function postJSON(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || "请求失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
if (!data.logged_in) {
|
||||
window.location.href = "/auth?next=/upgrade";
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function formatDateTime(tsSec) {
|
||||
const n = Number(tsSec || 0);
|
||||
if (!n) return "-";
|
||||
return new Date(n * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function formatDate(tsSec) {
|
||||
const n = Number(tsSec || 0);
|
||||
if (!n) return "-";
|
||||
const d = new Date(n * 1000);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function refreshPurchaseCycleText() {
|
||||
const el = $("purchaseCycleText");
|
||||
if (!el) return;
|
||||
const start = Math.floor(Date.now() / 1000);
|
||||
const end = start + 30 * 24 * 3600;
|
||||
el.textContent = `时长:1个月(到期:${formatDate(end)})`;
|
||||
}
|
||||
|
||||
function renderVip(vip) {
|
||||
const shared = Number(vip.shared_credits || vip.token_balance || 0);
|
||||
const seatRemaining = Number(vip.seat_remaining_credits || 0);
|
||||
const totalAvailable = Number(vip.total_available_credits || seatRemaining + shared);
|
||||
const enabled = Boolean(vip.vip_enabled);
|
||||
const cycleStart = Number(vip.cycle_started_at || 0);
|
||||
const cycleEnd = Number(vip.cycle_expires_at || 0);
|
||||
if ($("upgradeTokenBalance")) $("upgradeTokenBalance").textContent = String(totalAvailable);
|
||||
if ($("upgradeTokenBalanceHero")) $("upgradeTokenBalanceHero").textContent = String(totalAvailable);
|
||||
if ($("vipTokenBalance")) $("vipTokenBalance").textContent = String(shared);
|
||||
if ($("vipSeatRemaining")) $("vipSeatRemaining").textContent = String(seatRemaining);
|
||||
if ($("vipTotalConsumed")) $("vipTotalConsumed").textContent = String(Number(vip.total_consumed_tokens || 0));
|
||||
if ($("vipEnabledSelect")) $("vipEnabledSelect").value = enabled ? "1" : "0";
|
||||
if ($("vipStateText")) {
|
||||
$("vipStateText").textContent = totalAvailable <= 0 ? "额度已用完" : enabled ? "平台模型已开启" : "平台模型已关闭";
|
||||
}
|
||||
if ($("vipCycleHint")) {
|
||||
if (cycleStart > 0 && cycleEnd > 0) {
|
||||
const startText = formatDateTime(cycleStart);
|
||||
const endText = formatDateTime(cycleEnd);
|
||||
$("vipCycleHint").textContent = `当前周期:${startText} - ${endText}(到期自动清零)`;
|
||||
} else {
|
||||
$("vipCycleHint").textContent = "当前未开始月周期,首次支付成功后开始计时。";
|
||||
}
|
||||
}
|
||||
if (totalAvailable <= 0) {
|
||||
setStatus("Credits 额度已用完,请充值共享加油包或等待下个计费周期。", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const me = await fetchMe();
|
||||
if (me && me.vip) renderVip(me.vip);
|
||||
}
|
||||
|
||||
async function fetchBillingOverview() {
|
||||
const res = await fetch("/api/billing/overview");
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.detail || "账单读取失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
function stopPendingPoll() {
|
||||
if (pendingPollTimer) {
|
||||
window.clearInterval(pendingPollTimer);
|
||||
pendingPollTimer = null;
|
||||
}
|
||||
pendingPollCount = 0;
|
||||
}
|
||||
|
||||
function startPendingPoll(orderNo, showCreatedNotice = true) {
|
||||
if (!orderNo) return;
|
||||
pendingOrderNo = orderNo;
|
||||
stopPendingPoll();
|
||||
const msg = `订单 ${orderNo} 已创建,请完成支付,系统将自动刷新余额。`;
|
||||
setStatus(msg);
|
||||
if (showCreatedNotice) {
|
||||
importantNotice(msg, "订单已创建");
|
||||
}
|
||||
pendingPollTimer = window.setInterval(async () => {
|
||||
pendingPollCount += 1;
|
||||
try {
|
||||
const data = await fetchBillingOverview();
|
||||
const records = Array.isArray(data.recharge_records) ? data.recharge_records : [];
|
||||
const current = records.find((r) => (r.order_no || "") === pendingOrderNo);
|
||||
if (current && (current.status || "") === "paid") {
|
||||
stopPendingPoll();
|
||||
pendingOrderNo = "";
|
||||
setStatus("支付成功,余额已自动刷新。");
|
||||
await refresh();
|
||||
return;
|
||||
}
|
||||
if (current && ["cancelled", "closed"].includes(String(current.status || "").toLowerCase())) {
|
||||
stopPendingPoll();
|
||||
pendingOrderNo = "";
|
||||
setStatus("订单超过15分钟未支付,已自动取消。", true);
|
||||
await refresh();
|
||||
return;
|
||||
}
|
||||
if (pendingPollCount >= 120) {
|
||||
stopPendingPoll();
|
||||
setStatus("订单仍未支付,可支付后点击刷新查看余额。", true);
|
||||
}
|
||||
} catch {
|
||||
if (pendingPollCount >= 120) {
|
||||
stopPendingPoll();
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const saveVipBtn = $("saveVipBtn");
|
||||
const vipRechargeBtn = $("vipRechargeBtn");
|
||||
const vipRechargeTokensInput = $("vipRechargeTokens");
|
||||
const vipRechargeAmountInput = $("vipRechargeAmount");
|
||||
const payChannelOptions = Array.from(document.querySelectorAll(".pay-channel-option"));
|
||||
let selectedPayChannel = "wechat";
|
||||
|
||||
function bindPayChannelOptions() {
|
||||
if (!payChannelOptions.length) return;
|
||||
payChannelOptions.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
selectedPayChannel = (btn.dataset.channel || "wechat").trim() || "wechat";
|
||||
payChannelOptions.forEach((item) => {
|
||||
const active = item === btn;
|
||||
item.classList.toggle("is-active", active);
|
||||
item.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function packageRate() {
|
||||
const credits = Number((vipRechargeTokensInput && vipRechargeTokensInput.dataset.packageCredits) || "1500");
|
||||
const amount = Number((vipRechargeAmountInput && vipRechargeAmountInput.dataset.packageAmount) || "19.9");
|
||||
return {
|
||||
packageCredits: Number.isFinite(credits) && credits > 0 ? credits : 1500,
|
||||
packageAmount: Number.isFinite(amount) && amount > 0 ? amount : 19.9,
|
||||
};
|
||||
}
|
||||
|
||||
function syncRechargeAmount() {
|
||||
const { packageCredits, packageAmount } = packageRate();
|
||||
const credits = packageCredits;
|
||||
const amount = packageAmount;
|
||||
if (vipRechargeTokensInput) vipRechargeTokensInput.value = String(credits);
|
||||
vipRechargeAmountInput.value = amount.toFixed(2);
|
||||
if ($("purchaseCredits")) $("purchaseCredits").textContent = String(credits);
|
||||
if ($("purchaseAmount")) $("purchaseAmount").textContent = `¥${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
if (vipRechargeAmountInput) vipRechargeAmountInput.readOnly = true;
|
||||
syncRechargeAmount();
|
||||
refreshPurchaseCycleText();
|
||||
bindPayChannelOptions();
|
||||
|
||||
if (saveVipBtn) {
|
||||
saveVipBtn.addEventListener("click", async () => {
|
||||
setLoading(saveVipBtn, true, "保存升级设置", "保存中...");
|
||||
try {
|
||||
const enabled = (($("vipEnabledSelect") && $("vipEnabledSelect").value) || "0") === "1";
|
||||
const out = await postJSON("/api/auth/vip/toggle", { enabled });
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "VIP 设置保存失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("升级设置已保存。");
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus(e.message || "VIP 设置保存失败", true);
|
||||
} finally {
|
||||
setLoading(saveVipBtn, false, "保存升级设置", "保存中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (vipRechargeBtn) {
|
||||
vipRechargeBtn.addEventListener("click", async () => {
|
||||
setLoading(vipRechargeBtn, true, "订阅", "创建订单中...");
|
||||
try {
|
||||
const tokens = Number((($("vipRechargeTokens") && $("vipRechargeTokens").value) || "0").trim());
|
||||
const amount = Number((($("vipRechargeAmount") && $("vipRechargeAmount").value) || "0").trim());
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) {
|
||||
setStatus("请输入正确的 Credits 数量", true);
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setStatus("请输入正确的支付金额", true);
|
||||
return;
|
||||
}
|
||||
const out = await postJSON("/api/pay/wechat/", {
|
||||
tokens: Math.round(tokens),
|
||||
amount_cny: Number(amount.toFixed(2)),
|
||||
channel: selectedPayChannel || "wechat",
|
||||
subscriber_name: "",
|
||||
subscriber_phone: "",
|
||||
shipping_address: "",
|
||||
});
|
||||
if (!out.ok) {
|
||||
setStatus(out.detail || "充值失败", true);
|
||||
return;
|
||||
}
|
||||
const orderNo = out.order && out.order.order_no ? out.order.order_no : "";
|
||||
if (orderNo) startPendingPoll(orderNo, true);
|
||||
if (out.pay_url) {
|
||||
openPayLink(out.pay_url);
|
||||
return;
|
||||
}
|
||||
const tip = "订单已创建,但未获取到支付链接,请检查支付网关配置。";
|
||||
setStatus(tip, true);
|
||||
importantNotice(tip, "支付链接缺失");
|
||||
} catch (e) {
|
||||
setStatus(e.message || "充值失败", true);
|
||||
} finally {
|
||||
setLoading(vipRechargeBtn, false, "订阅", "创建订单中...");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
(async () => {
|
||||
await fetchMe();
|
||||
try {
|
||||
const data = await fetchBillingOverview();
|
||||
const records = Array.isArray(data.recharge_records) ? data.recharge_records : [];
|
||||
const pending = records.find((r) => (r.status || "") === "pending");
|
||||
if (pending && pending.order_no) startPendingPoll(pending.order_no, false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
window.setInterval(refreshPurchaseCycleText, 60000);
|
||||
Reference in New Issue
Block a user