feat(admin-v2): add billing management page

- Plan cards with feature comparison and pricing
- Usage progress bars with quota visualization
- Alipay/WeChat Pay method selection modal
- Payment status polling with auto-refresh on success
- Navigation + route registration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-02 00:48:35 +08:00
parent b1e3a27043
commit c8dc654fd4
4 changed files with 457 additions and 0 deletions

View File

@@ -0,0 +1,352 @@
// ============================================================
// 计费管理 — 计划/订阅/用量/支付
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Button, message, Tag, Modal, Card, Row, Col, Statistic, Typography,
Progress, Space, Radio, Spin, Empty, Divider,
} from 'antd'
import {
CrownOutlined, CheckCircleOutlined, ThunderboltOutlined,
RocketOutlined, TeamOutlined, AlipayCircleOutlined,
WechatOutlined, LoadingOutlined,
} from '@ant-design/icons'
import { PageHeader } from '@/components/PageHeader'
import { ErrorState } from '@/components/ErrorState'
import { billingService } from '@/services/billing'
import type { BillingPlan, SubscriptionInfo, PaymentResult } from '@/services/billing'
const { Text, Title } = Typography
// === 计划卡片 ===
const planIcons: Record<string, React.ReactNode> = {
free: <RocketOutlined style={{ fontSize: 24 }} />,
pro: <ThunderboltOutlined style={{ fontSize: 24 }} />,
team: <TeamOutlined style={{ fontSize: 24 }} />,
}
const planColors: Record<string, string> = {
free: '#8c8c8c',
pro: '#863bff',
team: '#47bfff',
}
function PlanCard({
plan,
isCurrent,
onSelect,
}: {
plan: BillingPlan
isCurrent: boolean
onSelect: (plan: BillingPlan) => void
}) {
const color = planColors[plan.name] || '#666'
const limits = plan.limits as Record<string, unknown> | undefined
const maxRelay = (limits?.max_relay_requests_monthly as number) ?? '∞'
const maxHand = (limits?.max_hand_executions_monthly as number) ?? '∞'
const maxPipeline = (limits?.max_pipeline_runs_monthly as number) ?? '∞'
return (
<Card
className={`relative overflow-hidden transition-all duration-200 hover:shadow-lg ${
isCurrent ? 'ring-2 ring-offset-2' : ''
}`}
style={isCurrent ? { borderColor: color, '--tw-ring-color': color } as React.CSSProperties : {}}
>
{isCurrent && (
<div
className="absolute top-0 right-0 px-3 py-1 text-xs font-medium text-white rounded-bl-lg"
style={{ background: color }}
>
</div>
)}
<div className="text-center mb-4">
<div style={{ color }} className="mb-2">
{planIcons[plan.name] || <CrownOutlined style={{ fontSize: 24 }} />}
</div>
<Title level={4} style={{ margin: 0 }}>{plan.display_name}</Title>
{plan.description && (
<Text type="secondary" className="text-sm">{plan.description}</Text>
)}
</div>
<div className="text-center mb-4">
<span className="text-3xl font-bold" style={{ color }}>
¥{plan.price_cents === 0 ? '0' : (plan.price_cents / 100).toFixed(0)}
</span>
<Text type="secondary"> /{plan.interval === 'month' ? '月' : '年'}</Text>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<span>: {maxRelay === Infinity ? '无限' : `${maxRelay} 次/月`}</span>
</div>
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<span>Hand : {maxHand === Infinity ? '无限' : `${maxHand} 次/月`}</span>
</div>
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<span>Pipeline : {maxPipeline === Infinity ? '无限' : `${maxPipeline} 次/月`}</span>
</div>
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<span>: {plan.name === 'free' ? '基础' : '高级'}</span>
</div>
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<span>: {plan.name === 'team' ? '最高' : plan.name === 'pro' ? '高' : '标准'}</span>
</div>
</div>
<Divider />
<Button
block
type={isCurrent ? 'default' : 'primary'}
disabled={isCurrent}
onClick={() => onSelect(plan)}
style={!isCurrent ? { background: color, borderColor: color } : {}}
>
{isCurrent ? '当前计划' : '升级'}
</Button>
</Card>
)
}
// === 用量进度条 ===
function UsageBar({ label, current, max }: { label: string; current: number; max: number | null }) {
const pct = max ? Math.min((current / max) * 100, 100) : 0
const displayMax = max ? max.toLocaleString() : '∞'
return (
<div className="mb-3">
<div className="flex justify-between text-xs text-neutral-500 dark:text-neutral-400 mb-1">
<span>{label}</span>
<span>{current.toLocaleString()} / {displayMax}</span>
</div>
<Progress
percent={pct}
showInfo={false}
strokeColor={pct >= 90 ? '#ff4d4f' : pct >= 70 ? '#faad14' : '#863bff'}
size="small"
/>
</div>
)
}
// === 主页面 ===
export default function Billing() {
const queryClient = useQueryClient()
const [payModalOpen, setPayModalOpen] = useState(false)
const [selectedPlan, setSelectedPlan] = useState<BillingPlan | null>(null)
const [payMethod, setPayMethod] = useState<'alipay' | 'wechat'>('alipay')
const [payResult, setPayResult] = useState<PaymentResult | null>(null)
const [pollingPayment, setPollingPayment] = useState<string | null>(null)
const { data: plans = [], isLoading: plansLoading, error: plansError, refetch } = useQuery({
queryKey: ['billing-plans'],
queryFn: ({ signal }) => billingService.listPlans(signal),
})
const { data: subInfo, isLoading: subLoading } = useQuery({
queryKey: ['billing-subscription'],
queryFn: ({ signal }) => billingService.getSubscription(signal),
})
// 支付状态轮询
const { data: paymentStatus } = useQuery({
queryKey: ['payment-status', pollingPayment],
queryFn: ({ signal }) => billingService.getPaymentStatus(pollingPayment!, signal),
enabled: !!pollingPayment,
refetchInterval: pollingPayment ? 3000 : false,
})
// 支付成功后刷新
if (paymentStatus?.status === 'succeeded' && pollingPayment) {
setPollingPayment(null)
setPayModalOpen(false)
setPayResult(null)
message.success('支付成功!计划已更新')
queryClient.invalidateQueries({ queryKey: ['billing-subscription'] })
}
const createPaymentMutation = useMutation({
mutationFn: (data: { plan_id: string; payment_method: 'alipay' | 'wechat' }) =>
billingService.createPayment(data),
onSuccess: (result) => {
setPayResult(result)
setPollingPayment(result.payment_id)
// 打开支付链接
window.open(result.pay_url, '_blank', 'width=480,height=640')
},
onError: (err: Error) => message.error(err.message || '创建支付失败'),
})
const handleSelectPlan = (plan: BillingPlan) => {
if (plan.price_cents === 0) return
setSelectedPlan(plan)
setPayResult(null)
setPayModalOpen(true)
}
const handleConfirmPay = () => {
if (!selectedPlan) return
createPaymentMutation.mutate({
plan_id: selectedPlan.id,
payment_method: payMethod,
})
}
if (plansError) {
return (
<>
<PageHeader title="计费管理" description="管理订阅计划和用量配额" />
<ErrorState message={(plansError as Error).message} onRetry={() => refetch()} />
</>
)
}
const currentPlanName = subInfo?.plan?.name || 'free'
const usage = subInfo?.usage
return (
<div>
<PageHeader title="计费管理" description="管理订阅计划和用量配额" />
{/* 当前计划 + 用量 */}
{subInfo && usage && (
<Card className="mb-6" title={<span className="text-sm font-semibold"></span>}>
<Row gutter={[24, 16]}>
<Col xs={24} md={8}>
<UsageBar
label="中转请求"
current={usage.relay_requests}
max={usage.max_relay_requests}
/>
</Col>
<Col xs={24} md={8}>
<UsageBar
label="Hand 执行"
current={usage.hand_executions}
max={usage.max_hand_executions}
/>
</Col>
<Col xs={24} md={8}>
<UsageBar
label="Pipeline 运行"
current={usage.pipeline_runs}
max={usage.max_pipeline_runs}
/>
</Col>
</Row>
{subInfo.subscription && (
<div className="mt-4 text-xs text-neutral-400">
: {new Date(subInfo.subscription.current_period_start).toLocaleDateString()} {new Date(subInfo.subscription.current_period_end).toLocaleDateString()}
</div>
)}
</Card>
)}
{/* 计划选择 */}
<Title level={5} className="mb-4"></Title>
{plansLoading ? (
<div className="flex justify-center py-8"><Spin /></div>
) : (
<Row gutter={[16, 16]}>
{plans.map((plan) => (
<Col key={plan.id} xs={24} sm={12} lg={8}>
<PlanCard
plan={plan}
isCurrent={plan.name === currentPlanName}
onSelect={handleSelectPlan}
/>
</Col>
))}
</Row>
)}
{/* 支付弹窗 */}
<Modal
title={selectedPlan ? `升级到 ${selectedPlan.display_name}` : '支付'}
open={payModalOpen}
onCancel={() => {
setPayModalOpen(false)
setPollingPayment(null)
setPayResult(null)
}}
footer={payResult ? null : undefined}
onOk={handleConfirmPay}
okText={createPaymentMutation.isPending ? '处理中...' : '确认支付'}
confirmLoading={createPaymentMutation.isPending}
>
{payResult ? (
<div className="text-center py-4">
<LoadingOutlined style={{ fontSize: 32, color: '#863bff' }} className="mb-4" />
<Title level={5}>...</Title>
<Text type="secondary">
<br />
: ¥{(payResult.amount_cents / 100).toFixed(2)}
</Text>
<div className="mt-4">
<Button onClick={() => { setPollingPayment(null); setPayModalOpen(false); setPayResult(null) }}>
</Button>
</div>
</div>
) : (
<div>
{selectedPlan && (
<div className="text-center mb-6">
<div className="text-2xl font-bold" style={{ color: planColors[selectedPlan.name] || '#666' }}>
¥{(selectedPlan.price_cents / 100).toFixed(0)}
</div>
<Text type="secondary">/{selectedPlan.interval === 'month' ? '月' : '年'}</Text>
</div>
)}
<Title level={5} className="text-center mb-4"></Title>
<Radio.Group
value={payMethod}
onChange={(e) => setPayMethod(e.target.value)}
className="w-full"
>
<Space direction="vertical" className="w-full" size={12}>
<Radio value="alipay" className="w-full">
<div className="flex items-center gap-3 p-3 border rounded-lg w-full hover:border-blue-400 transition-colors">
<AlipayCircleOutlined style={{ fontSize: 28, color: '#1677ff' }} />
<div>
<div className="font-medium"></div>
<div className="text-xs text-neutral-400"></div>
</div>
</div>
</Radio>
<Radio value="wechat" className="w-full">
<div className="flex items-center gap-3 p-3 border rounded-lg w-full hover:border-green-400 transition-colors">
<WechatOutlined style={{ fontSize: 28, color: '#07c160' }} />
<div>
<div className="font-medium"></div>
<div className="text-xs text-neutral-400"></div>
</div>
</div>
</Radio>
</Space>
</Radio.Group>
</div>
)}
</Modal>
</div>
)
}