feat: Phase 2 - 订阅管理前端页面(对接真实API) #84
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 订阅 API 模块
|
||||
* 提供订阅管理相关接口(当前使用 mock 数据,后端就绪后切换)
|
||||
* 对接后端订阅管理接口
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
@@ -66,98 +66,34 @@ export interface ChangePlanResponse {
|
||||
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');
|
||||
const response = await apiClient.get('/subscription/billing-records');
|
||||
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);
|
||||
const response = await apiClient.post('/subscription/change-plan', 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 });
|
||||
const response = await apiClient.post('/subscription/toggle-auto-renew', { enabled });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
/**
|
||||
* 账单管理页面
|
||||
* 展示当前订阅信息 + 自动续费开关
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button, Tag, message, Spin, Empty } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getBillingRecords, getCurrentSubscription } from '@/api/subscription';
|
||||
import type { BillingRecord, SubscriptionInfo } from '@/api/subscription';
|
||||
import { Switch, message, Spin } from 'antd';
|
||||
import { getCurrentSubscription, toggleAutoRenew } from '@/api/subscription';
|
||||
import type { 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' });
|
||||
@@ -26,10 +19,9 @@ const formatAmount = (amount: number): string => {
|
||||
};
|
||||
|
||||
const Billing: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [records, setRecords] = useState<BillingRecord[]>([]);
|
||||
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRenewChecked, setAutoRenewChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -37,25 +29,27 @@ const Billing: React.FC = () => {
|
||||
|
||||
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);
|
||||
const data = await getCurrentSubscription();
|
||||
setSubscription(data);
|
||||
setAutoRenewChecked(data.auto_renew);
|
||||
} catch {
|
||||
message.error('加载账单数据失败');
|
||||
message.error('加载订阅数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadInvoice = (record: BillingRecord) => {
|
||||
if (!record.invoice_url || record.invoice_url === '#') {
|
||||
message.info('发票功能暂未开放');
|
||||
return;
|
||||
const handleToggleAutoRenew = async (checked: boolean) => {
|
||||
try {
|
||||
const res = await toggleAutoRenew(checked);
|
||||
message.success(res.message);
|
||||
setAutoRenewChecked(checked);
|
||||
if (subscription) {
|
||||
setSubscription({ ...subscription, auto_renew: checked });
|
||||
}
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
window.open(record.invoice_url, '_blank');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -68,75 +62,49 @@ const Billing: React.FC = () => {
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
</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 className="xx-billing-auto-renew">
|
||||
<h2>自动续费</h2>
|
||||
<div className="xx-auto-renew-row">
|
||||
<div className="xx-auto-renew-info">
|
||||
<p className="xx-auto-renew-title">到期自动续费</p>
|
||||
<p className="xx-auto-renew-desc">
|
||||
开启后,将在每个计费周期结束时自动扣费续期,避免服务中断。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoRenewChecked}
|
||||
onChange={handleToggleAutoRenew}
|
||||
checkedChildren="开"
|
||||
unCheckedChildren="关"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user