diff --git a/apps/web/src/api/subscription.ts b/apps/web/src/api/subscription.ts new file mode 100644 index 000000000..8fecd715d --- /dev/null +++ b/apps/web/src/api/subscription.ts @@ -0,0 +1,163 @@ +/** + * 订阅 API 模块 + * 提供订阅管理相关接口(当前使用 mock 数据,后端就绪后切换) + */ +import apiClient from './client'; + +/** 套餐类型 */ +export type PlanType = 'free' | 'standard' | 'pro' | 'enterprise'; + +/** 订阅状态 */ +export type SubscriptionStatus = 'active' | 'expired' | 'cancelled' | 'trial'; + +/** 账单状态 */ +export type BillingStatus = 'paid' | 'pending' | 'failed' | 'refunded'; + +/** 计费周期 */ +export type BillingCycle = 'monthly' | 'yearly'; + +/** 套餐信息 */ +export interface Plan { + id: PlanType; + name: string; + price: number | null; + yearly_price?: number | null; + description: string; + recommended: boolean; + features: string[]; +} + +/** 当前订阅信息 */ +export interface SubscriptionInfo { + id: string; + plan_id: PlanType; + plan_name: string; + status: SubscriptionStatus; + billing_cycle: BillingCycle; + current_period_start: string; + current_period_end: string; + amount: number; + auto_renew: boolean; + created_at: string; +} + +/** 账单记录 */ +export interface BillingRecord { + id: string; + plan_name: string; + amount: number; + billing_cycle: BillingCycle; + status: BillingStatus; + payment_method: string; + created_at: string; + invoice_url?: string; +} + +/** 升级/降级请求 */ +export interface ChangePlanRequest { + target_plan_id: PlanType; + billing_cycle: BillingCycle; +} + +/** 升级/降级响应 */ +export interface ChangePlanResponse { + success: boolean; + message: string; + new_subscription?: SubscriptionInfo; +} + +// ============ Mock 数据 ============ + +const MOCK_SUBSCRIPTION: SubscriptionInfo = { + id: 'sub-001', + plan_id: 'standard', + plan_name: '标准版', + status: 'active', + billing_cycle: 'monthly', + current_period_start: '2026-06-01T00:00:00Z', + current_period_end: '2026-07-01T00:00:00Z', + amount: 99, + auto_renew: true, + created_at: '2026-03-01T00:00:00Z', +}; + +const MOCK_BILLING_RECORDS: BillingRecord[] = [ + { + id: 'bill-001', plan_name: '标准版', amount: 99, + billing_cycle: 'monthly', status: 'paid', payment_method: '微信支付', + created_at: '2026-06-01T00:00:00Z', invoice_url: '#', + }, + { + id: 'bill-002', plan_name: '标准版', amount: 99, + billing_cycle: 'monthly', status: 'paid', payment_method: '微信支付', + created_at: '2026-05-01T00:00:00Z', invoice_url: '#', + }, + { + id: 'bill-003', plan_name: '标准版', amount: 99, + billing_cycle: 'monthly', status: 'paid', payment_method: '支付宝', + created_at: '2026-04-01T00:00:00Z', invoice_url: '#', + }, +]; + +/** 是否使用 mock 数据(后端就绪后改为 false) */ +const USE_MOCK = true; + +// ============ API 函数 ============ + +/** 获取当前订阅信息 */ +export const getCurrentSubscription = async (): Promise => { + if (USE_MOCK) { + await new Promise((resolve) => setTimeout(resolve, 300)); + return MOCK_SUBSCRIPTION; + } + const response = await apiClient.get('/subscription/current'); + return response.data; +}; + +/** 获取账单记录列表 */ +export const getBillingRecords = async (): Promise => { + if (USE_MOCK) { + await new Promise((resolve) => setTimeout(resolve, 300)); + return MOCK_BILLING_RECORDS; + } + const response = await apiClient.get('/subscription/billing'); + return response.data; +}; + +/** 升级/降级套餐 */ +export const changePlan = async (request: ChangePlanRequest): Promise => { + if (USE_MOCK) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + success: true, + message: '套餐变更成功', + new_subscription: { + ...MOCK_SUBSCRIPTION, + plan_id: request.target_plan_id, + plan_name: request.target_plan_id === 'pro' ? '专业版' : request.target_plan_id === 'standard' ? '标准版' : '体验版', + }, + }; + } + const response = await apiClient.post('/subscription/change', request); + return response.data; +}; + +/** 取消订阅 */ +export const cancelSubscription = async (): Promise<{ success: boolean; message: string }> => { + if (USE_MOCK) { + await new Promise((resolve) => setTimeout(resolve, 800)); + return { success: true, message: '订阅已取消,当前周期结束后停止服务' }; + } + const response = await apiClient.post('/subscription/cancel'); + return response.data; +}; + +/** 切换自动续费 */ +export const toggleAutoRenew = async (enabled: boolean): Promise<{ success: boolean; message: string }> => { + if (USE_MOCK) { + await new Promise((resolve) => setTimeout(resolve, 300)); + return { success: true, message: enabled ? '已开启自动续费' : '已关闭自动续费' }; + } + const response = await apiClient.post('/subscription/auto-renew', { enabled }); + return response.data; +}; diff --git a/apps/web/src/pages/subscription/Billing.css b/apps/web/src/pages/subscription/Billing.css new file mode 100644 index 000000000..6d62e06bd --- /dev/null +++ b/apps/web/src/pages/subscription/Billing.css @@ -0,0 +1,128 @@ +/* 账单管理页面 */ +.xx-billing-page { + max-width: 960px; + margin: 0 auto; + padding: 40px 24px; +} + +/* 订阅概览 */ +.xx-billing-overview { + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(226, 232, 240, 0.95); + border-radius: 20px; + padding: 32px; + margin-bottom: 40px; + box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06); +} + +.xx-billing-overview h2 { + font-size: 24px; + font-weight: 800; + color: var(--slate, #0f172a); + margin: 0 0 24px; +} + +.xx-overview-details { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 24px; +} + +@media (max-width: 768px) { + .xx-overview-details { + grid-template-columns: repeat(2, 1fr); + } +} + +.xx-overview-item { + display: flex; + flex-direction: column; + gap: 6px; +} + +.xx-label { + font-size: 13px; + color: var(--muted, #64748b); + font-weight: 500; +} + +.xx-value { + font-size: 16px; + font-weight: 700; + color: var(--slate, #0f172a); +} + +/* 账单记录 */ +.xx-billing-history { + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(226, 232, 240, 0.95); + border-radius: 20px; + padding: 32px; + box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06); +} + +.xx-billing-history h2 { + font-size: 24px; + font-weight: 800; + color: var(--slate, #0f172a); + margin: 0 0 24px; +} + +/* 表格样式 */ +.xx-billing-table { + width: 100%; +} + +.xx-table-header { + display: grid; + grid-template-columns: 1.2fr 1fr 0.8fr 1fr 0.8fr 1fr; + gap: 12px; + padding: 12px 16px; + background: rgba(241, 245, 249, 0.8); + border-radius: 10px; + margin-bottom: 8px; + font-size: 13px; + font-weight: 700; + color: var(--muted, #64748b); +} + +.xx-table-row { + display: grid; + grid-template-columns: 1.2fr 1fr 0.8fr 1fr 0.8fr 1fr; + gap: 12px; + padding: 14px 16px; + align-items: center; + border-bottom: 1px solid rgba(226, 232, 240, 0.6); + font-size: 14px; + color: var(--slate, #0f172a); + transition: background 0.15s; +} + +.xx-table-row:hover { + background: rgba(241, 245, 249, 0.4); +} + +.xx-table-row:last-child { + border-bottom: none; +} + +.xx-amount { + font-weight: 700; + color: var(--indigo, #4f46e5); +} + +@media (max-width: 768px) { + .xx-table-header, + .xx-table-row { + grid-template-columns: 1fr 1fr 1fr; + font-size: 12px; + } + + .xx-table-header span:nth-child(4), + .xx-table-header span:nth-child(6), + .xx-table-row span:nth-child(4), + .xx-table-row span:nth-child(6) { + display: none; + } +} diff --git a/apps/web/src/pages/subscription/Billing.tsx b/apps/web/src/pages/subscription/Billing.tsx index b79a7cd1d..01e7a5cb9 100644 --- a/apps/web/src/pages/subscription/Billing.tsx +++ b/apps/web/src/pages/subscription/Billing.tsx @@ -1,29 +1,145 @@ -import React from 'react'; -import { Button, Result } from 'antd'; +/** + * 账单管理页面 + */ +import React, { useState, useEffect } from 'react'; +import { Button, Tag, message, Spin, Empty, Modal } from 'antd'; import { useNavigate } from 'react-router-dom'; -import '../profile/ProfileSettings.css'; +import { getBillingRecords, getCurrentSubscription } from '@/api/subscription'; +import type { BillingRecord, SubscriptionInfo } from '@/api/subscription'; +import './Billing.css'; + +const STATUS_MAP: Record = { + paid: { color: 'success', label: '已支付' }, + pending: { color: 'warning', label: '待支付' }, + failed: { color: 'error', label: '支付失败' }, + refunded: { color: 'default', label: '已退款' }, +}; + +const formatDate = (iso: string): string => { + const d = new Date(iso); + return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); +}; + +const formatAmount = (amount: number): string => { + if (amount === 0) return '免费'; + return `¥${amount.toFixed(2)}`; +}; const Billing: React.FC = () => { const navigate = useNavigate(); + const [records, setRecords] = useState([]); + const [subscription, setSubscription] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + const [billingData, subData] = await Promise.allSettled([ + getBillingRecords(), + getCurrentSubscription(), + ]); + if (billingData.status === 'fulfilled') setRecords(billingData.value); + if (subData.status === 'fulfilled') setSubscription(subData.value); + } catch { + message.error('加载账单数据失败'); + } finally { + setLoading(false); + } + }; + + const handleDownloadInvoice = (record: BillingRecord) => { + if (!record.invoice_url || record.invoice_url === '#') { + message.info('发票功能暂未开放'); + return; + } + window.open(record.invoice_url, '_blank'); + }; + + if (loading) { + return ( +
+ +
+ ); + } return ( -
-
- navigate('/subscription')}> - 返回订阅说明 - , - ]} - /> +
+ {/* 当前订阅概览 */} + {subscription && ( +
+

当前订阅

+
+
+ 套餐 + {subscription.plan_name} +
+
+ 计费周期 + + {subscription.billing_cycle === 'monthly' ? '月付' : '年付'} + +
+
+ 下次扣费 + {formatDate(subscription.current_period_end)} +
+
+ 自动续费 + {subscription.auto_renew ? '已开启' : '已关闭'} +
+
+ +
+ )} + + {/* 账单记录 */} +
+

账单记录

+ {records.length === 0 ? ( + + ) : ( +
+
+ 日期 + 套餐 + 金额 + 支付方式 + 状态 + 操作 +
+ {records.map((record) => { + const statusInfo = STATUS_MAP[record.status] ?? STATUS_MAP.pending; + return ( +
+ {formatDate(record.created_at)} + {record.plan_name} + {formatAmount(record.amount)} + {record.payment_method} + + {statusInfo.label} + + + {record.status === 'paid' && record.invoice_url && ( + + )} + +
+ ); + })} +
+ )}
); }; export default Billing; - export const Component = Billing; diff --git a/apps/web/src/pages/subscription/UpgradeSubscription.css b/apps/web/src/pages/subscription/UpgradeSubscription.css new file mode 100644 index 000000000..f70358604 --- /dev/null +++ b/apps/web/src/pages/subscription/UpgradeSubscription.css @@ -0,0 +1,115 @@ +/* 升级/降级页面 */ +.xx-upgrade-page { + max-width: 900px; + margin: 0 auto; + padding: 40px 24px; +} + +.xx-upgrade-header { + text-align: center; + margin-bottom: 40px; +} + +.xx-upgrade-header h2 { + font-size: 32px; + font-weight: 900; + color: var(--slate, #0f172a); + margin: 0 0 8px; +} + +.xx-upgrade-header p { + font-size: 16px; + color: var(--muted, #64748b); + margin: 0; +} + +/* 套餐卡片 */ +.xx-upgrade-plans { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-bottom: 40px; +} + +@media (max-width: 768px) { + .xx-upgrade-plans { + grid-template-columns: 1fr; + } +} + +.xx-upgrade-card { + background: rgba(255, 255, 255, 0.94); + border: 2px solid rgba(226, 232, 240, 0.95); + border-radius: 20px; + padding: 28px 24px; + text-align: center; + cursor: pointer; + transition: all 0.25s ease; + position: relative; +} + +.xx-upgrade-card:hover { + border-color: var(--indigo, #4f46e5); + box-shadow: 0 8px 30px rgba(79, 70, 229, 0.1); +} + +.xx-upgrade-card.selected { + border-color: var(--indigo, #4f46e5); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12), 0 8px 30px rgba(79, 70, 229, 0.1); +} + +.xx-upgrade-card.current { + border-color: var(--green, #10b981); +} + +.xx-current-badge { + position: absolute; + top: -10px; + right: 16px; + background: var(--green, #10b981); + color: white; + padding: 3px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.xx-upgrade-card h3 { + font-size: 20px; + font-weight: 800; + color: var(--slate, #0f172a); + margin: 0 0 16px; +} + +.xx-price { + margin-bottom: 8px; +} + +.xx-save { + display: inline-block; + margin-left: 4px; + font-size: 11px; + color: var(--green, #10b981); + font-weight: 600; +} + +/* 操作区 */ +.xx-upgrade-actions { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.xx-auto-renew-section { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted, #64748b); + font-size: 14px; +} + +.xx-cancel-btn { + margin-top: 8px; +} diff --git a/apps/web/src/pages/subscription/UpgradeSubscription.tsx b/apps/web/src/pages/subscription/UpgradeSubscription.tsx index 25fee58a5..0801a32fb 100644 --- a/apps/web/src/pages/subscription/UpgradeSubscription.tsx +++ b/apps/web/src/pages/subscription/UpgradeSubscription.tsx @@ -1,30 +1,188 @@ -import React from 'react'; -import { Button, Result } from 'antd'; +/** + * 升级/降级/续费页面 + */ +import React, { useState, useEffect } from 'react'; +import { Button, Radio, message, Spin, Modal } from 'antd'; import { useNavigate } from 'react-router-dom'; -import '../profile/ProfileSettings.css'; +import { getCurrentSubscription, changePlan, toggleAutoRenew, cancelSubscription } from '@/api/subscription'; +import type { SubscriptionInfo, PlanType, BillingCycle } from '@/api/subscription'; +import './UpgradeSubscription.css'; + +const PLANS_META: Record = { + free: { name: '体验版', price: 0, yearlyPrice: 0 }, + standard: { name: '标准版', price: 99, yearlyPrice: 990 }, + pro: { name: '专业版', price: 299, yearlyPrice: 2990 }, + enterprise: { name: '企业版', price: 0, yearlyPrice: 0 }, +}; const UpgradeSubscription: React.FC = () => { - // workspaceId removed - projects now belong directly to user const navigate = useNavigate(); + const [subscription, setSubscription] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [selectedPlan, setSelectedPlan] = useState('standard'); + const [billingCycle, setBillingCycle] = useState('monthly'); + + useEffect(() => { + loadSubscription(); + }, []); + + const loadSubscription = async () => { + try { + const data = await getCurrentSubscription(); + setSubscription(data); + setSelectedPlan(data.plan_id); + } catch { + message.error('获取订阅信息失败'); + } finally { + setLoading(false); + } + }; + + const handleUpgrade = async () => { + if (!subscription) return; + if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) { + message.info('当前已是该套餐'); + return; + } + + const plan = PLANS_META[selectedPlan]; + const price = billingCycle === 'yearly' ? plan.yearlyPrice : plan.price; + + Modal.confirm({ + title: '确认变更套餐', + content: `即将变更为「${plan.name}」(${billingCycle === 'monthly' ? '月付' : '年付'}),${price > 0 ? `费用 ¥${price}${billingCycle === 'monthly' ? '/月' : '/年'}` : '免费'}。变更立即生效。`, + okText: '确认变更', + cancelText: '取消', + onOk: async () => { + try { + setSubmitting(true); + const res = await changePlan({ target_plan_id: selectedPlan, billing_cycle: billingCycle }); + if (res.success) { + message.success(res.message); + setSubscription(res.new_subscription ?? null); + } else { + message.error(res.message); + } + } catch { + message.error('套餐变更失败,请重试'); + } finally { + setSubmitting(false); + } + }, + }); + }; + + const handleToggleAutoRenew = async (enabled: boolean) => { + try { + const res = await toggleAutoRenew(enabled); + message.success(res.message); + if (subscription) { + setSubscription({ ...subscription, auto_renew: enabled }); + } + } catch { + message.error('操作失败'); + } + }; + + const handleCancel = () => { + Modal.confirm({ + title: '确认取消订阅', + content: '取消后,当前周期结束前仍可正常使用,到期后降级为体验版。', + okText: '确认取消', + okType: 'danger', + cancelText: '再想想', + onOk: async () => { + try { + const res = await cancelSubscription(); + message.success(res.message); + navigate('/subscription'); + } catch { + message.error('取消失败'); + } + }, + }); + }; + + if (loading) { + return
; + } + + const currentPlan = subscription?.plan_id ?? 'free'; return ( -
-
- navigate('/subscription')}> - 查看套餐说明 - , - ]} - /> +
+
+

变更订阅方案

+

当前套餐:{PLANS_META[currentPlan]?.name ?? '体验版'}

+
+ +
+ {(['standard', 'pro', 'enterprise'] as PlanType[]).map((planId) => { + const plan = PLANS_META[planId]; + const isCurrent = planId === currentPlan; + return ( +
setSelectedPlan(planId)} + > + {isCurrent &&
当前
} +

{plan.name}

+
+ setBillingCycle(e.target.value)} + size="small" + > + + ¥{plan.price}/月 + + + ¥{plan.yearlyPrice}/年 + {plan.yearlyPrice > 0 && plan.price > 0 && ( + 省 ¥{plan.price * 12 - plan.yearlyPrice} + )} + + +
+
+ ); + })} +
+ +
+ + + {subscription && subscription.status === 'active' && ( +
+ 自动续费:{subscription.auto_renew ? '已开启' : '已关闭'} + +
+ )} + + {subscription && subscription.status === 'active' && currentPlan !== 'free' && ( + + )}
); }; export default UpgradeSubscription; - export const Component = UpgradeSubscription;