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:
@@ -17,6 +17,7 @@ import {
|
||||
MoonOutlined,
|
||||
ApiOutlined,
|
||||
BookOutlined,
|
||||
CrownOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -44,6 +45,7 @@ const navItems: NavItem[] = [
|
||||
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full', group: '运维' },
|
||||
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use', group: '运维' },
|
||||
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read', group: '资源管理' },
|
||||
{ path: '/billing', name: '计费管理', icon: <CrownOutlined />, permission: 'billing:read', group: '核心' },
|
||||
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full', group: '运维' },
|
||||
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read', group: '系统' },
|
||||
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
|
||||
@@ -207,6 +209,7 @@ const breadcrumbMap: Record<string, string> = {
|
||||
'/usage': '用量统计',
|
||||
'/relay': '中转任务',
|
||||
'/knowledge': '知识库',
|
||||
'/billing': '计费管理',
|
||||
'/config': '系统配置',
|
||||
'/prompts': '提示词管理',
|
||||
'/logs': '操作日志',
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'api-keys', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'billing', lazy: () => import('@/pages/Billing').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'knowledge', lazy: () => import('@/pages/Knowledge').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
|
||||
|
||||
101
admin-v2/src/services/billing.ts
Normal file
101
admin-v2/src/services/billing.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import request, { withSignal } from './request'
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface BillingPlan {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
description: string | null
|
||||
price_cents: number
|
||||
currency: string
|
||||
interval: string
|
||||
features: Record<string, unknown>
|
||||
limits: Record<string, unknown>
|
||||
is_default: boolean
|
||||
sort_order: number
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string
|
||||
account_id: string
|
||||
plan_id: string
|
||||
status: string
|
||||
current_period_start: string
|
||||
current_period_end: string
|
||||
trial_end: string | null
|
||||
canceled_at: string | null
|
||||
cancel_at_period_end: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface UsageQuota {
|
||||
id: string
|
||||
account_id: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
relay_requests: number
|
||||
hand_executions: number
|
||||
pipeline_runs: number
|
||||
max_input_tokens: number | null
|
||||
max_output_tokens: number | null
|
||||
max_relay_requests: number | null
|
||||
max_hand_executions: number | null
|
||||
max_pipeline_runs: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
plan: BillingPlan
|
||||
subscription: Subscription | null
|
||||
usage: UsageQuota
|
||||
}
|
||||
|
||||
export interface PaymentResult {
|
||||
payment_id: string
|
||||
trade_no: string
|
||||
pay_url: string
|
||||
amount_cents: number
|
||||
}
|
||||
|
||||
export interface PaymentStatus {
|
||||
id: string
|
||||
method: string
|
||||
amount_cents: number
|
||||
currency: string
|
||||
status: string
|
||||
}
|
||||
|
||||
// === Service ===
|
||||
|
||||
export const billingService = {
|
||||
listPlans: (signal?: AbortSignal) =>
|
||||
request.get<BillingPlan[]>('/billing/plans', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
getPlan: (id: string, signal?: AbortSignal) =>
|
||||
request.get<BillingPlan>(`/billing/plans/${id}`, withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
getSubscription: (signal?: AbortSignal) =>
|
||||
request.get<SubscriptionInfo>('/billing/subscription', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
getUsage: (signal?: AbortSignal) =>
|
||||
request.get<UsageQuota>('/billing/usage', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
createPayment: (data: { plan_id: string; payment_method: 'alipay' | 'wechat' }) =>
|
||||
request.post<PaymentResult>('/billing/payments', data).then((r) => r.data),
|
||||
|
||||
getPaymentStatus: (id: string, signal?: AbortSignal) =>
|
||||
request.get<PaymentStatus>(`/billing/payments/${id}`, withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
}
|
||||
Reference in New Issue
Block a user