fix(*): 支付与Agent运行 - 修复商品策略并保证审批恢复稳定

This commit is contained in:
ddcat666
2026-09-29 11:07:11 +08:00
parent 37bb4d7242
commit 3dd84ff25b
17 changed files with 1836 additions and 226 deletions

View File

@@ -12,7 +12,7 @@ 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" | "history";
type WalletModalTab = "topup" | "redeem" | "history";
export function WorkspaceWalletHost() {
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
@@ -62,17 +62,7 @@ export function WorkspaceWalletHost() {
);
}
export function WorkspaceWalletModal({
open,
onClose,
pendingPaymentOrderId,
paymentInvalid,
}: {
open: boolean;
onClose: () => void;
pendingPaymentOrderId?: string;
paymentInvalid?: boolean;
}) {
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);
@@ -121,7 +111,7 @@ export function WorkspaceWalletModal({
.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.enabled)?.id || "");
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 : "读取充值配置失败"))
@@ -194,6 +184,10 @@ export function WorkspaceWalletModal({
message.error("请选择充值商品和支付方式");
return;
}
if (!selectedProduct.canPurchase) {
message.error("该充值商品当前不可购买,请刷新后重试");
return;
}
setPaymentCreating(true);
try {
if (!idempotencyKey.current) idempotencyKey.current = crypto.randomUUID();
@@ -269,7 +263,10 @@ export function WorkspaceWalletModal({
<div className="workspace-wallet-shell">
<header className="workspace-wallet-header">
<div>
<span className="workspace-wallet-kicker"><Coins />积分中心</span>
<span className="workspace-wallet-kicker">
<Coins />
积分中心
</span>
<h2>充值、兑换与消费记录</h2>
<p>为下一次创作补充积分,随时查看每一笔收支。</p>
</div>
@@ -281,63 +278,215 @@ export function WorkspaceWalletModal({
</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 === "history"} onClick={() => setTab("history")}><History />积分消耗历史</button>
<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) => <button key={product.id} type="button" className={cn("workspace-wallet-product", selectedProductId === product.id && "is-selected")} aria-pressed={selectedProductId === product.id} onClick={() => setSelectedProductId(product.id)}>
<span>{product.name}</span><strong>{formatCredits(product.creditsMicrocredits, 6)} 积分</strong><small>¥ {(product.amountFen / 100).toFixed(2)}{product.description ? ` · ${product.description}` : ""}</small>{selectedProductId === product.id ? <Check /> : null}
</button>)}
<div className="workspace-wallet-section-heading">
<div>
<h3>在线充值</h3>
<p>选择积分套餐和支付方式。</p>
</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>)}
<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>
<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-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>
<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-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>}
{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>
<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
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>
</>
);
@@ -346,7 +495,20 @@ export function WorkspaceWalletModal({
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>;
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 }) {
@@ -357,11 +519,35 @@ function PaymentStatus({ order, now }: { order: PaymentOrder; now: number }) {
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>;
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>];
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>,
];
}

View File

@@ -54,6 +54,12 @@ type ProductFormValues = {
credits: number;
enabled: boolean;
sortOrder: number;
saleStrategy: TopupProduct["saleStrategy"];
periodDays?: number;
periodPurchaseLimit?: number;
stockTotal?: number;
saleStartAt?: Dayjs;
saleEndAt?: Dayjs;
};
const paymentOrderStatus: Record<string, { label: string; tone: "neutral" | "success" | "warning" | "error" | "info" }> = {
@@ -89,6 +95,7 @@ export default function AdminPaymentsPage() {
const [productDrawer, setProductDrawer] = useState<TopupProduct | null | undefined>();
const [productSaving, setProductSaving] = useState(false);
const [productForm] = Form.useForm<ProductFormValues>();
const selectedProductStrategy = Form.useWatch("saleStrategy", productForm) || "unlimited";
const [orders, setOrders] = useState<AdminPaymentOrder[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
@@ -262,8 +269,14 @@ export default function AdminPaymentsPage() {
credits: product.creditsMicrocredits / 1_000_000,
enabled: product.enabled,
sortOrder: product.sortOrder,
saleStrategy: product.saleStrategy || "unlimited",
periodDays: product.periodDays,
periodPurchaseLimit: product.periodPurchaseLimit,
stockTotal: product.stockTotal,
saleStartAt: product.saleStartAt ? dayjs(product.saleStartAt) : undefined,
saleEndAt: product.saleEndAt ? dayjs(product.saleEndAt) : undefined,
}
: { enabled: true, sortOrder: products.length * 10, amountYuan: 10, credits: 10 },
: { enabled: true, sortOrder: products.length * 10, amountYuan: 10, credits: 10, saleStrategy: "unlimited" },
);
setProductDrawer(product || null);
};
@@ -278,6 +291,12 @@ export default function AdminPaymentsPage() {
creditsMicrocredits: Math.round(values.credits * 1_000_000),
enabled: values.enabled,
sortOrder: values.sortOrder || 0,
saleStrategy: values.saleStrategy || "unlimited",
periodDays: values.periodDays || 0,
periodPurchaseLimit: values.periodPurchaseLimit || 0,
stockTotal: values.stockTotal || 0,
saleStartAt: values.saleStartAt?.toISOString(),
saleEndAt: values.saleEndAt?.toISOString(),
};
setProductSaving(true);
try {
@@ -431,7 +450,34 @@ export default function AdminPaymentsPage() {
{ title: "售价", dataIndex: "amountFen", width: 130, align: "right", render: (value) => <span className="font-medium tabular-nums">¥ {(value / 100).toFixed(2)}</span> },
{ title: "到账积分", dataIndex: "creditsMicrocredits", width: 150, align: "right", render: (value) => <span className="tabular-nums">{formatCredits(value)}</span> },
{ title: "排序", dataIndex: "sortOrder", width: 90, align: "center" },
{ title: "状态", dataIndex: "enabled", width: 100, align: "center", render: (value) => <AdminStatusBadge label={value ? "销售中" : "已停用"} tone={value ? "success" : "neutral"} /> },
{
title: "上架策略",
key: "strategy",
width: 180,
render: (_, product) => {
const strategy = product.saleStrategy || "unlimited";
const label =
strategy === "periodic"
? `周期 ${product.periodDays} 天 / ${product.periodPurchaseLimit} 次`
: strategy === "inventory"
? `库存 ${product.stockRemaining ?? 0} / ${product.stockTotal ?? 0}`
: strategy === "timed"
? `${product.saleStatus === "upcoming" ? "待发售" : product.saleStatus === "ended" ? "已结束" : "限时"}`
: "不限量";
return <span className="text-xs">{label}</span>;
},
},
{
title: "状态",
dataIndex: "enabled",
width: 120,
align: "center",
render: (value, product) => {
const status = product.saleStatus;
const label = !value ? "已停用" : status === "upcoming" ? "待发售" : status === "ended" ? "已结束" : status === "sold_out" ? "已售罄" : "销售中";
return <AdminStatusBadge label={label} tone={label === "销售中" ? "success" : label === "已售罄" || label === "已结束" ? "warning" : "neutral"} />;
},
},
{
title: "操作",
key: "actions",
@@ -452,16 +498,28 @@ export default function AdminPaymentsPage() {
width: 230,
render: (_, order) => (
<div className="min-w-0">
{order.user ? <>
<div className="flex min-w-0 items-baseline gap-2">
<button type="button" className="admin-table-primary-link truncate font-medium" title={order.user.displayName || order.user.username} onClick={() => setSelectedUserId(order.user!.id)}>{order.user.displayName || order.user.username}</button>
<span className="truncate text-xs text-foreground/45" title={`@${order.user.username}`}>@{order.user.username}</span>
</div>
<div className="mt-1 truncate text-xs text-foreground/60" title={order.user.email}>{order.user.email || "未填写邮箱"}</div>
</> : <>
<div className="text-foreground/60">用户不存在</div>
<Typography.Text className="text-xs" copyable={order.userId ? { text: order.userId } : false}>{order.userId ? `${order.userId.slice(0, 8)}…${order.userId.slice(-6)}` : "--"}</Typography.Text>
</>}
{order.user ? (
<>
<div className="flex min-w-0 items-baseline gap-2">
<button type="button" className="admin-table-primary-link truncate font-medium" title={order.user.displayName || order.user.username} onClick={() => setSelectedUserId(order.user!.id)}>
{order.user.displayName || order.user.username}
</button>
<span className="truncate text-xs text-foreground/45" title={`@${order.user.username}`}>
@{order.user.username}
</span>
</div>
<div className="mt-1 truncate text-xs text-foreground/60" title={order.user.email}>
{order.user.email || "未填写邮箱"}
</div>
</>
) : (
<>
<div className="text-foreground/60">用户不存在</div>
<Typography.Text className="text-xs" copyable={order.userId ? { text: order.userId } : false}>
{order.userId ? `${order.userId.slice(0, 8)}…${order.userId.slice(-6)}` : "--"}
</Typography.Text>
</>
)}
</div>
),
},
@@ -471,7 +529,9 @@ export default function AdminPaymentsPage() {
width: 210,
render: (_, order) => (
<div>
<Typography.Text className="font-mono text-xs" title={order.merchantOrderNo} copyable={{ text: order.merchantOrderNo }}>{order.merchantOrderNo.length > 20 ? `${order.merchantOrderNo.slice(0, 10)}…${order.merchantOrderNo.slice(-6)}` : order.merchantOrderNo}</Typography.Text>
<Typography.Text className="font-mono text-xs" title={order.merchantOrderNo} copyable={{ text: order.merchantOrderNo }}>
{order.merchantOrderNo.length > 20 ? `${order.merchantOrderNo.slice(0, 10)}…${order.merchantOrderNo.slice(-6)}` : order.merchantOrderNo}
</Typography.Text>
<div className="mt-1 truncate text-xs text-foreground/45" title={order.productName}>
{order.productName}
</div>
@@ -515,7 +575,14 @@ export default function AdminPaymentsPage() {
visibleActionCount={0}
actions={[
{ key: "sync", label: "同步支付状态", icon: <RefreshCw className="size-3.5" />, disabled: Boolean(orderActionId) || ["credited", "closed"].includes(order.status), onClick: () => queryOrder(order) },
{ key: "close", label: "关闭订单", icon: <XCircle className="size-3.5" />, danger: true, disabled: Boolean(orderActionId) || !["created", "pending", "create_failed", "closing"].includes(order.status), onClick: () => closeOrder(order) },
{
key: "close",
label: "关闭订单",
icon: <XCircle className="size-3.5" />,
danger: true,
disabled: Boolean(orderActionId) || !["created", "pending", "create_failed", "closing"].includes(order.status),
onClick: () => closeOrder(order),
},
]}
/>
),
@@ -748,18 +815,76 @@ export default function AdminPaymentsPage() {
/>
<AdminDrawer title="支付订单详情" size="min(680px, 100vw)" open={Boolean(selectedOrder)} onClose={() => setSelectedOrder(null)}>
{selectedOrder && <Descriptions column={1} bordered size="small" items={[
{ key: "user", label: "用户", children: selectedOrder.user ? <button type="button" className="admin-table-primary-link" onClick={() => { setSelectedUserId(selectedOrder.user!.id); setSelectedOrder(null); }}>{selectedOrder.user.displayName || selectedOrder.user.username} · @{selectedOrder.user.username}</button> : "用户不存在" },
{ key: "email", label: "邮箱", children: selectedOrder.user?.email || "未填写邮箱" },
{ key: "userId", label: "用户 ID", children: <Typography.Text copyable className="break-all">{selectedOrder.userId || "--"}</Typography.Text> },
{ key: "order", label: "订单号", children: <Typography.Text copyable className="break-all">{selectedOrder.merchantOrderNo}</Typography.Text> },
{ key: "trade", label: "渠道交易号", children: selectedOrder.providerTradeNo ? <Typography.Text copyable className="break-all">{selectedOrder.providerTradeNo}</Typography.Text> : "--" },
{ key: "product", label: "商品", children: selectedOrder.productName },
{ key: "channel", label: "支付渠道", children: providerNames[selectedOrder.providerId] || selectedOrder.providerId },
{ key: "amount", label: "金额 / 积分", children: `¥ ${(selectedOrder.amountFen / 100).toFixed(2)} / ${formatCredits(selectedOrder.creditsMicrocredits)} 积分` },
{ key: "status", label: "状态", children: paymentOrderStatus[selectedOrder.status]?.label || selectedOrder.status },
...([{ key: "createdAt", label: "创建时间" }, { key: "expiresAt", label: "过期时间" }, { key: "providerPaidAt", label: "支付时间" }, { key: "creditedAt", label: "入账时间" }, { key: "closedAt", label: "关闭时间" }] as const).map(({ key, label }) => ({ key, label, children: selectedOrder[key] ? formatDateTime(selectedOrder[key]!) : "--" })),
]} />}
{selectedOrder && (
<Descriptions
column={1}
bordered
size="small"
items={[
{
key: "user",
label: "用户",
children: selectedOrder.user ? (
<button
type="button"
className="admin-table-primary-link"
onClick={() => {
setSelectedUserId(selectedOrder.user!.id);
setSelectedOrder(null);
}}
>
{selectedOrder.user.displayName || selectedOrder.user.username} · @{selectedOrder.user.username}
</button>
) : (
"用户不存在"
),
},
{ key: "email", label: "邮箱", children: selectedOrder.user?.email || "未填写邮箱" },
{
key: "userId",
label: "用户 ID",
children: (
<Typography.Text copyable className="break-all">
{selectedOrder.userId || "--"}
</Typography.Text>
),
},
{
key: "order",
label: "订单号",
children: (
<Typography.Text copyable className="break-all">
{selectedOrder.merchantOrderNo}
</Typography.Text>
),
},
{
key: "trade",
label: "渠道交易号",
children: selectedOrder.providerTradeNo ? (
<Typography.Text copyable className="break-all">
{selectedOrder.providerTradeNo}
</Typography.Text>
) : (
"--"
),
},
{ key: "product", label: "商品", children: selectedOrder.productName },
{ key: "channel", label: "支付渠道", children: providerNames[selectedOrder.providerId] || selectedOrder.providerId },
{ key: "amount", label: "金额 / 积分", children: `¥ ${(selectedOrder.amountFen / 100).toFixed(2)} / ${formatCredits(selectedOrder.creditsMicrocredits)} 积分` },
{ key: "status", label: "状态", children: paymentOrderStatus[selectedOrder.status]?.label || selectedOrder.status },
...(
[
{ key: "createdAt", label: "创建时间" },
{ key: "expiresAt", label: "过期时间" },
{ key: "providerPaidAt", label: "支付时间" },
{ key: "creditedAt", label: "入账时间" },
{ key: "closedAt", label: "关闭时间" },
] as const
).map(({ key, label }) => ({ key, label, children: selectedOrder[key] ? formatDateTime(selectedOrder[key]!) : "--" })),
]}
/>
)}
</AdminDrawer>
<AdminUserDetailDrawer userId={selectedUserId} onClose={() => setSelectedUserId(null)} />
@@ -850,6 +975,41 @@ export default function AdminPaymentsPage() {
<InputNumber min={0.000001} max={1_000_000_000} precision={6} className="w-full" />
</Form.Item>
</div>
<Form.Item name="saleStrategy" label="上架策略" rules={[{ required: true }]}>
<Select
options={[
{ value: "unlimited", label: "不限量(默认)" },
{ value: "periodic", label: "周期性" },
{ value: "inventory", label: "库存性" },
{ value: "timed", label: "限时性" },
]}
/>
</Form.Item>
{selectedProductStrategy === "periodic" ? (
<div className="grid grid-cols-2 gap-3">
<Form.Item name="periodDays" label="周期天数" rules={[{ required: true }, { type: "number", min: 1, max: 3650 }]}>
<InputNumber min={1} max={3650} precision={0} className="w-full" />
</Form.Item>
<Form.Item name="periodPurchaseLimit" label="周期内可购买次数" rules={[{ required: true }, { type: "number", min: 1, max: 100000 }]}>
<InputNumber min={1} max={100000} precision={0} className="w-full" />
</Form.Item>
</div>
) : null}
{selectedProductStrategy === "inventory" ? (
<Form.Item name="stockTotal" label="平台总库存" extra="编辑库存时不能低于已售数量;未支付订单会暂时占用库存。" rules={[{ required: true }, { type: "number", min: 1, max: 9_000_000_000 }]}>
<InputNumber min={1} max={9_000_000_000} precision={0} className="w-full" />
</Form.Item>
) : null}
{selectedProductStrategy === "timed" ? (
<div className="grid grid-cols-2 gap-3">
<Form.Item name="saleStartAt" label="发售时间" rules={[{ required: true, message: "请选择发售时间" }]}>
<DatePicker showTime className="w-full" />
</Form.Item>
<Form.Item name="saleEndAt" label="结束时间" rules={[{ required: true, message: "请选择结束时间" }]}>
<DatePicker showTime className="w-full" />
</Form.Item>
</div>
) : null}
<Form.Item name="sortOrder" label="排序" rules={[{ required: true }]}>
<InputNumber precision={0} className="w-full" />
</Form.Item>

View File

@@ -4,19 +4,9 @@ import { useEffect, useState } from "react";
import { createAdminUser, updateAdminUser, type AdminUser, type LocalUser } from "@/services/api/auth";
type UserFormValues = Pick<LocalUser, "displayName" | "email" | "role" | "status">;
type UserFormValues = Pick<LocalUser, "displayName" | "email" | "role" | "status"> & { password?: string };
export function AdminUserEditDrawer({
user,
actorId,
onClose,
onSaved,
}: {
user: AdminUser | null;
actorId?: string;
onClose: () => void;
onSaved: (user: LocalUser) => void;
}) {
export function AdminUserEditDrawer({ user, actorId, onClose, onSaved }: { user: AdminUser | null; actorId?: string; onClose: () => void; onSaved: (user: LocalUser) => void }) {
const { message, modal } = App.useApp();
const [saving, setSaving] = useState(false);
const [form] = Form.useForm<UserFormValues>();
@@ -59,6 +49,7 @@ export function AdminUserEditDrawer({
email: values.email?.trim() || "",
role: values.role,
status: values.status,
...(values.password?.trim() ? { password: values.password } : {}),
});
onSaved(result.user);
form.resetFields();
@@ -79,7 +70,11 @@ export function AdminUserEditDrawer({
onClose={close}
mask={{ closable: !saving }}
destroyOnHidden
extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}
extra={
<Button type="primary" loading={saving} onClick={() => void save()}>
保存
</Button>
}
>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item label="用户名">
@@ -91,11 +86,26 @@ export function AdminUserEditDrawer({
<Form.Item name="email" label="邮箱" rules={[{ type: "email", message: "请输入有效邮箱" }]}>
<Input placeholder="name@example.com" />
</Form.Item>
<Form.Item name="password" label="重置密码" extra="留空表示不修改;保存后该用户已有登录会话会失效。" rules={[{ min: 8, message: "密码至少 8 位" }]}>
<Input.Password placeholder="输入新密码以手动重置" autoComplete="new-password" />
</Form.Item>
<Form.Item name="role" label="角色" extra={editingSelf ? "不能在此修改当前管理员自己的角色。" : "角色变更会立即影响后台访问权限。"}>
<Select disabled={editingSelf} options={[{ label: "管理员", value: "admin" }, { label: "普通用户", value: "user" }]} />
<Select
disabled={editingSelf}
options={[
{ label: "管理员", value: "admin" },
{ label: "普通用户", value: "user" },
]}
/>
</Form.Item>
<Form.Item name="status" label="账号状态" extra={editingSelf ? "不能停用当前登录账号。" : "停用后会清除登录态,但保留身份、任务和积分流水。"}>
<Select disabled={editingSelf} options={[{ label: "已启用", value: "active" }, { label: "已停用", value: "disabled" }]} />
<Select
disabled={editingSelf}
options={[
{ label: "已启用", value: "active" },
{ label: "已停用", value: "disabled" },
]}
/>
</Form.Item>
</Form>
</Drawer>
@@ -111,15 +121,7 @@ type CreateUserFormValues = {
status: LocalUser["status"];
};
export function AdminUserCreateDrawer({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated: (user: AdminUser) => void;
}) {
export function AdminUserCreateDrawer({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: (user: AdminUser) => void }) {
const { message, modal } = App.useApp();
const [saving, setSaving] = useState(false);
const [form] = Form.useForm<CreateUserFormValues>();
@@ -177,7 +179,11 @@ export function AdminUserCreateDrawer({
onClose={close}
mask={{ closable: !saving }}
destroyOnHidden
extra={<Button type="primary" loading={saving} onClick={() => void save()}>{"\u4fdd\u5b58"}</Button>}
extra={
<Button type="primary" loading={saving} onClick={() => void save()}>
{"\u4fdd\u5b58"}
</Button>
}
>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="username" label={"\u7528\u6237\u540d"} rules={[{ required: true, whitespace: true, message: "\u8bf7\u8f93\u5165\u7528\u6237\u540d" }]}>
@@ -193,10 +199,20 @@ export function AdminUserCreateDrawer({
<Input.Password placeholder={"\u81f3\u5c11 8 \u4f4d"} />
</Form.Item>
<Form.Item name="role" label={"\u89d2\u8272"}>
<Select options={[{ label: "\u7ba1\u7406\u5458", value: "admin" }, { label: "\u666e\u901a\u7528\u6237", value: "user" }]} />
<Select
options={[
{ label: "\u7ba1\u7406\u5458", value: "admin" },
{ label: "\u666e\u901a\u7528\u6237", value: "user" },
]}
/>
</Form.Item>
<Form.Item name="status" label={"\u8d26\u53f7\u72b6\u6001"}>
<Select options={[{ label: "\u5df2\u542f\u7528", value: "active" }, { label: "\u5df2\u505c\u7528", value: "disabled" }]} />
<Select
options={[
{ label: "\u5df2\u542f\u7528", value: "active" },
{ label: "\u5df2\u505c\u7528", value: "disabled" },
]}
/>
</Form.Item>
</Form>
</Drawer>

View File

@@ -12,6 +12,8 @@ export type PaymentProvider = {
closeAfterMinutes: number;
};
export type TopupSaleStrategy = "unlimited" | "periodic" | "inventory" | "timed";
export type TopupProduct = {
id: string;
name: string;
@@ -20,6 +22,15 @@ export type TopupProduct = {
creditsMicrocredits: number;
enabled: boolean;
sortOrder: number;
saleStrategy: TopupSaleStrategy;
periodDays?: number;
periodPurchaseLimit?: number;
stockTotal?: number;
stockRemaining?: number;
saleStartAt?: string;
saleEndAt?: string;
saleStatus?: "disabled" | "on_sale" | "upcoming" | "ended" | "sold_out" | string;
canPurchase: boolean;
createdBy: string;
updatedBy: string;
createdAt: string;
@@ -115,7 +126,14 @@ export function listAdminTopupProducts() {
return http.get<{ products: TopupProduct[] }>("/admin/payments/products");
}
export type TopupProductInput = Pick<TopupProduct, "name" | "amountFen" | "creditsMicrocredits" | "enabled" | "sortOrder"> & { description?: string };
export type TopupProductInput = Pick<TopupProduct, "name" | "amountFen" | "creditsMicrocredits" | "enabled" | "sortOrder" | "saleStrategy"> & {
description?: string;
periodDays?: number;
periodPurchaseLimit?: number;
stockTotal?: number;
saleStartAt?: string;
saleEndAt?: string;
};
export function createAdminTopupProduct(input: TopupProductInput) {
return http.post<{ product: TopupProduct }>("/admin/payments/products", input);