feat: Phase 2 - 订阅管理前端页面 #77

Merged
xiaoxia merged 1 commits from feat/phase2-subscription-ui into develop 2026-06-28 16:28:00 +08:00
5 changed files with 713 additions and 33 deletions
+163
View File
@@ -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<SubscriptionInfo> => {
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<BillingRecord[]> => {
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<ChangePlanResponse> => {
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;
};
+128
View File
@@ -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;
}
}
+132 -16
View File
@@ -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<string, { color: string; label: string }> = {
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<BillingRecord[]>([]);
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(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 (
<div className="xx-billing-page">
<Spin size="large" />
</div>
);
}
return (
<div className="xx-settings-page">
<div className="xx-result-page">
<Result
status="info"
title="账单管理暂未开放"
subTitle="当前版本未接入账单、支付、发票和下载后端服务,因此不展示模拟账单,也不会伪造下载成功。"
extra={[
<Button key="plans" type="primary" onClick={() => navigate('/subscription')}>
</Button>,
]}
/>
<div className="xx-billing-page">
{/* 当前订阅概览 */}
{subscription && (
<div className="xx-billing-overview">
<h2></h2>
<div className="xx-overview-details">
<div className="xx-overview-item">
<span className="xx-label"></span>
<span className="xx-value">{subscription.plan_name}</span>
</div>
<div className="xx-overview-item">
<span className="xx-label"></span>
<span className="xx-value">
{subscription.billing_cycle === 'monthly' ? '月付' : '年付'}
</span>
</div>
<div className="xx-overview-item">
<span className="xx-label"></span>
<span className="xx-value">{formatDate(subscription.current_period_end)}</span>
</div>
<div className="xx-overview-item">
<span className="xx-label"></span>
<span className="xx-value">{subscription.auto_renew ? '已开启' : '已关闭'}</span>
</div>
</div>
<Button type="primary" onClick={() => navigate('/subscription/upgrade')}>
</Button>
</div>
)}
{/* 账单记录 */}
<div className="xx-billing-history">
<h2></h2>
{records.length === 0 ? (
<Empty description="暂无账单记录" />
) : (
<div className="xx-billing-table">
<div className="xx-table-header">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{records.map((record) => {
const statusInfo = STATUS_MAP[record.status] ?? STATUS_MAP.pending;
return (
<div key={record.id} className="xx-table-row">
<span>{formatDate(record.created_at)}</span>
<span>{record.plan_name}</span>
<span className="xx-amount">{formatAmount(record.amount)}</span>
<span>{record.payment_method}</span>
<span>
<Tag color={statusInfo.color}>{statusInfo.label}</Tag>
</span>
<span>
{record.status === 'paid' && record.invoice_url && (
<Button type="link" size="small" onClick={() => handleDownloadInvoice(record)}>
</Button>
)}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
};
export default Billing;
export const Component = Billing;
@@ -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;
}
@@ -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<string, { name: string; price: number; yearlyPrice: number }> = {
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<SubscriptionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<PlanType>('standard');
const [billingCycle, setBillingCycle] = useState<BillingCycle>('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 <div className="xx-upgrade-page"><Spin size="large" /></div>;
}
const currentPlan = subscription?.plan_id ?? 'free';
return (
<div className="xx-settings-page">
<div className="xx-result-page">
<Result
status="info"
title="订阅升级暂未开放"
subTitle="当前版本未接入订阅、支付和配额变更后端服务。为避免假成功或调用不存在的接口,此入口已暂时关闭。"
extra={[
<Button key="plans" onClick={() => navigate('/subscription')}>
</Button>,
]}
/>
<div className="xx-upgrade-page">
<div className="xx-upgrade-header">
<h2></h2>
<p>{PLANS_META[currentPlan]?.name ?? '体验版'}</p>
</div>
<div className="xx-upgrade-plans">
{(['standard', 'pro', 'enterprise'] as PlanType[]).map((planId) => {
const plan = PLANS_META[planId];
const isCurrent = planId === currentPlan;
return (
<div
key={planId}
className={`xx-upgrade-card ${isCurrent ? 'current' : ''} ${selectedPlan === planId ? 'selected' : ''}`}
onClick={() => setSelectedPlan(planId)}
>
{isCurrent && <div className="xx-current-badge"></div>}
<h3>{plan.name}</h3>
<div className="xx-price">
<Radio.Group
value={billingCycle}
onChange={(e) => setBillingCycle(e.target.value)}
size="small"
>
<Radio.Button value="monthly">
¥{plan.price}/
</Radio.Button>
<Radio.Button value="yearly">
¥{plan.yearlyPrice}/
{plan.yearlyPrice > 0 && plan.price > 0 && (
<span className="xx-save"> ¥{plan.price * 12 - plan.yearlyPrice}</span>
)}
</Radio.Button>
</Radio.Group>
</div>
</div>
);
})}
</div>
<div className="xx-upgrade-actions">
<Button
type="primary"
size="large"
loading={submitting}
onClick={handleUpgrade}
disabled={selectedPlan === currentPlan}
>
</Button>
{subscription && subscription.status === 'active' && (
<div className="xx-auto-renew-section">
<span>{subscription.auto_renew ? '已开启' : '已关闭'}</span>
<Button
type="link"
onClick={() => handleToggleAutoRenew(!subscription.auto_renew)}
>
{subscription.auto_renew ? '关闭' : '开启'}
</Button>
</div>
)}
{subscription && subscription.status === 'active' && currentPlan !== 'free' && (
<Button type="link" danger onClick={handleCancel} className="xx-cancel-btn">
</Button>
)}
</div>
</div>
);
};
export default UpgradeSubscription;
export const Component = UpgradeSubscription;