Files
Blizzard caa2602131 feat(web): 用户面订阅 —— 购买/续订入口 + 到期提醒
账单页新增:当前订阅状态(套餐/发放节奏/已发放次数/到期倒计时)+ 可购套餐。
已有订阅时购买区标题自动变成「续订(在当前到期时间上顺延)」,与后端的顺延
语义一致,免得用户以为会新开一条。

到期倒计时 ≤7 天转琥珀、≤3 天转红:到期即失效且**没有任何扣款或续费通知**,
用户只能从这里看见,不显眼等于没有。

支付弹窗改为对「买什么」中立(PayItem:积分包 | 订阅),下单/轮询/超时/warn
处理全共用。给订阅复制一份弹窗的话,两边迟早漂移——之前修过的轮询问题就得修
两遍。

真环境验证:账单页显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)、余额 3.0K
(NULL 回填后的 2981)、套餐卡片算出「周期内共 3 次,合计 3.0K 积分」。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:33:59 +08:00

450 lines
21 KiB
TypeScript
Raw Permalink 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.
import { useCallback, useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { Coins, ReceiptText, Ticket, QrCode } from "lucide-react";
import { useTenant } from "../shell/AppShell";
import { myUsage, redeemCode, billingOrders, billingPacks, createWechatOrder, orderStatus, fmtCredits, subPlans, mySubscription, type MyUsage, type TopupOrder, type Pack, type SubPlan, type MySub } from "../api";
import { Badge, Button, Dialog, Input, Panel, Table, Tr, Td, cn, useToast } from "../ui";
// 用量与账单:余额 + 兑换码充值(P5.1) + 消耗趋势 + 最近消耗/充值。
// 微信扫码支付在 P5.2 挂上(渠道适配器已留位),此前不做假支付入口。
export function Usage() {
const toast = useToast();
const { ctx, refresh } = useTenant();
const [days, setDays] = useState(7);
const [u, setU] = useState<MyUsage | null>(null);
const [orders, setOrders] = useState<TopupOrder[]>([]);
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [packs, setPacks] = useState<Pack[]>([]);
const [wechatOn, setWechatOn] = useState(false); // 服务端配了商户号才亮
const [paying, setPaying] = useState<PayItem | null>(null); // 正在扫码支付的商品(积分包或订阅)
const [plans, setPlans] = useState<SubPlan[]>([]);
const [sub, setSub] = useState<MySub | null>(null);
const [subPlan, setSubPlan] = useState<SubPlan | null>(null);
const load = useCallback(() => {
myUsage(days).then(setU).catch(() => {});
billingOrders().then(setOrders).catch(() => {});
billingPacks()
.then((r) => {
setPacks(r.packs);
setWechatOn(r.channels.includes("wechat"));
})
.catch(() => {});
subPlans().then(setPlans).catch(() => {});
mySubscription()
.then((r) => {
setSub(r.subscription);
setSubPlan(r.plan);
})
.catch(() => {});
}, [days]);
useEffect(load, [load, ctx?.tenant?.id]);
const canTopup = ctx?.role !== "viewer"; // 真闸在后端(≥member),这里只是不摆没用的输入框
const redeem = async () => {
if (!code.trim() || busy) return;
setBusy(true);
try {
const r = await redeemCode(code.trim());
toast.push("success", `已入账,当前余额 ${fmtCredits(r.balance_micro)} 积分`);
setCode("");
load();
refresh(); // 顶栏/概览的余额也跟着变
} catch (e) {
toast.push("error", (e as Error).message);
} finally {
setBusy(false);
}
};
const maxTok = Math.max(...(u?.trend.map((d) => d.total_tok) ?? []), 1);
const fmtTok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : `${n}`);
return (
<div className="mx-auto max-w-3xl space-y-4 p-8">
<div>
<h1 className="text-xl font-semibold tracking-tight text-slate-100"></h1>
<p className="mt-1 text-xs text-slate-500">{ctx?.tenant?.name ?? "…"} · </p>
</div>
{/* 余额 hero */}
<div className="rounded-xl border border-line bg-ink-900 p-5 shadow-card">
<div className="flex items-end justify-between">
<div>
<div className="text-xs text-slate-500"></div>
<div className="mt-1 text-3xl font-semibold tabular-nums text-slate-100">
{fmtCredits(u?.balance_micro ?? ctx?.credit_balance_micro ?? 0)}
<span className="ml-1.5 text-sm font-normal text-slate-500"></span>
</div>
</div>
{u?.credit_enforce && (u?.balance_micro ?? 0) <= 0 ? (
<Badge tone="danger"></Badge>
) : null}
</div>
{canTopup ? (
<div className="mt-4 border-t border-line pt-4">
{/* 微信扫码:服务端配了商户号且有在售包才出现 */}
{/* 订阅:到期即失效、不自动续费,所以"还剩几天"必须显眼——用户不会收到扣款提醒 */}
{sub && subPlan && (
<div className="mb-4 rounded-lg border border-brand/30 bg-brand/5 px-4 py-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="text-sm font-medium text-slate-200">
· {subPlan.name}
</div>
<SubExpiry expiresAt={sub.expires_at} />
</div>
<div className="mt-1 text-xs text-slate-500">
{subPlan.refill_interval_days} {" "}
<span className="tabular-nums text-brand-400">{fmtCredits(subPlan.refill_credits_micro)}</span> ·
<span className="tabular-nums">{sub.refill_seq}</span> ·
</div>
</div>
)}
{wechatOn && plans.length > 0 && canTopup && (
<div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
<QrCode className="h-3.5 w-3.5" /> {sub ? "续订(在当前到期时间上顺延)" : "订阅"}
</div>
<div className="flex flex-wrap gap-2">
{plans.map((pl) => {
const times = pl.refill_interval_days > 0 ? Math.floor(pl.duration_days / pl.refill_interval_days) : 0;
return (
<button key={pl.id}
onClick={() => setPaying({
id: pl.id, name: pl.name, price_fen: pl.price_fen, kind: "sub",
note: `微信扫一扫支付,开通后每 ${pl.refill_interval_days} 天发放 ${fmtCredits(pl.refill_credits_micro)} 积分`,
})}
className="group rounded-lg border border-line bg-ink-850 px-4 py-2.5 text-left transition-colors hover:border-brand/50">
<div className="text-sm font-medium text-slate-200">{pl.name}</div>
<div className="mt-0.5 text-xs text-slate-500">
{pl.duration_days} · {pl.refill_interval_days} {" "}
<span className="tabular-nums text-brand-400">{fmtCredits(pl.refill_credits_micro)}</span> ·
<span className="ml-1 tabular-nums">¥{(pl.price_fen / 100).toFixed(2)}</span>
</div>
<div className="mt-0.5 text-[10px] text-slate-600">
{times} {fmtCredits(pl.refill_credits_micro * times)}
</div>
</button>
);
})}
</div>
</div>
)}
{wechatOn && packs.length > 0 && (
<div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
<QrCode className="h-3.5 w-3.5" />
</div>
<div className="flex flex-wrap gap-2">
{packs.map((p) => (
<button key={p.id}
onClick={() => setPaying({
id: p.id, name: p.name, price_fen: p.price_fen, kind: "pack",
note: `微信扫一扫支付,到账 ${fmtCredits(p.credits_micro)} 积分`,
})}
className="group rounded-lg border border-line bg-ink-850 px-4 py-2.5 text-left transition-colors hover:border-brand/50">
<div className="text-sm font-medium text-slate-200">{p.name}</div>
<div className="mt-0.5 text-xs text-slate-500">
<span className="tabular-nums text-brand-400">{fmtCredits(p.credits_micro)}</span> ·
<span className="ml-1 tabular-nums">¥{(p.price_fen / 100).toFixed(2)}</span>
</div>
</button>
))}
</div>
</div>
)}
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
<Ticket className="h-3.5 w-3.5" />
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
className="w-56 font-mono uppercase placeholder:font-sans placeholder:normal-case"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && redeem()}
placeholder="SDX-XXXX-XXXX-XXXX"
/>
<Button variant="primary" onClick={redeem} disabled={busy || !code.trim()}>
{busy ? "入账中…" : "兑换"}
</Button>
<span className="text-[11px] text-slate-600">
{wechatOn ? "兑换码请向平台获取。" : "微信扫码支付即将上线;兑换码请向平台获取。"}
</span>
</div>
</div>
) : (
<p className="mt-3 text-[11px] text-slate-600"></p>
)}
</div>
{/* 趋势 */}
<Panel
title="消耗趋势"
icon={Coins}
actions={
<div className="flex items-center gap-1 rounded-md border border-line p-0.5">
{[7, 30].map((d) => (
<button key={d} onClick={() => setDays(d)}
className={cn("rounded px-2 py-0.5 text-[11px] transition", days === d ? "bg-ink-800 text-slate-100" : "text-slate-500 hover:text-slate-300")}>
{d}
</button>
))}
</div>
}>
<div className="flex h-28 items-end gap-1.5">
{(u?.trend ?? []).map((d) => (
<div key={d.day} className="flex flex-1 flex-col items-center gap-1">
<div className="w-full rounded-t bg-brand/60 transition-all hover:bg-brand"
style={{ height: `${Math.max((d.total_tok / maxTok) * 100, 2)}%` }}
title={`${d.day}${fmtTok(d.total_tok)} tok · ${fmtCredits(d.credits_micro)} 积分 · ${d.task_count}`} />
<span className="text-[9px] tabular-nums text-slate-600">{d.day.slice(-2)}</span>
</div>
))}
{(!u || u.trend.length === 0) && <p className="flex-1 self-center text-center text-xs text-slate-600"></p>}
</div>
<div className="mt-3 flex gap-4 border-t border-line pt-3 text-[11px] text-slate-500">
<span> <span className="tabular-nums text-slate-300">{fmtTok(u?.totals.total_tok ?? 0)}</span> tok</span>
<span><span className="tabular-nums text-slate-300">{fmtCredits(u?.totals.credits_micro ?? 0)}</span> </span>
<span><span className="tabular-nums text-slate-300">{u?.totals.task_count ?? 0}</span> </span>
</div>
</Panel>
{/* 最近消耗 */}
<Panel title="最近消耗" icon={ReceiptText} bodyClassName="p-0">
<Table cols={["任务", "模型", "tokens", "积分"]}>
{(u?.recent ?? []).map((r) => (
<Tr key={r.task_id}>
<Td><span className="font-mono text-[11px] text-slate-400">{r.task_id}</span></Td>
<Td><span className="text-xs text-slate-300">{r.model || "—"}</span></Td>
<Td><span className="text-xs tabular-nums text-slate-300">{fmtTok(r.total_tok)}</span></Td>
<Td><span className="text-xs tabular-nums text-slate-300">{fmtCredits(r.credits_micro)}</span></Td>
</Tr>
))}
</Table>
{(!u || u.recent.length === 0) && <p className="p-4 text-xs text-slate-600"></p>}
</Panel>
{/* 最近充值(订单流:兑换码与将来的微信支付一个查法) */}
{orders.length > 0 && (
<Panel title="最近充值" icon={Ticket} bodyClassName="p-0">
<Table cols={["时间", "渠道", "积分", "状态"]}>
{orders.map((o) => (
<Tr key={o.id}>
<Td><span className="text-xs text-slate-400">{new Date(o.created_at).toLocaleString()}</span></Td>
<Td><span className="text-xs text-slate-300">{o.channel === "redeem" ? "兑换码" : o.channel === "wechat" ? "微信支付" : o.channel}</span></Td>
<Td><span className="text-xs tabular-nums text-slate-200">+{fmtCredits(o.credits_micro)}</span></Td>
<Td>
<Badge tone={o.status === "paid" ? "success" : o.status === "pending" ? "warn" : "danger"}>
{o.status === "paid" ? "已入账" : o.status === "pending" ? "待支付" : o.status}
</Badge>
</Td>
</Tr>
))}
</Table>
</Panel>
)}
{paying && (
<PayDialog
item={paying}
onClose={(paid) => {
const wasSub = paying.kind === "sub";
setPaying(null);
if (paid) toast.push("success", wasSub ? "订阅已开通,首笔积分已发放" : "支付成功,积分已入账");
// 无论如何都刷一次:用户可能在关弹窗前一刻付款、状态刚落地还没被轮询看到。
load();
refresh();
}}
/>
)}
</div>
);
}
// PayDialog 微信 Native 扫码支付:下单 → code_url 画二维码 → 轮询单态确认到账。
//
// 轮询这条链上几件必须处理的事:
// - 递归 setTimeout 而非 setInterval:请求慢于间隔时 setInterval 会把请求摞起来,
// 而这个端点每次都可能触发一次渠道查单,摞起来等于放大对微信的调用。
// - 标签页切走时暂停:用户扫完码要切到微信 App 付款,这段时间页面在后台空转没意义;
// 切回来立刻补查一次,回前台的确认几乎无延迟。
// - 连续失败指数退避:断网时别以固定频率猛打,封顶 15s。
// - warn(已付但金额与订单不符)必须显式呈现:这种单服务端**不入账**、订单一直停在
// pending,不提示的话用户会一直等一个永远不会来的结果。
const POLL_BASE_MS = 2500;
const POLL_MAX_MS = 15000;
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
// 支付弹窗对「买什么」保持中立:积分包与订阅只是标题与到账说明不同,
// 下单/轮询/超时/warn 处理全共用——复制一份给订阅的话,两边迟早漂移。
type PayItem = {
id: string;
name: string;
price_fen: number;
kind: "pack" | "sub";
note: string; // 支付成功前显示的"买到什么"说明
};
function PayDialog({ item, onClose }: { item: PayItem; onClose: (paid: boolean) => void }) {
const [qr, setQr] = useState("");
const [err, setErr] = useState("");
const [warn, setWarn] = useState("");
const [state, setState] = useState<"creating" | "waiting" | "paid" | "expired">("creating");
const [left, setLeft] = useState(0); // 二维码剩余有效秒数
const orderRef = useRef("");
const expiresRef = useRef(0);
useEffect(() => {
let alive = true;
let timer: number | null = null;
let failures = 0;
const stop = () => {
if (timer) window.clearTimeout(timer);
timer = null;
};
const schedule = (ms: number) => {
stop();
timer = window.setTimeout(() => void tick(), ms);
};
const tick = async () => {
if (!alive) return;
if (document.hidden) return schedule(POLL_BASE_MS); // 后台不打请求,等切回来补
try {
const { order, warn: w } = await orderStatus(orderRef.current);
if (!alive) return;
failures = 0;
setWarn(w ?? "");
if (order.status === "paid") {
setState("paid");
stop();
window.setTimeout(() => onClose(true), 800);
return;
}
if (order.status === "expired" || order.status === "failed") {
setState("expired");
stop();
return;
}
schedule(POLL_BASE_MS);
} catch {
if (!alive) return;
failures += 1;
schedule(Math.min(POLL_BASE_MS * 2 ** failures, POLL_MAX_MS));
}
};
// 切回前台立刻查一次,不用等下一个 tick
const onVisible = () => {
if (!document.hidden && alive && orderRef.current) schedule(0);
};
document.addEventListener("visibilitychange", onVisible);
(async () => {
try {
const o = await createWechatOrder(item.kind === "sub" ? { planId: item.id } : { packId: item.id });
if (!alive) return;
orderRef.current = o.order_id;
expiresRef.current = new Date(o.expires_at).getTime();
setLeft(Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000)));
setQr(await QRCode.toDataURL(o.code_url, { width: 240, margin: 1 }));
setState("waiting");
schedule(POLL_BASE_MS);
} catch (e) {
if (alive) setErr((e as Error).message);
}
})();
return () => {
alive = false;
stop();
document.removeEventListener("visibilitychange", onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.id]);
// 倒计时:到点本地先置过期,省一次「扫了个死码才知道」。服务端 TTL 仍是权威。
useEffect(() => {
if (state !== "waiting") return;
const t = window.setInterval(() => {
const s = Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000));
setLeft(s);
if (s === 0) setState("expired");
}, 1000);
return () => window.clearInterval(t);
}, [state]);
return (
<Dialog open title={`微信扫码 · ${item.name}`} onClose={() => onClose(state === "paid")}>
<div className="flex flex-col items-center gap-3 py-2">
{err ? (
<p className="text-xs text-danger">{err}</p>
) : state === "creating" ? (
<p className="py-16 text-xs text-slate-500"></p>
) : (
<>
{/* 二维码底色固定纯白:扫码器对暗色主题下的低对比码识别率差 */}
<div className="relative rounded-lg bg-white p-2">
<img src={qr} alt="微信支付二维码" width={240} height={240} />
{state === "expired" && (
// 盖住失效的码,避免用户扫一个必然失败的二维码
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-slate-900/80 text-xs text-slate-200">
</div>
)}
</div>
<div className="text-center">
<div className="text-lg font-semibold tabular-nums text-slate-100">¥{(item.price_fen / 100).toFixed(2)}</div>
<div className="mt-1 text-xs text-slate-500">
{state === "paid"
? "✅ 已支付,入账中…"
: state === "expired"
? "订单已过期,请关闭后重新下单"
: item.note}
</div>
{state === "waiting" && (
<div className="mt-1 text-[11px] tabular-nums text-slate-500"> {mmss(left)} </div>
)}
</div>
{/* 已付但金额与订单不符:服务端不入账、挂起人工核对,必须告诉用户别再等 */}
{warn && (
<p className="rounded-md bg-amber-500/10 px-3 py-2 text-center text-[11px] leading-relaxed text-amber-300">
{warn}
<br />
{orderRef.current}
</p>
)}
{/* 掉单补偿定时器在后台兜底,关页面不会丢钱——说清楚,省得用户守着不敢关 */}
{state === "waiting" && !warn && (
<p className="text-center text-[11px] leading-relaxed text-slate-600">
</p>
)}
</>
)}
</div>
</Dialog>
);
}
// SubExpiry 到期倒计时。到期即失效且没有自动续费,临近到期必须变色提醒——
// 用户不会收到任何扣款或续费通知,只能靠这里看见。
function SubExpiry({ expiresAt }: { expiresAt: string }) {
const left = Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86400000);
const tone = left <= 3 ? "text-rose-400" : left <= 7 ? "text-amber-400" : "text-slate-400";
return (
<span className={`text-xs tabular-nums ${tone}`}>
{new Date(expiresAt).toLocaleDateString("zh-CN")} {left}
</span>
);
}