Files
ttstd-infinite-canvas/web/src/components/layout/workspace-wallet-modal.tsx

554 lines
30 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 { App, Button, Input, Skeleton } from "antd";
import { Check, ChevronLeft, ChevronRight, CircleAlert, Coins, CreditCard, History, RefreshCw, TicketCheck, WalletCards } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router";
import { PaymentCheckoutCode } from "@/components/payment-checkout-code";
import { AppModal } from "@/components/ui/product/app-modal";
import { formatCredits } from "@/constant/credits";
import { closePaymentOrder, createPaymentOrder, getPaymentOrder, listPaymentProviders, listTopupProducts, queryPaymentOrder, refreshPaymentCheckout, type PaymentOrder, type PaymentProvider, type TopupProduct } from "@/services/api/payments";
import { getWallet, redeemCredits, type CreditLedgerEntry, type WalletSummary } from "@/services/api/wallet";
import { cn } from "@/lib/utils";
import { openWorkspaceWallet, WORKSPACE_WALLET_OPEN_EVENT, type WorkspaceWalletOpenDetail } from "@/lib/workspace-wallet";
import { useUserStore } from "@/stores/use-user-store";
type WalletModalTab = "topup" | "redeem" | "history";
export function WorkspaceWalletHost() {
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const { pathname, search } = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [pendingPaymentOrderId, setPendingPaymentOrderId] = useState("");
const [paymentInvalid, setPaymentInvalid] = useState(false);
const applyOpen = (detail: WorkspaceWalletOpenDetail = {}) => {
if (!useUserStore.getState().features.creditsEnabled) return;
setPendingPaymentOrderId(detail.paymentOrderId || "");
setPaymentInvalid(Boolean(detail.paymentInvalid));
setOpen(true);
};
useEffect(() => {
const handleOpen = (raw: Event) => {
applyOpen((raw as CustomEvent<WorkspaceWalletOpenDetail>).detail || {});
};
window.addEventListener(WORKSPACE_WALLET_OPEN_EVENT, handleOpen);
return () => window.removeEventListener(WORKSPACE_WALLET_OPEN_EVENT, handleOpen);
}, []);
useEffect(() => {
if (pathname !== "/wallet") return;
const params = new URLSearchParams(search);
openWorkspaceWallet({
paymentOrderId: params.get("paymentOrder") || undefined,
paymentInvalid: params.get("payment") === "invalid",
});
navigate("/", { replace: true });
}, [navigate, pathname, search]);
if (!creditsEnabled) return null;
return (
<WorkspaceWalletModal
open={open}
pendingPaymentOrderId={pendingPaymentOrderId}
paymentInvalid={paymentInvalid}
onClose={() => {
setOpen(false);
setPendingPaymentOrderId("");
setPaymentInvalid(false);
}}
/>
);
}
export function WorkspaceWalletModal({ open, onClose, pendingPaymentOrderId, paymentInvalid }: { open: boolean; onClose: () => void; pendingPaymentOrderId?: string; paymentInvalid?: boolean }) {
const { message } = App.useApp();
const [tab, setTab] = useState<WalletModalTab>("topup");
const [wallet, setWallet] = useState<WalletSummary | null>(null);
const [walletLoading, setWalletLoading] = useState(false);
const [walletError, setWalletError] = useState("");
const [page, setPage] = useState(1);
const [products, setProducts] = useState<TopupProduct[]>([]);
const [providers, setProviders] = useState<PaymentProvider[]>([]);
const [paymentsLoading, setPaymentsLoading] = useState(false);
const [selectedProductId, setSelectedProductId] = useState("");
const [selectedProviderId, setSelectedProviderId] = useState("");
const [code, setCode] = useState("");
const [redeeming, setRedeeming] = useState(false);
const [paymentCreating, setPaymentCreating] = useState(false);
const [paymentQuerying, setPaymentQuerying] = useState(false);
const [paymentOrder, setPaymentOrder] = useState<PaymentOrder | null>(null);
const [paymentOpen, setPaymentOpen] = useState(false);
const [clock, setClock] = useState(Date.now());
const idempotencyKey = useRef("");
const completedOrderId = useRef("");
const requestSequence = useRef(0);
const selectedProduct = useMemo(() => products.find((item) => item.id === selectedProductId), [products, selectedProductId]);
const selectedProvider = useMemo(() => providers.find((item) => item.id === selectedProviderId), [providers, selectedProviderId]);
const reloadWallet = async (targetPage = page) => {
const sequence = ++requestSequence.current;
setWalletLoading(true);
setWalletError("");
try {
const result = await getWallet(targetPage, 20, "all");
if (sequence === requestSequence.current) setWallet(result);
} catch (error) {
if (sequence === requestSequence.current) setWalletError(error instanceof Error ? error.message : "读取积分账户失败");
} finally {
if (sequence === requestSequence.current) setWalletLoading(false);
}
};
useEffect(() => {
if (!open) return;
setPage(1);
void reloadWallet(1);
setPaymentsLoading(true);
Promise.all([listTopupProducts(), listPaymentProviders()])
.then(([productResult, providerResult]) => {
setProducts(productResult.products.filter((item) => item.enabled));
setProviders(providerResult.providers.filter((item) => item.enabled && item.pluginEnabled && item.configured));
setSelectedProductId((current) => current || productResult.products.find((item) => item.canPurchase)?.id || productResult.products.find((item) => item.enabled)?.id || "");
setSelectedProviderId((current) => current || providerResult.providers.find((item) => item.enabled && item.pluginEnabled && item.configured)?.id || "");
})
.catch((error) => message.error(error instanceof Error ? error.message : "读取充值配置失败"))
.finally(() => setPaymentsLoading(false));
}, [open]);
useEffect(() => {
if (!open || !paymentInvalid) return;
message.error("支付结果无效,未产生积分充值");
}, [open, paymentInvalid]);
useEffect(() => {
idempotencyKey.current = "";
}, [selectedProductId, selectedProviderId]);
useEffect(() => {
if (!paymentOpen || !paymentOrder || !["created", "pending", "closing"].includes(paymentOrder.status)) return;
const interval = window.setInterval(() => {
void refreshPaymentStatus(paymentOrder.id, true);
}, 4_000);
return () => window.clearInterval(interval);
}, [paymentOpen, paymentOrder?.id, paymentOrder?.status]);
useEffect(() => {
if (!paymentOpen) return;
const interval = window.setInterval(() => setClock(Date.now()), 1_000);
return () => window.clearInterval(interval);
}, [paymentOpen]);
const announceWalletUpdated = async (orderId?: string) => {
if (orderId && completedOrderId.current === orderId) return;
if (orderId) completedOrderId.current = orderId;
setPage(1);
await reloadWallet(1);
window.dispatchEvent(new CustomEvent("wallet:updated"));
};
useEffect(() => {
if (!open || !pendingPaymentOrderId) return;
getPaymentOrder(pendingPaymentOrderId)
.then(async ({ order }) => {
setPaymentOrder(order);
setPaymentOpen(true);
if (order.status === "credited") await announceWalletUpdated(order.id);
})
.catch((error) => message.error(error instanceof Error ? error.message : "读取支付结果失败"));
}, [open, pendingPaymentOrderId]);
const redeem = async () => {
const normalized = code.trim().toLowerCase();
if (normalized.length !== 32) {
message.error("请输入完整的 32 位兑换码");
return;
}
setRedeeming(true);
try {
await redeemCredits(normalized);
setCode("");
await announceWalletUpdated();
message.success("兑换成功,积分已到账");
} catch (error) {
message.error(error instanceof Error ? error.message : "兑换失败");
} finally {
setRedeeming(false);
}
};
const startPayment = async () => {
if (!selectedProduct || !selectedProvider) {
message.error("请选择充值商品和支付方式");
return;
}
if (!selectedProduct.canPurchase) {
message.error("该充值商品当前不可购买,请刷新后重试");
return;
}
setPaymentCreating(true);
try {
if (!idempotencyKey.current) idempotencyKey.current = crypto.randomUUID();
const result = await createPaymentOrder({ productId: selectedProduct.id, providerId: selectedProvider.id, idempotencyKey: idempotencyKey.current });
idempotencyKey.current = "";
setPaymentOrder(result.order);
if (result.order.status === "credited") await announceWalletUpdated(result.order.id);
if (result.order.checkout.mode === "redirect" && result.order.checkout.url) {
window.location.assign(result.order.checkout.url);
return;
}
setPaymentOpen(true);
} catch (error) {
message.error(error instanceof Error ? error.message : "创建支付订单失败");
} finally {
setPaymentCreating(false);
}
};
async function refreshPaymentStatus(orderId = paymentOrder?.id, silent = false) {
if (!orderId || paymentQuerying) return;
setPaymentQuerying(true);
try {
const result = await queryPaymentOrder(orderId);
setPaymentOrder(result.order);
if (result.order.status === "credited") {
await announceWalletUpdated(result.order.id);
if (!silent) message.success("支付已确认,积分已到账");
} else if (!silent && result.order.status === "closed") message.warning("订单已关闭,未产生积分充值");
else if (!silent) message.info("渠道尚未确认支付,请稍后再试");
} catch (error) {
if (!silent) message.error(error instanceof Error ? error.message : "查询支付结果失败");
} finally {
setPaymentQuerying(false);
}
}
const cancelPayment = async () => {
if (!paymentOrder) return;
setPaymentQuerying(true);
try {
const result = await closePaymentOrder(paymentOrder.id);
setPaymentOrder(result.order);
if (result.order.status === "credited") await announceWalletUpdated(result.order.id);
else message.success("未支付订单已关闭");
} catch (error) {
message.error(error instanceof Error ? error.message : "关闭订单失败");
} finally {
setPaymentQuerying(false);
}
};
const retryCheckout = async () => {
if (!paymentOrder) return;
setPaymentQuerying(true);
try {
const result = await refreshPaymentCheckout(paymentOrder.id);
setPaymentOrder(result.order);
if (result.order.checkout.mode === "redirect" && result.order.checkout.url) window.location.assign(result.order.checkout.url);
} catch (error) {
message.error(error instanceof Error ? error.message : "刷新支付入口失败");
} finally {
setPaymentQuerying(false);
}
};
const available = wallet?.account.availableMicrocredits ?? 0;
const totalPages = Math.max(1, Math.ceil((wallet?.total || 0) / 20));
return (
<>
<AppModal flush open={open} title={null} footer={null} centered width="min(880px, calc(100vw - 28px))" onCancel={onClose} rootClassName="workspace-wallet-modal">
<div className="workspace-wallet-shell">
<header className="workspace-wallet-header">
<div>
<span className="workspace-wallet-kicker">
<Coins />
积分中心
</span>
<h2>充值、兑换与消费记录</h2>
<p>为下一次创作补充积分,随时查看每一笔收支。</p>
</div>
<div className="workspace-wallet-balance">
<span>可用积分</span>
<strong>{wallet ? formatCredits(available, 6) : "--"}</strong>
<small>冻结 {wallet ? formatCredits(wallet.account.reservedMicrocredits, 6) : "--"}</small>
</div>
</header>
<div className="workspace-wallet-tabs" role="tablist" aria-label="积分中心">
<button type="button" role="tab" aria-selected={tab === "topup"} onClick={() => setTab("topup")}>
<WalletCards />
充值
</button>
<button type="button" role="tab" aria-selected={tab === "redeem"} onClick={() => setTab("redeem")}>
<TicketCheck />
兑换
</button>
<button type="button" role="tab" aria-selected={tab === "history"} onClick={() => setTab("history")}>
<History />
积分消耗历史
</button>
</div>
{tab === "topup" ? (
<div className="workspace-wallet-content is-topup">
<section className="workspace-wallet-section">
<div className="workspace-wallet-section-heading">
<div>
<h3>在线充值</h3>
<p>选择积分套餐和支付方式。</p>
</div>
<CreditCard />
</div>
{paymentsLoading ? (
<Skeleton active paragraph={{ rows: 4 }} />
) : products.length && providers.length ? (
<>
<div className="workspace-wallet-products">
{products.map((product) => {
const unavailableLabel =
product.saleStatus === "upcoming"
? `发售时间:${product.saleStartAt ? new Date(product.saleStartAt).toLocaleString("zh-CN", { hour12: false }) : "待定"}`
: product.saleStatus === "ended"
? "已结束"
: product.saleStatus === "sold_out"
? `已售罄(库存 ${product.stockRemaining ?? 0})`
: product.saleStrategy === "inventory"
? `库存 ${product.stockRemaining ?? 0}`
: product.saleStrategy === "periodic"
? `每 ${product.periodDays} 天限购 ${product.periodPurchaseLimit} 次`
: "不限量";
return (
<button
key={product.id}
type="button"
className={cn("workspace-wallet-product", selectedProductId === product.id && "is-selected")}
aria-pressed={selectedProductId === product.id}
disabled={!product.canPurchase}
onClick={() => setSelectedProductId(product.id)}
>
<span>{product.name}</span>
<strong>{formatCredits(product.creditsMicrocredits, 6)} 积分</strong>
<small>
¥ {(product.amountFen / 100).toFixed(2)} · {unavailableLabel}
{product.description ? ` · ${product.description}` : ""}
</small>
{selectedProductId === product.id ? <Check /> : null}
</button>
);
})}
</div>
<div className="workspace-wallet-provider-row">
<div className="workspace-wallet-providers" role="radiogroup" aria-label="支付方式">
{providers.map((provider) => (
<button
key={provider.id}
type="button"
role="radio"
aria-checked={selectedProviderId === provider.id}
className={selectedProviderId === provider.id ? "is-selected" : ""}
onClick={() => setSelectedProviderId(provider.id)}
>
<CreditCard />
{provider.name}
</button>
))}
</div>
<Button type="primary" size="large" loading={paymentCreating} disabled={!selectedProduct || !selectedProvider} onClick={() => void startPayment()}>
立即充值
</Button>
</div>
</>
) : (
<div className="workspace-wallet-inline-state">
<CircleAlert />
<div>
<strong>在线充值暂不可用</strong>
<span>当前没有已启用的充值商品或支付渠道,请使用兑换码或联系管理员。</span>
</div>
</div>
)}
</section>
</div>
) : tab === "redeem" ? (
<div className="workspace-wallet-content is-redeem">
<section className="workspace-wallet-section is-redeem">
<div className="workspace-wallet-section-heading">
<div>
<h3>兑换码</h3>
<p>输入兑换码,将积分存入当前账户。</p>
</div>
<TicketCheck />
</div>
<div className="workspace-wallet-redeem-row">
<Input size="large" value={code} maxLength={32} placeholder="输入 32 位兑换码" onChange={(event) => setCode(event.target.value.replace(/\s/g, ""))} onPressEnter={() => void redeem()} />
<Button size="large" loading={redeeming} disabled={code.trim().length !== 32} onClick={() => void redeem()}>
确认兑换
</Button>
</div>
</section>
</div>
) : (
<div className="workspace-wallet-content is-history">
<div className="workspace-wallet-history-toolbar">
<div>
<h3>积分消耗历史</h3>
<p>包含充值、兑换、生成消费、冻结与退款。</p>
</div>
<Button type="text" icon={<RefreshCw />} loading={walletLoading} onClick={() => void reloadWallet(page)}>
刷新
</Button>
</div>
<div className="workspace-wallet-history-scroll">
{walletError ? (
<div className="workspace-wallet-inline-state is-error">
<CircleAlert />
<div>
<strong>记录加载失败</strong>
<span>{walletError}</span>
</div>
<Button onClick={() => void reloadWallet(page)}>重试</Button>
</div>
) : walletLoading && !wallet ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : wallet?.entries.length ? (
<div className="workspace-wallet-ledger">
{wallet.entries.map((entry) => (
<WalletLedgerRow key={entry.id} entry={entry} />
))}
</div>
) : (
<div className="workspace-wallet-empty">
<History />
<strong>还没有积分记录</strong>
<span>完成充值、兑换或生成任务后,记录会显示在这里。</span>
</div>
)}
</div>
<div className="workspace-wallet-pagination">
<span>
第 {page} / {totalPages} 页
</span>
<button
type="button"
disabled={page <= 1 || walletLoading}
aria-label="上一页"
onClick={() => {
const next = page - 1;
setPage(next);
void reloadWallet(next);
}}
>
<ChevronLeft />
</button>
<button
type="button"
disabled={page >= totalPages || walletLoading}
aria-label="下一页"
onClick={() => {
const next = page + 1;
setPage(next);
void reloadWallet(next);
}}
>
<ChevronRight />
</button>
</div>
</div>
)}
</div>
</AppModal>
<AppModal
open={paymentOpen}
title={paymentOrder?.status === "credited" ? "充值完成" : paymentOrder?.checkout.mode === "qr_code" ? "扫码支付" : "确认支付结果"}
centered
width={430}
onCancel={() => setPaymentOpen(false)}
footer={paymentFooter(paymentOrder, paymentQuerying, () => setPaymentOpen(false), cancelPayment, refreshPaymentStatus, retryCheckout)}
>
{paymentOrder ? (
<div className="workspace-wallet-payment">
<span className="workspace-wallet-payment-icon">
<CreditCard />
</span>
<strong>¥ {(paymentOrder.amountFen / 100).toFixed(2)}</strong>
<p>
{paymentOrder.productName} · {formatCredits(paymentOrder.creditsMicrocredits, 6)} 积分
</p>
{paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? (
<>
<PaymentCheckoutCode value={paymentOrder.checkout.value} />
<span>请使用支付应用扫码完成支付</span>
</>
) : null}
<PaymentStatus order={paymentOrder} now={clock} />
</div>
) : null}
</AppModal>
</>
);
}
function WalletLedgerRow({ entry }: { entry: CreditLedgerEntry }) {
const positive = entry.amountMicrocredits > 0;
const title = entry.type === "consume" ? "模型调用" : entry.type === "refund" ? "消费退款" : entry.type === "payment_topup" ? "在线充值" : entry.type === "redeem" ? "兑换码充值" : entry.note || "积分调整";
return (
<article className="workspace-wallet-ledger-row">
<span className={cn("workspace-wallet-ledger-icon", positive ? "is-income" : "is-consume")}>{positive ? <Coins /> : <CreditCard />}</span>
<div>
<strong>{title}</strong>
<span>{[entry.scene, entry.model, entry.note].filter(Boolean).join(" · ") || "积分账户变动"}</span>
</div>
<time>{new Date(entry.createdAt).toLocaleString("zh-CN", { hour12: false })}</time>
<b className={positive ? "is-income" : "is-consume"}>
{positive ? "+" : ""}
{formatCredits(entry.amountMicrocredits, 6)}
</b>
</article>
);
}
function PaymentStatus({ order, now }: { order: PaymentOrder; now: number }) {
if (order.status === "credited") return <div className="workspace-wallet-payment-status is-success">支付已确认,积分已经到账</div>;
if (order.status === "closed") return <div className="workspace-wallet-payment-status">订单已关闭,未产生积分充值</div>;
if (order.status === "create_failed") return <div className="workspace-wallet-payment-status is-error">支付入口创建失败,请重新生成支付入口。</div>;
const remaining = Math.max(0, Math.floor((new Date(order.expiresAt).getTime() - now) / 1000));
const hours = Math.floor(remaining / 3600);
const minutes = Math.floor((remaining % 3600) / 60);
const seconds = remaining % 60;
return (
<div className="workspace-wallet-payment-status">
订单剩余 {String(hours).padStart(2, "0")}:{String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")},将自动确认支付结果
</div>
);
}
function paymentFooter(order: PaymentOrder | null, loading: boolean, close: () => void, cancel: () => Promise<void>, query: (id?: string, silent?: boolean) => Promise<void>, retry: () => Promise<void>) {
if (order?.status === "pending")
return [
<Button key="cancel" danger disabled={loading} onClick={() => void cancel()}>
关闭订单
</Button>,
<Button key="query" type="primary" loading={loading} onClick={() => void query()}>
我已完成支付
</Button>,
];
if (order?.status === "create_failed")
return [
<Button key="close" onClick={close}>
稍后处理
</Button>,
<Button key="retry" type="primary" loading={loading} onClick={() => void retry()}>
重新生成支付入口
</Button>,
];
return [
<Button key="done" type="primary" onClick={close}>
完成
</Button>,
];
}