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:
352
admin-v2/src/pages/Billing.tsx
Normal file
352
admin-v2/src/pages/Billing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user