From 11cfa6df12fe74ecdf3e4d68c68146ecc22bba13 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:43:20 +0800 Subject: [PATCH] chore(points): fix lint - apps/web/src/pages/subscription/Plans.tsx --- apps/web/src/pages/subscription/Plans.tsx | 642 ++++++++++++++++------ 1 file changed, 489 insertions(+), 153 deletions(-) diff --git a/apps/web/src/pages/subscription/Plans.tsx b/apps/web/src/pages/subscription/Plans.tsx index 52e10bbfa..430771bc8 100644 --- a/apps/web/src/pages/subscription/Plans.tsx +++ b/apps/web/src/pages/subscription/Plans.tsx @@ -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([]) - const [loading, setLoading] = useState(true) - const [processing, setProcessing] = useState(null) - /** 当前选中的计费周期(免费/月卡/季卡/年卡用 plan_id 直接标识,billingCycle 决定续费周期) */ - const [selectedPlan, setSelectedPlan] = useState("quarterly") + const [packagesResp, setPackagesResp] = useState(null) + const [subscribing, setSubscribing] = useState(false) + const [buying, setBuying] = useState(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: , + 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: , + 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 ( -
- -
- ) + const handleBuyPoints = async (pkg: PointsPackage) => { + try { + setBuying(pkg.code) + const order = await createPointsOrder({ package_id: pkg.code }) + Modal.info({ + title: "支付功能开发中", + icon: , + content: ( +
+ + 订单 {order.id.slice(0, 16)}… 已创建,购买 {pkg.points} 积分,金额{" "} + {formatYuan(order.amount_cents)}, + {order.points_amount !== undefined && `到账 ${order.points_amount} 积分。`} + 微信/支付宝支付正在接入中,正式上线后可直接付款。 + + + 现阶段所有功能均处于免费体验阶段,积分仅为演示数据。 + +
+ ), + 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 ( -
-

会员订阅

- {subscription && subscription.plan_id !== "free" && ( - navigate("/subscription/billing")}> - 管理账单 +
+ + - } - /> - )} - - {/* 免费版权益(用于对比) */} - {freePlan && ( - - - 当前免费版权益: - - 每日 {freePlan.features.free_clips_daily} 次免费生成 ·{" "} - {freePlan.features.max_resolution} 导出 · 非会员积分 ×{1 / freePlan.points_discount}{" "} - 倍率 - - 当前积分:{balance?.balance ?? 0} - - )} + } + /> - {/* 套餐卡片 */} - setSelectedPlan(e.target.value)} - style={{ width: "100%" }} - > -
- {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 ( - setSelectedPlan(plan.plan_id)} - > - {isHot && ( - - 🔥 推荐 - - )} -
- -

{plan.name}

-
-
- ¥ - - {(plan.price_cents / 100) - .toFixed(plan.price_cents % 100 === 0 ? 0 : 1) - .replace(/\.0$/, "")} - - - /{plan.plan_id === "yearly" ? "年" : plan.plan_id === "quarterly" ? "季" : "月"} - -
- {monthly > 0 && plan.price_cents !== monthly && ( -
合 {formatYuan(monthly)}/月
- )} -
- 积分消耗 {(plan.points_discount * 10).toFixed(1)} 折 -
-
-
- 每日{" "} - {plan.features.free_clips_daily} 次免费生成 -
-
- {plan.features.max_resolution}{" "} - 高清导出 -
-
- 全功能可用 -
- {plan.plan_id === "yearly" && ( -
- 4K 超清 -
+ {/* 当前状态卡片 */} + + + + +
+ 当前身份 +
+ {isMember ? ( + } + style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }} + > + {memberPlanId + ? PLAN_LABEL[memberPlanId as keyof typeof PLAN_LABEL] || "付费会员" + : "付费会员"} + + ) : ( + 免费会员 )} -
- 非会员倍率消耗 + {balance?.member_expires_at && isMember && ( + + 到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")} + + )} + {membership?.max_resolution && ( + + · 最高 {membership.max_resolution} + + )} +
+
+
+ 可用积分 +
+ + {bal} +
+
+ {!isMember && freeLimit > 0 && ( +
+ 今日免费混剪 +
+ {freeUsed} + / {freeLimit} 条 + {!isMember && ( + + 剩余 {freeRemain} 条 + + )}
- + + + + + {/* 两档会员对比 */} + + <CrownFilled style={{ color: "#f59e0b", marginRight: 8 }} /> + 选择适合您的方案 + + + {/* 计费周期切换 */} +
+ {billingOptions.map((p) => ( + + ))} +
+ + + {/* 免费会员 */} + + +
+ + 免费会员 + +
+ ¥ + 0 + /永久 +
+ 体验 AI 剪辑的基础能力 +
+ +
    + {FREE_FEATURES.map((f, i) => ( +
  • + {f.include ? ( + + ) : ( + — + )} + {f.text} +
  • + ))} +
+ +
+ + + {/* 付费会员 */} + + +
+ 推荐 +
+
+ + <CrownFilled style={{ color: "#f59e0b" }} /> {selectedPlan?.name || "付费会员"} + +
+ ¥ + + {selectedPlan + ? (selectedPlan.price_cents / 100) + .toFixed(selectedPlan.price_cents % 100 === 0 ? 0 : 1) + .replace(/\.0$/, "") + : "—"} + + {selectedPlan?.billing_label || ""} +
+ + 折合 ¥{selectedPlan?.per_month_yuan}/月 · 解锁全部 AI 能力 + +
+ +
    + {PAID_FEATURES.map((f, i) => ( +
  • + + {f.text} +
  • + ))} +
+ +
+ 虚拟商品不支持退款 · 支付接入中先演示 +
+
+ +
+ + {/* 积分充值 */} +
+ + <ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} /> + 积分充值 + <Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣"> + <Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}> + (永久有效) + </Text> + </Tooltip> + + + + {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 ( + + 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`} + hoverable > - {isCurrent ? "当前方案" : `开通${plan.name}`} - - + {isHot &&
热门
} + {discount > 0 && ( + + {Math.round((priceCents / originalCents) * 10) / 1}折 + + )} +
{pkg.name}
+
+ {pkg.points.toLocaleString()} 积分 +
+
+ ¥ + + {(priceCents / 100) + .toFixed(priceCents % 100 === 0 ? 0 : 1) + .replace(/\.0$/, "")} + + {discount > 0 && ( + ¥{(originalCents / 100).toFixed(0)} + )} +
+
≈¥{unit.toFixed(3)}/积分
+ + + ) })} -
- + +
) } -export default PlansPage +export default Plans +export const Component = Plans