diff --git a/admin-v2/src/layouts/AdminLayout.tsx b/admin-v2/src/layouts/AdminLayout.tsx index c4555b4..1887c7c 100644 --- a/admin-v2/src/layouts/AdminLayout.tsx +++ b/admin-v2/src/layouts/AdminLayout.tsx @@ -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: , permission: 'admin:full', group: '运维' }, { path: '/relay', name: '中转任务', icon: , permission: 'relay:use', group: '运维' }, { path: '/knowledge', name: '知识库', icon: , permission: 'knowledge:read', group: '资源管理' }, + { path: '/billing', name: '计费管理', icon: , permission: 'billing:read', group: '核心' }, { path: '/logs', name: '操作日志', icon: , permission: 'admin:full', group: '运维' }, { path: '/config', name: '系统配置', icon: , permission: 'config:read', group: '系统' }, { path: '/prompts', name: '提示词管理', icon: , permission: 'prompt:read', group: '系统' }, @@ -207,6 +209,7 @@ const breadcrumbMap: Record = { '/usage': '用量统计', '/relay': '中转任务', '/knowledge': '知识库', + '/billing': '计费管理', '/config': '系统配置', '/prompts': '提示词管理', '/logs': '操作日志', diff --git a/admin-v2/src/pages/Billing.tsx b/admin-v2/src/pages/Billing.tsx new file mode 100644 index 0000000..e9e54f5 --- /dev/null +++ b/admin-v2/src/pages/Billing.tsx @@ -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 = { + free: , + pro: , + team: , +} + +const planColors: Record = { + 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 | 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 ( + + {isCurrent && ( +
+ 当前计划 +
+ )} + +
+
+ {planIcons[plan.name] || } +
+ {plan.display_name} + {plan.description && ( + {plan.description} + )} +
+ +
+ + ¥{plan.price_cents === 0 ? '0' : (plan.price_cents / 100).toFixed(0)} + + /{plan.interval === 'month' ? '月' : '年'} +
+ +
+
+ + 中转请求: {maxRelay === Infinity ? '无限' : `${maxRelay} 次/月`} +
+
+ + Hand 执行: {maxHand === Infinity ? '无限' : `${maxHand} 次/月`} +
+
+ + Pipeline 运行: {maxPipeline === Infinity ? '无限' : `${maxPipeline} 次/月`} +
+
+ + 知识库: {plan.name === 'free' ? '基础' : '高级'} +
+
+ + 优先级队列: {plan.name === 'team' ? '最高' : plan.name === 'pro' ? '高' : '标准'} +
+
+ + + + +
+ ) +} + +// === 用量进度条 === + +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 ( +
+
+ {label} + {current.toLocaleString()} / {displayMax} +
+ = 90 ? '#ff4d4f' : pct >= 70 ? '#faad14' : '#863bff'} + size="small" + /> +
+ ) +} + +// === 主页面 === + +export default function Billing() { + const queryClient = useQueryClient() + const [payModalOpen, setPayModalOpen] = useState(false) + const [selectedPlan, setSelectedPlan] = useState(null) + const [payMethod, setPayMethod] = useState<'alipay' | 'wechat'>('alipay') + const [payResult, setPayResult] = useState(null) + const [pollingPayment, setPollingPayment] = useState(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 ( + <> + + refetch()} /> + + ) + } + + const currentPlanName = subInfo?.plan?.name || 'free' + const usage = subInfo?.usage + + return ( +
+ + + {/* 当前计划 + 用量 */} + {subInfo && usage && ( + 当前用量}> + + + + + + + + + + + + + {subInfo.subscription && ( +
+ 订阅周期: {new Date(subInfo.subscription.current_period_start).toLocaleDateString()} — {new Date(subInfo.subscription.current_period_end).toLocaleDateString()} +
+ )} +
+ )} + + {/* 计划选择 */} + 选择计划 + + {plansLoading ? ( +
+ ) : ( + + {plans.map((plan) => ( + + + + ))} + + )} + + {/* 支付弹窗 */} + { + setPayModalOpen(false) + setPollingPayment(null) + setPayResult(null) + }} + footer={payResult ? null : undefined} + onOk={handleConfirmPay} + okText={createPaymentMutation.isPending ? '处理中...' : '确认支付'} + confirmLoading={createPaymentMutation.isPending} + > + {payResult ? ( +
+ + 等待支付确认... + + 支付窗口已打开,请在新窗口完成支付。 +
+ 支付金额: ¥{(payResult.amount_cents / 100).toFixed(2)} +
+
+ +
+
+ ) : ( +
+ {selectedPlan && ( +
+
+ ¥{(selectedPlan.price_cents / 100).toFixed(0)} +
+ /{selectedPlan.interval === 'month' ? '月' : '年'} +
+ )} + + 选择支付方式 + + setPayMethod(e.target.value)} + className="w-full" + > + + +
+ +
+
支付宝
+
推荐个人用户
+
+
+
+ +
+ +
+
微信支付
+
扫码支付
+
+
+
+
+
+
+ )} +
+
+ ) +} diff --git a/admin-v2/src/router/index.tsx b/admin-v2/src/router/index.tsx index 06698f2..5385805 100644 --- a/admin-v2/src/router/index.tsx +++ b/admin-v2/src/router/index.tsx @@ -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 })) }, diff --git a/admin-v2/src/services/billing.ts b/admin-v2/src/services/billing.ts new file mode 100644 index 0000000..1c98c04 --- /dev/null +++ b/admin-v2/src/services/billing.ts @@ -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 + limits: Record + 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('/billing/plans', withSignal({}, signal)) + .then((r) => r.data), + + getPlan: (id: string, signal?: AbortSignal) => + request.get(`/billing/plans/${id}`, withSignal({}, signal)) + .then((r) => r.data), + + getSubscription: (signal?: AbortSignal) => + request.get('/billing/subscription', withSignal({}, signal)) + .then((r) => r.data), + + getUsage: (signal?: AbortSignal) => + request.get('/billing/usage', withSignal({}, signal)) + .then((r) => r.data), + + createPayment: (data: { plan_id: string; payment_method: 'alipay' | 'wechat' }) => + request.post('/billing/payments', data).then((r) => r.data), + + getPaymentStatus: (id: string, signal?: AbortSignal) => + request.get(`/billing/payments/${id}`, withSignal({}, signal)) + .then((r) => r.data), +}