341 lines
12 KiB
JavaScript
341 lines
12 KiB
JavaScript
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();
|
||
})();
|