Files
makelore/src/components/account/PointWallet.tsx

224 lines
16 KiB
TypeScript
Raw 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, 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>
</>;
}