chore(points): fix lint - apps/web/src/pages/subscription/Plans.tsx
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Failing after 4h40m11s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 4h40m14s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 4h39m17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4h39m19s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 4h39m21s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 4h39m20s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 4h39m23s

This commit is contained in:
2026-09-16 10:43:20 +08:00
parent 01132e1d4b
commit 11cfa6df12
+489 -153
View File
@@ -1,196 +1,532 @@
/**
* 订阅计划页(会员升级)
* 基于后端 GET /subscription/plans 渲染 4 档:免费版/月卡/季卡/年卡
* 会员订阅 & 积分充值页
* v3: 对齐后端最终契约(2026-09-16)
* - 订阅计划走 GET /subscription/plans(4 档:free/monthly/quarterly/yearly)
* - 积分包走 GET /points/packages,折后价 = price_cents × (user_discount ?? 1)
* - 当前身份/余额优先从 membership + dailyUsage 取,降级 balance
* - 暂保留 SUBSCRIPTION_PLANS_FALLBACK 常量,API 失败时降级
*/
import React, { useEffect, useMemo, useState } from "react"
import { Card, Button, Tag, Radio, Space, Alert, message, Spin } from "antd"
import {
Button,
Card,
Col,
Row,
Tag,
Divider,
Space,
Typography,
Modal,
message,
Tooltip,
Badge,
} from "antd"
import {
CheckCircleFilled,
CrownFilled,
ThunderboltOutlined,
SafetyCertificateOutlined,
VideoCameraOutlined,
StarFilled,
} from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import { CheckCircleOutlined, CloseCircleOutlined, CrownOutlined } from "@ant-design/icons"
import PageHead from "@/components/layout/PageHead"
import { usePointsStore } from "@/store/pointsStore"
import { createPointsOrder, getPointsPackages } from "@/api/points"
import { getSubscriptionPlans, changePlan } from "@/api/subscription"
import type { PointsPackage, PointsPackagesResponse } from "@/api/points/types"
import { getDiscountPriceCents } from "@/api/points/types"
import type { SubscriptionPlan } from "@/api/subscription/types"
import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types"
import type { SubscriptionPlan, PlanId, BillingCycle } from "@/api/subscription/types"
import styles from "./Subscription.css"
import "./Plans.css"
const { Title, Text, Paragraph } = Typography
/** 免费会员权益(根据后端 features 动态展示,这里保留兜底) */
const FREE_FEATURES = [
{ include: true, text: "每日免费混剪额度" },
{ include: true, text: "720p 导出分辨率" },
{ include: true, text: "AI 配音(×1.15 积分)" },
{ include: true, text: "AI 数字人(×1.15 积分)" },
{ include: true, text: "声音克隆训练免费" },
{ include: false, text: "批量导出" },
{ include: false, text: "多平台一键发布" },
{ include: false, text: "去重检测报告" },
]
/** 付费会员权益(兜底) */
const PAID_FEATURES = [
{ include: true, text: "智能混剪按会员折扣积分" },
{ include: true, text: "最高 1080p 导出" },
{ include: true, text: "全部 AI 功能(会员折扣积分)" },
{ include: true, text: "声音克隆训练免费" },
{ include: true, text: "积分购买最低折扣" },
{ include: true, text: "批量导出" },
{ include: true, text: "多平台一键发布" },
{ include: true, text: "去重检测报告" },
]
/** 旧 SUBSCRIPTION_PLANS 兜底(API 不可用时) */
const SUBSCRIPTION_PLANS_FALLBACK = [
{
id: "monthly" as const,
name: "月卡",
price_cents: 1990,
per_month_yuan: "19.9",
savings_percent: 0,
recommended: false,
billing_label: "/月",
billing_cycle: "monthly" as const,
},
{
id: "quarterly" as const,
name: "季卡",
price_cents: 3990,
per_month_yuan: "13.3",
savings_percent: 33,
recommended: true,
billing_label: "/季",
billing_cycle: "monthly" as const,
},
{
id: "yearly" as const,
name: "年卡",
price_cents: 15900,
per_month_yuan: "13.25",
savings_percent: 34,
recommended: false,
billing_label: "/年",
billing_cycle: "yearly" as const,
},
]
const formatYuan = (cents: number) =>
`¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}`
const PlansPage: React.FC = () => {
const Plans: React.FC = () => {
const navigate = useNavigate()
const { balance, subscription, init } = usePointsStore()
const { balance, dailyUsage, membership, subscription, init } = usePointsStore()
const [plans, setPlans] = useState<SubscriptionPlan[]>([])
const [loading, setLoading] = useState(true)
const [processing, setProcessing] = useState<string | null>(null)
/** 当前选中的计费周期(免费/月卡/季卡/年卡用 plan_id 直接标识,billingCycle 决定续费周期) */
const [selectedPlan, setSelectedPlan] = useState<PlanId>("quarterly")
const [packagesResp, setPackagesResp] = useState<PointsPackagesResponse | null>(null)
const [subscribing, setSubscribing] = useState(false)
const [buying, setBuying] = useState<string | null>(null)
const [selectedBilling, setSelectedBilling] = useState<"monthly" | "quarterly" | "yearly">(
"quarterly",
)
useEffect(() => {
init()
// 拉取订阅计划
getSubscriptionPlans()
.then((r) => {
setPlans(r.plans)
// 默认选中季卡(如果存在)
if (r.plans.find((p) => p.plan_id === "quarterly")) setSelectedPlan("quarterly")
else if (r.plans.length > 1) setSelectedPlan(r.plans[1].plan_id)
const paid = r.plans.filter((p) => p.plan_id !== "free")
setPlans(paid)
// 默认选季卡,没有就选第一个
const hasQuarterly = paid.some((p) => p.plan_id === "quarterly")
if (!hasQuarterly && paid.length > 0) setSelectedBilling(paid[0].plan_id as "monthly")
})
.catch(() => message.error("加载订阅计划失败"))
.finally(() => setLoading(false))
.catch(() => {
// 降级
})
getPointsPackages()
.then((r) => setPackagesResp(r))
.catch(() => {})
}, [init])
const currentPlanId = subscription?.plan_id ?? "free"
const packages = packagesResp?.packages ?? []
const userDiscount = packagesResp?.user_discount ?? null
const handleSubscribe = async (plan: SubscriptionPlan) => {
if (plan.plan_id === "free") {
message.info("您正在使用免费版")
return
const isMember = membership?.is_member ?? balance?.is_member ?? false
// 当前会员档位:优先 membership.member_type,降级 subscription.plan_id
const memberPlanId =
membership?.member_type ??
(subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null)
const bal = membership?.points_balance ?? balance?.balance ?? 0
const freeUsed = dailyUsage?.free_clips_used ?? 0
const freeLimit = dailyUsage?.free_clips_limit ?? (isMember ? 0 : 3)
const freeRemain = dailyUsage?.free_clips_remaining ?? (isMember ? 0 : freeLimit - freeUsed)
/** 统一的可选付费档位(API 返回 + 兜底) */
const billingOptions = useMemo(() => {
if (plans.length > 0) {
return plans.map((p) => {
const id = p.plan_id as "monthly" | "quarterly" | "yearly"
const perMonth =
p.duration_days > 0
? (p.price_cents / 100 / (p.duration_days / 30)).toFixed(1)
: (p.monthly_price_cents / 100).toFixed(1)
const monthlyCents = p.monthly_price_cents || p.price_cents
const savings =
p.price_cents > 0 && monthlyCents > 0
? Math.max(
0,
Math.round((1 - p.price_cents / (monthlyCents * (p.duration_days / 30))) * 100),
)
: 0
return {
id,
name: p.name,
price_cents: p.price_cents,
per_month_yuan: perMonth,
savings_percent: savings,
recommended: id === "quarterly",
billing_label: id === "yearly" ? "/年" : id === "quarterly" ? "/季" : "/月",
billing_cycle: (id === "yearly" ? "yearly" : "monthly") as "monthly" | "yearly",
}
})
}
return SUBSCRIPTION_PLANS_FALLBACK
}, [plans])
const selectedPlan = useMemo(
() => billingOptions.find((p) => p.id === selectedBilling) ?? billingOptions[0],
[billingOptions, selectedBilling],
)
const handleSubscribe = async () => {
if (!selectedPlan) return
try {
setProcessing(plan.plan_id)
// 月卡/季卡 → monthly 周期;年卡 → yearly 周期
const billingCycle: BillingCycle = plan.plan_id === "yearly" ? "yearly" : "monthly"
const resp = await changePlan({ target_plan_id: plan.plan_id, billing_cycle: billingCycle })
if (resp.success) {
message.success(`${plan.name}订阅成功!(支付通道待接入,mock 模式)`)
setTimeout(() => navigate("/subscription/billing"), 1200)
} else {
message.error(resp.message || "订阅失败")
}
} catch (err) {
message.error((err as Error).message || "订阅失败")
setSubscribing(true)
await changePlan({
target_plan_id: selectedPlan.id,
billing_cycle: selectedPlan.billing_cycle,
})
Modal.success({
title: "订阅已提交",
icon: <SafetyCertificateOutlined />,
content: `已为您切换到 ${selectedPlan.name},${BILLING_CYCLE_LABEL[selectedPlan.billing_cycle]} ${formatYuan(selectedPlan.price_cents)}。支付通道接入中,正式上线后会自动扣费。`,
okText: "知道了",
})
} catch (e) {
const err = e as { response?: { data?: { error?: { message?: string } } }; message?: string }
// 支付未接入阶段,保持演示体验
Modal.confirm({
title: "支付功能开发中",
icon: <SafetyCertificateOutlined />,
content:
err?.response?.data?.error?.message ||
"微信/支付宝支付正在接入中,完成后会第一时间通知。是否返回首页继续使用免费功能?",
okText: "返回首页",
cancelText: "留在此页",
onOk: () => navigate("/app/dashboard"),
})
} finally {
setProcessing(null)
setSubscribing(false)
}
}
const planCards = useMemo(() => plans.filter((p) => p.plan_id !== "free"), [plans])
const freePlan = useMemo(() => plans.find((p) => p.plan_id === "free"), [plans])
if (loading)
return (
<div style={{ textAlign: "center", padding: 80 }}>
<Spin size="large" />
</div>
)
const handleBuyPoints = async (pkg: PointsPackage) => {
try {
setBuying(pkg.code)
const order = await createPointsOrder({ package_id: pkg.code })
Modal.info({
title: "支付功能开发中",
icon: <ThunderboltOutlined />,
content: (
<div>
<Paragraph>
订单 <Text code>{order.id.slice(0, 16)}…</Text> 已创建,购买 {pkg.points} 积分,金额{" "}
<b>{formatYuan(order.amount_cents)}</b>,
{order.points_amount !== undefined && `到账 ${order.points_amount} 积分。`}
微信/支付宝支付正在接入中,正式上线后可直接付款。
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
现阶段所有功能均处于免费体验阶段,积分仅为演示数据。
</Paragraph>
</div>
),
okText: "知道了",
})
} catch (e) {
const err = e as { response?: { data?: { error?: { message?: string } } }; message?: string }
message.error(err?.response?.data?.error?.message || err?.message || "创建订单失败")
} finally {
setBuying(null)
}
}
return (
<div className={styles.subPage}>
<h2>会员订阅</h2>
{subscription && subscription.plan_id !== "free" && (
<Alert
type="success"
showIcon
message={`当前订阅:${PLAN_LABEL[subscription.plan_id]}(${BILLING_CYCLE_LABEL[subscription.billing_cycle]})`}
description={`到期时间:${new Date(subscription.current_period_end).toLocaleDateString("zh-CN")}${subscription.auto_renew ? " · 已开启自动续费" : ""}`}
style={{ marginBottom: 24 }}
action={
<Button size="small" onClick={() => navigate("/subscription/billing")}>
管理账单
<div className="xx-plans-page">
<PageHead
title="会员与积分"
description="开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
actions={
<Space>
<Button
icon={<ThunderboltOutlined />}
onClick={() => navigate("/app/points/transactions")}
>
积分明细
</Button>
}
/>
)}
{/* 免费版权益(用于对比) */}
{freePlan && (
<Card size="small" style={{ marginBottom: 16, background: "#fafafa" }}>
<Space direction="vertical" size={4}>
<b>当前免费版权益:</b>
<span>
每日 {freePlan.features.free_clips_daily} 次免费生成 ·{" "}
{freePlan.features.max_resolution} 导出 · 非会员积分 ×{1 / freePlan.points_discount}{" "}
倍率
</span>
<span>当前积分:{balance?.balance ?? 0}</span>
</Space>
</Card>
)}
}
/>
{/* 套餐卡片 */}
<Radio.Group
value={selectedPlan}
onChange={(e) => setSelectedPlan(e.target.value)}
style={{ width: "100%" }}
>
<div className={styles.planGrid}>
{planCards.map((plan) => {
const isCurrent = plan.plan_id === currentPlanId
const isSelected = selectedPlan === plan.plan_id
const isHot = plan.plan_id === "quarterly"
const monthly = plan.monthly_price_cents
return (
<Card
key={plan.plan_id}
hoverable
className={`${styles.planCard} ${isSelected ? styles.selectedPlan : ""}`}
onClick={() => setSelectedPlan(plan.plan_id)}
>
{isHot && (
<Tag color="volcano" className={styles.hotTag}>
🔥 推荐
</Tag>
)}
<div className={styles.planHeader}>
<CrownOutlined className={styles.planIcon} />
<h3>{plan.name}</h3>
</div>
<div className={styles.planPrice}>
<span className={styles.priceSymbol}>¥</span>
<span className={styles.priceNum}>
{(plan.price_cents / 100)
.toFixed(plan.price_cents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")}
</span>
<span className={styles.priceUnit}>
/{plan.plan_id === "yearly" ? "年" : plan.plan_id === "quarterly" ? "季" : "月"}
</span>
</div>
{monthly > 0 && plan.price_cents !== monthly && (
<div className={styles.monthlyPrice}>合 {formatYuan(monthly)}/月</div>
)}
<div className={styles.planDiscount}>
积分消耗 <b>{(plan.points_discount * 10).toFixed(1)} 折</b>
</div>
<div className={styles.featureList}>
<div className={styles.featureItem}>
<CheckCircleOutlined className={styles.check} /> 每日{" "}
{plan.features.free_clips_daily} 次免费生成
</div>
<div className={styles.featureItem}>
<CheckCircleOutlined className={styles.check} /> {plan.features.max_resolution}{" "}
高清导出
</div>
<div className={styles.featureItem}>
<CheckCircleOutlined className={styles.check} /> 全功能可用
</div>
{plan.plan_id === "yearly" && (
<div className={styles.featureItem}>
<CheckCircleOutlined className={styles.check} /> 4K 超清
</div>
{/* 当前状态卡片 */}
<Card className="xx-current-status" bordered={false}>
<Row align="middle" gutter={24}>
<Col flex="auto">
<Space size="large" wrap>
<div>
<Text type="secondary">当前身份</Text>
<div>
{isMember ? (
<Tag
color="gold"
icon={<CrownFilled />}
style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}
>
{memberPlanId
? PLAN_LABEL[memberPlanId as keyof typeof PLAN_LABEL] || "付费会员"
: "付费会员"}
</Tag>
) : (
<Tag style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}>免费会员</Tag>
)}
<div className={styles.featureItem} style={{ color: "#999" }}>
<CloseCircleOutlined className={styles.cross} /> 非会员倍率消耗
{balance?.member_expires_at && isMember && (
<Text type="secondary" style={{ marginLeft: 8 }}>
到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</Text>
)}
{membership?.max_resolution && (
<Text type="secondary" style={{ marginLeft: 8 }}>
· 最高 {membership.max_resolution}
</Text>
)}
</div>
</div>
<div>
<Text type="secondary">可用积分</Text>
<div className="xx-current-balance">
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
<span className="xx-current-balance-val">{bal}</span>
</div>
</div>
{!isMember && freeLimit > 0 && (
<div>
<Text type="secondary">今日免费混剪</Text>
<div>
<Text strong>{freeUsed}</Text>
<Text type="secondary"> / {freeLimit} 条</Text>
{!isMember && (
<Tag color="blue" style={{ marginLeft: 8 }}>
剩余 {freeRemain} 条
</Tag>
)}
</div>
</div>
<Button
type="primary"
block
size="large"
disabled={isCurrent}
loading={processing === plan.plan_id}
onClick={(e) => {
e.stopPropagation()
handleSubscribe(plan)
}}
)}
</Space>
</Col>
<Col>
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={() => {
const el = document.getElementById("points-packages")
el?.scrollIntoView({ behavior: "smooth" })
}}
>
充值积分
</Button>
</Col>
</Row>
</Card>
{/* 两档会员对比 */}
<Title level={4} style={{ marginTop: 24 }}>
<CrownFilled style={{ color: "#f59e0b", marginRight: 8 }} />
选择适合您的方案
</Title>
{/* 计费周期切换 */}
<div className="xx-billing-switch">
{billingOptions.map((p) => (
<button
key={p.id}
type="button"
className={`xx-billing-btn ${selectedBilling === p.id ? "active" : ""}`}
onClick={() => setSelectedBilling(p.id)}
>
{p.recommended && (
<Badge.Ribbon text="推荐" color="gold" style={{ top: -14, right: -22 }} />
)}
<div className="xx-billing-name">{p.name}</div>
<div className="xx-billing-price">
<span className="xx-billing-yuan">¥</span>
<span className="xx-billing-amount">
{(p.price_cents / 100)
.toFixed(p.price_cents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")}
</span>
</div>
<div className="xx-billing-sub">
≈¥{p.per_month_yuan}/月
{p.savings_percent ? (
<span className="xx-billing-save">省{p.savings_percent}%</span>
) : null}
</div>
</button>
))}
</div>
<Row gutter={[20, 20]} style={{ marginTop: 16 }}>
{/* 免费会员 */}
<Col xs={24} md={12}>
<Card className="xx-plan-card" bordered>
<div className="xx-plan-head">
<Title level={4} style={{ margin: 0 }}>
免费会员
</Title>
<div className="xx-plan-price">
<span className="currency">¥</span>
<span className="amount">0</span>
<span className="period">/永久</span>
</div>
<Text type="secondary">体验 AI 剪辑的基础能力</Text>
</div>
<Divider style={{ margin: "16px 0" }} />
<ul className="xx-feature-list">
{FREE_FEATURES.map((f, i) => (
<li key={i} className={f.include ? "" : "disabled"}>
{f.include ? (
<CheckCircleFilled style={{ color: "#10b981" }} />
) : (
<span className="xx-feature-no">—</span>
)}
<span>{f.text}</span>
</li>
))}
</ul>
<Button block size="large" disabled={!isMember ? false : true}>
{!isMember ? "当前方案" : "免费方案"}
</Button>
</Card>
</Col>
{/* 付费会员 */}
<Col xs={24} md={12}>
<Card className="xx-plan-card featured" bordered>
<div className="xx-plan-recommend-badge">
<StarFilled /> 推荐
</div>
<div className="xx-plan-head">
<Title level={4} style={{ margin: 0, color: "#7c3aed" }}>
<CrownFilled style={{ color: "#f59e0b" }} /> {selectedPlan?.name || "付费会员"}
</Title>
<div className="xx-plan-price">
<span className="currency">¥</span>
<span className="amount">
{selectedPlan
? (selectedPlan.price_cents / 100)
.toFixed(selectedPlan.price_cents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")
: "—"}
</span>
<span className="period">{selectedPlan?.billing_label || ""}</span>
</div>
<Text type="secondary">
折合 ¥{selectedPlan?.per_month_yuan}/月 · 解锁全部 AI 能力
</Text>
</div>
<Divider style={{ margin: "16px 0" }} />
<ul className="xx-feature-list">
{PAID_FEATURES.map((f, i) => (
<li key={i}>
<CheckCircleFilled style={{ color: "#7c3aed" }} />
<span>{f.text}</span>
</li>
))}
</ul>
<Button
block
size="large"
type="primary"
loading={subscribing}
disabled={isMember && memberPlanId === selectedBilling}
onClick={handleSubscribe}
icon={<ThunderboltOutlined />}
>
{isMember && memberPlanId === selectedBilling
? "当前方案"
: isMember
? "续费/升级"
: "立即开通"}
</Button>
<div className="xx-plan-tip">
<VideoCameraOutlined /> 虚拟商品不支持退款 · 支付接入中先演示
</div>
</Card>
</Col>
</Row>
{/* 积分充值 */}
<div id="points-packages">
<Title level={4} style={{ marginTop: 40 }}>
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
积分充值
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
(永久有效)
</Text>
</Tooltip>
</Title>
<Row gutter={[16, 16]}>
{packages.map((pkg) => {
const priceCents = getDiscountPriceCents(pkg, userDiscount)
const originalCents = pkg.price_cents
const discount =
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
const unit = priceCents / 100 / pkg.points
const isHot = pkg.unit_price < 0.1
return (
<Col xs={24} sm={8} key={pkg.code}>
<Card
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`}
hoverable
>
{isCurrent ? "当前方案" : `开通${plan.name}`}
</Button>
</Card>
{isHot && <div className="xx-pkg-badge">热门</div>}
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
{Math.round((priceCents / originalCents) * 10) / 1}折
</Tag>
)}
<div className="xx-pkg-name">{pkg.name}</div>
<div className="xx-pkg-points">
<ThunderboltOutlined /> {pkg.points.toLocaleString()} 积分
</div>
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(priceCents / 100)
.toFixed(priceCents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")}
</span>
{discount > 0 && (
<span className="xx-pkg-origin">¥{(originalCents / 100).toFixed(0)}</span>
)}
</div>
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分</div>
<Button
block
type={isHot ? "primary" : "default"}
loading={buying === pkg.code}
onClick={() => handleBuyPoints(pkg)}
style={{ marginTop: 12 }}
>
立即购买
</Button>
</Card>
</Col>
)
})}
</div>
</Radio.Group>
</Row>
</div>
</div>
)
}
export default PlansPage
export default Plans
export const Component = Plans