Files
AIcreat/app/static/billing.js
2026-04-28 18:36:38 +08:00

341 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
})();