chore(points): align types/api with final backend contract - apps/web/src/pages/subscription/Billing.tsx
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user