8637ed1576
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 47s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 47s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 51s
CI/CD Pipeline / Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m15s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m40s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m31s
CI/CD Pipeline / Validate - Style (push) Successful in 4m40s
CI/CD Pipeline / Validate - Security (push) Successful in 10m6s
CI/CD Pipeline / Unit Tests (push) Successful in 11m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 39m49s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 44m37s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
187 lines
6.0 KiB
TypeScript
187 lines
6.0 KiB
TypeScript
/**
|
||
* 升级/降级/续费页面(#1894 清理后:价格统一走 /subscription/plans API)
|
||
*/
|
||
import React, { useEffect, useMemo, useState } from "react"
|
||
import { Modal } from "@/components/ui"
|
||
import { useNavigate } from "react-router-dom"
|
||
import type { PlanId, SubscriptionPlan, BillingCycle } from "@/api/subscription/types"
|
||
import PageHead from "@/components/layout/PageHead"
|
||
import { Button } from "@/components/ui"
|
||
import { getPlanName } from "./constants"
|
||
import { getSubscriptionPlans } from "@/api/subscription"
|
||
import { BillingCycleSwitch, Spinner } from "./components/SubscriptionUI"
|
||
import { useSubscription } from "./hooks/useSubscription"
|
||
import "./UpgradeSubscription.css"
|
||
|
||
/** 可选择的付费档位(不含 free) */
|
||
const PAID_PLANS: PlanId[] = ["monthly", "quarterly", "yearly"]
|
||
|
||
const UpgradeSubscription: React.FC = () => {
|
||
const navigate = useNavigate()
|
||
const {
|
||
subscription,
|
||
loading,
|
||
submitting,
|
||
selectedPlan,
|
||
billingCycle,
|
||
setSelectedPlan,
|
||
setBillingCycle,
|
||
executeChangePlan,
|
||
handleToggleAutoRenew,
|
||
handleCancel,
|
||
} = useSubscription()
|
||
|
||
// #1894: 从 /subscription/plans 拉真实价格,不使用任何硬编码价格
|
||
const [apiPlans, setApiPlans] = useState<SubscriptionPlan[]>([])
|
||
useEffect(() => {
|
||
getSubscriptionPlans()
|
||
.then((r) => setApiPlans(r.plans))
|
||
.catch(() => setApiPlans([]))
|
||
}, [])
|
||
|
||
const planMap = useMemo(() => {
|
||
const m = new Map<string, SubscriptionPlan>()
|
||
apiPlans.forEach((p) => m.set(p.plan_id, p))
|
||
return m
|
||
}, [apiPlans])
|
||
|
||
const getPlanDisplay = (planId: PlanId) => {
|
||
const apiPlan = planMap.get(planId)
|
||
const name = apiPlan?.name ?? getPlanName(planId)
|
||
// 年卡按年价,月/季卡按总价
|
||
const isYearly = planId === "yearly"
|
||
const priceCents = apiPlan?.price_cents ?? 0
|
||
const monthlyCents =
|
||
apiPlan?.monthly_price_cents ??
|
||
(apiPlan && apiPlan.duration_days > 0
|
||
? Math.round(apiPlan.price_cents / (apiPlan.duration_days / 30))
|
||
: 0)
|
||
return {
|
||
name,
|
||
priceYuan: priceCents / 100,
|
||
monthlyYuan: monthlyCents / 100,
|
||
billingLabel: isYearly ? "/年" : planId === "quarterly" ? "/季" : "/月",
|
||
}
|
||
}
|
||
|
||
const handleUpgradeClick = () => {
|
||
if (!subscription) return
|
||
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
|
||
return
|
||
}
|
||
|
||
const display = getPlanDisplay(selectedPlan)
|
||
const cycleLabel = billingCycle === "monthly" ? "月付" : "年付"
|
||
const priceLabel =
|
||
display.priceYuan > 0 ? `费用 ¥${display.priceYuan}${display.billingLabel}` : "免费"
|
||
|
||
Modal.confirm({
|
||
title: "确认变更套餐",
|
||
content: `即将变更为「${display.name}」(${cycleLabel}),${priceLabel}。变更立即生效。`,
|
||
okText: "确认变更",
|
||
cancelText: "取消",
|
||
onOk: executeChangePlan,
|
||
})
|
||
}
|
||
|
||
const handleCancelClick = () => {
|
||
Modal.confirm({
|
||
title: "确认取消订阅",
|
||
content: "取消后,当前周期结束前仍可正常使用,到期后降级为免费版。",
|
||
okText: "确认取消",
|
||
cancelText: "再想想",
|
||
onOk: async () => {
|
||
const ok = await handleCancel()
|
||
if (ok) navigate("/subscription")
|
||
},
|
||
})
|
||
}
|
||
|
||
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={`当前套餐:${getPlanName(currentPlan)}`} />
|
||
|
||
<div className="xx-upgrade-plans">
|
||
{PAID_PLANS.map((planId) => {
|
||
const display = getPlanDisplay(planId)
|
||
const isCurrent = planId === currentPlan
|
||
// 年卡显示年价,其他显示月价折算
|
||
const monthlyPrice = display.monthlyYuan
|
||
const yearlyPrice = display.priceYuan
|
||
// 选中季卡时默认切到月付周期;年卡切到年付
|
||
const resolvedCycle: BillingCycle = planId === "yearly" ? "yearly" : "monthly"
|
||
return (
|
||
<div
|
||
key={planId}
|
||
className={`xx-upgrade-card ${isCurrent ? "current" : ""} ${selectedPlan === planId ? "selected" : ""}`}
|
||
onClick={() => {
|
||
setSelectedPlan(planId)
|
||
setBillingCycle(resolvedCycle)
|
||
}}
|
||
>
|
||
{isCurrent && <div className="xx-current-badge">当前</div>}
|
||
<h3>{display.name}</h3>
|
||
<div className="xx-price">
|
||
<BillingCycleSwitch
|
||
value={billingCycle}
|
||
onChange={setBillingCycle}
|
||
monthlyPrice={monthlyPrice}
|
||
yearlyPrice={yearlyPrice}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="xx-upgrade-actions">
|
||
<Button
|
||
buttonType="primary"
|
||
buttonSize="lg"
|
||
disabled={submitting || selectedPlan === currentPlan}
|
||
onClick={handleUpgradeClick}
|
||
>
|
||
{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={handleCancelClick}
|
||
className="xx-cancel-btn"
|
||
>
|
||
取消订阅
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default UpgradeSubscription
|
||
export const Component = UpgradeSubscription
|