0fcb77b991
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
236 lines
7.5 KiB
TypeScript
236 lines
7.5 KiB
TypeScript
/**
|
|
* 升级/降级/续费页面
|
|
* P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件
|
|
*/
|
|
import React, { useState, useEffect } from "react"
|
|
import { message } from "antd"
|
|
import { Button, Modal } from "@/components/ui"
|
|
import { useNavigate } from "react-router-dom"
|
|
import {
|
|
getCurrentSubscription,
|
|
changePlan,
|
|
toggleAutoRenew,
|
|
cancelSubscription,
|
|
} from "@/api/subscription"
|
|
import type { SubscriptionInfo, PlanType, BillingCycle } from "@/api/subscription"
|
|
import PageHead from "@/components/layout/PageHead"
|
|
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 BillingCycleSwitch: React.FC<{
|
|
value: BillingCycle
|
|
onChange: (cycle: BillingCycle) => void
|
|
monthlyPrice: number
|
|
yearlyPrice: number
|
|
}> = ({ value, onChange, monthlyPrice, yearlyPrice }) => (
|
|
<div className="xx-billing-cycle-switch">
|
|
<button
|
|
type="button"
|
|
className={`xx-billing-cycle-btn ${value === "monthly" ? "active" : ""}`}
|
|
onClick={() => onChange("monthly")}
|
|
>
|
|
¥{monthlyPrice}/月
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`xx-billing-cycle-btn ${value === "yearly" ? "active" : ""}`}
|
|
onClick={() => onChange("yearly")}
|
|
>
|
|
¥{yearlyPrice}/年
|
|
{yearlyPrice > 0 && monthlyPrice > 0 && (
|
|
<span className="xx-save">省 ¥{monthlyPrice * 12 - yearlyPrice}</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)
|
|
|
|
/** 自定义 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 UpgradeSubscription: React.FC = () => {
|
|
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 (err: unknown) {
|
|
if (!(err as { __msgShown?: boolean })?.__msgShown) 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 (err: unknown) {
|
|
if (!(err as { __msgShown?: boolean })?.__msgShown) 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 (err: unknown) {
|
|
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
|
|
}
|
|
}
|
|
|
|
const handleCancel = () => {
|
|
Modal.confirm({
|
|
title: "确认取消订阅",
|
|
content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
|
|
okText: "确认取消",
|
|
cancelText: "再想想",
|
|
onOk: async () => {
|
|
try {
|
|
const res = await cancelSubscription()
|
|
message.success(res.message)
|
|
navigate("/app/subscription")
|
|
} catch (err: unknown) {
|
|
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("取消失败")
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="xx-upgrade-page">
|
|
<Spinner size="large" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const currentPlan = subscription?.plan_id ?? "free"
|
|
|
|
return (
|
|
<div className="xx-upgrade-page">
|
|
<PageHead
|
|
title="变更订阅方案"
|
|
description={`当前套餐:${PLANS_META[currentPlan]?.name ?? "体验版"}`}
|
|
/>
|
|
|
|
<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">
|
|
<BillingCycleSwitch
|
|
value={billingCycle}
|
|
onChange={setBillingCycle}
|
|
monthlyPrice={plan.price}
|
|
yearlyPrice={plan.yearlyPrice}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div className="xx-upgrade-actions">
|
|
<Button
|
|
buttonType="primary"
|
|
buttonSize="lg"
|
|
disabled={submitting || selectedPlan === currentPlan}
|
|
onClick={handleUpgrade}
|
|
>
|
|
{submitting ? "处理中..." : "确认变更"}
|
|
</Button>
|
|
|
|
{subscription && subscription.status === "active" && (
|
|
<div className="xx-auto-renew-section">
|
|
<span>自动续费:{subscription.auto_renew ? "已开启" : "已关闭"}</span>
|
|
<Button
|
|
buttonType="text"
|
|
buttonSize="sm"
|
|
onClick={() => handleToggleAutoRenew(!subscription.auto_renew)}
|
|
>
|
|
{subscription.auto_renew ? "关闭" : "开启"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{subscription && subscription.status === "active" && currentPlan !== "free" && (
|
|
<Button
|
|
buttonType="danger"
|
|
buttonSize="sm"
|
|
onClick={handleCancel}
|
|
className="xx-cancel-btn"
|
|
>
|
|
取消订阅
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default UpgradeSubscription
|
|
export const Component = UpgradeSubscription
|