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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user