caa2602131
账单页新增:当前订阅状态(套餐/发放节奏/已发放次数/到期倒计时)+ 可购套餐。 已有订阅时购买区标题自动变成「续订(在当前到期时间上顺延)」,与后端的顺延 语义一致,免得用户以为会新开一条。 到期倒计时 ≤7 天转琥珀、≤3 天转红:到期即失效且**没有任何扣款或续费通知**, 用户只能从这里看见,不显眼等于没有。 支付弹窗改为对「买什么」中立(PayItem:积分包 | 订阅),下单/轮询/超时/warn 处理全共用。给订阅复制一份弹窗的话,两边迟早漂移——之前修过的轮询问题就得修 两遍。 真环境验证:账单页显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)、余额 3.0K (NULL 回填后的 2981)、套餐卡片算出「周期内共 3 次,合计 3.0K 积分」。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
450 lines
21 KiB
TypeScript
450 lines
21 KiB
TypeScript
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>
|
||
);
|
||
}
|