224 lines
16 KiB
TypeScript
224 lines
16 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import QRCode from 'qrcode';
|
||
import { Coins, RefreshCw, X } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
import { AppError } from '@/lib/error-model';
|
||
import { formatWorksTokenPointValue, isWorksTokenPointBalanceExhausted } from '@/lib/works-square-token-points';
|
||
import {
|
||
createWorksRechargeOrder, fetchWorksBillingSummary, fetchWorksPointHistory, openWorksBillingAccount,
|
||
fetchWorksRechargeOrder, fetchWorksRechargeOrders,
|
||
type WorksBillingSummary, type WorksPointHistory, type WorksRechargeOrder,
|
||
} from '@/lib/works-billing';
|
||
|
||
const labels: Record<string, string> = {
|
||
pending: '待支付', succeeded: '已到账', failed: '支付失败', canceled: '已关闭', manual_review: '待核查',
|
||
credited: '已入账', reserved: '已预占', dispatched: '处理中', pending_review: '待核对',
|
||
settled: '已结算', released: '已释放', refunded: '已退款', expired: '已释放',
|
||
};
|
||
const points = (value: string | null) => formatWorksTokenPointValue(value) ?? '—';
|
||
const money = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
|
||
const time = (value: string) => new Date(value).toLocaleString('zh-CN');
|
||
const unresolved = (order: WorksRechargeOrder) => order.status === 'pending' || order.status === 'manual_review';
|
||
|
||
export function PointWallet({ active = true }: { active?: boolean }) {
|
||
const [summary, setSummary] = useState<WorksBillingSummary | null>(null);
|
||
const [summaryError, setSummaryError] = useState(false);
|
||
const [open, setOpen] = useState(false);
|
||
const [tab, setTab] = useState<'recharge' | 'orders' | 'history'>('recharge');
|
||
const [offset, setOffset] = useState(0);
|
||
const [orders, setOrders] = useState<WorksRechargeOrder[]>([]);
|
||
const [pending, setPending] = useState<WorksRechargeOrder | null>(null);
|
||
const [history, setHistory] = useState<WorksPointHistory>({ items: [], has_more: false });
|
||
const [loading, setLoading] = useState(false);
|
||
const [loaded, setLoaded] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [checkout, setCheckout] = useState<WorksRechargeOrder | null>(null);
|
||
const [qr, setQr] = useState('');
|
||
const [unknownProduct, setUnknownProduct] = useState<string | null>(null);
|
||
const intent = useRef<{ productId: string; requestId: string } | null>(null);
|
||
const alive = useRef(true);
|
||
const summaryRequest = useRef(0);
|
||
const pageRequest = useRef(0);
|
||
const purchaseBusy = useRef(false);
|
||
|
||
useEffect(() => {
|
||
alive.current = true;
|
||
return () => { alive.current = false; summaryRequest.current += 1; pageRequest.current += 1; };
|
||
}, []);
|
||
|
||
const refreshSummary = useCallback(async () => {
|
||
const request = ++summaryRequest.current;
|
||
try {
|
||
const next = await fetchWorksBillingSummary();
|
||
if (!alive.current || summaryRequest.current !== request) return;
|
||
setSummary(next); setSummaryError(false);
|
||
} catch {
|
||
if (!alive.current || summaryRequest.current !== request) return;
|
||
setSummary(null); setSummaryError(true);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (active) void refreshSummary();
|
||
const focus = () => { void refreshSummary(); };
|
||
const visibility = () => { if (document.visibilityState === 'visible') focus(); };
|
||
window.addEventListener('focus', focus);
|
||
document.addEventListener('visibilitychange', visibility);
|
||
return () => {
|
||
window.removeEventListener('focus', focus);
|
||
document.removeEventListener('visibilitychange', visibility);
|
||
};
|
||
}, [active, refreshSummary]);
|
||
|
||
const refreshPage = useCallback(async () => {
|
||
const request = ++pageRequest.current;
|
||
setLoading(true); setLoaded(false);
|
||
try {
|
||
const latest = await fetchWorksRechargeOrders();
|
||
const page = tab === 'orders' && offset > 0 ? await fetchWorksRechargeOrders(offset) : latest;
|
||
const ledger = tab === 'history' ? await fetchWorksPointHistory(offset) : null;
|
||
if (!alive.current || pageRequest.current !== request) return;
|
||
setOrders(page.items); setPending(latest.items.find(unresolved) ?? null);
|
||
if (ledger) setHistory(ledger);
|
||
setLoaded(true);
|
||
} catch {
|
||
if (alive.current && pageRequest.current === request) setError('记录加载失败,请刷新后再充值。');
|
||
} finally {
|
||
if (alive.current && pageRequest.current === request) setLoading(false);
|
||
}
|
||
}, [offset, tab]);
|
||
|
||
useEffect(() => {
|
||
if (open) { setError(''); void refreshSummary(); void refreshPage(); }
|
||
return () => { pageRequest.current += 1; };
|
||
}, [open, refreshPage, refreshSummary]);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
setQr('');
|
||
if (checkout?.qr_payload) {
|
||
void QRCode.toDataURL(checkout.qr_payload, { width: 224, margin: 1 }).then((value) => {
|
||
if (active) setQr(value);
|
||
}).catch(() => { if (active) setError('二维码生成失败,请刷新此订单。'); });
|
||
}
|
||
return () => { active = false; };
|
||
}, [checkout?.qr_payload]);
|
||
|
||
const checkOrder = useCallback(async (id: string) => {
|
||
const order = await fetchWorksRechargeOrder(id);
|
||
if (!alive.current) return;
|
||
setCheckout((current) => current?.id === id ? order : current);
|
||
if (!unresolved(order)) {
|
||
intent.current = null; setUnknownProduct(null);
|
||
void refreshSummary(); void refreshPage();
|
||
}
|
||
}, [refreshPage, refreshSummary]);
|
||
|
||
const checkoutId = checkout?.id;
|
||
const checkoutStatus = checkout?.status;
|
||
useEffect(() => {
|
||
if (!open || !checkoutId || (checkoutStatus !== 'pending' && checkoutStatus !== 'manual_review')) return;
|
||
let active = true;
|
||
let timer: ReturnType<typeof setTimeout>;
|
||
const poll = async () => {
|
||
if (document.visibilityState === 'visible') {
|
||
try { await checkOrder(checkoutId); } catch { /* Keep the frozen order visible; never resubmit payment. */ }
|
||
}
|
||
if (active) timer = setTimeout(() => void poll(), 4000);
|
||
};
|
||
timer = setTimeout(() => void poll(), 4000);
|
||
return () => { active = false; clearTimeout(timer); };
|
||
}, [open, checkoutId, checkoutStatus, checkOrder]);
|
||
|
||
async function purchase(productId: string) {
|
||
if (purchaseBusy.current || !loaded || !summary?.can_recharge || !summary.payment_configured || pending) return;
|
||
if (intent.current && intent.current.productId !== productId) return;
|
||
purchaseBusy.current = true; setBusy(true); setError('');
|
||
intent.current ??= { productId, requestId: crypto.randomUUID() };
|
||
try {
|
||
const order = await createWorksRechargeOrder(productId, intent.current.requestId);
|
||
if (!alive.current) return;
|
||
setCheckout(order); setPending(unresolved(order) ? order : null);
|
||
intent.current = null; setUnknownProduct(null);
|
||
void refreshSummary(); void refreshPage();
|
||
} catch (cause) {
|
||
if (!alive.current) return;
|
||
const definitive = cause instanceof AppError && cause.details?.commandOutcome === 'definitive_failure';
|
||
if (definitive) { intent.current = null; setUnknownProduct(null); }
|
||
else setUnknownProduct(productId);
|
||
setError(definitive ? '本次充值未提交成功,请刷新后重试。' : '付款结果尚未确认。请先检查订单;重试将沿用同一次请求,请勿重复付款。');
|
||
void refreshPage();
|
||
} finally {
|
||
purchaseBusy.current = false;
|
||
if (alive.current) setBusy(false);
|
||
}
|
||
}
|
||
|
||
const openWebAccount = () => { void openWorksBillingAccount().catch(() => setError('网页账户页暂时无法打开。')); };
|
||
const own = summary?.own_points;
|
||
const selected = summary?.points;
|
||
const usingShared = typeof selected?.shared_available === 'boolean';
|
||
return <>
|
||
<div className="rounded-lg bg-surface-subtle/70 px-2 py-2 text-xs" data-testid="sidebar-point-balance">
|
||
<div className="flex items-center justify-between gap-2"><span>我的词元点数</span><strong data-testid="sidebar-total-token-points">{own ? `${points(own.total_remaining)} 点` : summaryError ? '暂时不可用' : '加载中…'}</strong></div>
|
||
{own && <p className="mt-1 text-[11px] text-muted-foreground">永久有效 · 预占 {points(own.reserved)} 点</p>}
|
||
{usingShared && <p className="mt-1 text-[11px] text-muted-foreground">AI 编程使用共享点数:{selected?.shared_available ? '可用' : '已用尽,请联系付款人'}</p>}
|
||
{summary?.funding_error && <p className="mt-1 text-[11px] text-muted-foreground">{summary.funding_error}</p>}
|
||
{isWorksTokenPointBalanceExhausted(own) && !usingShared && <p className="mt-1 text-[11px] text-muted-foreground">{summary?.can_recharge ? '点数已用尽,充值后可继续使用。' : '点数已用尽,请联系家长。'}</p>}
|
||
{summaryError && <button className="mt-1 underline" onClick={() => void refreshSummary()}>重试余额</button>}
|
||
</div>
|
||
<button type="button" role="menuitem" data-testid="sidebar-point-wallet-open" className="flex min-h-10 w-full items-center gap-2 rounded-md px-1 text-left text-sm hover:bg-surface-subtle" onClick={() => { setOpen(true); setTab('recharge'); setOffset(0); }}><Coins className="h-4 w-4" />词元点数与充值</button>
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto" data-testid="point-wallet-dialog">
|
||
<DialogHeader>
|
||
<div className="flex items-center justify-between">
|
||
<DialogTitle>词元点数</DialogTitle>
|
||
<Button variant="ghost" size="icon" aria-label="关闭词元点数" onClick={() => setOpen(false)}><X className="h-4 w-4" /></Button>
|
||
</div>
|
||
<DialogDescription>注册赠送与充值点数永久有效。新账号注册赠送 100 点,1 元充值 50 点。</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="flex items-center justify-between gap-3 rounded-xl bg-surface-subtle p-4">
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">我的可用点数</p>
|
||
<p className="mt-1 text-2xl font-semibold tabular-nums">
|
||
{own ? points(own.total_remaining) : '—'} <span className="text-sm">点</span>
|
||
</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
预占 {points(own?.reserved ?? null)} 点 · 充值到账本人钱包
|
||
<button className="ml-2 underline" onClick={openWebAccount}>管理付款来源</button>
|
||
</p>
|
||
</div>
|
||
<Button variant="outline" size="sm" disabled={loading || busy} onClick={() => { setError(''); void refreshSummary(); void refreshPage(); }}><RefreshCw className="mr-1 h-3.5 w-3.5" />刷新</Button>
|
||
</div>
|
||
{summary?.funding_error && <p className="text-sm text-muted-foreground">{summary.funding_error}</p>}
|
||
{usingShared && <p className="text-sm text-muted-foreground">AI 编程使用共享点数({selected?.shared_available ? '可用' : '已用尽'})。下方余额、充值与记录均属于你自己的钱包;充值不会自动切换付款来源。</p>}
|
||
<div className="flex gap-2" role="tablist" aria-label="词元点数记录">{(['recharge', 'orders', 'history'] as const).map((value) => <Button key={value} role="tab" aria-selected={tab === value} variant={tab === value ? 'default' : 'outline'} onClick={() => { setTab(value); setOffset(0); }}>{value === 'recharge' ? '充值' : value === 'orders' ? '充值订单' : '收支记录'}</Button>)}</div>
|
||
{summaryError && <p role="alert" className="text-sm text-destructive">余额读取失败,请刷新后重试。</p>}
|
||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||
{loading && <p role="status" className="text-sm text-muted-foreground">正在读取记录…</p>}
|
||
{tab === 'recharge' && <div className="grid gap-3">
|
||
{summary && !summary.can_recharge && <p className="text-sm text-muted-foreground">仅家长身份可以充值,请联系家长。你仍可查看自己的余额和收支。</p>}
|
||
{summary?.can_recharge && !summary.payment_configured && <p className="text-sm text-muted-foreground">充值暂未开放,请稍后再试。</p>}
|
||
{pending && checkout?.id !== pending.id && <div className="rounded-lg border p-3 text-sm"><p>已有{labels[pending.status]}订单:{money(pending.amount_cents)} / {points(pending.point_amount)} 点。请先完成或联系运营核查。</p><Button variant="outline" className="mt-2" onClick={() => setCheckout(pending)}>查看原订单</Button></div>}
|
||
{(!checkout || !unresolved(checkout)) && <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">{summary?.recharge_products.map((product) => <Button key={product.id} className="h-auto flex-col gap-1 py-4" variant="outline" disabled={!loaded || busy || Boolean(pending) || !summary.can_recharge || !summary.payment_configured || Boolean(unknownProduct)} onClick={() => void purchase(product.id)}><span className="text-lg font-semibold">{points(product.point_amount)} 点</span><span>{money(product.amount_cents)}</span></Button>)}</div>}
|
||
{unknownProduct && !pending && <Button disabled={!loaded || busy} variant="outline" onClick={() => void purchase(unknownProduct)}>按原请求重试</Button>}
|
||
{checkout && <section className="rounded-xl border p-4 text-center" data-testid="point-recharge-checkout">
|
||
<p className="font-semibold">{labels[checkout.status]} · {money(checkout.amount_cents)} · {points(checkout.point_amount)} 点</p>
|
||
<p className="mt-1 break-all text-xs text-muted-foreground">订单 {checkout.id}</p>
|
||
{qr && checkout.status === 'pending' && <img src={qr} alt="充值支付二维码" className="mx-auto my-3 h-56 w-56" />}
|
||
{checkout.status === 'pending' && !checkout.qr_payload && <p className="my-3 text-sm text-muted-foreground">当前支付方式未提供扫码信息,请在网页账户页继续此订单。<button className="ml-1 underline" onClick={openWebAccount}>打开账户页</button></p>}
|
||
{checkout.status === 'pending' && <p className="mt-2 text-xs text-muted-foreground">付款后会自动核对到账状态,关闭窗口不会取消订单。</p>}
|
||
{checkout.status === 'manual_review' && <p className="mt-2 text-sm text-muted-foreground">付款结果需要运营核查,请勿再次付款。</p>}
|
||
<Button variant="outline" size="sm" className="mt-3" onClick={() => void checkOrder(checkout.id).catch(() => setError('订单状态暂时无法获取,请稍后刷新。'))}>刷新此订单</Button>
|
||
</section>}
|
||
</div>}
|
||
{tab === 'orders' && !loading && <div className="grid gap-2">{orders.length === 0 && <p className="text-sm text-muted-foreground">暂无充值订单。</p>}{orders.map((order) => <article key={order.id} className="rounded-lg border p-3"><div className="flex justify-between gap-2 text-sm"><strong>{points(order.point_amount)} 点 · {money(order.amount_cents)}</strong><span>{labels[order.status]}</span></div><p className="mt-1 text-xs text-muted-foreground">{time(order.created_at)}</p>{unresolved(order) && <Button variant="outline" size="sm" className="mt-2" onClick={() => { setCheckout(order); setTab('recharge'); setOffset(0); }}>查看订单</Button>}</article>)}</div>}
|
||
{tab === 'history' && !loading && <div className="grid gap-2">{history.items.length === 0 && <p className="text-sm text-muted-foreground">暂无收支记录。</p>}{history.items.map((item) => <article key={`${item.kind}:${item.id}`} className="rounded-lg border p-3"><div className="flex justify-between gap-3 text-sm"><span>{item.description}</span><strong>{item.kind === 'credit' ? '+' : item.points.startsWith('-') ? '−' : ''}{points(item.points.replace(/^-/, ''))} 点</strong></div><p className="mt-1 text-xs text-muted-foreground">{time(item.created_at)} · {labels[item.status] ?? '待核对'}{item.reserved_points !== '0.00' ? ` · 预占 ${points(item.reserved_points)} 点` : ''}</p></article>)}</div>}
|
||
{tab !== 'recharge' && <div className="flex items-center justify-between"><Button variant="outline" size="sm" disabled={loading || offset === 0} onClick={() => setOffset((value) => Math.max(0, value - 20))}>上一页</Button><span className="text-xs text-muted-foreground">第 {offset / 20 + 1} 页</span><Button variant="outline" size="sm" disabled={!loaded || loading || (tab === 'history' ? !history.has_more : orders.length < 20)} onClick={() => setOffset((value) => value + 20)}>下一页</Button></div>}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>;
|
||
}
|