chore(points): align types/api with final backend contract - apps/web/src/pages/subscription/Billing.tsx

This commit is contained in:
2026-09-16 10:19:32 +08:00
parent a4e54011ca
commit 5745d51b5a
+173 -124
View File
@@ -1,153 +1,202 @@
/**
* 账单管理页面
* 展示当前订阅信息 + 自动续费开关
* P1-3: antd Switch→自定义ToggleSwitch, antd Spin→自定义Spinner
* 订阅管理 & 账单页
* - 展示当前订阅信息
* - 取消订阅 / 开关自动续费
* - 账单历史
*/
import React, { useState, useEffect } from "react"
import { message } from "antd"
import { getCurrentSubscription, toggleAutoRenew } from "@/api/subscription"
import type { SubscriptionInfo } from "@/api/subscription"
import PageHead from "@/components/layout/PageHead"
import "./Billing.css"
import React, { useEffect, useState } from "react"
import { Card, Button, Tag, Table, Alert, Space, Modal, message, Spin, Descriptions } from "antd"
import { useNavigate } from "react-router-dom"
import type { ColumnsType } from "antd/es/table"
import { usePointsStore } from "@/store/pointsStore"
import { getBillingRecords, cancelSubscription, toggleAutoRenew } from "@/api/subscription"
import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types"
import type { BillingRecord } from "@/api/subscription/types"
import styles from "./Subscription.css"
const formatDate = (iso: string): string => {
const d = new Date(iso)
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
}
const formatYuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`
/** 自定义 ToggleSwitch 组件 */
const ToggleSwitch: React.FC<{
checked: boolean
onChange: (checked: boolean) => void
loading?: boolean
checkedChildren?: string
unCheckedChildren?: string
}> = ({ checked, onChange, loading, checkedChildren, unCheckedChildren }) => (
<button
type="button"
className={`xx-toggle-switch ${checked ? "checked" : ""} ${loading ? "loading" : ""}`}
onClick={() => !loading && onChange(!checked)}
disabled={loading}
role="switch"
aria-checked={checked}
>
<span className="xx-toggle-switch-handle">
{loading && <span className="xx-toggle-switch-spinner" />}
</span>
<span className="xx-toggle-switch-inner">{checked ? checkedChildren : unCheckedChildren}</span>
</button>
)
/** 自定义 Spinner 组件 */
const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => (
<div className={`xx-spinner xx-spinner--${size}`}>
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
</div>
)
const Billing: React.FC = () => {
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
const BillingPage: React.FC = () => {
const navigate = useNavigate()
const { subscription, init, refreshBalance } = usePointsStore()
const [records, setRecords] = useState<BillingRecord[]>([])
const [loading, setLoading] = useState(true)
const [autoRenewChecked, setAutoRenewChecked] = useState(false)
const [autoRenewLoading, setAutoRenewLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
useEffect(() => {
loadData()
}, [])
init()
loadBilling()
}, [init])
const loadData = async () => {
const loadBilling = async () => {
try {
const data = await getCurrentSubscription()
setSubscription(data)
setAutoRenewChecked(data.auto_renew)
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("加载订阅数据失败")
setLoading(true)
const data = await getBillingRecords()
setRecords(data)
} catch {
// 拦截器处理
} finally {
setLoading(false)
}
}
const handleToggleAutoRenew = async (checked: boolean) => {
setAutoRenewLoading(true)
const handleCancel = () => {
Modal.confirm({
title: "确认取消订阅?",
content: "取消后当前计费周期结束时将不再续费,期间仍可使用会员权益。",
okText: "确认取消",
cancelText: "再想想",
okType: "danger",
onOk: async () => {
try {
setActionLoading(true)
const resp = await cancelSubscription()
message.success(resp.message || "已取消订阅")
await refreshBalance()
} catch (err) {
message.error((err as Error).message || "操作失败")
} finally {
setActionLoading(false)
}
},
})
}
const handleToggleAutoRenew = async () => {
if (!subscription) return
const next = !subscription.auto_renew
try {
const res = await toggleAutoRenew(checked)
message.success(res.message)
setAutoRenewChecked(checked)
if (subscription) {
setSubscription({ ...subscription, auto_renew: checked })
}
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
setActionLoading(true)
const resp = await toggleAutoRenew({ enabled: next })
message.success(resp.message)
await refreshBalance()
} catch (err) {
message.error((err as Error).message || "操作失败")
} finally {
setAutoRenewLoading(false)
setActionLoading(false)
}
}
if (loading) {
return (
<div className="xx-billing-page">
<Spinner size="large" />
</div>
)
}
const columns: ColumnsType<BillingRecord> = [
{
title: "时间",
dataIndex: "created_at",
render: (v: string) => new Date(v).toLocaleString("zh-CN"),
},
{
title: "类型",
dataIndex: "order_type",
render: (v: string) => {
const map: Record<string, string> = {
subscribe: "订阅",
renew: "续费",
upgrade: "升级",
refund: "退款",
}
return map[v] || v
},
},
{
title: "套餐",
dataIndex: "plan_id",
render: (v: string) => PLAN_LABEL[v as keyof typeof PLAN_LABEL] || v,
},
{
title: "金额",
dataIndex: "amount_cents",
align: "right",
render: (v: number) => formatYuan(v),
},
{
title: "状态",
dataIndex: "status",
render: (s: string) => {
const map: Record<string, { color: string; label: string }> = {
paid: { color: "green", label: "已支付" },
pending: { color: "orange", label: "待支付" },
failed: { color: "red", label: "失败" },
refunded: { color: "default", label: "已退款" },
}
const cfg = map[s] || { color: "default", label: s }
return <Tag color={cfg.color}>{cfg.label}</Tag>
},
},
]
const isPaid = subscription && subscription.plan_id !== "free"
return (
<div className="xx-billing-page">
<PageHead title="账单管理" description="管理您的订阅和账单信息" />
<div className={styles.subPage}>
<h2>订阅管理</h2>
{subscription && (
{!subscription ? (
<Spin />
) : !isPaid ? (
<Alert
type="info"
showIcon
message="您当前使用免费版"
action={
<Button type="primary" onClick={() => navigate("/subscription")}>
开通会员
</Button>
}
/>
) : (
<>
{/* 当前订阅概览 */}
<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>
{/* 自动续费 */}
<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>
<ToggleSwitch
checked={autoRenewChecked}
onChange={handleToggleAutoRenew}
loading={autoRenewLoading}
checkedChildren="开"
unCheckedChildren="关"
/>
</div>
</div>
<Card title="当前订阅" style={{ marginBottom: 16 }}>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="套餐">{PLAN_LABEL[subscription.plan_id]}</Descriptions.Item>
<Descriptions.Item label="计费周期">
{BILLING_CYCLE_LABEL[subscription.billing_cycle]}
</Descriptions.Item>
<Descriptions.Item label="本期金额">
{formatYuan(subscription.amount)}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={subscription.status === "active" ? "green" : "orange"}>
{subscription.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="开始时间">
{new Date(subscription.current_period_start).toLocaleDateString("zh-CN")}
</Descriptions.Item>
<Descriptions.Item label="到期时间">
{new Date(subscription.current_period_end).toLocaleDateString("zh-CN")}
</Descriptions.Item>
<Descriptions.Item label="自动续费" span={2}>
<Tag color={subscription.auto_renew ? "green" : "default"}>
{subscription.auto_renew ? "已开启" : "已关闭"}
</Tag>
</Descriptions.Item>
</Descriptions>
<Space style={{ marginTop: 16 }}>
<Button onClick={handleToggleAutoRenew} loading={actionLoading}>
{subscription.auto_renew ? "关闭自动续费" : "开启自动续费"}
</Button>
<Button danger onClick={handleCancel} loading={actionLoading}>
取消订阅
</Button>
<Button type="primary" onClick={() => navigate("/subscription")}>
变更套餐
</Button>
</Space>
</Card>
</>
)}
<Card title="账单历史">
<Table
rowKey="id"
columns={columns}
dataSource={records}
loading={loading}
pagination={false}
size="middle"
/>
</Card>
</div>
)
}
export default Billing
export const Component = Billing
export default BillingPage