fix:优化当前的项目

This commit is contained in:
Daniel
2026-04-28 18:36:38 +08:00
parent 04f26bdaaf
commit f47453a656
22 changed files with 3671 additions and 89 deletions

View File

@@ -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"));

View File

@@ -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
View 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
View 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();
})();

View File

@@ -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;

View File

@@ -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
View 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
View 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);