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(null); const [orders, setOrders] = useState([]); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); const [packs, setPacks] = useState([]); const [wechatOn, setWechatOn] = useState(false); // 服务端配了商户号才亮 const [paying, setPaying] = useState(null); // 正在扫码支付的商品(积分包或订阅) const [plans, setPlans] = useState([]); const [sub, setSub] = useState(null); const [subPlan, setSubPlan] = useState(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 (

用量与账单

{ctx?.tenant?.name ?? "…"} · 消耗以积分计

{/* 余额 hero */}
当前余额
{fmtCredits(u?.balance_micro ?? ctx?.credit_balance_micro ?? 0)} 积分
{u?.credit_enforce && (u?.balance_micro ?? 0) <= 0 ? ( 余额不足,任务提交将被拒绝 ) : null}
{canTopup ? (
{/* 微信扫码:服务端配了商户号且有在售包才出现 */} {/* 订阅:到期即失效、不自动续费,所以"还剩几天"必须显眼——用户不会收到扣款提醒 */} {sub && subPlan && (
订阅中 · {subPlan.name}
每 {subPlan.refill_interval_days} 天发放{" "} {fmtCredits(subPlan.refill_credits_micro)} 积分 · 已发放 {sub.refill_seq} 次 · 到期后不再发放,需重新购买
)} {wechatOn && plans.length > 0 && canTopup && (
{sub ? "续订(在当前到期时间上顺延)" : "订阅"}
{plans.map((pl) => { const times = pl.refill_interval_days > 0 ? Math.floor(pl.duration_days / pl.refill_interval_days) : 0; return ( ); })}
)} {wechatOn && packs.length > 0 && (
微信扫码充值
{packs.map((p) => ( ))}
)}
兑换码充值
setCode(e.target.value)} onKeyDown={(e) => e.key === "Enter" && redeem()} placeholder="SDX-XXXX-XXXX-XXXX" /> {wechatOn ? "兑换码请向平台获取。" : "微信扫码支付即将上线;兑换码请向平台获取。"}
) : (

你是只读角色,充值请联系组织管理员。

)}
{/* 趋势 */} {[7, 30].map((d) => ( ))}
}>
{(u?.trend ?? []).map((d) => (
{d.day.slice(-2)}
))} {(!u || u.trend.length === 0) &&

区间内暂无消耗

}
合计 {fmtTok(u?.totals.total_tok ?? 0)} tok {fmtCredits(u?.totals.credits_micro ?? 0)} 积分 {u?.totals.task_count ?? 0} 次任务
{/* 最近消耗 */} {(u?.recent ?? []).map((r) => ( ))}
{r.task_id} {r.model || "—"} {fmtTok(r.total_tok)} {fmtCredits(r.credits_micro)}
{(!u || u.recent.length === 0) &&

暂无消耗记录。

}
{/* 最近充值(订单流:兑换码与将来的微信支付一个查法) */} {orders.length > 0 && ( {orders.map((o) => ( ))}
{new Date(o.created_at).toLocaleString()} {o.channel === "redeem" ? "兑换码" : o.channel === "wechat" ? "微信支付" : o.channel} +{fmtCredits(o.credits_micro)} {o.status === "paid" ? "已入账" : o.status === "pending" ? "待支付" : o.status}
)} {paying && ( { const wasSub = paying.kind === "sub"; setPaying(null); if (paid) toast.push("success", wasSub ? "订阅已开通,首笔积分已发放" : "支付成功,积分已入账"); // 无论如何都刷一次:用户可能在关弹窗前一刻付款、状态刚落地还没被轮询看到。 load(); refresh(); }} /> )}
); } // 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 ( onClose(state === "paid")}>
{err ? (

{err}

) : state === "creating" ? (

正在生成支付二维码…

) : ( <> {/* 二维码底色固定纯白:扫码器对暗色主题下的低对比码识别率差 */}
微信支付二维码 {state === "expired" && ( // 盖住失效的码,避免用户扫一个必然失败的二维码
二维码已失效
)}
¥{(item.price_fen / 100).toFixed(2)}
{state === "paid" ? "✅ 已支付,入账中…" : state === "expired" ? "订单已过期,请关闭后重新下单" : item.note}
{state === "waiting" && (
二维码 {mmss(left)} 后失效
)}
{/* 已付但金额与订单不符:服务端不入账、挂起人工核对,必须告诉用户别再等 */} {warn && (

{warn}
请联系客服并提供订单号 {orderRef.current}

)} {/* 掉单补偿定时器在后台兜底,关页面不会丢钱——说清楚,省得用户守着不敢关 */} {state === "waiting" && !warn && (

付完没反应?关掉也没关系,到账后积分会自动补上。

)} )}
); } // 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 ( {new Date(expiresAt).toLocaleDateString("zh-CN")} 到期(剩 {left} 天) ); }