From 1d960a911152e84057b3dac3f8b79152a46f6c32 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:19:09 +0800 Subject: [PATCH 01/36] chore(points): align types/api with final backend contract - apps/web/src/api/points/types.ts --- apps/web/src/api/points/types.ts | 340 ++++++++++++++++--------------- 1 file changed, 175 insertions(+), 165 deletions(-) diff --git a/apps/web/src/api/points/types.ts b/apps/web/src/api/points/types.ts index 577c26ef1..db0208a59 100644 --- a/apps/web/src/api/points/types.ts +++ b/apps/web/src/api/points/types.ts @@ -1,76 +1,134 @@ /** - * 积分 & 会员系统 API 类型定义(v2 两档会员制) - * 接口契约对齐后端设计文档 membership-points-design-v2.md + * 积分系统类型定义 + * 对齐后端 staging 实测最终契约(2026-09-16) + * + * Base path: /api/v1/ + * 会员/订阅相关类型请从 @/api/subscription/types 引入,本文件仅保留积分核心类型。 */ -/** 会员类型 */ -export type MemberType = "free" | "monthly" | "quarterly" | "yearly" - -/** 积分流水类型 */ -export type PointsTxType = "earn" | "spend" | "refund" - -/** 积分来源/消耗场景 */ +/* ================================================================ + * 场景键 + * ================================================================ */ +/** + * 积分消耗场景键(9 个) + * - ai_script 已拆分为 douyin_extract / ai_rewrite / ai_title,前端禁止再传 ai_script + */ export type PointsSource = - | "recharge" // 充值 - | "task_reward" // 任务奖励 | "ai_voice" // AI 配音 + | "ai_video" // AI 视频生成 | "ai_digital_human" // AI 数字人 - | "ai_video" // 智能混剪 | "voice_clone_train" // 声音克隆训练 | "voice_clone_synth" // 声音克隆合成 - | "douyin_extract" // 抖音链接提取 - | "ai_rewrite" // AI 改写文案 + | "douyin_extract" // 抖音提取文案 + | "ai_rewrite" // AI 文案改写 | "ai_title" // AI 标题生成 | "ai_cover" // AI 封面生成 - | "subscription_bonus" // 会员赠送 - | "admin_adjust" // 管理员调整 - | "refund" // 失败退还 -/** 会员 & 积分余额响应 */ +/** 非消耗场景 source 前缀(用于流水 source 字段) */ +export type PointsSourceExtra = + PointsSource | `refund:${string}` | "recharge" | "sign_up" | "bind_phone" | "gift" | "admin" + +/* ================================================================ + * 通用 + * ================================================================ */ +/** ISO 8601 时间字符串 */ +export type ISODate = string + +/* ================================================================ + * 积分余额(GET /points/balance) + * ================================================================ */ export interface PointsBalance { /** 当前可用积分 */ balance: number - /** 累计获得 */ + /** 累计获得积分 */ total_earned: number - /** 累计消耗 */ + /** 累计消耗积分 */ total_spent: number - /** 是否付费会员(free 用户为 false) */ + /** 是否为付费会员 */ is_member: boolean - /** 会员类型:monthly / quarterly / yearly;free 用户为 null */ - member_type: Extract | null - /** 会员到期时间 ISO 字符串 */ - member_expires_at: string | null - /** 今日免费混剪已用次数 */ - free_clips_used?: number - /** 今日免费混剪额度上限 */ - free_clips_limit?: number - /** 今日免费混剪剩余 */ - free_clips_remaining?: number + /** 会员类型(monthly/quarterly/yearly,非会员 null)。推荐使用 /subscription/current 的 plan_id+billing_cycle 做判断 */ + member_type: "monthly" | "quarterly" | "yearly" | null + /** 会员到期时间 */ + member_expires_at: ISODate | null } -/** 积分流水记录 */ +/* ================================================================ + * 积分规则(GET /points/rules) + * ================================================================ */ +export interface PointsRule { + scene_key: PointsSource + /** 场景中文名 */ + name: string + /** 基准消耗积分(points_per_use 改名) */ + base_points: number + /** 单位描述,如「次」「分钟」「个」 */ + unit: string + /** 超过30秒后每30秒额外积分(视频/语音类) */ + extra_per_30s?: number + /** 场景说明(后端已补回) */ + description?: string +} + +export interface PointsRulesResponse { + rules: PointsRule[] + /** 非会员消耗倍率(如 1.15) */ + free_user_multiplier: number +} + +/* ================================================================ + * 充值包(GET /points/packages) + * ================================================================ */ +export interface PointsPackage { + /** 包编码(id 改名) */ + code: string + name: string + points: number + /** 原价,单位分 */ + price_cents: number + /** 每积分单价(元),展示用 */ + unit_price: number +} + +export interface PointsPackagesResponse { + packages: PointsPackage[] + /** 当前用户折扣(会员折扣或活动折扣),null 表示无折扣 */ + user_discount: number | null +} + +/** + * 充值包前端展示辅助:折后价(分) + * 后端废弃 4 档 discounted_price_for_*,前端按 price_cents * (user_discount ?? 1) 计算。 + */ +export function getDiscountPriceCents(pkg: PointsPackage, userDiscount: number | null): number { + return Math.round(pkg.price_cents * (userDiscount ?? 1)) +} + +/* ================================================================ + * 积分流水(GET /points/transactions) + * ================================================================ */ +export type PointsTxType = "add" | "deduct" + export interface PointsTransaction { - id: string - /** earn / spend / refund */ + id: number + /** 流水类型:add=获得/退款,deduct=消耗 */ type: PointsTxType - /** 来源场景 */ - source: PointsSource - /** 场景中文名称 */ - source_name: string - /** 变动数量(正数) */ + /** + * 消耗/获得来源: + * - 消耗场景直接用 PointsSource 值 + * - 充值/退款/赠送使用 recharge / refund: / sign_up / bind_phone / gift / admin + */ + source: string + /** 变动数量(绝对值,正负由 type 决定) */ amount: number - /** 带符号的变动数(收入+,支出-) */ - signed_amount: number /** 变动后余额 */ balance_after: number - /** 备注描述 */ + /** 中文描述 */ description: string - /** 关联业务 ID */ - ref_id: string | null - created_at: string + /** 关联订单/任务 ID,空字符串 "" 表示无关联(不是 null) */ + ref_id: string + created_at: ISODate } -/** 积分流水分页响应 */ export interface PointsTransactionsResponse { items: PointsTransaction[] total: number @@ -78,140 +136,92 @@ export interface PointsTransactionsResponse { page_size: number } -/** 积分流水查询参数 */ -export interface PointsTransactionsParams { - page?: number - page_size?: number - type?: PointsTxType - source?: PointsSource - start_date?: string - end_date?: string -} - -/** 积分包 */ -export interface PointsPackage { - id: "starter_pack" | "basic_pack" | "pro_pack" | string - /** 中文名称 */ - name: string - /** 积分数量 */ - points: number - /** 原价(分) */ - price: number - /** 各会员类型折扣价(分) */ - discounted_price_for_free: number - discounted_price_for_monthly: number - discounted_price_for_quarterly: number - discounted_price_for_yearly: number -} - -/** 积分包列表响应 */ -export interface PointsPackagesResponse { - packages: PointsPackage[] - /** 当前用户会员类型,用于前端计算折后价 */ - user_member_type: MemberType - /** 积分单价(元/积分,按会员价计) */ - unit_price_yuan: number -} - -/** 创建充值订单请求 */ -export interface PointsRechargeRequest { +/* ================================================================ + * 创建充值订单(POST /points/recharge) + * ================================================================ */ +export interface CreateRechargeOrderRequest { + /** 充值包 code(字段名保留 package_id 与后端一致) */ package_id: string - payment_method?: "wechat_pay" | "alipay" } -/** 订单状态 */ -export type OrderStatus = "pending" | "paid" | "failed" | "refunded" | "expired" - -/** 充值订单响应 */ -export interface PointsOrder { +export interface CreateRechargeOrderResponse { id: string - package_id: string - package_name: string + order_type: string + product_code: string + /** 订单金额(分) */ + amount_cents: number + /** 充值积分数量 */ points_amount: number - price_cents: number - original_price_cents: number - discount: number - currency: "CNY" - status: OrderStatus - payment_method: string | null - payment_id: string | null - paid_at: string | null - expire_at: string | null - created_at: string - /** 微信/支付宝支付参数(mock 阶段前端自行处理) */ - pay_params?: Record + status: string + /** + * 支付参数(支付通道未接入时返回空对象 {},前端可透传) + */ + pay_params: Record + /** 订单过期时间 */ + expire_at: ISODate + created_at: ISODate } -/** 订阅套餐(月/季/年) */ -export interface SubscriptionPlan { - id: "monthly" | "quarterly" | "yearly" - name: string - price_cents: number - price_yuan: number - per_month_yuan: number - savings_percent?: number - recommended?: boolean - billing_label: string -} - -/** 当前订阅详情 */ -export interface SubscriptionCurrent { - is_member: boolean - member_type: Extract | null - member_type_name: string - status: "active" | "expired" | "cancelled" | "none" - current_period_start: string | null - current_period_end: string | null - auto_renew: boolean - /** 订阅会员对应的积分折扣 */ - points_discount: number -} - -/** 开通/续费订阅请求 */ -export interface SubscribeRequest { - member_type: "monthly" | "quarterly" | "yearly" - payment_method?: "wechat_pay" | "alipay" -} - -/** 积分消耗规则 */ -export interface PointsRule { - scene_key: PointsSource - scene_name: string - /** 每次消耗基础积分(会员价) */ - points_per_use: number - /** 计量单位:条/分钟/次/张 */ - unit: string - /** 额外每 30s 加积分(ai_video 用) */ - extra_per_30s?: number - /** 说明文案 */ - description?: string -} - -export interface PointsRulesResponse { - rules: PointsRule[] - /** 免费用户消耗倍率 */ - free_user_multiplier: number - note: string -} - -/** 消费前余额检查请求 */ +/* ================================================================ + * 积分预检查(POST /points/check) + * ================================================================ */ export interface PointsCheckRequest { scene_key: PointsSource - /** 单位数量(时长/条数),默认 1 */ - units?: number + /** 数量(units 改名) */ + quantity: number + /** 预计时长(分钟),可选 */ + duration_minutes?: number } -/** 消费前余额检查响应 */ export interface PointsCheckResponse { + /** 是否可以执行 */ allowed: boolean + /** 需要消耗积分 */ required_points: number + /** 当前余额 */ current_balance: number + /** 扣除后剩余 */ remaining_after: number - /** 是否走免费额度(混剪场景) */ + /** 是否走免费额度 */ is_free_quota: boolean - /** 拒绝原因代码 */ - code?: "INSUFFICIENT_POINTS" | "FREE_QUOTA_EXCEEDED" | "SCENE_NOT_FOUND" - message?: string - /** 充值页跳转 URL */ - recharge_url?: string +} + +/* ================================================================ + * 每日使用情况(GET /usage/daily,新接口) + * ================================================================ */ +export interface DailyUsage { + /** 今日已用免费次数 */ + free_clips_used: number + /** 每日免费次数上限 */ + free_clips_limit: number + /** 今日剩余免费次数 */ + free_clips_remaining: number + /** 额度重置时间 */ + reset_at: ISODate +} + +/* ================================================================ + * 会员聚合信息(GET /points/subscription/membership,新接口) + * ================================================================ */ +export interface MembershipResponse { + is_member: boolean + /** 会员类型(monthly/quarterly/yearly,非会员 null) */ + member_type: "monthly" | "quarterly" | "yearly" | null + member_expires_at: ISODate | null + /** 当前积分余额(冗余,可与 balance 互校) */ + points_balance: number + /** 最大分辨率,如 "720p" / "1080p" / "4k" */ + max_resolution: string +} + +/* ================================================================ + * 错误响应(统一格式 {error:{code,message}}) + * ================================================================ */ +export interface ApiError { + error: { + code: number + message: string + /** 部分场景会返回,如 unknown scene_key */ + valid_scenes?: PointsSource[] + } } -- 2.54.0 From d022895542eac1773a7439e5d3d678efeb876e6d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:19:13 +0800 Subject: [PATCH 02/36] chore(points): align types/api with final backend contract - apps/web/src/api/subscription/types.ts --- apps/web/src/api/subscription/types.ts | 110 ++++++++++++++++++------- 1 file changed, 80 insertions(+), 30 deletions(-) diff --git a/apps/web/src/api/subscription/types.ts b/apps/web/src/api/subscription/types.ts index fefabf5bc..e0432e9e3 100644 --- a/apps/web/src/api/subscription/types.ts +++ b/apps/web/src/api/subscription/types.ts @@ -1,65 +1,115 @@ /** - * 订阅相关类型定义 + * 订阅/会员类型定义 + * 对齐后端 staging 实测最终契约(2026-09-16) + * + * Base path: /api/v1/ */ -/** 套餐类型 */ -export type PlanType = "free" | "standard" | "pro" | "enterprise" - -/** 订阅状态 */ -export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial" - -/** 账单状态 */ -export type BillingStatus = "paid" | "pending" | "failed" | "refunded" +/** 订阅计划 ID */ +export type PlanId = "free" | "monthly" | "quarterly" | "yearly" /** 计费周期 */ export type BillingCycle = "monthly" | "yearly" -/** 套餐信息 */ -export interface Plan { - id: PlanType - name: string - price: number | null - yearly_price?: number | null - description: string - recommended: boolean - features: string[] -} +/** 订阅状态 */ +export type SubscriptionStatus = "active" | "expired" | "cancelled" | "pending" -/** 当前订阅信息 */ +/** 账单状态 */ +export type BillingStatus = "paid" | "pending" | "failed" | "refunded" + +/* ================================================================ + * 当前订阅(GET /subscription/current) + * ================================================================ */ export interface SubscriptionInfo { id: string - plan_id: PlanType + plan_id: PlanId plan_name: string status: SubscriptionStatus + /** 当前计费周期:monthly 对月卡/季卡按自然月续费;yearly 对年卡 */ billing_cycle: BillingCycle current_period_start: string current_period_end: string + /** 本期金额(分) */ amount: number auto_renew: boolean created_at: string } -/** 账单记录 */ +/* ================================================================ + * 订阅计划(GET /subscription/plans) + * ================================================================ */ +export interface SubscriptionPlan { + plan_id: PlanId + /** 中文名 */ + name: string + /** 价格(分),年卡/季卡为总价 */ + price_cents: number + /** 折算月价(分),对比用 */ + monthly_price_cents: number + /** 时长(天) */ + duration_days: number + /** 积分折扣(0.9 = 9折,1 = 无折扣) */ + points_discount: number + features: { + max_resolution: string + free_clips_daily: number + [key: string]: unknown + } +} + +export interface SubscriptionPlansResponse { + plans: SubscriptionPlan[] +} + +/* ================================================================ + * 账单(GET /subscription/billing-records) + * ================================================================ */ export interface BillingRecord { id: string - plan_name: string - amount: number - billing_cycle: BillingCycle + /** 订单类型:subscribe/renew/upgrade/refund */ + order_type: string + plan_id: PlanId + /** 金额(分) */ + amount_cents: number status: BillingStatus - payment_method: string created_at: string - invoice_url?: string + paid_at?: string } -/** 升级/降级请求 */ +/* ================================================================ + * 变更/取消/开关自动续费 + * ================================================================ */ export interface ChangePlanRequest { - target_plan_id: PlanType + target_plan_id: PlanId billing_cycle: BillingCycle } -/** 升级/降级响应 */ export interface ChangePlanResponse { success: boolean message: string new_subscription?: SubscriptionInfo } + +export interface ToggleAutoRenewRequest { + enabled: boolean +} + +/* ================================================================ + * 中文标签映射 + * ================================================================ */ +export const PLAN_LABEL: Record = { + free: "免费版", + monthly: "月度会员", + quarterly: "季度会员", + yearly: "年度会员", +} + +export const BILLING_CYCLE_LABEL: Record = { + monthly: "月付", + yearly: "年付", +} + +/** + * @deprecated 旧命名保留别名,新代码请直接用 PlanId + */ +export type PlanType = PlanId -- 2.54.0 From 7e9a6ff35872e3d561ba77f184d4725402aadcc5 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:19:18 +0800 Subject: [PATCH 03/36] chore(points): align types/api with final backend contract - apps/web/src/pages/points/Transactions.tsx --- apps/web/src/pages/points/Transactions.tsx | 296 ++++++++------------- 1 file changed, 106 insertions(+), 190 deletions(-) diff --git a/apps/web/src/pages/points/Transactions.tsx b/apps/web/src/pages/points/Transactions.tsx index e58d66b24..0f765fb83 100644 --- a/apps/web/src/pages/points/Transactions.tsx +++ b/apps/web/src/pages/points/Transactions.tsx @@ -1,124 +1,109 @@ /** - * 积分明细页(/app/points/transactions) - * 分页展示积分流水,支持按类型/来源筛选 + * 积分流水页(分页) + * - type=add/deduct(refund 通过 source="refund:xxx" 前缀区分) + * - amount 为绝对值,前端根据 type 决定正负显示 + * - 不再有 source_name 字段,前端按 scene_key + 前缀映射中文名 */ -import React, { useEffect, useState, useCallback } from "react" -import { - Card, - Table, - Tag, - Select, - DatePicker, - Space, - Typography, - Empty, - Input, - Spin, - Button, -} from "antd" -import { SearchOutlined, ReloadOutlined } from "@ant-design/icons" +import React, { useEffect, useState } from "react" +import { Card, Table, Tag, Pagination, Spin, Tabs } from "antd" import type { ColumnsType } from "antd/es/table" -import { useNavigate } from "react-router-dom" -import dayjs from "dayjs" -import PageHead from "@/components/layout/PageHead" import { getPointsTransactions } from "@/api/points" -import type { PointsTransaction, PointsTxType } from "@/api/points/types" -import "./Points.css" +import { usePointsStore } from "@/store/pointsStore" +import type { PointsTransaction } from "@/api/points/types" +import styles from "./Points.css" -const { Text } = Typography -const { RangePicker } = DatePicker - -const TYPE_LABEL: Record = { - earn: { text: "获得", color: "green" }, - spend: { text: "消耗", color: "red" }, - refund: { text: "退还", color: "blue" }, -} +const PAGE_SIZE = 20 +/** source → 中文标签映射 */ const SOURCE_LABEL: Record = { - recharge: "充值", - task_reward: "任务奖励", ai_voice: "AI 配音", + ai_video: "AI 视频生成", ai_digital_human: "AI 数字人", - ai_video: "智能混剪", voice_clone_train: "声音克隆训练", - voice_clone_synth: "克隆合成", - douyin_extract: "抖音链接提取", + voice_clone_synth: "声音克隆合成", + douyin_extract: "抖音文案提取", ai_rewrite: "AI 文案改写", ai_title: "AI 标题生成", ai_cover: "AI 封面生成", - subscription_bonus: "会员赠送", - admin_adjust: "管理员调整", - refund: "失败退还", + recharge: "积分充值", + sign_up: "注册赠送", + bind_phone: "绑定手机", + gift: "活动赠送", + admin: "管理员调整", } -const PointsTransactions: React.FC = () => { - const navigate = useNavigate() - const [loading, setLoading] = useState(false) +function getSourceLabel(source: string): string { + if (source.startsWith("refund:")) { + const origin = source.slice(7) + return `${SOURCE_LABEL[origin] || origin}退款` + } + return SOURCE_LABEL[source] || source +} + +const PointsTransactionsPage: React.FC = () => { + const { init } = usePointsStore() const [data, setData] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(20) - const [type, setType] = useState("all") - const [source, setSource] = useState("all") - const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null) - const [keyword, setKeyword] = useState("") + const [filter, setFilter] = useState<"all" | "add" | "deduct">("all") + const [loading, setLoading] = useState(true) - const load = useCallback(async () => { - setLoading(true) + useEffect(() => { + init() + }, [init]) + + useEffect(() => { + loadPage(1) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const loadPage = async (p: number) => { try { - const params: Record = { page, page_size: pageSize } - if (type !== "all") params.type = type - if (source !== "all") params.source = source - if (dateRange && dateRange[0] && dateRange[1]) { - params.start_date = dateRange[0].format("YYYY-MM-DD") - params.end_date = dateRange[1].format("YYYY-MM-DD") - } - const res = await getPointsTransactions(params) - let items = res.items - if (keyword) { - const k = keyword.toLowerCase() - items = items.filter( - (it) => - it.description.toLowerCase().includes(k) || - (SOURCE_LABEL[it.source] ?? it.source).includes(keyword), - ) - } - setData(items) - setTotal(res.total) + setLoading(true) + const resp = await getPointsTransactions(p, PAGE_SIZE) + setData(resp.items) + setTotal(resp.total) + setPage(p) + } catch (err) { + // 错误由拦截器处理 } finally { setLoading(false) } - }, [page, pageSize, type, source, dateRange, keyword]) + } - useEffect(() => { - load() - }, [load]) + const filtered = filter === "all" ? data : data.filter((t) => t.type === filter) const columns: ColumnsType = [ { title: "时间", dataIndex: "created_at", - width: 180, - render: (v: string) => dayjs(v).format("YYYY-MM-DD HH:mm"), + width: 170, + render: (v: string) => new Date(v).toLocaleString("zh-CN"), }, { title: "类型", dataIndex: "type", - width: 90, - render: (t: PointsTxType) => { - const cfg = TYPE_LABEL[t] - return {cfg.text} - }, + width: 100, + render: (t: PointsTransaction["type"]) => + t === "add" ? 获得 : 消耗, }, { - title: "来源/场景", + title: "来源", dataIndex: "source", - width: 150, - render: (s: string, r: PointsTransaction) => ( - - {r.source_name || SOURCE_LABEL[s] || s} - - ), + width: 160, + render: (s: string) => { + const isRefund = s.startsWith("refund:") + return ( + + {isRefund && ( + + 退款 + + )} + {getSourceLabel(s)} + + ) + }, }, { title: "说明", @@ -126,127 +111,58 @@ const PointsTransactions: React.FC = () => { ellipsis: true, }, { - title: "变动", - dataIndex: "signed_amount", - width: 110, + title: "数量", + dataIndex: "amount", + width: 120, align: "right", - render: (v: number, r: PointsTransaction) => ( - - {v > 0 ? "+" : ""} - {v} + render: (amount: number, record: PointsTransaction) => ( + + {record.type === "add" ? "+" : "-"} + {amount} ), }, { title: "余额", dataIndex: "balance_after", - width: 110, + width: 100, align: "right", - render: (v: number) => ( - - {v} - - ), }, ] return ( -
- - - - - } - /> - +
+

积分流水

-
- - { - setSource(v) - setPage(1) - }} - style={{ width: 160 }} - showSearch - options={[ - { value: "all", label: "全部来源" }, - ...Object.entries(SOURCE_LABEL).map(([k, v]) => ({ value: k, label: v })), - ]} - /> - { - setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) - setPage(1) - }} - /> - } - allowClear - style={{ width: 200 }} - value={keyword} - onChange={(e) => setKeyword(e.target.value)} - onPressEnter={() => { - setPage(1) - load() - }} - /> - -
- - - - rowKey="id" - columns={columns} - dataSource={data} - locale={{ emptyText: }} - pagination={{ - current: page, - pageSize, - total, - showSizeChanger: true, - showTotal: (t) => `共 ${t} 条记录`, - onChange: (p, ps) => { - setPage(p) - setPageSize(ps) - }, - }} + setFilter(k as typeof filter)} + items={[ + { key: "all", label: "全部" }, + { key: "deduct", label: "消耗" }, + { key: "add", label: "获得" }, + ]} + /> + +
+ - +
) } -export default PointsTransactions -export const Component = PointsTransactions +export default PointsTransactionsPage -- 2.54.0 From 875b54df7ff3ffd2b80708f04dda760370ccb98c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:19:22 +0800 Subject: [PATCH 04/36] chore(points): align types/api with final backend contract - apps/web/src/pages/subscription/hooks/useSubscription.ts --- .../subscription/hooks/useSubscription.ts | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/apps/web/src/pages/subscription/hooks/useSubscription.ts b/apps/web/src/pages/subscription/hooks/useSubscription.ts index eeeb4e8c2..536c493b7 100644 --- a/apps/web/src/pages/subscription/hooks/useSubscription.ts +++ b/apps/web/src/pages/subscription/hooks/useSubscription.ts @@ -1,3 +1,8 @@ +/** + * 订阅管理 Hook + * 封装订阅信息查询、套餐变更、自动续费切换、取消订阅等逻辑 + * 对齐最终契约(plan_id=free/monthly/quarterly/yearly + billing_cycle=monthly/yearly) + */ import { useState, useEffect, useCallback } from "react" import { message } from "antd" import { @@ -5,20 +10,14 @@ import { changePlan, toggleAutoRenew, cancelSubscription, - type SubscriptionInfo, - type PlanType, - type BillingCycle, } from "@/api/subscription" +import type { SubscriptionInfo, PlanId, BillingCycle } from "@/api/subscription/types" -/** - * 订阅管理 Hook - * 封装订阅信息查询、套餐变更、自动续费切换、取消订阅等逻辑 - */ export function useSubscription() { const [subscription, setSubscription] = useState(null) const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) - const [selectedPlan, setSelectedPlan] = useState("standard") + const [selectedPlan, setSelectedPlan] = useState("monthly") const [billingCycle, setBillingCycle] = useState("monthly") const loadSubscription = useCallback(async () => { @@ -26,6 +25,7 @@ export function useSubscription() { const data = await getCurrentSubscription() setSubscription(data) setSelectedPlan(data.plan_id) + setBillingCycle(data.billing_cycle) } catch (err: unknown) { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("获取订阅信息失败") } finally { @@ -37,45 +37,41 @@ export function useSubscription() { loadSubscription() }, [loadSubscription]) - const handleUpgrade = useCallback(async () => { - if (!subscription) return + const executeChangePlan = useCallback(async () => { + if (!subscription) return false if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) { message.info("当前已是该套餐") - return + return false } - // 由调用方决定是否弹确认框 - }, [subscription, selectedPlan, billingCycle]) - - const executeChangePlan = useCallback(async () => { try { setSubmitting(true) - const res = await changePlan({ - target_plan_id: selectedPlan, - billing_cycle: billingCycle, - }) + const res = await changePlan({ target_plan_id: selectedPlan, billing_cycle: billingCycle }) if (res.success) { message.success(res.message) setSubscription(res.new_subscription ?? null) + return true } else { message.error(res.message) + return false } } catch (err: unknown) { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("套餐变更失败,请重试") + return false } finally { setSubmitting(false) } - }, [selectedPlan, billingCycle]) + }, [subscription, selectedPlan, billingCycle]) const handleToggleAutoRenew = useCallback( async (enabled: boolean) => { try { - const res = await toggleAutoRenew(enabled) + const res = await toggleAutoRenew({ enabled }) message.success(res.message) - if (subscription) { - setSubscription({ ...subscription, auto_renew: enabled }) - } + if (subscription) setSubscription({ ...subscription, auto_renew: enabled }) + return true } catch (err: unknown) { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败") + return false } }, [subscription], @@ -93,7 +89,6 @@ export function useSubscription() { }, []) return { - // 状态 subscription, loading, submitting, @@ -101,9 +96,7 @@ export function useSubscription() { billingCycle, setSelectedPlan, setBillingCycle, - // 操作 loadSubscription, - handleUpgrade, executeChangePlan, handleToggleAutoRenew, handleCancel, -- 2.54.0 From a4e54011cab8f606d9f30309fcabf05f87d1ab78 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:19:26 +0800 Subject: [PATCH 05/36] chore(points): align types/api with final backend contract - apps/web/src/components/common/PointsBadge/index.tsx --- .../components/common/PointsBadge/index.tsx | 144 ++++++------------ 1 file changed, 44 insertions(+), 100 deletions(-) diff --git a/apps/web/src/components/common/PointsBadge/index.tsx b/apps/web/src/components/common/PointsBadge/index.tsx index 3c26fff21..3e25927b0 100644 --- a/apps/web/src/components/common/PointsBadge/index.tsx +++ b/apps/web/src/components/common/PointsBadge/index.tsx @@ -1,114 +1,58 @@ /** - * Header 右上角的积分徽章(💎 580) - * - 余额 <10 时橙色告警 - * - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口 + * 头部积分徽章组件 + * - 展示当前积分余额 + * - 会员标签(基于 membership 聚合信息或 subscription.plan_id 判断) + * - 点击跳转积分中心 */ -import React, { useEffect } from "react" -import { Popover, Button, Tag, Space, Typography, Badge } from "antd" -import { - ThunderboltOutlined, - CrownOutlined, - RightOutlined, - WarningOutlined, -} from "@ant-design/icons" +import React from "react" import { useNavigate } from "react-router-dom" +import { Tooltip, Typography } from "antd" +import { CrownOutlined, StarFilled } from "@ant-design/icons" import { usePointsStore } from "@/store/pointsStore" -import "./PointsBadge.css" +import styles from "./PointsBadge.module.css" -const { Text, Paragraph } = Typography +const { Text } = Typography + +/** 会员类型标签映射(基于 GET /points/subscription/membership 返回的 member_type 字符串) */ +const MEMBER_LABEL: Record = { + monthly: "月卡会员", + quarterly: "季卡会员", + yearly: "年卡会员", +} + +const MEMBER_COLOR: Record = { + monthly: "#fa8c16", + quarterly: "#eb2f96", + yearly: "#fadb14", +} const PointsBadge: React.FC = () => { const navigate = useNavigate() - const { balance, init, loading } = usePointsStore() + const { balance, membership, subscription } = usePointsStore() - useEffect(() => { - if (!balance) init() - }, [balance, init]) - - const bal = balance?.balance ?? 0 - const lowBalance = bal > 0 && bal < 10 - const zero = bal === 0 - const isMember = !!balance?.is_member - const memberLabel = isMember - ? balance?.member_type === "yearly" - ? "年卡会员" - : balance?.member_type === "quarterly" - ? "季卡会员" - : balance?.member_type === "monthly" - ? "月卡会员" - : "付费会员" - : "免费会员" - - const popContent = ( -
-
-
- - {loading ? "…" : bal} - 积分 -
- }> - {memberLabel} - -
- - {(zero || lowBalance) && ( - - 积分不足,充值后可继续使用 AI 功能 - - )} - - {balance?.member_expires_at && ( - - 会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")} - - )} - -
-
- 累计获得 -
+{balance?.total_earned ?? 0}
-
-
- 累计消耗 -
-{balance?.total_spent ?? 0}
-
-
- - - - - {!isMember && ( - - )} - -
- ) + const points = membership?.points_balance ?? balance?.balance ?? 0 + // 优先用 membership.member_type 判定会员状态;否则用 subscription.plan_id + const memberKey: string | null = + membership?.member_type ?? + (subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null) + const isMember = membership?.is_member ?? balance?.is_member ?? false + const memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "" : "" + const memberColor = memberKey ? MEMBER_COLOR[memberKey] || "#fa8c16" : "#fa8c16" return ( - - - +
navigate("/points")}> + + + {points} + + {isMember && memberLabel && ( + + + {memberLabel} + + + )} +
) } -- 2.54.0 From 5745d51b5a0cef785df9f5c9f2bf10554c707e40 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:19:32 +0800 Subject: [PATCH 06/36] chore(points): align types/api with final backend contract - apps/web/src/pages/subscription/Billing.tsx --- apps/web/src/pages/subscription/Billing.tsx | 297 ++++++++++++-------- 1 file changed, 173 insertions(+), 124 deletions(-) diff --git a/apps/web/src/pages/subscription/Billing.tsx b/apps/web/src/pages/subscription/Billing.tsx index 68a8107a9..f421a155b 100644 --- a/apps/web/src/pages/subscription/Billing.tsx +++ b/apps/web/src/pages/subscription/Billing.tsx @@ -1,153 +1,202 @@ /** - * 账单管理页面 - * 展示当前订阅信息 + 自动续费开关 - * P1-3: antd Switch→自定义ToggleSwitch, antd Spin→自定义Spinner + * 订阅管理 & 账单页 + * - 展示当前订阅信息 + * - 取消订阅 / 开关自动续费 + * - 账单历史 */ -import React, { useState, useEffect } from "react" -import { message } from "antd" -import { getCurrentSubscription, toggleAutoRenew } from "@/api/subscription" -import type { SubscriptionInfo } from "@/api/subscription" -import PageHead from "@/components/layout/PageHead" -import "./Billing.css" +import React, { useEffect, useState } from "react" +import { Card, Button, Tag, Table, Alert, Space, Modal, message, Spin, Descriptions } from "antd" +import { useNavigate } from "react-router-dom" +import type { ColumnsType } from "antd/es/table" +import { usePointsStore } from "@/store/pointsStore" +import { getBillingRecords, cancelSubscription, toggleAutoRenew } from "@/api/subscription" +import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types" +import type { BillingRecord } from "@/api/subscription/types" +import styles from "./Subscription.css" -const formatDate = (iso: string): string => { - const d = new Date(iso) - return d.toLocaleDateString("zh-CN", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }) -} +const formatYuan = (cents: number) => `¥${(cents / 100).toFixed(2)}` -/** 自定义 ToggleSwitch 组件 */ -const ToggleSwitch: React.FC<{ - checked: boolean - onChange: (checked: boolean) => void - loading?: boolean - checkedChildren?: string - unCheckedChildren?: string -}> = ({ checked, onChange, loading, checkedChildren, unCheckedChildren }) => ( - -) - -/** 自定义 Spinner 组件 */ -const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => ( -
-
-
-
-
-) - -const Billing: React.FC = () => { - const [subscription, setSubscription] = useState(null) +const BillingPage: React.FC = () => { + const navigate = useNavigate() + const { subscription, init, refreshBalance } = usePointsStore() + const [records, setRecords] = useState([]) const [loading, setLoading] = useState(true) - const [autoRenewChecked, setAutoRenewChecked] = useState(false) - const [autoRenewLoading, setAutoRenewLoading] = useState(false) + const [actionLoading, setActionLoading] = useState(false) useEffect(() => { - loadData() - }, []) + init() + loadBilling() + }, [init]) - const loadData = async () => { + const loadBilling = async () => { try { - const data = await getCurrentSubscription() - setSubscription(data) - setAutoRenewChecked(data.auto_renew) - } catch (err: unknown) { - if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("加载订阅数据失败") + setLoading(true) + const data = await getBillingRecords() + setRecords(data) + } catch { + // 拦截器处理 } finally { setLoading(false) } } - const handleToggleAutoRenew = async (checked: boolean) => { - setAutoRenewLoading(true) + const handleCancel = () => { + Modal.confirm({ + title: "确认取消订阅?", + content: "取消后当前计费周期结束时将不再续费,期间仍可使用会员权益。", + okText: "确认取消", + cancelText: "再想想", + okType: "danger", + onOk: async () => { + try { + setActionLoading(true) + const resp = await cancelSubscription() + message.success(resp.message || "已取消订阅") + await refreshBalance() + } catch (err) { + message.error((err as Error).message || "操作失败") + } finally { + setActionLoading(false) + } + }, + }) + } + + const handleToggleAutoRenew = async () => { + if (!subscription) return + const next = !subscription.auto_renew try { - const res = await toggleAutoRenew(checked) - message.success(res.message) - setAutoRenewChecked(checked) - if (subscription) { - setSubscription({ ...subscription, auto_renew: checked }) - } - } catch (err: unknown) { - if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败") + setActionLoading(true) + const resp = await toggleAutoRenew({ enabled: next }) + message.success(resp.message) + await refreshBalance() + } catch (err) { + message.error((err as Error).message || "操作失败") } finally { - setAutoRenewLoading(false) + setActionLoading(false) } } - if (loading) { - return ( -
- -
- ) - } + const columns: ColumnsType = [ + { + title: "时间", + dataIndex: "created_at", + render: (v: string) => new Date(v).toLocaleString("zh-CN"), + }, + { + title: "类型", + dataIndex: "order_type", + render: (v: string) => { + const map: Record = { + subscribe: "订阅", + renew: "续费", + upgrade: "升级", + refund: "退款", + } + return map[v] || v + }, + }, + { + title: "套餐", + dataIndex: "plan_id", + render: (v: string) => PLAN_LABEL[v as keyof typeof PLAN_LABEL] || v, + }, + { + title: "金额", + dataIndex: "amount_cents", + align: "right", + render: (v: number) => formatYuan(v), + }, + { + title: "状态", + dataIndex: "status", + render: (s: string) => { + const map: Record = { + paid: { color: "green", label: "已支付" }, + pending: { color: "orange", label: "待支付" }, + failed: { color: "red", label: "失败" }, + refunded: { color: "default", label: "已退款" }, + } + const cfg = map[s] || { color: "default", label: s } + return {cfg.label} + }, + }, + ] + + const isPaid = subscription && subscription.plan_id !== "free" return ( -
- +
+

订阅管理

- {subscription && ( + {!subscription ? ( + + ) : !isPaid ? ( + navigate("/subscription")}> + 开通会员 + + } + /> + ) : ( <> - {/* 当前订阅概览 */} -
-

当前订阅

-
-
- 套餐 - {subscription.plan_name} -
-
- 计费周期 - - {subscription.billing_cycle === "monthly" ? "月付" : "年付"} - -
-
- 下次扣费 - {formatDate(subscription.current_period_end)} -
-
-
- - {/* 自动续费 */} -
-

自动续费

-
-
-

到期自动续费

-

- 开启后,将在每个计费周期结束时自动扣费续期,避免服务中断。 -

-
- -
-
+ + + {PLAN_LABEL[subscription.plan_id]} + + {BILLING_CYCLE_LABEL[subscription.billing_cycle]} + + + {formatYuan(subscription.amount)} + + + + {subscription.status} + + + + {new Date(subscription.current_period_start).toLocaleDateString("zh-CN")} + + + {new Date(subscription.current_period_end).toLocaleDateString("zh-CN")} + + + + {subscription.auto_renew ? "已开启" : "已关闭"} + + + + + + + + + )} + + +
+ ) } -export default Billing -export const Component = Billing +export default BillingPage -- 2.54.0 From e4f70f84495925edad0ffecba4fcbaae8dd2af08 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:19:38 +0800 Subject: [PATCH 07/36] chore(points): align types/api with final backend contract - apps/web/src/pages/points/Center.tsx --- apps/web/src/pages/points/Center.tsx | 407 +++++++++------------------ 1 file changed, 126 insertions(+), 281 deletions(-) diff --git a/apps/web/src/pages/points/Center.tsx b/apps/web/src/pages/points/Center.tsx index 0e0d844a5..d53251a04 100644 --- a/apps/web/src/pages/points/Center.tsx +++ b/apps/web/src/pages/points/Center.tsx @@ -1,309 +1,154 @@ /** - * 积分中心主页(/app/points) - * 展示余额、会员信息、本月统计、快捷入口 + * 积分中心首页 + * 展示: + * - 当前积分余额 + 会员状态(来自 balance + membership) + * - 每日免费额度(来自 dailyUsage) + * - 快捷入口(充值 / 消费记录 / 订阅) */ import React, { useEffect } from "react" -import { - Card, - Col, - Row, - Statistic, - Button, - Space, - Tag, - Typography, - Progress, - List, - Avatar, - Empty, -} from "antd" +import { Card, Button, Space, Statistic, Tag, Progress, Alert } from "antd" +import { useNavigate } from "react-router-dom" import { ThunderboltOutlined, - CrownFilled, - ArrowUpOutlined, - ArrowDownOutlined, + CrownOutlined, HistoryOutlined, - WalletOutlined, - FileTextOutlined, - InfoCircleOutlined, + PlusCircleOutlined, + GiftOutlined, } from "@ant-design/icons" -import { useNavigate } from "react-router-dom" -import PageHead from "@/components/layout/PageHead" import { usePointsStore } from "@/store/pointsStore" -import "./Points.css" +import styles from "./Points.css" -const { Text } = Typography - -const SOURCE_NAME: Record = { - recharge: "充值", - task_reward: "任务奖励", - ai_voice: "AI 配音", - ai_digital_human: "AI 数字人", - ai_video: "智能混剪", - voice_clone_train: "声音克隆训练", - voice_clone_synth: "声音克隆合成", - douyin_extract: "抖音提取", - ai_rewrite: "AI 改写", - ai_title: "AI 标题", - ai_cover: "AI 封面", - subscription_bonus: "会员赠送", - admin_adjust: "管理员调整", - refund: "失败退还", +const MEMBER_LABEL: Record = { + monthly: "月卡会员", + quarterly: "季卡会员", + yearly: "年卡会员", } -const PointsCenter: React.FC = () => { +const PointsCenterPage: React.FC = () => { const navigate = useNavigate() - const { balance, subscription, init, loading } = usePointsStore() + const { balance, dailyUsage, membership, subscription, rules, init } = usePointsStore() useEffect(() => { init() }, [init]) - const bal = balance?.balance ?? 0 - const earned = balance?.total_earned ?? 0 - const spent = balance?.total_spent ?? 0 - const isMember = !!balance?.is_member - const freeUsed = balance?.free_clips_used ?? 0 - const freeLimit = balance?.free_clips_limit ?? 2 - const freeRemain = balance?.free_clips_remaining ?? (isMember ? 0 : 2) - - // 近 5 条流水 mock(实际从 transactions 页加载) - const recentTx = [ - { type: "spend", source: "ai_voice", amount: 1, time: "今天 10:30" }, - { type: "spend", source: "ai_video", amount: 3, time: "今天 09:15" }, - { type: "earn", source: "task_reward", amount: 20, time: "昨天" }, - ] + const isMember = membership?.is_member ?? balance?.is_member ?? false + const memberKey = + membership?.member_type ?? + (subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null) + const memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "" : "" + const freeLimit = dailyUsage?.free_clips_limit ?? 0 + const freeUsed = dailyUsage?.free_clips_used ?? 0 + const freeRemain = dailyUsage?.free_clips_remaining ?? 0 + const freePercent = freeLimit > 0 ? Math.round((freeUsed / freeLimit) * 100) : 0 + const freeMultiplier = rules?.free_user_multiplier ?? 1.15 return ( -
- +

积分中心

- actions={ - - - - - } - /> - - {/* 顶部大卡 */} - - -
- - - 当前可用积分 - -
- - {loading ? "…" : bal.toLocaleString()} -
- - {isMember ? ( - } style={{ padding: "4px 10px" }}> - {subscription?.member_type === "yearly" - ? "年卡" - : subscription?.member_type === "quarterly" - ? "季卡" - : "月卡"} - 会员 - - ) : ( - - 免费会员 - - )} - {balance?.member_expires_at && ( - - 到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")} - - )} - {!isMember && ( - - )} - -
- - - {/* 今日免费额度 */} - {!isMember && freeLimit > 0 && ( -
-
- 今日免费混剪 -
- `${freeUsed}/${freeLimit} 条`} - /> - - 剩余 {freeRemain} 条免费混剪,超出部分消耗积分 - -
- )} - - + {/* 余额卡片 */} + +
+ } + valueStyle={{ color: "#faad14", fontSize: 36, fontWeight: 700 }} + /> + {isMember && memberLabel && ( + } className={styles.memberTag}> + {memberLabel} + {membership?.max_resolution ? ` · ${membership.max_resolution}` : ""} + + )} +
+ + + + + + {balance?.member_expires_at && isMember && ( +
+ 会员到期时间:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")} +
+ )}
- {/* 统计 */} - - - - } - valueStyle={{ color: "#10b981" }} + {/* 每日免费额度卡片 */} + + {!isMember && freeLimit > 0 ? ( + <> + `${freeUsed}/${freeLimit}`} /> - - - - - } - valueStyle={{ color: "#ef4444" }} - /> - - - - - } - valueStyle={{ color: "#8b5cf6" }} - /> - - - - - } - valueStyle={{ color: "#f59e0b" }} - /> - - - +
+ 今日还剩 {freeRemain} 次免费生成额度 + {dailyUsage?.reset_at && ( + + ( + {new Date(dailyUsage.reset_at).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + })}{" "} + 重置) + + )} +
+ + ) : isMember ? ( + + ) : ( + + )} + - {/* 快捷入口 & 最近流水 */} - - - - - 最近流水 - - } - extra={ navigate("/app/points/transactions")}>查看全部 →} - > - {recentTx.length === 0 ? ( - - ) : ( - ( - - : } - /> - } - title={SOURCE_NAME[item.source] ?? item.source} - description={item.time} - /> -
- {item.type === "earn" ? "+" : "-"} - {item.amount} -
-
- )} - /> - )} -
- - - - - - - - - - - - + {/* 快捷提示 */} + {!isMember && rules && ( + navigate("/subscription")}> + 开通会员 + + } + style={{ marginTop: 16 }} + /> + )} + + {/* 累计统计 */} + +
+ + +
+
) } -export default PointsCenter -export const Component = PointsCenter +export default PointsCenterPage -- 2.54.0 From d022454b2bb955792cb83c3e6d1697633971dec2 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:19:44 +0800 Subject: [PATCH 08/36] chore(points): align types/api with final backend contract - apps/web/src/pages/points/Packages.tsx --- apps/web/src/pages/points/Packages.tsx | 262 +++++++++++-------------- 1 file changed, 116 insertions(+), 146 deletions(-) diff --git a/apps/web/src/pages/points/Packages.tsx b/apps/web/src/pages/points/Packages.tsx index a4d7e71ee..58f3d4354 100644 --- a/apps/web/src/pages/points/Packages.tsx +++ b/apps/web/src/pages/points/Packages.tsx @@ -1,174 +1,144 @@ /** - * 积分充值页(/app/points/recharge) - * 单独展示积分包,供入口直接跳转使用 - * 主 Plans 页面也有充值区,这里提供独立 URL 方便从"积分不足"弹窗跳转 + * 积分充值页面 + * 展示充值包列表 + 创建充值订单 + * + * 注意:当前后端 POST /points/recharge 返回 pay_params={}(支付通道未接入), + * 前端可完成订单创建 UI,但发起支付需等待支付通道接入。 */ import React, { useEffect, useState } from "react" -import { Card, Col, Row, Button, Tag, Typography, Space, Modal, message, Tooltip } from "antd" -import { ThunderboltOutlined, SafetyCertificateOutlined, CrownFilled } from "@ant-design/icons" -import { useNavigate } from "react-router-dom" -import PageHead from "@/components/layout/PageHead" +import { Card, Button, Tag, message, Spin, Alert } from "antd" +import { ThunderboltOutlined, CheckCircleFilled } from "@ant-design/icons" import { usePointsStore } from "@/store/pointsStore" -import { createPointsOrder, getPointsPackages } from "@/api/points" -import type { PointsPackage } from "@/api/points/types" -import "./Points.css" +import { getPointsPackages, createPointsOrder } from "@/api/points" +import { getDiscountPriceCents } from "@/api/points/types" +import type { PointsPackage, PointsPackagesResponse } from "@/api/points/types" +import styles from "./Points.css" -const { Title, Text, Paragraph } = Typography - -const PointsRecharge: React.FC = () => { - const navigate = useNavigate() - const { balance, init } = usePointsStore() - const [packages, setPackages] = useState([]) - const [buying, setBuying] = useState(null) +const PointsPackagesPage: React.FC = () => { + const { init, refreshBalance } = usePointsStore() + const [pkgResp, setPkgResp] = useState(null) + const [loading, setLoading] = useState(true) + const [ordering, setOrdering] = useState(null) + const [selectedCode, setSelectedCode] = useState(null) useEffect(() => { init() - getPointsPackages() - .then((r) => setPackages(r.packages)) - .catch(() => {}) + loadPackages() }, [init]) - const getPackPrice = (pkg: PointsPackage): number => { - const mt = balance?.member_type ?? "free" - type DiscountKey = - | "discounted_price_for_free" - | "discounted_price_for_monthly" - | "discounted_price_for_quarterly" - | "discounted_price_for_yearly" - const key = `discounted_price_for_${mt}` as DiscountKey - return pkg[key] ?? pkg.price - } - - const handleBuy = async (pkg: PointsPackage) => { + const loadPackages = async () => { try { - setBuying(pkg.id) - const order = await createPointsOrder({ package_id: pkg.id }) - Modal.info({ - title: "支付功能开发中", - icon: , - content: ( -
- - 订单已创建({order.id.slice(0, 16)}…),金额{" "} - ¥{(order.price_cents / 100).toFixed(2).replace(/\.00$/, "")}。 - 微信/支付宝支付正在接入中。 - - - 现阶段所有功能免费体验,积分仅为演示数据。 - -
- ), - okText: "知道了", - }) - } catch (e) { - const err = e as { message?: string } - message.error(err?.message ?? "下单失败") + setLoading(true) + const data = await getPointsPackages() + setPkgResp(data) + if (data.packages.length > 0) setSelectedCode(data.packages[1]?.code || data.packages[0].code) + } catch (err) { + message.error("加载充值包失败") } finally { - setBuying(null) + setLoading(false) } } + const handleRecharge = async (pkg: PointsPackage) => { + try { + setOrdering(pkg.code) + const order = await createPointsOrder({ package_id: pkg.code }) + message.success(`订单已创建:${order.id}(支付通道待接入)`) + // pay_params 目前是 {},支付通道接入后再处理跳转 + if (order.pay_params && Object.keys(order.pay_params).length > 0) { + // TODO: 支付通道接入后发起支付 + console.log("[recharge] pay_params:", order.pay_params) + } + await refreshBalance() + } catch (err) { + message.error((err as Error).message || "充值失败,请稍后再试") + } finally { + setOrdering(null) + } + } + + if (loading) + return ( +
+ +
+ ) + if (!pkgResp) return
充值包加载失败
+ + const userDiscount = pkgResp.user_discount + const hasDiscount = userDiscount != null && userDiscount < 1 + return ( -
- +

积分充值

- actions={ - - {!balance?.is_member && ( - - )} - - - } - /> - - {balance && ( - - -
- 当前可用积分 -
- {balance.balance.toLocaleString()} -
-
-
-
+ {hasDiscount && ( + )} - 选择积分包 - - {packages.map((pkg) => { - const price = getPackPrice(pkg) - const discount = price < pkg.price ? Math.round((1 - price / pkg.price) * 100) : 0 - const unit = price / pkg.points +
+ {pkgResp.packages.map((pkg) => { + const isSelected = selectedCode === pkg.code + const discountPrice = getDiscountPriceCents(pkg, userDiscount) + const isDiscounted = discountPrice < pkg.price_cents + const priceYuan = (discountPrice / 100).toFixed(2).replace(/\.00$/, "") + const originalYuan = (pkg.price_cents / 100).toFixed(2).replace(/\.00$/, "") + return ( -
- 0 ? "has-discount" : ""}`} - hoverable + setSelectedCode(pkg.code)} + > + {isSelected && } +
+ + {pkg.points} + 积分 +
+
{pkg.name}
+
+ ¥ + {priceYuan} + {isDiscounted && ¥{originalYuan}} +
+ {pkg.unit_price < 0.1 && ( + + 超值 + + )} + -
- + 立即充值 + +
) })} - + - - 积分消耗说明 -
    -
  • 智能混剪:3 积分/条(≤30s),每加 30s +1 积分
  • -
  • AI 配音 / 声音克隆合成:1 积分/分钟
  • -
  • AI 数字人:15 积分/分钟
  • -
  • 抖音链接提取 / AI 改写 / AI 标题 / AI 封面:1~2 积分/次
  • -
  • 声音克隆训练:免费
  • -
  • 免费用户每日 2 条混剪免费,其余 AI 功能消耗为会员价 ×1.15
  • -
- - - 所有规则以页面实际显示为准 - - -
+ ) } -export default PointsRecharge -export const Component = PointsRecharge +export default PointsPackagesPage -- 2.54.0 From 917152a8ac37c183e34cf000dd231917a1de3a4e Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:19:50 +0800 Subject: [PATCH 09/36] chore(points): align types/api with final backend contract - apps/web/src/pages/generate/hooks/pointsCost.ts --- .../src/pages/generate/hooks/pointsCost.ts | 100 +++++++++++------- 1 file changed, 60 insertions(+), 40 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/pointsCost.ts b/apps/web/src/pages/generate/hooks/pointsCost.ts index 1bb463703..92b390c92 100644 --- a/apps/web/src/pages/generate/hooks/pointsCost.ts +++ b/apps/web/src/pages/generate/hooks/pointsCost.ts @@ -1,58 +1,74 @@ /** * 生成前积分消耗估算与余额校验 - * 用于步骤4「确认生成」按钮前展示本次消耗,积分不足时禁用并提示充值 + * 用于步骤「确认生成」按钮前展示本次消耗,积分不足时禁用并提示充值 + * + * 契约对齐(2026-09-16 最终版): + * - balance 不再包含 free_clips_* 字段,免费额度通过 dailyUsage 传入 + * - 乘数逻辑:非会员 ceil(base × free_user_multiplier),会员 floor(base × points_discount) + * - points_discount 从 subscription.plans.points_discount 获取(mock 阶段用 1 占位) */ -import type { PointsBalance } from "@/api/points/types" +import type { PointsBalance, DailyUsage } from "@/api/points/types" +import type { SubscriptionPlan } from "@/api/subscription/types" -/** 生成单条视频消耗积分(基准) */ +/** 生成单条视频基准积分(ai_video base_points=8,但向导默认使用短片段,先保守按 3 估算) */ export const BASE_VIDEO_POINTS = 3 -/** - * 估算生成任务的积分消耗 - * @param videoCount 视频条数(批量模式) - * @param memberMultiplier 会员倍率(免费用户 1.15) - */ -export function estimateGenerateCost(videoCount: number, memberMultiplier = 1): number { - const raw = BASE_VIDEO_POINTS * videoCount * memberMultiplier - // 向上取整,避免小数 - return Math.ceil(raw) -} +/** 默认免费用户倍率(后端 free_user_multiplier,mock 默认 1.15) */ +const DEFAULT_FREE_MULTIPLIER = 1.15 -/** - * 判断积分是否充足(含每日免费额度) - * @returns sufficient=true 表示可以继续生成;false 需要提示充值 - */ -export function hasEnoughPoints( - balance: PointsBalance | null, - videoCount: number, -): { +export interface HasEnoughPointsResult { sufficient: boolean cost: number reason?: string freeClipsUsed?: number freeClipsRemaining?: number -} { +} + +/** + * 判断积分是否充足(含每日免费额度) + * @param balance 积分余额 + * @param videoCount 视频条数(批量模式下为 variant 数) + * @param dailyUsage 每日免费额度(可选;不传视为 0) + * @param plans 当前可用订阅计划列表(用于计算会员积分折扣;mock 传 []) + * @param currentPlanId 当前用户 plan_id(free/monthly/quarterly/yearly) + * @param freeMultiplier 免费用户倍率,默认 1.15 + */ +export function hasEnoughPoints( + balance: PointsBalance | null, + videoCount: number, + dailyUsage?: DailyUsage | null, + plans: SubscriptionPlan[] = [], + currentPlanId: string = "free", + freeMultiplier: number = DEFAULT_FREE_MULTIPLIER, +): HasEnoughPointsResult { if (!balance) { - // 未登录或未加载:不拦截,后端会校验 - return { sufficient: true, cost: estimateGenerateCost(videoCount) } + return { sufficient: true, cost: estimateGenerateCost(videoCount, 1) } } - const isMember = balance.is_member - const multiplier = isMember ? 1 : 1.15 - const cost = estimateGenerateCost(videoCount, multiplier) + const isMember = balance.is_member && currentPlanId !== "free" + const plan = plans.find((p) => p.plan_id === currentPlanId) + const multiplier = isMember ? (plan?.points_discount ?? 1) : freeMultiplier + const raw = BASE_VIDEO_POINTS * videoCount * multiplier + const cost = isMember ? Math.floor(raw) : Math.ceil(raw) - // 免费用户优先使用每日免费额度 - if (!isMember && balance.free_clips_remaining && balance.free_clips_remaining > 0) { - const freeUsed = Math.min(balance.free_clips_remaining, videoCount) - const remainingAfterFree = videoCount - freeUsed - const paidCost = estimateGenerateCost(remainingAfterFree, multiplier) + const freeRemain = dailyUsage?.free_clips_remaining ?? 0 + + // 非会员优先用每日免费额度 + if (!isMember && freeRemain > 0) { + const freeUsed = Math.min(freeRemain, videoCount) + const afterFree = videoCount - freeUsed + const paidCost = + afterFree === 0 + ? 0 + : isMember + ? Math.floor(BASE_VIDEO_POINTS * afterFree * multiplier) + : Math.ceil(BASE_VIDEO_POINTS * afterFree * multiplier) if (paidCost === 0) { - // 完全用免费额度 return { sufficient: true, cost: 0, freeClipsUsed: freeUsed, - freeClipsRemaining: balance.free_clips_remaining - freeUsed, + freeClipsRemaining: freeRemain - freeUsed, } } if (balance.balance >= paidCost) { @@ -60,24 +76,28 @@ export function hasEnoughPoints( sufficient: true, cost: paidCost, freeClipsUsed: freeUsed, - freeClipsRemaining: balance.free_clips_remaining - freeUsed, + freeClipsRemaining: freeRemain - freeUsed, } } return { sufficient: false, cost: paidCost, - reason: `积分不足:本次需 ${paidCost} 积分(使用 ${freeUsed} 次免费额度后),当前余额 ${balance.balance},还差 ${paidCost - balance.balance} 积分`, + reason: `积分不足:本次需 ${paidCost} 积分(已用 ${freeUsed} 次免费额度),当前余额 ${balance.balance},还差 ${paidCost - balance.balance} 积分`, freeClipsUsed: freeUsed, } } - // 付费会员或免费额度用完 - if (balance.balance >= cost) { - return { sufficient: true, cost } - } + if (balance.balance >= cost) return { sufficient: true, cost } return { sufficient: false, cost, reason: `积分不足:本次需 ${cost} 积分,当前余额 ${balance.balance},还差 ${cost - balance.balance} 积分`, } } + +/** + * 估算生成任务的积分消耗(导出给 UI 直接使用) + */ +export function estimateGenerateCost(videoCount: number, multiplier = 1): number { + return Math.ceil(BASE_VIDEO_POINTS * videoCount * multiplier) +} -- 2.54.0 From e8174c16801b8892298be66f5f90112b9e461acf Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:19:56 +0800 Subject: [PATCH 10/36] chore(points): align types/api with final backend contract - apps/web/src/api/subscription/index.ts --- apps/web/src/api/subscription/index.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/api/subscription/index.ts b/apps/web/src/api/subscription/index.ts index 74bdd8f84..f5f908d24 100644 --- a/apps/web/src/api/subscription/index.ts +++ b/apps/web/src/api/subscription/index.ts @@ -1,24 +1,30 @@ /** * 订阅 API — 目录化入口 - * 保持与原 subscription.ts 相同导出,向后兼容 + * 对齐后端 staging 最终契约(2026-09-16) */ // 类型 export type { + PlanId, PlanType, SubscriptionStatus, BillingStatus, BillingCycle, - Plan, SubscriptionInfo, + SubscriptionPlan, + SubscriptionPlansResponse, BillingRecord, ChangePlanRequest, ChangePlanResponse, + ToggleAutoRenewRequest, } from "./types" +export { PLAN_LABEL, BILLING_CYCLE_LABEL } from "./types" + // API 函数 export { getCurrentSubscription, + getSubscriptionPlans, getBillingRecords, changePlan, cancelSubscription, -- 2.54.0 From 5cefc5d63024a625671cd82478cd7fe6f2ae0751 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:20:02 +0800 Subject: [PATCH 11/36] chore(points): align types/api with final backend contract - apps/web/src/pages/subscription/UpgradeSubscription.tsx --- .../subscription/UpgradeSubscription.tsx | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/web/src/pages/subscription/UpgradeSubscription.tsx b/apps/web/src/pages/subscription/UpgradeSubscription.tsx index 36033d6b0..8f9e9f95a 100644 --- a/apps/web/src/pages/subscription/UpgradeSubscription.tsx +++ b/apps/web/src/pages/subscription/UpgradeSubscription.tsx @@ -1,11 +1,10 @@ /** * 升级/降级/续费页面 - * P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件 */ import React from "react" import { Modal } from "@/components/ui" import { useNavigate } from "react-router-dom" -import type { PlanType } from "@/api/subscription" +import type { PlanId } from "@/api/subscription/types" import PageHead from "@/components/layout/PageHead" import { Button } from "@/components/ui" import { PLANS_META, getPlanName, getPlanPrice } from "./constants" @@ -13,6 +12,9 @@ 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 { @@ -49,12 +51,12 @@ const UpgradeSubscription: React.FC = () => { const handleCancelClick = () => { Modal.confirm({ title: "确认取消订阅", - content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。", + content: "取消后,当前周期结束前仍可正常使用,到期后降级为免费版。", okText: "确认取消", cancelText: "再想想", onOk: async () => { const ok = await handleCancel() - if (ok) navigate("/app/subscription") + if (ok) navigate("/subscription") }, }) } @@ -74,14 +76,22 @@ const UpgradeSubscription: React.FC = () => {
- {(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => { + {PAID_PLANS.map((planId) => { const plan = PLANS_META[planId] const isCurrent = planId === currentPlan + // 年卡显示年价,其他显示月价 + const monthlyPrice = plan.priceYuan + const yearlyPrice = plan.yearlyPriceYuan || plan.priceYuan * 12 + // 选中季卡时默认切到月付周期;年卡切到年付 + const resolvedCycle: "monthly" | "yearly" = planId === "yearly" ? "yearly" : "monthly" return (
setSelectedPlan(planId)} + onClick={() => { + setSelectedPlan(planId) + setBillingCycle(resolvedCycle) + }} > {isCurrent &&
当前
}

{plan.name}

@@ -89,8 +99,8 @@ const UpgradeSubscription: React.FC = () => {
-- 2.54.0 From 0abd23466a7051cb016bcd7cf9f132222c753ccd Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:20:08 +0800 Subject: [PATCH 12/36] chore(points): align types/api with final backend contract - apps/web/src/store/pointsStore.ts --- apps/web/src/store/pointsStore.ts | 119 +++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 36 deletions(-) diff --git a/apps/web/src/store/pointsStore.ts b/apps/web/src/store/pointsStore.ts index 9ce4d9cb0..f64cd1144 100644 --- a/apps/web/src/store/pointsStore.ts +++ b/apps/web/src/store/pointsStore.ts @@ -1,83 +1,130 @@ /** - * 积分 & 会员状态管理(Zustand) - * - 启动时拉取余额 & 订阅信息 - * - 提供刷新、余额扣减(乐观更新)等工具 + * 积分 & 会员状态管理 + * 对齐后端 staging 最终契约(2026-09-16): + * - balance: GET /points/balance(无 free_clips_*) + * - dailyUsage: GET /usage/daily(每日免费额度) + * - membership: GET /points/subscription/membership(聚合会员信息) + * - rules: GET /points/rules(base_points + free_user_multiplier) + * - subscription: GET /subscription/current(plan_id + billing_cycle) */ import { create } from "zustand" -import type { PointsBalance, PointsRulesResponse, SubscriptionCurrent } from "@/api/points/types" -import { getCurrentSubscription, getPointsBalance, getPointsRules } from "@/api/points" +import { getPointsBalance, getPointsRules, getDailyUsage, getMembership } from "@/api/points" +import { getCurrentSubscription } from "@/api/subscription" +import type { + PointsBalance, + PointsRulesResponse, + DailyUsage, + MembershipResponse, +} from "@/api/points/types" +import type { SubscriptionInfo } from "@/api/subscription/types" interface PointsState { - /** 积分余额 & 会员状态(来自 /points/balance) */ balance: PointsBalance | null - /** 订阅详情(来自 /subscription/current) */ - subscription: SubscriptionCurrent | null - /** 积分消耗规则缓存 */ + dailyUsage: DailyUsage | null + membership: MembershipResponse | null rules: PointsRulesResponse | null + subscription: SubscriptionInfo | null loading: boolean error: string | null - /** 初始化:拉取余额 + 订阅信息 + 规则 */ + /** 初始化:并行拉取 balance / rules / subscription / dailyUsage / membership */ init: () => Promise - /** 强制刷新余额 */ + /** 刷新余额(充值/消费后调用) */ refreshBalance: () => Promise - /** 乐观扣减:在支付/业务发起前调用,失败时用 refreshBalance 兜底 */ + /** 乐观扣减 */ optimisticDeduct: (points: number) => void - /** 乐观增加(充值成功后调用) */ + /** 乐观增加 */ optimisticAdd: (points: number) => void + /** 清除积分状态(退出登录) */ + reset: () => void } export const usePointsStore = create((set, get) => ({ balance: null, - subscription: null, + dailyUsage: null, + membership: null, rules: null, + subscription: null, loading: false, error: null, init: async () => { - if (get().loading) return + // 已加载过不重复拉取 + if (get().balance && get().rules && get().subscription) return set({ loading: true, error: null }) try { - const [balance, sub, rules] = await Promise.all([ - getPointsBalance(), - getCurrentSubscription(), - getPointsRules(), + const [balance, rules, subscription, dailyUsage, membership] = await Promise.all([ + getPointsBalance().catch(() => null), + getPointsRules().catch(() => null), + getCurrentSubscription().catch(() => null), + getDailyUsage().catch(() => null), + getMembership().catch(() => null), ]) - set({ balance, subscription: sub, rules, loading: false }) - } catch (e) { - set({ error: (e as Error).message, loading: false }) + set({ + balance, + rules, + subscription, + dailyUsage, + membership, + loading: false, + }) + } catch (err) { + set({ error: (err as Error).message || "加载积分信息失败", loading: false }) } }, refreshBalance: async () => { try { - const balance = await getPointsBalance() - set({ balance }) - } catch (e) { - set({ error: (e as Error).message }) + const [balance, dailyUsage, membership, subscription] = await Promise.all([ + getPointsBalance(), + getDailyUsage().catch(() => null), + getMembership().catch(() => null), + getCurrentSubscription().catch(() => get().subscription), + ]) + set({ balance, dailyUsage, membership, subscription }) + } catch (err) { + set({ error: (err as Error).message || "刷新积分失败" }) } }, optimisticDeduct: (points: number) => { - const b = get().balance - if (!b) return + const { balance, membership } = get() + if (!balance) return set({ balance: { - ...b, - balance: Math.max(0, b.balance - points), - total_spent: b.total_spent + points, + ...balance, + balance: Math.max(0, balance.balance - points), + total_spent: balance.total_spent + points, }, + membership: membership + ? { ...membership, points_balance: Math.max(0, membership.points_balance - points) } + : null, }) }, optimisticAdd: (points: number) => { - const b = get().balance - if (!b) return + const { balance, membership } = get() + if (!balance) return set({ balance: { - ...b, - balance: b.balance + points, - total_earned: b.total_earned + points, + ...balance, + balance: balance.balance + points, + total_earned: balance.total_earned + points, }, + membership: membership + ? { ...membership, points_balance: membership.points_balance + points } + : null, + }) + }, + + reset: () => { + set({ + balance: null, + dailyUsage: null, + membership: null, + rules: null, + subscription: null, + loading: false, + error: null, }) }, })) -- 2.54.0 From 838418614b680291a375457a27452a67faf41d8f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:20:14 +0800 Subject: [PATCH 13/36] chore(points): align types/api with final backend contract - apps/web/src/api/subscription/subscription.ts --- apps/web/src/api/subscription/subscription.ts | 153 +++++++++++++++--- 1 file changed, 130 insertions(+), 23 deletions(-) diff --git a/apps/web/src/api/subscription/subscription.ts b/apps/web/src/api/subscription/subscription.ts index 3a6a0010f..f0a32375f 100644 --- a/apps/web/src/api/subscription/subscription.ts +++ b/apps/web/src/api/subscription/subscription.ts @@ -1,47 +1,154 @@ /** - * 订阅相关 API 函数 + * 订阅/会员 API 封装 + * 对齐后端 staging 实测最终契约(2026-09-16) + * + * Base path: /api/v1/ + * 所有请求走 apiClient(已配置 baseURL=/api/v1 和 token 拦截器)。 */ import apiClient from "../client" import type { + SubscriptionInfo, + SubscriptionPlan, + SubscriptionPlansResponse, BillingRecord, ChangePlanRequest, ChangePlanResponse, - SubscriptionInfo, + ToggleAutoRenewRequest, } from "./types" -/** 获取当前订阅信息 */ -export const getCurrentSubscription = async (): Promise => { - const response = await apiClient.get("/subscription/current") - return response.data +const MOCK_DELAY = 500 + +const MOCK_SUBSCRIPTION: SubscriptionInfo = { + id: "sub_mock_001", + plan_id: "free", + plan_name: "免费版", + status: "active", + billing_cycle: "monthly", + current_period_start: new Date(Date.now() - 30 * 86400_000).toISOString(), + current_period_end: new Date(Date.now() + 30 * 86400_000).toISOString(), + amount: 0, + auto_renew: false, + created_at: new Date(Date.now() - 30 * 86400_000).toISOString(), } -/** 获取账单记录列表 */ +const MOCK_PLANS: SubscriptionPlan[] = [ + { + plan_id: "free", + name: "免费版", + price_cents: 0, + monthly_price_cents: 0, + duration_days: 0, + points_discount: 1, + features: { max_resolution: "720p", free_clips_daily: 3 }, + }, + { + plan_id: "monthly", + name: "月度会员", + price_cents: 1990, + monthly_price_cents: 1990, + duration_days: 30, + points_discount: 0.9, + features: { max_resolution: "1080p", free_clips_daily: 10 }, + }, + { + plan_id: "quarterly", + name: "季度会员", + price_cents: 3990, + monthly_price_cents: 1330, + duration_days: 90, + points_discount: 0.85, + features: { max_resolution: "1080p", free_clips_daily: 15 }, + }, + { + plan_id: "yearly", + name: "年度会员", + price_cents: 15900, + monthly_price_cents: 1325, + duration_days: 365, + points_discount: 0.8, + features: { max_resolution: "4k", free_clips_daily: 30 }, + }, +] + +const MOCK_BILLING: BillingRecord[] = [] + +const isMock = () => (process.env.POINTS_API_MOCK as string | undefined) === "true" + +/** 获取当前订阅 */ +export const getCurrentSubscription = async (): Promise => { + if (isMock()) { + await new Promise((r) => setTimeout(r, MOCK_DELAY)) + return { ...MOCK_SUBSCRIPTION } + } + const { data } = await apiClient.get("/subscription/current") + return data +} + +/** 获取所有订阅档位 */ +export const getSubscriptionPlans = async (): Promise => { + if (isMock()) { + await new Promise((r) => setTimeout(r, MOCK_DELAY)) + return { plans: MOCK_PLANS.map((p) => ({ ...p, features: { ...p.features } })) } + } + const { data } = await apiClient.get("/subscription/plans") + return data +} + +/** 获取账单记录 */ export const getBillingRecords = async (): Promise => { - const response = await apiClient.get("/subscription/billing-records") - return response.data + if (isMock()) { + await new Promise((r) => setTimeout(r, MOCK_DELAY)) + return MOCK_BILLING.map((r) => ({ ...r })) + } + const { data } = await apiClient.get("/subscription/billing-records") + return data } /** 升级/降级套餐 */ export const changePlan = async (request: ChangePlanRequest): Promise => { - const response = await apiClient.post("/subscription/change-plan", request) - return response.data + if (isMock()) { + await new Promise((r) => setTimeout(r, MOCK_DELAY * 2)) + const plan = MOCK_PLANS.find((p) => p.plan_id === request.target_plan_id) + if (!plan) return { success: false, message: "套餐不存在" } + const newSub: SubscriptionInfo = { + ...MOCK_SUBSCRIPTION, + plan_id: plan.plan_id, + plan_name: plan.name, + billing_cycle: request.billing_cycle, + amount: plan.price_cents, + status: "pending", + current_period_start: new Date().toISOString(), + current_period_end: new Date(Date.now() + plan.duration_days * 86400_000).toISOString(), + auto_renew: true, + } + return { + success: true, + message: "订阅变更成功(mock,支付通道待接入)", + new_subscription: newSub, + } + } + const { data } = await apiClient.post("/subscription/change-plan", request) + return data } -/** 取消订阅 */ -export const cancelSubscription = async (): Promise<{ - success: boolean - message: string -}> => { - const response = await apiClient.post("/subscription/cancel") - return response.data +/** 取消订阅(到期后失效) */ +export const cancelSubscription = async (): Promise<{ success: boolean; message: string }> => { + if (isMock()) { + await new Promise((r) => setTimeout(r, MOCK_DELAY)) + return { success: true, message: "已取消订阅,到期后将不再续费" } + } + const { data } = await apiClient.post("/subscription/cancel") + return data } /** 切换自动续费 */ export const toggleAutoRenew = async ( - enabled: boolean, + req: ToggleAutoRenewRequest, ): Promise<{ success: boolean; message: string }> => { - const response = await apiClient.post("/subscription/toggle-auto-renew", { - enabled, - }) - return response.data + if (isMock()) { + await new Promise((r) => setTimeout(r, MOCK_DELAY)) + return { success: true, message: req.enabled ? "已开启自动续费" : "已关闭自动续费" } + } + const { data } = await apiClient.post("/subscription/toggle-auto-renew", req) + return data } -- 2.54.0 From efa33f210fd021524246549f40269ca8ae91798b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:20:20 +0800 Subject: [PATCH 14/36] chore(points): align types/api with final backend contract - apps/web/src/pages/subscription/Plans.tsx --- apps/web/src/pages/subscription/Plans.tsx | 549 +++++++--------------- 1 file changed, 160 insertions(+), 389 deletions(-) diff --git a/apps/web/src/pages/subscription/Plans.tsx b/apps/web/src/pages/subscription/Plans.tsx index d511ed08e..52e10bbfa 100644 --- a/apps/web/src/pages/subscription/Plans.tsx +++ b/apps/web/src/pages/subscription/Plans.tsx @@ -1,425 +1,196 @@ /** - * 会员订阅 & 积分充值页 - * v2 两档会员制:免费 vs 付费 - * 付费三档:月¥19.9 / 季¥39.9(推荐)/ 年¥159 - * 积分包:100/¥9.9、500/¥39、2000/¥129 + * 订阅计划页(会员升级) + * 基于后端 GET /subscription/plans 渲染 4 档:免费版/月卡/季卡/年卡 */ import React, { useEffect, useMemo, useState } from "react" -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 { Card, Button, Tag, Radio, Space, Alert, message, Spin } from "antd" import { useNavigate } from "react-router-dom" -import PageHead from "@/components/layout/PageHead" +import { CheckCircleOutlined, CloseCircleOutlined, CrownOutlined } from "@ant-design/icons" import { usePointsStore } from "@/store/pointsStore" -import { SUBSCRIPTION_PLANS, createPointsOrder } from "@/api/points" -import type { PointsPackage } from "@/api/points/types" -import { getPointsPackages } from "@/api/points" -import "./Plans.css" - -const { Title, Text, Paragraph } = Typography - -/** 免费会员权益 */ -const FREE_FEATURES = [ - { include: true, text: "每日 2 条免费混剪" }, - { 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: "积分购买最低 8 折" }, - { include: true, text: "批量导出" }, - { include: true, text: "多平台一键发布" }, - { include: true, text: "去重检测报告" }, -] +import { getSubscriptionPlans, changePlan } from "@/api/subscription" +import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types" +import type { SubscriptionPlan, PlanId, BillingCycle } from "@/api/subscription/types" +import styles from "./Subscription.css" const formatYuan = (cents: number) => `¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}` -const Plans: React.FC = () => { +const PlansPage: React.FC = () => { const navigate = useNavigate() - const { balance, init } = usePointsStore() - const [selectedBilling, setSelectedBilling] = useState<"monthly" | "quarterly" | "yearly">( - "quarterly", - ) - const [packages, setPackages] = useState([]) - const [subscribing] = useState(false) - const [buying, setBuying] = useState(null) + const { balance, 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") useEffect(() => { init() - getPointsPackages() - .then((r) => setPackages(r.packages)) - .catch(() => {}) + 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) + }) + .catch(() => message.error("加载订阅计划失败")) + .finally(() => setLoading(false)) }, [init]) - const isMember = !!balance?.is_member - const memberType = balance?.member_type ?? null + const currentPlanId = subscription?.plan_id ?? "free" - /** 根据会员等级计算积分包折后价(分) */ - const getPackPrice = (pkg: PointsPackage): number => { - const mt = memberType ?? "free" - type DiscountKey = - | "discounted_price_for_free" - | "discounted_price_for_monthly" - | "discounted_price_for_quarterly" - | "discounted_price_for_yearly" - const key = `discounted_price_for_${mt}` as DiscountKey - return pkg[key] ?? pkg.price - } - - const selectedPlan = useMemo( - () => SUBSCRIPTION_PLANS.find((p) => p.id === selectedBilling)!, - [selectedBilling], - ) - - const handleSubscribe = async () => { - Modal.confirm({ - title: "支付功能开发中", - icon: , - content: "微信/支付宝支付正在接入中,完成后会第一时间通知。是否返回首页继续使用免费功能?", - okText: "返回首页", - cancelText: "留在此页", - onOk: () => navigate("/app/dashboard"), - }) - // 实际对接时: - // try { - // setSubscribing(true) - // const order = await subscribe({ member_type: selectedBilling, payment_method: "wechat_pay" }) - // // 拉起支付... - // } catch (e: any) { - // message.error(e?.message ?? "订阅失败") - // } finally { - // setSubscribing(false) - // } - } - - const handleBuyPoints = async (pkg: PointsPackage) => { + const handleSubscribe = async (plan: SubscriptionPlan) => { + if (plan.plan_id === "free") { + message.info("您正在使用免费版") + return + } try { - setBuying(pkg.id) - const order = await createPointsOrder({ package_id: pkg.id }) - Modal.info({ - title: "支付功能开发中", - icon: , - content: ( -
- - 订单 {order.id.slice(0, 16)}… 已创建,金额{" "} - {formatYuan(order.price_cents)}, - 微信/支付宝支付正在接入中,正式上线后可直接付款。 - - - 现阶段所有功能均处于免费体验阶段,积分仅为演示数据。 - -
- ), - okText: "知道了", - }) - } catch (e) { - const err = e as { message?: string } - message.error(err?.message ?? "创建订单失败") + 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 || "订阅失败") } finally { - setBuying(null) + setProcessing(null) } } + 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 ( +
+ +
+ ) + return ( -
- - - - } - /> + } + /> + )} - {/* 当前状态卡片 */} - {balance && ( - - -
- -
- 当前身份 -
- {isMember ? ( - } - style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }} - > - {memberType === "yearly" - ? "年卡" - : memberType === "quarterly" - ? "季卡" - : "月卡"} - 会员 - - ) : ( - - 免费会员 - - )} - {balance.member_expires_at && ( - - 到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")} - - )} -
-
-
- 可用积分 -
- - {balance.balance} -
-
- {balance.free_clips_limit ? ( -
- 今日免费混剪 -
- {balance.free_clips_used ?? 0} - / {balance.free_clips_limit} 条 - {!isMember && ( - - 剩余 {balance.free_clips_remaining ?? 0} 条 - - )} -
-
- ) : null} -
- - - - - + {/* 免费版权益(用于对比) */} + {freePlan && ( + + + 当前免费版权益: + + 每日 {freePlan.features.free_clips_daily} 次免费生成 ·{" "} + {freePlan.features.max_resolution} 导出 · 非会员积分 ×{1 / freePlan.points_discount}{" "} + 倍率 + + 当前积分:{balance?.balance ?? 0} + )} - {/* 两档会员对比 */} - - <CrownFilled style={{ color: "#f59e0b", marginRight: 8 }} /> - 选择适合您的方案 - - - {/* 计费周期切换 */} -
- {SUBSCRIPTION_PLANS.map((p) => ( - - ))} -
- - - {/* 免费会员 */} - - -
- - 免费会员 - -
- ¥ - 0 - /永久 -
- 体验 AI 剪辑的基础能力 -
- -
    - {FREE_FEATURES.map((f, i) => ( -
  • - {f.include ? ( - - ) : ( - — - )} - {f.text} -
  • - ))} -
- -
- - - {/* 付费会员 */} - - -
- 推荐 -
-
- - <CrownFilled style={{ color: "#f59e0b" }} /> 付费会员 - -
- ¥ - {selectedPlan.price_yuan} - {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 price = getPackPrice(pkg) - const discount = price < pkg.price ? Math.round((1 - price / pkg.price) * 100) : 0 - const unit = price / pkg.points + {/* 套餐卡片 */} + 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 ( -
- 0 ? "has-discount" : ""} ${pkg.id === "basic_pack" ? "recommended" : ""}`} - hoverable - > - {pkg.id === "basic_pack" &&
热门
} - {discount > 0 && ( - - 会员{10 - discount / 10}折 - + 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 超清 +
)} -
{pkg.name}
-
- {pkg.points.toLocaleString()} 积分 +
+ 非会员倍率消耗
-
- ¥ - - {(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")} - - {discount > 0 && ( - ¥{(pkg.price / 100).toFixed(0)} - )} -
-
≈¥{unit.toFixed(3)}/积分
- - - +
+ + ) })} - -
+ + ) } -export default Plans -export const Component = Plans +export default PlansPage -- 2.54.0 From e404de4fed9d03731fdeb23b4cec21f62d7148c6 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:20:26 +0800 Subject: [PATCH 15/36] chore(points): align types/api with final backend contract - apps/web/src/pages/subscription/constants.ts --- apps/web/src/pages/subscription/constants.ts | 36 ++++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/web/src/pages/subscription/constants.ts b/apps/web/src/pages/subscription/constants.ts index 0e1b1843e..34f2ae02d 100644 --- a/apps/web/src/pages/subscription/constants.ts +++ b/apps/web/src/pages/subscription/constants.ts @@ -1,16 +1,32 @@ -import type { PlanType, BillingCycle } from "@/api/subscription" +/** + * 订阅套餐元数据 + * 价格硬编码兜底,真实价格以 GET /subscription/plans 为准 + */ +import type { PlanId, BillingCycle } from "@/api/subscription/types" -export const PLANS_META: Record = { - free: { name: "体验版", price: 0, yearlyPrice: 0 }, - standard: { name: "标准版", price: 99, yearlyPrice: 990 }, - pro: { name: "专业版", price: 299, yearlyPrice: 2990 }, - enterprise: { name: "企业版", price: 0, yearlyPrice: 0 }, +export const PLANS_META: Record< + PlanId, + { name: string; priceYuan: number; yearlyPriceYuan: number } +> = { + free: { name: "免费版", priceYuan: 0, yearlyPriceYuan: 0 }, + monthly: { name: "月度会员", priceYuan: 19.9, yearlyPriceYuan: 0 }, + quarterly: { name: "季度会员", priceYuan: 39.9, yearlyPriceYuan: 0 }, + yearly: { name: "年度会员", priceYuan: 0, yearlyPriceYuan: 159 }, } -export const getPlanName = (planId: PlanType | string) => PLANS_META[planId]?.name ?? "体验版" +export const getPlanName = (planId: PlanId | string): string => + PLANS_META[planId as PlanId]?.name ?? "免费版" -export const getPlanPrice = (planId: PlanType | string, cycle: BillingCycle) => { - const plan = PLANS_META[planId] +/** + * 获取展示价格(元) + * - monthly 周期:月卡/季卡按自身价格,年卡按月折算 + * - yearly 周期:年卡按年价,其他按年价 * 12 + */ +export const getPlanPrice = (planId: PlanId | string, cycle: BillingCycle): number => { + const plan = PLANS_META[planId as PlanId] if (!plan) return 0 - return cycle === "yearly" ? plan.yearlyPrice : plan.price + if (cycle === "yearly") { + return plan.yearlyPriceYuan > 0 ? plan.yearlyPriceYuan : plan.priceYuan * 12 + } + return plan.priceYuan } -- 2.54.0 From 71e36d1f302e27bd1741f028695f1dc8afa7c6d9 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:20:32 +0800 Subject: [PATCH 16/36] chore(points): align types/api with final backend contract - apps/web/src/pages/generate/GeneratePage.tsx --- apps/web/src/pages/generate/GeneratePage.tsx | 25 ++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index e41a8da8c..e7ca18edd 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -39,7 +39,7 @@ const GeneratePage: React.FC = () => { /* ── 表单状态 ── */ const formState = useGenerateFormState() /* ── 积分状态 ── */ - const { balance, init: initPoints } = usePointsStore() + const { balance, dailyUsage, rules, init: initPoints } = usePointsStore() useEffect(() => { initPoints() }, [initPoints]) @@ -358,7 +358,14 @@ const GeneratePage: React.FC = () => { const handleConfirmGenerate = useCallback(async () => { // 积分预检查 const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1 - const check = hasEnoughPoints(balance ?? null, units) + const check = hasEnoughPoints( + balance ?? null, + units, + dailyUsage ?? null, + [], + "free", + rules?.free_user_multiplier ?? 1.15, + ) if (!check.sufficient) { message.error(check.reason ?? "积分不足,请充值") return @@ -397,6 +404,8 @@ const GeneratePage: React.FC = () => { handleGenerate, setCurrentStep, balance, + dailyUsage, + rules, ]) /* ── 步骤导航 ── */ @@ -423,8 +432,16 @@ const GeneratePage: React.FC = () => { /* ── 积分消耗估算(步骤3确认生成展示用) ── */ const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1 const pointsEstimate = useMemo( - () => hasEnoughPoints(balance ?? null, unitsForCost), - [unitsForCost, balance], + () => + hasEnoughPoints( + balance ?? null, + unitsForCost, + dailyUsage ?? null, + [], + "free", + rules?.free_user_multiplier ?? 1.15, + ), + [unitsForCost, balance, dailyUsage, rules], ) const insufficientPoints = !pointsEstimate.sufficient -- 2.54.0 From b37ca41207d7a7a6e6e22fc1f5f02d21ec44e08b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:20:38 +0800 Subject: [PATCH 17/36] chore(points): align types/api with final backend contract - apps/web/src/api/points/index.ts --- apps/web/src/api/points/index.ts | 714 +++++++++++++------------------ 1 file changed, 292 insertions(+), 422 deletions(-) diff --git a/apps/web/src/api/points/index.ts b/apps/web/src/api/points/index.ts index 8344b97af..83438bd75 100644 --- a/apps/web/src/api/points/index.ts +++ b/apps/web/src/api/points/index.ts @@ -1,477 +1,347 @@ /** - * 积分 & 会员 API 封装(v2 两档会员制) - * 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。 + * 积分系统 API 封装 + * 对齐后端 staging 实测最终契约(2026-09-16) + * + * 当前 POINTS_API_MOCK=true:使用 MOCK_* 常量 + setTimeout 模拟延迟, + * 等后端 P0(支付通道接入、change-plan 校验)稳定后切 false 联调。 + * + * 会员/订阅 API 在 @/api/subscription 中定义,避免重复封装。 */ import apiClient from "../client" import type { PointsBalance, - PointsTransaction, - PointsTransactionsParams, - PointsTransactionsResponse, - PointsPackagesResponse, - PointsRechargeRequest, - PointsOrder, + PointsRule, PointsRulesResponse, + PointsPackage, + PointsPackagesResponse, + PointsTransaction, + PointsTransactionsResponse, PointsCheckRequest, PointsCheckResponse, - SubscriptionCurrent, - SubscribeRequest, - SubscriptionPlan, + CreateRechargeOrderRequest, + CreateRechargeOrderResponse, + DailyUsage, + MembershipResponse, } from "./types" -/** - * 是否启用 mock 数据(后端 PR 合入前为 true;对接真实接口后改为 false) - */ -export const POINTS_API_MOCK = true +/** 模拟网络延迟(ms) */ +const MOCK_DELAY = 500 -// ==================== Mock 数据 ==================== +/* ================================================================ + * Mock 数据 + * ================================================================ */ + +/** mock 余额(无 free_clips_* 字段,已拆分到 dailyUsage) */ const MOCK_BALANCE: PointsBalance = { - balance: 580, - total_earned: 1200, - total_spent: 620, + balance: 258, + total_earned: 500, + total_spent: 242, is_member: false, member_type: null, member_expires_at: null, - free_clips_used: 1, - free_clips_limit: 2, - free_clips_remaining: 1, -} - -const MOCK_PACKAGES: PointsPackagesResponse = { - packages: [ - { - id: "starter_pack", - name: "体验包", - points: 100, - price: 990, - discounted_price_for_free: 990, - discounted_price_for_monthly: 891, - discounted_price_for_quarterly: 861, - discounted_price_for_yearly: 792, - }, - { - id: "basic_pack", - name: "基础包", - points: 500, - price: 3900, - discounted_price_for_free: 3900, - discounted_price_for_monthly: 3510, - discounted_price_for_quarterly: 3393, - discounted_price_for_yearly: 3120, - }, - { - id: "pro_pack", - name: "专业包", - points: 2000, - price: 12900, - discounted_price_for_free: 12900, - discounted_price_for_monthly: 11610, - discounted_price_for_quarterly: 11223, - discounted_price_for_yearly: 10320, - }, - ], - user_member_type: "free", - unit_price_yuan: 0.1, } const MOCK_RULES: PointsRulesResponse = { rules: [ { scene_key: "ai_voice", - scene_name: "AI 配音", - points_per_use: 1, - unit: "分钟", - description: "每生成 1 分钟配音", + name: "AI 配音", + base_points: 2, + unit: "次", + description: "单次配音消耗 2 积分,超 30 秒每 30 秒 +1 积分", + extra_per_30s: 1, }, { scene_key: "ai_video", - scene_name: "智能混剪", - points_per_use: 3, + name: "AI 视频生成", + base_points: 8, unit: "条", - extra_per_30s: 1, - description: "每条 ≤30s 3 积分,每加 30s +1", + description: "单条视频 8 积分起,按视频时长加收", + extra_per_30s: 3, }, { scene_key: "ai_digital_human", - scene_name: "AI 数字人", - points_per_use: 15, - unit: "分钟", - description: "每生成 1 分钟口播", + name: "AI 数字人", + base_points: 15, + unit: "次", + description: "数字人生成 15 积分起", + extra_per_30s: 5, }, { scene_key: "voice_clone_train", - scene_name: "声音克隆训练", - points_per_use: 0, + name: "声音克隆训练", + base_points: 20, unit: "次", - description: "训练免费", + description: "声音模型训练一次性消耗 20 积分", + }, + { + scene_key: "voice_clone_synth", + name: "声音克隆合成", + base_points: 3, + unit: "次", + description: "使用克隆声音合成音频每次 3 积分", + }, + { + scene_key: "douyin_extract", + name: "抖音文案提取", + base_points: 1, + unit: "次", + description: "提取抖音视频文案每次 1 积分", + }, + { + scene_key: "ai_rewrite", + name: "AI 文案改写", + base_points: 2, + unit: "次", + description: "AI 改写文案每次 2 积分", + }, + { + scene_key: "ai_title", + name: "AI 标题生成", + base_points: 1, + unit: "次", + description: "AI 生成标题每次 1 积分,一次生成多条", + }, + { + scene_key: "ai_cover", + name: "AI 封面生成", + base_points: 3, + unit: "次", + description: "AI 生成封面每次 3 积分", }, - { scene_key: "voice_clone_synth", scene_name: "声音克隆合成", points_per_use: 1, unit: "分钟" }, - { scene_key: "douyin_extract", scene_name: "抖音链接提取", points_per_use: 1, unit: "次" }, - { scene_key: "ai_rewrite", scene_name: "AI 改写文案", points_per_use: 1, unit: "次" }, - { scene_key: "ai_title", scene_name: "AI 标题生成", points_per_use: 1, unit: "次" }, - { scene_key: "ai_cover", scene_name: "AI 封面生成", points_per_use: 1, unit: "张" }, ], free_user_multiplier: 1.15, - note: "免费用户消耗 = 会员消耗 × 1.15,向上取整", } -function genMockTransactions(): PointsTransactionsResponse { - const now = new Date() - const list = [ +const MOCK_PACKAGES: PointsPackagesResponse = { + packages: [ + { code: "points_100", name: "100 积分", points: 100, price_cents: 990, unit_price: 0.099 }, + { code: "points_500", name: "500 积分", points: 500, price_cents: 4490, unit_price: 0.0898 }, + { code: "points_1000", name: "1000 积分", points: 1000, price_cents: 7990, unit_price: 0.0799 }, { - src: "ai_voice", - name: "AI 配音", - type: "spend" as const, - amt: 1, - desc: "生成配音 1 分钟", - days: 0, - hours: 0, - mins: 30, + code: "points_3000", + name: "3000 积分", + points: 3000, + price_cents: 19900, + unit_price: 0.0663, }, - { - src: "ai_video", - name: "智能混剪", - type: "spend" as const, - amt: 5, - desc: "生成 1 分钟视频(基础3+30s*2)", - days: 0, - hours: 1, - mins: 15, - }, - { - src: "task_reward", - name: "任务奖励", - type: "earn" as const, - amt: 20, - desc: "首次生成视频奖励", - days: 1, - hours: 0, - mins: 0, - }, - { - src: "recharge", - name: "充值", - type: "earn" as const, - amt: 500, - desc: "基础包充值", - days: 15, - hours: 0, - mins: 0, - }, - { - src: "ai_rewrite", - name: "AI 改写文案", - type: "spend" as const, - amt: 2, - desc: "免费用户价(1×1.15 向上取整)", - days: 16, - hours: 2, - mins: 10, - }, - { - src: "ai_title", - name: "AI 标题生成", - type: "spend" as const, - amt: 2, - desc: "免费用户价", - days: 16, - hours: 3, - mins: 0, - }, - { - src: "douyin_extract", - name: "抖音链接提取", - type: "spend" as const, - amt: 2, - desc: "提取 3 分钟文案", - days: 18, - hours: 0, - mins: 0, - }, - { - src: "ai_digital_human", - name: "AI 数字人", - type: "spend" as const, - amt: 18, - desc: "数字人口播 1 分钟(免费用户价)", - days: 20, - hours: 0, - mins: 0, - }, - { - src: "task_reward", - name: "任务奖励", - type: "earn" as const, - amt: 50, - desc: "注册赠送", - days: 30, - hours: 0, - mins: 0, - }, - ] - let bal = MOCK_BALANCE.balance - const items = list - .map((t, i) => { - const signed = t.type === "earn" ? t.amt : -t.amt - const balance_after = bal // 按时间倒序:earliest 先算 - // adjust running bal - bal = t.type === "earn" ? bal - t.amt : bal + t.amt - const d = new Date(now) - d.setDate(d.getDate() - t.days) - d.setHours(d.getHours() - t.hours) - d.setMinutes(d.getMinutes() - t.mins) - return { - id: `tx_${i + 1}`, - type: t.type, - source: t.src as PointsBalance extends never ? never : string, - source_name: t.name, - amount: t.amt, - signed_amount: signed, - balance_after, - description: t.desc, - ref_id: null, - created_at: d.toISOString(), - } - }) - .reverse() - // Rebuild balance_after going forward - let running = 50 + 0 // after registration gift - for (let i = items.length - 1; i >= 0; i--) { - const it = items[i] as PointsTransaction & { balance_after?: number } - if (it.source === "task_reward" && it.description.includes("注册")) running = 50 - } - running = 50 - const fwd = [...items].reverse() as Array - for (const it of fwd) { - running += it.signed_amount - it.balance_after = running - } - return { items: fwd, total: fwd.length, page: 1, page_size: 20 } as PointsTransactionsResponse + ], + user_discount: null, } -// ==================== 真实 API ==================== - -/** 查询积分余额 + 会员状态 */ -export async function getPointsBalance(): Promise { - if (POINTS_API_MOCK) { - return new Promise((r) => setTimeout(() => r({ ...MOCK_BALANCE }), 180)) - } - const res = await apiClient.get("/points/balance") - return res.data -} - -/** 查询积分流水(分页) */ -export async function getPointsTransactions( - params: PointsTransactionsParams = {}, -): Promise { - if (POINTS_API_MOCK) { - return new Promise((r) => setTimeout(() => r(genMockTransactions()), 200)) - } - const res = await apiClient.get("/points/transactions", { params }) - return res.data -} - -/** 查询积分包列表 */ -export async function getPointsPackages(): Promise { - if (POINTS_API_MOCK) { - return new Promise((r) => setTimeout(() => r({ ...MOCK_PACKAGES }), 150)) - } - const res = await apiClient.get("/points/packages") - return res.data -} - -/** 创建积分充值订单(mock 阶段返回 "pending" 订单,前端弹"支付开发中") */ -export async function createPointsOrder(req: PointsRechargeRequest): Promise { - if (POINTS_API_MOCK) { - const pkg = MOCK_PACKAGES.packages.find((p) => p.id === req.package_id) - const mt = MOCK_PACKAGES.user_member_type - type DiscountKey = - | "discounted_price_for_free" - | "discounted_price_for_monthly" - | "discounted_price_for_quarterly" - | "discounted_price_for_yearly" - const discountKey = `discounted_price_for_${mt}` as DiscountKey - const price = pkg?.[discountKey] ?? pkg?.price ?? 0 - return new Promise((r) => - setTimeout( - () => - r({ - id: `mock_order_${Date.now()}`, - package_id: req.package_id, - package_name: pkg?.name ?? "", - points_amount: pkg?.points ?? 0, - price_cents: price, - original_price_cents: pkg?.price ?? 0, - discount: price / (pkg?.price || 1), - currency: "CNY", - status: "pending", - payment_method: null, - payment_id: null, - paid_at: null, - expire_at: null, - created_at: new Date().toISOString(), - }), - 300, - ), - ) - } - const res = await apiClient.post("/points/recharge", req) - return res.data -} - -/** 查询积分消耗规则 */ -export async function getPointsRules(): Promise { - if (POINTS_API_MOCK) { - return new Promise((r) => setTimeout(() => r({ ...MOCK_RULES }), 120)) - } - const res = await apiClient.get("/points/rules") - return res.data -} - -/** 消费前余额预检查 */ -export async function checkPoints(req: PointsCheckRequest): Promise { - if (POINTS_API_MOCK) { - const rule = MOCK_RULES.rules.find((r) => r.scene_key === req.scene_key) - if (!rule) { - return { - allowed: false, - required_points: 0, - current_balance: MOCK_BALANCE.balance, - remaining_after: MOCK_BALANCE.balance, - is_free_quota: false, - code: "SCENE_NOT_FOUND", - message: "未知场景", - recharge_url: "/app/points", - } - } - const units = req.units ?? 1 - let base = rule.points_per_use * units - if (rule.extra_per_30s && units > 1) { - // ai_video extra_per_30s: base already covers first 30s, subtract - base = rule.points_per_use + rule.extra_per_30s * (units - 1) - } - const isFree = - MOCK_BALANCE.is_member === false && - req.scene_key === "ai_video" && - (MOCK_BALANCE.free_clips_remaining ?? 0) > 0 - const needed = isFree - ? 0 - : MOCK_BALANCE.is_member - ? base - : Math.ceil(base * MOCK_RULES.free_user_multiplier) - const allowed = isFree || MOCK_BALANCE.balance >= needed - return { - allowed, - required_points: needed, - current_balance: MOCK_BALANCE.balance, - remaining_after: MOCK_BALANCE.balance - needed, - is_free_quota: isFree, - code: allowed ? undefined : "INSUFFICIENT_POINTS", - message: allowed - ? undefined - : `积分不足,需要 ${needed} 积分,当前余额 ${MOCK_BALANCE.balance}`, - recharge_url: "/app/points", - } - } - const res = await apiClient.post("/points/check", req) - return res.data -} - -// ==================== 订阅相关 ==================== - -/** 订阅套餐(定价常量,前端硬编码;折扣由后端会员类型决定) */ -export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [ +const MOCK_TRANSACTIONS: PointsTransaction[] = [ { - id: "monthly", - name: "月卡", - price_cents: 1990, - price_yuan: 19.9, - per_month_yuan: 19.9, - billing_label: "/月", + id: 1, + type: "deduct", + source: "ai_video", + amount: 10, + balance_after: 248, + description: "AI 视频生成 ×1(非会员倍率)", + ref_id: "task_abc123", + created_at: "2026-09-16T08:30:00Z", }, { - id: "quarterly", - name: "季卡", - price_cents: 3990, - price_yuan: 39.9, - per_month_yuan: 13.3, - savings_percent: 33, - recommended: true, - billing_label: "/季", + id: 2, + type: "add", + source: "recharge", + amount: 100, + balance_after: 258, + description: "充值 100 积分", + ref_id: "order_xyz789", + created_at: "2026-09-15T14:20:00Z", }, { - id: "yearly", - name: "年卡", - price_cents: 15900, - price_yuan: 159, - per_month_yuan: 13.25, - savings_percent: 33, - billing_label: "/年", + id: 3, + type: "deduct", + source: "ai_voice", + amount: 3, + balance_after: 158, + description: "AI 配音 ×1(45s 加收)", + ref_id: "", + created_at: "2026-09-15T10:15:00Z", + }, + { + id: 4, + type: "add", + source: "sign_up", + amount: 60, + balance_after: 161, + description: "新用户注册赠送", + ref_id: "", + created_at: "2026-09-10T09:00:00Z", + }, + { + id: 5, + type: "deduct", + source: "ai_title", + amount: 1, + balance_after: 101, + description: "AI 标题生成 ×1", + ref_id: "", + created_at: "2026-09-14T16:45:00Z", }, ] -/** 查询当前订阅 */ -export async function getCurrentSubscription(): Promise { - if (POINTS_API_MOCK) { - return new Promise((r) => - setTimeout( - () => - r({ - is_member: MOCK_BALANCE.is_member, - member_type: MOCK_BALANCE.member_type, - member_type_name: MOCK_BALANCE.is_member ? "付费会员" : "免费会员", - status: MOCK_BALANCE.is_member ? "active" : "none", - current_period_start: null, - current_period_end: MOCK_BALANCE.member_expires_at, - auto_renew: false, - points_discount: MOCK_BALANCE.is_member ? 0.9 : 1.0, - }), - 150, - ), - ) - } - const res = await apiClient.get("/subscription/current") - return res.data +const MOCK_DAILY_USAGE: DailyUsage = { + free_clips_used: 1, + free_clips_limit: 3, + free_clips_remaining: 2, + reset_at: new Date(Date.now() + 8 * 3600_000).toISOString(), } -/** 开通/续费会员 */ -export async function subscribe(req: SubscribeRequest): Promise { - if (POINTS_API_MOCK) { - const plan = SUBSCRIPTION_PLANS.find((p) => p.id === req.member_type)! - return new Promise((r) => - setTimeout( - () => - r({ - id: `mock_sub_${Date.now()}`, - package_id: plan.id, - package_name: plan.name, - points_amount: 0, - price_cents: plan.price_cents, - original_price_cents: plan.price_cents, - discount: 1, - currency: "CNY", - status: "pending", - payment_method: null, - payment_id: null, - paid_at: null, - expire_at: null, - created_at: new Date().toISOString(), - }), - 300, - ), - ) - } - const res = await apiClient.post("/subscription/subscribe", req) - return res.data +const MOCK_MEMBERSHIP: MembershipResponse = { + is_member: false, + member_type: null, + member_expires_at: null, + points_balance: 258, + max_resolution: "720p", } -/** 取消自动续费 */ -export async function cancelAutoRenew(): Promise<{ success: boolean; message: string }> { - if (POINTS_API_MOCK) { - return new Promise((r) => - setTimeout(() => r({ success: true, message: "已取消自动续费" }), 200), - ) +/* ================================================================ + * 积分 API + * ================================================================ */ + +/** 获取积分余额 */ +export async function getPointsBalance(): Promise { + if (process.env.POINTS_API_MOCK === "true") { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) + return { ...MOCK_BALANCE } } - const res = await apiClient.post("/subscription/cancel") - return res.data + const { data } = await apiClient.get(`/points/balance`) + return data +} + +/** 获取积分消耗规则 */ +export async function getPointsRules(): Promise { + if (process.env.POINTS_API_MOCK === "true") { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) + return { rules: [...MOCK_RULES.rules], free_user_multiplier: MOCK_RULES.free_user_multiplier } + } + const { data } = await apiClient.get(`/points/rules`) + return data +} + +/** 获取充值包列表 */ +export async function getPointsPackages(): Promise { + if (process.env.POINTS_API_MOCK === "true") { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) + return { packages: MOCK_PACKAGES.packages.map((p) => ({ ...p })), user_discount: null } + } + const { data } = await apiClient.get(`/points/packages`) + return data +} + +/** + * 获取积分流水(分页) + */ +export async function getPointsTransactions( + page = 1, + pageSize = 20, +): Promise { + if (process.env.POINTS_API_MOCK === "true") { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) + const start = (page - 1) * pageSize + const items = MOCK_TRANSACTIONS.slice(start, start + pageSize) + return { + items: items.map((t) => ({ ...t })), + total: MOCK_TRANSACTIONS.length, + page, + page_size: pageSize, + } + } + const { data } = await apiClient.get(`/points/transactions`, { + params: { page, page_size: pageSize }, + }) + return data +} + +/** + * 创建充值订单 + * 注意:当前 pay_params 返回空对象 {}(支付通道未接入), + * 前端可以完成订单创建 UI,但无法发起真实支付,待后续支付通道接入后联调。 + */ +export async function createPointsOrder( + data: CreateRechargeOrderRequest, +): Promise { + if (process.env.POINTS_API_MOCK === "true") { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY * 2)) + const pkg = MOCK_PACKAGES.packages.find((p) => p.code === data.package_id) + if (!pkg) throw new Error("充值包不存在") + return { + id: `mock_order_${Date.now()}`, + order_type: "points_recharge", + product_code: pkg.code, + amount_cents: pkg.price_cents, + points_amount: pkg.points, + status: "pending", + pay_params: {}, + expire_at: new Date(Date.now() + 30 * 60_000).toISOString(), + created_at: new Date().toISOString(), + } + } + const { data: d } = await apiClient.post(`/points/recharge`, data) + return d +} + +/** + * 积分预检查(消耗前调用) + */ +export async function checkPoints(data: PointsCheckRequest): Promise { + if (process.env.POINTS_API_MOCK === "true") { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) + const rule = MOCK_RULES.rules.find((r) => r.scene_key === data.scene_key) + if (!rule) { + throw { + error: { + code: 400, + message: `未知场景:${data.scene_key}`, + valid_scenes: MOCK_RULES.rules.map((r) => r.scene_key), + }, + } + } + const durationExtra = + data.duration_minutes && data.duration_minutes > 0.5 && rule.extra_per_30s + ? Math.ceil((data.duration_minutes * 60 - 30) / 30) * rule.extra_per_30s + : 0 + const base = (rule.base_points + durationExtra) * data.quantity + const balance = MOCK_BALANCE.balance + const multiplier = MOCK_BALANCE.is_member ? 1 : MOCK_RULES.free_user_multiplier + const required = Math.ceil(base * multiplier) + // 免费额度抵扣 + const isFreeQuota = !MOCK_BALANCE.is_member && MOCK_DAILY_USAGE.free_clips_remaining > 0 + const finalRequired = isFreeQuota ? 0 : required + return { + allowed: balance >= finalRequired, + required_points: finalRequired, + current_balance: balance, + remaining_after: balance - finalRequired, + is_free_quota: isFreeQuota, + } + } + const { data: d2 } = await apiClient.post(`/points/check`, data) + return d2 +} + +/* ================================================================ + * 每日免费额度 + 会员聚合信息(新接口) + * ================================================================ */ + +/** 获取每日免费额度使用情况 */ +export async function getDailyUsage(): Promise { + if (process.env.POINTS_API_MOCK === "true") { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) + return { ...MOCK_DAILY_USAGE } + } + const { data } = await apiClient.get(`/usage/daily`) + return data +} + +/** 获取会员聚合信息(创作页可用来判断 max_resolution) */ +export async function getMembership(): Promise { + if (process.env.POINTS_API_MOCK === "true") { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)) + return { ...MOCK_MEMBERSHIP } + } + const { data } = await apiClient.get(`/points/subscription/membership`) + return data } -- 2.54.0 From 8dbc8b48f3d6fee85e1bb2586b75d131c77cd28f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:20:44 +0800 Subject: [PATCH 18/36] chore(points): align types/api with final backend contract - apps/web/src/components/common/PointsCost/index.tsx --- .../components/common/PointsCost/index.tsx | 207 +++++++----------- 1 file changed, 74 insertions(+), 133 deletions(-) diff --git a/apps/web/src/components/common/PointsCost/index.tsx b/apps/web/src/components/common/PointsCost/index.tsx index ba8fac4d5..72021a42d 100644 --- a/apps/web/src/components/common/PointsCost/index.tsx +++ b/apps/web/src/components/common/PointsCost/index.tsx @@ -1,161 +1,102 @@ /** - * 功能操作按钮旁的"消耗积分"提示 - * 例:[生成配音] 💎 -1 积分 - * - 根据 scene_key 自动读取规则 - * - 免费用户自动计算 ×1.15 向上取整 - * - 余额不足时显示红色告警 + 充值提示 + * 积分消耗提示组件(创作页/按钮旁使用) * - * 使用: + * 展示规则: + * - 非会员 + 免费额度未用完:展示「消耗 X 积分(今日剩余 Y 次免费)」 + * - 非会员 + 免费额度用完:展示「消耗 X 积分(非会员 ×free_user_multiplier)」 + * - 会员:展示「消耗 X 积分(会员折扣)」 + * + * 兼容旧 props: + * - 旧 props.units 会自动映射为 quantity,保持现有调用方不报错 */ -import React, { useEffect, useMemo, useState } from "react" -import { Tooltip } from "antd" -import { WarningOutlined } from "@ant-design/icons" +import React, { useMemo } from "react" +import { Tooltip, Tag } from "antd" +import { ThunderboltOutlined, InfoCircleOutlined } from "@ant-design/icons" import { usePointsStore } from "@/store/pointsStore" +import styles from "./PointsCost.module.css" import type { PointsSource } from "@/api/points/types" -import { getPointsRules } from "@/api/points" -import "./PointsCost.css" interface Props { - /** 消耗场景 key */ - scene: PointsSource - /** 单位数(分钟数/条数/张数),默认 1 */ - units?: number - /** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */ + /** 积分场景键 */ + source: PointsSource + /** 消耗数量(新字段) */ + quantity?: number + /** 预计时长(分钟),可选 */ + durationMinutes?: number + /** 简化显示(只显示图标 + 数字) */ compact?: boolean - /** 余额不足时,是否显示充值提示 */ - showRechargeHint?: boolean - /** 自定义 class */ - className?: string -} - -/** 单位中文 */ -const UNIT_LABEL: Record = { - 分钟: "分钟", - 条: "条", - 次: "次", - 张: "张", + /** + * @deprecated 已改名为 quantity,保留旧字段做兼容映射 + */ + units?: number } const PointsCost: React.FC = ({ - scene, - units = 1, + source, + quantity, + units, + durationMinutes, compact = false, - showRechargeHint = true, - className = "", }) => { - const { balance, init } = usePointsStore() - const [rules, setRules] = useState> | null>(null) + const { balance, rules, dailyUsage, membership } = usePointsStore() + const qty = quantity ?? units ?? 1 + const rule = rules?.rules.find((r) => r.scene_key === source) + const freeMultiplier = rules?.free_user_multiplier ?? 1.15 + const pointsDiscount = membership?.is_member + ? 1 // 会员折扣可通过 subscription.plans.points_discount 读取,这里简化用 1 占位 + : freeMultiplier - useEffect(() => { - if (!balance) init() - if (!rules) { - getPointsRules() - .then(setRules) - .catch(() => {}) - } - }, [balance, init, rules]) + const cost = useMemo(() => { + if (!rule) return 0 + const extra = + durationMinutes && durationMinutes > 0.5 && rule.extra_per_30s + ? Math.ceil((durationMinutes * 60 - 30) / 30) * rule.extra_per_30s + : 0 + const base = (rule.base_points + extra) * qty + return membership?.is_member + ? Math.floor(base * pointsDiscount) + : Math.ceil(base * pointsDiscount) + }, [rule, qty, durationMinutes, pointsDiscount, membership?.is_member]) - const { cost, isFreeQuota, rule, isFreeUser, insufficient } = useMemo(() => { - if (!rules || !balance) { - return { - cost: 0, - isFreeQuota: false, - rule: null, - isFreeUser: !balance?.is_member, - insufficient: false, - } - } - const rule = rules.rules.find((r) => r.scene_key === scene) - if (!rule) - return { - cost: 0, - isFreeQuota: false, - rule: null, - isFreeUser: !balance.is_member, - insufficient: false, - } - // 免费训练不扣费 - if (rule.points_per_use === 0) { - return { - cost: 0, - isFreeQuota: false, - rule, - isFreeUser: !balance.is_member, - insufficient: false, - } - } + if (!rule) return null - // 智能混剪:首条30s=3分,每+30s +1 - let baseCost: number - if (scene === "ai_video" && rule.extra_per_30s) { - // units 当作"30s 段数"更简单;按分钟算:minutes 个 30s 段 - 1 - const segments = Math.max(1, Math.ceil(units * 2)) // 1min = 2 segments - baseCost = rule.points_per_use + rule.extra_per_30s * (segments - 1) - } else { - baseCost = rule.points_per_use * Math.max(1, units) - } + const freeRemain = dailyUsage?.free_clips_remaining ?? 0 + const isFreeQuota = !membership?.is_member && freeRemain > 0 - // 混剪 + 免费用户 + 今日有免费额度 → 免费 - const isFree = - scene === "ai_video" && !balance.is_member && (balance.free_clips_remaining ?? 0) > 0 - - const isFreeUser = !balance.is_member - const cost = isFree - ? 0 - : isFreeUser - ? Math.ceil(baseCost * rules.free_user_multiplier) - : baseCost - const insufficient = !isFree && balance.balance < cost - return { cost, isFreeQuota: isFree, rule, isFreeUser, insufficient } - }, [rules, balance, scene, units]) - - if (!rule || !balance) { - return - } - - if (rule.points_per_use === 0) { + if (compact) { return ( - - 免费 - - ) - } - - if (isFreeQuota) { - return ( - - - 免费 - {!compact && ( - (今日剩余 {balance.free_clips_remaining} 条) - )} - + + + {cost} + ) } - const unitLabel = compact - ? "" - : ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}` - return ( - - 💎 - -{cost} - {unitLabel && 积分{unitLabel}} - {isFreeUser && !compact && ( - - 非会员 - +
+ + 消耗 {cost} 积分 + {isFreeQuota ? ( + + 今日剩 {freeRemain} 次免费 + + ) : ( + !membership?.is_member && ( + + + 非会员 ×{freeMultiplier} + + + + ) )} - {insufficient && showRechargeHint && ( - - - + {balance && balance.balance < cost && ( + + 余额不足 + )} - +
) } -- 2.54.0 From 01132e1d4b41870fca71448ae30b6d521c51131d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:43:17 +0800 Subject: [PATCH 19/36] chore(points): fix lint - apps/web/src/api/subscription/index.ts -- 2.54.0 From 11cfa6df12fe74ecdf3e4d68c68146ecc22bba13 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:43:20 +0800 Subject: [PATCH 20/36] 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 -- 2.54.0 From 75e06bed45848c119a073bf9d885646f28c7cb7d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:43:26 +0800 Subject: [PATCH 21/36] chore(points): fix lint - apps/web/src/api/points/types.ts -- 2.54.0 From bfcea1e44392c8895a6828e419d988e885f2ef9d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:43:33 +0800 Subject: [PATCH 22/36] chore(points): fix lint - apps/web/src/components/common/PointsBadge/index.tsx --- .../components/common/PointsBadge/index.tsx | 144 +++++++++++++----- 1 file changed, 108 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/common/PointsBadge/index.tsx b/apps/web/src/components/common/PointsBadge/index.tsx index 3e25927b0..3ac88e25f 100644 --- a/apps/web/src/components/common/PointsBadge/index.tsx +++ b/apps/web/src/components/common/PointsBadge/index.tsx @@ -1,58 +1,130 @@ /** - * 头部积分徽章组件 - * - 展示当前积分余额 - * - 会员标签(基于 membership 聚合信息或 subscription.plan_id 判断) - * - 点击跳转积分中心 + * Header 右上角积分徽章 + * - 余额 <10 时橙色告警 + * - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口 + * + * 字段对齐新契约: + * - balance.is_member / balance.member_type 保留但降级;推荐用 membership.member_type + * - 免费额度、会员 max_resolution 在 popover 展示 */ -import React from "react" +import React, { useEffect } from "react" +import { Popover, Button, Tag, Space, Typography, Badge } from "antd" +import { + ThunderboltOutlined, + CrownOutlined, + RightOutlined, + WarningOutlined, +} from "@ant-design/icons" import { useNavigate } from "react-router-dom" -import { Tooltip, Typography } from "antd" -import { CrownOutlined, StarFilled } from "@ant-design/icons" import { usePointsStore } from "@/store/pointsStore" -import styles from "./PointsBadge.module.css" +import "./PointsBadge.css" -const { Text } = Typography +const { Text, Paragraph } = Typography -/** 会员类型标签映射(基于 GET /points/subscription/membership 返回的 member_type 字符串) */ const MEMBER_LABEL: Record = { monthly: "月卡会员", quarterly: "季卡会员", yearly: "年卡会员", } -const MEMBER_COLOR: Record = { - monthly: "#fa8c16", - quarterly: "#eb2f96", - yearly: "#fadb14", -} - const PointsBadge: React.FC = () => { const navigate = useNavigate() - const { balance, membership, subscription } = usePointsStore() + const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore() - const points = membership?.points_balance ?? balance?.balance ?? 0 - // 优先用 membership.member_type 判定会员状态;否则用 subscription.plan_id - const memberKey: string | null = + useEffect(() => { + if (!balance) init() + }, [balance, init]) + + // 余额:优先用 membership.points_balance(冗余字段),降级 balance.balance + const bal = membership?.points_balance ?? balance?.balance ?? 0 + const lowBalance = bal > 0 && bal < 10 + const zero = bal === 0 + const isMember = membership?.is_member ?? balance?.is_member ?? false + const memberKey = membership?.member_type ?? (subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null) - const isMember = membership?.is_member ?? balance?.is_member ?? false - const memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "" : "" - const memberColor = memberKey ? MEMBER_COLOR[memberKey] || "#fa8c16" : "#fa8c16" + const memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "付费会员" : "免费会员" + const maxRes = membership?.max_resolution + + const freeRemain = dailyUsage?.free_clips_remaining ?? 0 + + const popContent = ( +
+
+
+ + {loading ? "…" : bal} + 积分 +
+ }> + {memberLabel} + +
+ + {(zero || lowBalance) && ( + + 积分不足,充值后可继续使用 AI 功能 + + )} + + {!isMember && dailyUsage && freeRemain > 0 && ( + + 今日剩余免费次数:{freeRemain}/{dailyUsage.free_clips_limit} + + )} + + {balance?.member_expires_at && isMember && ( + + 会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")} + {maxRes ? ` · ${maxRes}` : ""} + + )} + +
+
+ 累计获得 +
+{balance?.total_earned ?? 0}
+
+
+ 累计消耗 +
-{balance?.total_spent ?? 0}
+
+
+ + + + + {!isMember && ( + + )} + +
+ ) return ( -
navigate("/points")}> - - - {points} - - {isMember && memberLabel && ( - - - {memberLabel} - - - )} -
+ + + ) } -- 2.54.0 From 5d1943292eae179f638cc399ab23a3aa1dbe7270 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:43:39 +0800 Subject: [PATCH 23/36] chore(points): fix lint - apps/web/src/pages/points/Packages.tsx --- apps/web/src/pages/points/Packages.tsx | 272 +++++++++++++++---------- 1 file changed, 165 insertions(+), 107 deletions(-) diff --git a/apps/web/src/pages/points/Packages.tsx b/apps/web/src/pages/points/Packages.tsx index 58f3d4354..c3ef1ecd4 100644 --- a/apps/web/src/pages/points/Packages.tsx +++ b/apps/web/src/pages/points/Packages.tsx @@ -1,144 +1,202 @@ /** - * 积分充值页面 - * 展示充值包列表 + 创建充值订单 + * 积分充值页(/points/recharge) + * 单独展示积分包,供入口直接跳转使用 * - * 注意:当前后端 POST /points/recharge 返回 pay_params={}(支付通道未接入), - * 前端可完成订单创建 UI,但发起支付需等待支付通道接入。 + * 字段对齐新契约(2026-09-16): + * - package 用 code 做唯一键(替代 id) + * - 价格单位为 cents,折后价 = price_cents × (user_discount ?? 1) + * - 下单接口返回 pay_params(当前为 {},支付通道未接入) + * - 余额优先取 membership.points_balance,降级 balance.balance */ import React, { useEffect, useState } from "react" -import { Card, Button, Tag, message, Spin, Alert } from "antd" -import { ThunderboltOutlined, CheckCircleFilled } from "@ant-design/icons" +import { + Card, + Col, + Row, + Button, + Tag, + Typography, + Space, + Modal, + message, + Tooltip, + Alert, +} from "antd" +import { ThunderboltOutlined, SafetyCertificateOutlined, CrownFilled } from "@ant-design/icons" +import { useNavigate } from "react-router-dom" +import PageHead from "@/components/layout/PageHead" import { usePointsStore } from "@/store/pointsStore" -import { getPointsPackages, createPointsOrder } from "@/api/points" -import { getDiscountPriceCents } from "@/api/points/types" +import { createPointsOrder, getPointsPackages } from "@/api/points" import type { PointsPackage, PointsPackagesResponse } from "@/api/points/types" -import styles from "./Points.css" +import { getDiscountPriceCents } from "@/api/points/types" +import "./Points.css" -const PointsPackagesPage: React.FC = () => { - const { init, refreshBalance } = usePointsStore() - const [pkgResp, setPkgResp] = useState(null) - const [loading, setLoading] = useState(true) - const [ordering, setOrdering] = useState(null) - const [selectedCode, setSelectedCode] = useState(null) +const { Title, Text, Paragraph } = Typography + +const PointsRecharge: React.FC = () => { + const navigate = useNavigate() + const { balance, membership, init } = usePointsStore() + const [packagesResp, setPackagesResp] = useState(null) + const [buying, setBuying] = useState(null) useEffect(() => { init() - loadPackages() + getPointsPackages() + .then((r) => setPackagesResp(r)) + .catch(() => {}) }, [init]) - const loadPackages = async () => { - try { - setLoading(true) - const data = await getPointsPackages() - setPkgResp(data) - if (data.packages.length > 0) setSelectedCode(data.packages[1]?.code || data.packages[0].code) - } catch (err) { - message.error("加载充值包失败") - } finally { - setLoading(false) - } - } + const packages = packagesResp?.packages ?? [] + const userDiscount = packagesResp?.user_discount ?? null + const isMember = membership?.is_member ?? balance?.is_member ?? false + const currentBalance = membership?.points_balance ?? balance?.balance ?? 0 - const handleRecharge = async (pkg: PointsPackage) => { + const handleBuy = async (pkg: PointsPackage) => { try { - setOrdering(pkg.code) + setBuying(pkg.code) const order = await createPointsOrder({ package_id: pkg.code }) - message.success(`订单已创建:${order.id}(支付通道待接入)`) - // pay_params 目前是 {},支付通道接入后再处理跳转 - if (order.pay_params && Object.keys(order.pay_params).length > 0) { - // TODO: 支付通道接入后发起支付 - console.log("[recharge] pay_params:", order.pay_params) - } - await refreshBalance() - } catch (err) { - message.error((err as Error).message || "充值失败,请稍后再试") + Modal.info({ + title: "支付功能开发中", + icon: , + content: ( +
+ + 订单已创建({order.id.slice(0, 16)}…),购买 {pkg.points} 积分,金额{" "} + ¥{(order.amount_cents / 100).toFixed(2).replace(/\.00$/, "")}。 + {order.points_amount !== undefined && ` 到账 ${order.points_amount} 积分。`} + {order.expire_at && ( + 有效期至 {new Date(order.expire_at).toLocaleDateString("zh-CN")}。 + )} + 微信/支付宝支付正在接入中。 + + + 现阶段所有功能免费体验,积分仅为演示数据。 + +
+ ), + okText: "知道了", + }) + } catch (e) { + const err = e as { response?: { data?: { error?: { message?: string } } }; message?: string } + const msg = err?.response?.data?.error?.message || err?.message || "下单失败" + message.error(msg) } finally { - setOrdering(null) + setBuying(null) } } - if (loading) - return ( -
- -
- ) - if (!pkgResp) return
充值包加载失败
- - const userDiscount = pkgResp.user_discount - const hasDiscount = userDiscount != null && userDiscount < 1 - return ( -
-

积分充值

+
+ + {!isMember && ( + + )} + + + } + /> - {hasDiscount && ( + + +
+ 当前可用积分 +
+ {currentBalance.toLocaleString()} +
+
+
+
+ + {userDiscount !== null && userDiscount < 1 && ( )} -
- {pkgResp.packages.map((pkg) => { - const isSelected = selectedCode === pkg.code - const discountPrice = getDiscountPriceCents(pkg, userDiscount) - const isDiscounted = discountPrice < pkg.price_cents - const priceYuan = (discountPrice / 100).toFixed(2).replace(/\.00$/, "") - const originalYuan = (pkg.price_cents / 100).toFixed(2).replace(/\.00$/, "") - + 选择积分包 + + {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 // 单价低于 0.1 元/分视为热门 return ( - setSelectedCode(pkg.code)} - > - {isSelected && } -
- - {pkg.points} - 积分 -
-
{pkg.name}
-
- ¥ - {priceYuan} - {isDiscounted && ¥{originalYuan}} -
- {pkg.unit_price < 0.1 && ( - - 超值 - - )} -
+ 0 ? "has-discount" : ""}`} + hoverable > - 立即充值 - - + {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)}/积分 · 永久有效
+ + + ) })} - + - + + 积分消耗说明 +
    +
  • 智能混剪:基础积分/条(≤30s),每加 30s 额外消耗
  • +
  • AI 配音 / 声音克隆合成:按分钟消耗积分
  • +
  • AI 数字人:按分钟消耗积分
  • +
  • 抖音链接提取 / AI 改写 / AI 标题 / AI 封面:按次消耗
  • +
  • 声音克隆训练:免费
  • +
  • 免费用户每日若干条免费混剪,其余 AI 功能按会员价 ×1.15 消耗
  • +
  • 会员享积分折扣(具体档位见会员中心)
  • +
+ + + 所有规则以页面实际显示为准 + + +
) } -export default PointsPackagesPage +export default PointsRecharge +export const Component = PointsRecharge -- 2.54.0 From 6feba696d35f69eea9f6e5b9ebb4c6a7f1a4a167 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:43:44 +0800 Subject: [PATCH 24/36] chore(points): fix lint - apps/web/src/pages/subscription/UpgradeSubscription.tsx -- 2.54.0 From 13bdb0487a18d4a80ee7e47795101228a7de08b6 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:43:49 +0800 Subject: [PATCH 25/36] chore(points): fix lint - apps/web/src/pages/generate/hooks/pointsCost.ts -- 2.54.0 From 56dcfcdde2cb0ac8f567e9482f7968e1e208434f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:43:55 +0800 Subject: [PATCH 26/36] chore(points): fix lint - apps/web/src/api/subscription/subscription.ts -- 2.54.0 From 984df6be475b768a6bffdfd82fc42dc3e3055e4d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:44:00 +0800 Subject: [PATCH 27/36] chore(points): fix lint - apps/web/src/api/subscription/types.ts -- 2.54.0 From 0905799681b3b7f2db755a2e5475aa3275ffb40c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:44:06 +0800 Subject: [PATCH 28/36] chore(points): fix lint - apps/web/src/components/common/PointsCost/index.tsx --- .../components/common/PointsCost/index.tsx | 192 ++++++++++++------ 1 file changed, 129 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/common/PointsCost/index.tsx b/apps/web/src/components/common/PointsCost/index.tsx index 72021a42d..e9ec2edba 100644 --- a/apps/web/src/components/common/PointsCost/index.tsx +++ b/apps/web/src/components/common/PointsCost/index.tsx @@ -1,102 +1,168 @@ /** - * 积分消耗提示组件(创作页/按钮旁使用) + * 功能操作按钮旁的"消耗积分"提示 + * 例:[生成配音] 💎 -1 积分 + * - 根据 scene_key 自动读取规则(来自 store.rules) + * - 免费用户自动计算 ×free_user_multiplier 向上取整 + * - 会员 floor(base × points_discount) + * - 余额不足时显示红色告警 + 充值提示 * - * 展示规则: - * - 非会员 + 免费额度未用完:展示「消耗 X 积分(今日剩余 Y 次免费)」 - * - 非会员 + 免费额度用完:展示「消耗 X 积分(非会员 ×free_user_multiplier)」 - * - 会员:展示「消耗 X 积分(会员折扣)」 - * - * 兼容旧 props: - * - 旧 props.units 会自动映射为 quantity,保持现有调用方不报错 + * 字段对齐新契约: + * - rule.points_per_use → base_points + * - balance.free_clips_remaining → dailyUsage.free_clips_remaining + * - props.units → 保留兼容,新代码优先用 quantity */ import React, { useMemo } from "react" -import { Tooltip, Tag } from "antd" -import { ThunderboltOutlined, InfoCircleOutlined } from "@ant-design/icons" +import { Tooltip } from "antd" +import { WarningOutlined } from "@ant-design/icons" import { usePointsStore } from "@/store/pointsStore" -import styles from "./PointsCost.module.css" import type { PointsSource } from "@/api/points/types" +import "./PointsCost.css" interface Props { - /** 积分场景键 */ - source: PointsSource - /** 消耗数量(新字段) */ + /** 消耗场景 key */ + scene: PointsSource + /** 数量(新字段),默认 1 */ quantity?: number /** 预计时长(分钟),可选 */ durationMinutes?: number - /** 简化显示(只显示图标 + 数字) */ + /** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */ compact?: boolean + /** 余额不足时,是否显示充值提示 */ + showRechargeHint?: boolean + /** 自定义 class */ + className?: string /** - * @deprecated 已改名为 quantity,保留旧字段做兼容映射 + * @deprecated 旧字段保留兼容,内部映射为 quantity */ units?: number } +/** 单位中文 */ +const UNIT_LABEL: Record = { + 分钟: "分钟", + 条: "条", + 次: "次", + 张: "张", +} + const PointsCost: React.FC = ({ - source, + scene, quantity, units, durationMinutes, compact = false, + showRechargeHint = true, + className = "", }) => { - const { balance, rules, dailyUsage, membership } = usePointsStore() + const { balance, dailyUsage, rules, membership } = usePointsStore() const qty = quantity ?? units ?? 1 - const rule = rules?.rules.find((r) => r.scene_key === source) - const freeMultiplier = rules?.free_user_multiplier ?? 1.15 - const pointsDiscount = membership?.is_member - ? 1 // 会员折扣可通过 subscription.plans.points_discount 读取,这里简化用 1 占位 - : freeMultiplier - const cost = useMemo(() => { - if (!rule) return 0 - const extra = - durationMinutes && durationMinutes > 0.5 && rule.extra_per_30s - ? Math.ceil((durationMinutes * 60 - 30) / 30) * rule.extra_per_30s - : 0 - const base = (rule.base_points + extra) * qty - return membership?.is_member - ? Math.floor(base * pointsDiscount) - : Math.ceil(base * pointsDiscount) - }, [rule, qty, durationMinutes, pointsDiscount, membership?.is_member]) + const { cost, isFreeQuota, rule, isFreeUser, insufficient, freeRemain } = useMemo(() => { + const isMem = membership?.is_member ?? balance?.is_member ?? false + if (!rules || !balance) { + return { + cost: 0, + isFreeQuota: false, + rule: null, + isFreeUser: !isMem, + insufficient: false, + freeRemain: 0, + } + } + const rule = rules.rules.find((r) => r.scene_key === scene) + if (!rule) + return { + cost: 0, + isFreeQuota: false, + rule: null, + isFreeUser: !isMem, + insufficient: false, + freeRemain: 0, + } + // 免费训练不扣费 + if (rule.base_points === 0) { + return { + cost: 0, + isFreeQuota: false, + rule, + isFreeUser: !isMem, + insufficient: false, + freeRemain: 0, + } + } - if (!rule) return null + // 计算 base + let baseCost: number + if (scene === "ai_video" && rule.extra_per_30s) { + const minutes = durationMinutes ?? qty + const segments = Math.max(1, Math.ceil(minutes * 2)) + baseCost = rule.base_points + rule.extra_per_30s * (segments - 1) + } else { + baseCost = rule.base_points * Math.max(1, qty) + } - const freeRemain = dailyUsage?.free_clips_remaining ?? 0 - const isFreeQuota = !membership?.is_member && freeRemain > 0 + // 非会员 + 今日免费额度 → 免费 + const freeRemain = dailyUsage?.free_clips_remaining ?? 0 + const isFree = scene === "ai_video" && !isMem && freeRemain > 0 - if (compact) { + const multiplier = rules.free_user_multiplier ?? 1.15 + const cost = isFree ? 0 : isMem ? Math.floor(baseCost) : Math.ceil(baseCost * multiplier) + const insufficient = !isFree && balance.balance < cost + return { + cost, + isFreeQuota: isFree, + rule, + isFreeUser: !isMem, + insufficient, + freeRemain, + } + }, [rules, balance, dailyUsage, membership, scene, qty, durationMinutes]) + + if (!rule || !balance) { + return + } + + if (rule.base_points === 0) { return ( - - - {cost} - + + 免费 + + ) + } + + if (isFreeQuota) { + return ( + + + 免费 + {!compact && (今日剩余 {freeRemain} 条)} + ) } + const unitLabel = compact + ? "" + : `/${qty > 1 ? `${qty}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}` + return ( -
- - 消耗 {cost} 积分 - {isFreeQuota ? ( - - 今日剩 {freeRemain} 次免费 - - ) : ( - !membership?.is_member && ( - - - 非会员 ×{freeMultiplier} - - - - ) + + 💎 + -{cost} + {unitLabel && 积分{unitLabel}} + {isFreeUser && !compact && ( + + 非会员 + )} - {balance && balance.balance < cost && ( - - 余额不足 - + {insufficient && showRechargeHint && ( + + + )} -
+
) } -- 2.54.0 From c17a6d3e819ff498c996c22493c0590591b5eaaf Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:44:10 +0800 Subject: [PATCH 29/36] chore(points): fix lint - apps/web/src/api/points/index.ts --- apps/web/src/api/points/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/api/points/index.ts b/apps/web/src/api/points/index.ts index 83438bd75..116e2b863 100644 --- a/apps/web/src/api/points/index.ts +++ b/apps/web/src/api/points/index.ts @@ -10,9 +10,7 @@ import apiClient from "../client" import type { PointsBalance, - PointsRule, PointsRulesResponse, - PointsPackage, PointsPackagesResponse, PointsTransaction, PointsTransactionsResponse, -- 2.54.0 From 913688110ae82529ec913694a6e909a018cf347b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:44:16 +0800 Subject: [PATCH 30/36] chore(points): fix lint - apps/web/src/pages/points/Transactions.tsx --- apps/web/src/pages/points/Transactions.tsx | 303 ++++++++++++++------- 1 file changed, 203 insertions(+), 100 deletions(-) diff --git a/apps/web/src/pages/points/Transactions.tsx b/apps/web/src/pages/points/Transactions.tsx index 0f765fb83..70f7ffb61 100644 --- a/apps/web/src/pages/points/Transactions.tsx +++ b/apps/web/src/pages/points/Transactions.tsx @@ -1,168 +1,271 @@ /** - * 积分流水页(分页) - * - type=add/deduct(refund 通过 source="refund:xxx" 前缀区分) - * - amount 为绝对值,前端根据 type 决定正负显示 - * - 不再有 source_name 字段,前端按 scene_key + 前缀映射中文名 + * 积分明细页(/points/transactions) + * 分页展示积分流水,支持按类型/来源筛选 + * + * 字段对齐新契约(2026-09-16): + * - 分页接口返回 {items, total, page, page_size} + * - type 仅 add/deduct;refund 通过 source=refund:xxx 前缀体现 + * - source_name 字段已移除,中文名在前端 SOURCE_LABEL 映射 + * - signed_amount 字段已移除,根据 type 显示 +/- + * - ref_id 在新契约中为 string(不再是 number) */ -import React, { useEffect, useState } from "react" -import { Card, Table, Tag, Pagination, Spin, Tabs } from "antd" +import React, { useEffect, useState, useCallback } from "react" +import { + Card, + Table, + Tag, + Select, + DatePicker, + Space, + Typography, + Empty, + Input, + Spin, + Button, +} from "antd" +import { SearchOutlined, ReloadOutlined } from "@ant-design/icons" import type { ColumnsType } from "antd/es/table" +import { useNavigate } from "react-router-dom" +import dayjs from "dayjs" +import PageHead from "@/components/layout/PageHead" import { getPointsTransactions } from "@/api/points" -import { usePointsStore } from "@/store/pointsStore" -import type { PointsTransaction } from "@/api/points/types" -import styles from "./Points.css" +import type { PointsTransaction, PointsTxType } from "@/api/points/types" +import "./Points.css" -const PAGE_SIZE = 20 +const { Text } = Typography +const { RangePicker } = DatePicker + +const TYPE_LABEL: Record = { + add: { text: "获得", color: "green" }, + deduct: { text: "消耗", color: "red" }, +} -/** source → 中文标签映射 */ const SOURCE_LABEL: Record = { + recharge: "充值", ai_voice: "AI 配音", - ai_video: "AI 视频生成", ai_digital_human: "AI 数字人", + ai_video: "智能混剪", voice_clone_train: "声音克隆训练", - voice_clone_synth: "声音克隆合成", - douyin_extract: "抖音文案提取", + voice_clone_synth: "克隆合成", + douyin_extract: "抖音链接提取", ai_rewrite: "AI 文案改写", ai_title: "AI 标题生成", ai_cover: "AI 封面生成", - recharge: "积分充值", sign_up: "注册赠送", bind_phone: "绑定手机", gift: "活动赠送", admin: "管理员调整", } -function getSourceLabel(source: string): string { - if (source.startsWith("refund:")) { - const origin = source.slice(7) - return `${SOURCE_LABEL[origin] || origin}退款` +const sourceLabel = (s: string): { label: string; isRefund: boolean } => { + if (s.startsWith("refund:")) { + const inner = s.slice(7) + return { label: `${SOURCE_LABEL[inner] || inner}(退款)`, isRefund: true } } - return SOURCE_LABEL[source] || source + return { label: SOURCE_LABEL[s] || s, isRefund: false } } -const PointsTransactionsPage: React.FC = () => { - const { init } = usePointsStore() +const PointsTransactions: React.FC = () => { + const navigate = useNavigate() + const [loading, setLoading] = useState(false) const [data, setData] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) - const [filter, setFilter] = useState<"all" | "add" | "deduct">("all") - const [loading, setLoading] = useState(true) + const [pageSize, setPageSize] = useState(20) + const [type, setType] = useState("all") + const [source, setSource] = useState("all") + const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null) + const [keyword, setKeyword] = useState("") - useEffect(() => { - init() - }, [init]) - - useEffect(() => { - loadPage(1) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - const loadPage = async (p: number) => { + const load = useCallback(async () => { + setLoading(true) try { - setLoading(true) - const resp = await getPointsTransactions(p, PAGE_SIZE) - setData(resp.items) - setTotal(resp.total) - setPage(p) - } catch (err) { - // 错误由拦截器处理 + // 新契约后端暂不支持 type/source/date/keyword 过滤参数,先前端过滤 + const res = await getPointsTransactions(page, pageSize) + let items = res.items + if (type !== "all") { + items = items.filter((it) => it.type === type) + } + if (source !== "all") { + items = items.filter((it) => it.source === source || it.source === `refund:${source}`) + } + if (dateRange && dateRange[0] && dateRange[1]) { + const start = dateRange[0].startOf("day") + const end = dateRange[1].endOf("day") + items = items.filter((it) => { + const t = dayjs(it.created_at) + return t.isAfter(start) && t.isBefore(end) + }) + } + if (keyword) { + const k = keyword.toLowerCase() + items = items.filter((it) => { + const sl = sourceLabel(it.source).label + return (it.description || "").toLowerCase().includes(k) || sl.toLowerCase().includes(k) + }) + } + setData(items) + setTotal(res.total) } finally { setLoading(false) } - } + }, [page, pageSize, type, source, dateRange, keyword]) - const filtered = filter === "all" ? data : data.filter((t) => t.type === filter) + useEffect(() => { + load() + }, [load]) const columns: ColumnsType = [ { title: "时间", dataIndex: "created_at", - width: 170, - render: (v: string) => new Date(v).toLocaleString("zh-CN"), + width: 180, + render: (v: string) => dayjs(v).format("YYYY-MM-DD HH:mm"), }, { title: "类型", dataIndex: "type", - width: 100, - render: (t: PointsTransaction["type"]) => - t === "add" ? 获得 : 消耗, + width: 90, + render: (t: PointsTxType, r: PointsTransaction) => { + if (r.source.startsWith("refund:")) { + return 退还 + } + const cfg = TYPE_LABEL[t] + return {cfg?.text || t} + }, }, { - title: "来源", + title: "来源/场景", dataIndex: "source", - width: 160, - render: (s: string) => { - const isRefund = s.startsWith("refund:") - return ( - - {isRefund && ( - - 退款 - - )} - {getSourceLabel(s)} - - ) - }, + width: 180, + render: (s: string) => {sourceLabel(s).label}, }, { title: "说明", dataIndex: "description", ellipsis: true, + render: (v: string) => v || "-", }, { - title: "数量", + title: "变动", dataIndex: "amount", - width: 120, + width: 110, align: "right", - render: (amount: number, record: PointsTransaction) => ( - - {record.type === "add" ? "+" : "-"} - {amount} - - ), + render: (v: number, r: PointsTransaction) => { + const isRefund = r.source.startsWith("refund:") + const positive = r.type === "add" || isRefund + return ( + + {positive ? "+" : "-"} + {v} + + ) + }, }, { title: "余额", dataIndex: "balance_after", - width: 100, + width: 110, align: "right", + render: (v: number) => ( + + {v} + + ), }, ] return ( -
-

积分流水

+
+ + + + + } + /> + - setFilter(k as typeof filter)} - items={[ - { key: "all", label: "全部" }, - { key: "deduct", label: "消耗" }, - { key: "add", label: "获得" }, - ]} - /> -
-
- +
+ + { + setSource(v) + setPage(1) + }} + style={{ width: 160 }} + showSearch + options={[ + { value: "all", label: "全部来源" }, + ...Object.entries(SOURCE_LABEL).map(([k, v]) => ({ value: k, label: v })), + ]} + /> + { + setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) + setPage(1) + }} + /> + } + allowClear + style={{ width: 200 }} + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onPressEnter={() => { + setPage(1) + load() + }} + /> +
+ + + + rowKey="id" + columns={columns} + dataSource={data} + locale={{ emptyText: }} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条记录`, + onChange: (p, ps) => { + setPage(p) + setPageSize(ps) + }, + }} + /> +
) } -export default PointsTransactionsPage +export default PointsTransactions +export const Component = PointsTransactions -- 2.54.0 From 5ec4a10457131882f594d284957d470af61e01d4 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:44:21 +0800 Subject: [PATCH 31/36] chore(points): fix lint - apps/web/src/pages/subscription/Billing.tsx --- apps/web/src/pages/subscription/Billing.tsx | 380 +++++++++++++------- 1 file changed, 245 insertions(+), 135 deletions(-) diff --git a/apps/web/src/pages/subscription/Billing.tsx b/apps/web/src/pages/subscription/Billing.tsx index f421a155b..91e400eb1 100644 --- a/apps/web/src/pages/subscription/Billing.tsx +++ b/apps/web/src/pages/subscription/Billing.tsx @@ -1,202 +1,312 @@ /** - * 订阅管理 & 账单页 - * - 展示当前订阅信息 - * - 取消订阅 / 开关自动续费 - * - 账单历史 + * 账单管理页面 + * 展示当前订阅信息 + 自动续费开关 + 账单历史 + * + * 字段对齐新契约(2026-09-16): + * - toggleAutoRenew 参数改为 {enabled} 对象 + * - billing_cycle 仅 monthly/yearly(季卡走 monthly 周期 + 3 个月时长) + * - 新增账单历史表格:order_type / amount_cents / status / created_at */ -import React, { useEffect, useState } from "react" -import { Card, Button, Tag, Table, Alert, Space, Modal, message, Spin, Descriptions } from "antd" -import { useNavigate } from "react-router-dom" +import React, { useState, useEffect } from "react" +import { message, Table, Tag, Card, Space, Button, Modal, Typography } from "antd" import type { ColumnsType } from "antd/es/table" -import { usePointsStore } from "@/store/pointsStore" -import { getBillingRecords, cancelSubscription, toggleAutoRenew } from "@/api/subscription" +import { + getCurrentSubscription, + toggleAutoRenew, + cancelSubscription, + getBillingRecords, +} from "@/api/subscription" +import type { SubscriptionInfo, BillingRecord } from "@/api/subscription/types" import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types" -import type { BillingRecord } from "@/api/subscription/types" -import styles from "./Subscription.css" +import PageHead from "@/components/layout/PageHead" +import "./Billing.css" -const formatYuan = (cents: number) => `¥${(cents / 100).toFixed(2)}` +const { Text } = Typography -const BillingPage: React.FC = () => { - const navigate = useNavigate() - const { subscription, init, refreshBalance } = usePointsStore() - const [records, setRecords] = useState([]) +const formatDate = (iso: string): string => { + const d = new Date(iso) + return d.toLocaleDateString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }) +} + +/** 自定义 ToggleSwitch 组件 */ +const ToggleSwitch: React.FC<{ + checked: boolean + onChange: (checked: boolean) => void + loading?: boolean + checkedChildren?: string + unCheckedChildren?: string +}> = ({ checked, onChange, loading, checkedChildren, unCheckedChildren }) => ( + +) + +/** 自定义 Spinner 组件 */ +const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => ( +
+
+
+
+
+) + +const ORDER_TYPE_LABEL: Record = { + subscribe: "新购", + renew: "续费", + upgrade: "升级", + downgrade: "降级", + refund: "退款", +} + +const BILLING_STATUS_TAG: Record = { + paid: { color: "green", text: "已支付" }, + pending: { color: "orange", text: "待支付" }, + failed: { color: "red", text: "支付失败" }, + refunded: { color: "blue", text: "已退款" }, + cancelled: { color: "default", text: "已取消" }, +} + +const Billing: React.FC = () => { + const [subscription, setSubscription] = useState(null) + const [billingRecords, setBillingRecords] = useState([]) const [loading, setLoading] = useState(true) - const [actionLoading, setActionLoading] = useState(false) + const [recordsLoading, setRecordsLoading] = useState(false) + const [autoRenewChecked, setAutoRenewChecked] = useState(false) + const [autoRenewLoading, setAutoRenewLoading] = useState(false) useEffect(() => { - init() - loadBilling() - }, [init]) + loadData() + loadRecords() + }, []) - const loadBilling = async () => { + const loadData = async () => { try { - setLoading(true) - const data = await getBillingRecords() - setRecords(data) - } catch { - // 拦截器处理 + const data = await getCurrentSubscription() + setSubscription(data) + setAutoRenewChecked(!!data.auto_renew) + } catch (err: unknown) { + if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("加载订阅数据失败") } finally { setLoading(false) } } - const handleCancel = () => { + const loadRecords = async () => { + try { + setRecordsLoading(true) + const list = await getBillingRecords() + setBillingRecords(Array.isArray(list) ? list : []) + } catch { + // 账单加载失败不阻塞主流程 + setBillingRecords([]) + } finally { + setRecordsLoading(false) + } + } + + const handleToggleAutoRenew = async (checked: boolean) => { + setAutoRenewLoading(true) + try { + const res = await toggleAutoRenew({ enabled: checked }) + message.success(res?.message ?? (checked ? "已开启自动续费" : "已关闭自动续费")) + setAutoRenewChecked(checked) + if (subscription) { + setSubscription({ ...subscription, auto_renew: checked }) + } + } catch (err: unknown) { + const e = err as { response?: { data?: { error?: { message?: string } } }; message?: string } + message.error(e?.response?.data?.error?.message || e?.message || "操作失败") + } finally { + setAutoRenewLoading(false) + } + } + + const handleCancelSubscription = () => { Modal.confirm({ title: "确认取消订阅?", - content: "取消后当前计费周期结束时将不再续费,期间仍可使用会员权益。", + content: "取消后,当前周期结束时将不再自动续费。您仍可使用当前会员权益至到期日。", okText: "确认取消", - cancelText: "再想想", okType: "danger", + cancelText: "我再想想", onOk: async () => { try { - setActionLoading(true) - const resp = await cancelSubscription() - message.success(resp.message || "已取消订阅") - await refreshBalance() - } catch (err) { - message.error((err as Error).message || "操作失败") - } finally { - setActionLoading(false) + await cancelSubscription() + message.success("已取消订阅,到期后不再续费") + await loadData() + } catch (err: unknown) { + const e = err as { + response?: { data?: { error?: { message?: string } } } + message?: string + } + message.error(e?.response?.data?.error?.message || e?.message || "取消失败") } }, }) } - const handleToggleAutoRenew = async () => { - if (!subscription) return - const next = !subscription.auto_renew - try { - setActionLoading(true) - const resp = await toggleAutoRenew({ enabled: next }) - message.success(resp.message) - await refreshBalance() - } catch (err) { - message.error((err as Error).message || "操作失败") - } finally { - setActionLoading(false) - } - } - const columns: ColumnsType = [ { title: "时间", dataIndex: "created_at", - render: (v: string) => new Date(v).toLocaleString("zh-CN"), + width: 170, + render: (v: string) => formatDate(v), }, { title: "类型", dataIndex: "order_type", - render: (v: string) => { - const map: Record = { - subscribe: "订阅", - renew: "续费", - upgrade: "升级", - refund: "退款", - } - return map[v] || v - }, + width: 100, + render: (v: string) => ORDER_TYPE_LABEL[v] || v || "-", }, { title: "套餐", dataIndex: "plan_id", - render: (v: string) => PLAN_LABEL[v as keyof typeof PLAN_LABEL] || v, + width: 120, + render: (v: string) => (v ? PLAN_LABEL[v as keyof typeof PLAN_LABEL] || v : "-"), }, { title: "金额", dataIndex: "amount_cents", + width: 110, align: "right", - render: (v: number) => formatYuan(v), + render: (v: number) => ( + + ¥{((v ?? 0) / 100).toFixed(2)} + + ), }, { title: "状态", dataIndex: "status", - render: (s: string) => { - const map: Record = { - paid: { color: "green", label: "已支付" }, - pending: { color: "orange", label: "待支付" }, - failed: { color: "red", label: "失败" }, - refunded: { color: "default", label: "已退款" }, - } - const cfg = map[s] || { color: "default", label: s } - return {cfg.label} + width: 100, + render: (v: string) => { + const cfg = BILLING_STATUS_TAG[v] + return {cfg?.text || v || "-"} }, }, + { + title: "支付时间", + dataIndex: "paid_at", + width: 170, + render: (v?: string) => (v ? formatDate(v) : —), + }, ] - const isPaid = subscription && subscription.plan_id !== "free" + if (loading) { + return ( +
+ +
+ ) + } return ( -
-

订阅管理

+
+ - {!subscription ? ( - - ) : !isPaid ? ( - navigate("/subscription")}> - 开通会员 - - } - /> - ) : ( + {subscription && ( <> - - - {PLAN_LABEL[subscription.plan_id]} - - {BILLING_CYCLE_LABEL[subscription.billing_cycle]} - - - {formatYuan(subscription.amount)} - - - - {subscription.status} - - - - {new Date(subscription.current_period_start).toLocaleDateString("zh-CN")} - - - {new Date(subscription.current_period_end).toLocaleDateString("zh-CN")} - - - - {subscription.auto_renew ? "已开启" : "已关闭"} - - - - - - - - - + {/* 当前订阅概览 */} +
+

当前订阅

+
+
+ 套餐 + + {subscription.plan_name || + PLAN_LABEL[subscription.plan_id as keyof typeof PLAN_LABEL] || + "-"} + +
+
+ 计费周期 + + {BILLING_CYCLE_LABEL[ + subscription.billing_cycle as keyof typeof BILLING_CYCLE_LABEL + ] || + subscription.billing_cycle || + "-"} + +
+
+ 本期金额 + ¥{((subscription.amount ?? 0) / 100).toFixed(2)} +
+
+ 周期开始 + {formatDate(subscription.current_period_start)} +
+
+ 下次扣费/到期 + {formatDate(subscription.current_period_end)} +
+
+ {subscription.plan_id !== "free" && ( + + + + )} +
+ + {/* 自动续费 */} +
+

自动续费

+
+
+

到期自动续费

+

+ 开启后,将在每个计费周期结束时自动扣费续期,避免服务中断。 +

+
+ +
+
)} - -
+ 刷新 + + } + > + rowKey="id" columns={columns} - dataSource={records} - loading={loading} - pagination={false} - size="middle" + dataSource={billingRecords} + loading={recordsLoading} + pagination={{ pageSize: 10, showSizeChanger: false }} + locale={{ emptyText: "暂无账单记录" }} /> ) } -export default BillingPage +export default Billing +export const Component = Billing -- 2.54.0 From ddc433b7cc80fb88db955d953b06670856ed2a00 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:44:26 +0800 Subject: [PATCH 32/36] chore(points): fix lint - apps/web/src/pages/subscription/constants.ts -- 2.54.0 From 3bebad374a0b7270d52a5aad5fe1f5382285cb58 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:44:31 +0800 Subject: [PATCH 33/36] chore(points): fix lint - apps/web/src/pages/generate/GeneratePage.tsx -- 2.54.0 From d03dd7ca42e04e419ab4f630fc1db78842daf77b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:44:36 +0800 Subject: [PATCH 34/36] chore(points): fix lint - apps/web/src/pages/points/Center.tsx --- apps/web/src/pages/points/Center.tsx | 447 +++++++++++++++++++-------- 1 file changed, 322 insertions(+), 125 deletions(-) diff --git a/apps/web/src/pages/points/Center.tsx b/apps/web/src/pages/points/Center.tsx index d53251a04..546fbfb6d 100644 --- a/apps/web/src/pages/points/Center.tsx +++ b/apps/web/src/pages/points/Center.tsx @@ -1,154 +1,351 @@ /** - * 积分中心首页 - * 展示: - * - 当前积分余额 + 会员状态(来自 balance + membership) - * - 每日免费额度(来自 dailyUsage) - * - 快捷入口(充值 / 消费记录 / 订阅) + * 积分中心主页(/points 或 /app/points) + * 展示余额、会员信息、免费额度、快捷入口、最近流水 + * + * 字段对齐新契约(2026-09-16): + * - balance 不含 free_clips_*,从 dailyUsage 取 + * - subscription.member_type → plan_id(free/monthly/quarterly/yearly) + * - subscription.member_type_name → 前端 PLAN_LABEL 映射 */ import React, { useEffect } from "react" -import { Card, Button, Space, Statistic, Tag, Progress, Alert } from "antd" -import { useNavigate } from "react-router-dom" +import { + Card, + Col, + Row, + Statistic, + Button, + Space, + Tag, + Typography, + Progress, + List, + Avatar, + Empty, +} from "antd" import { ThunderboltOutlined, - CrownOutlined, + CrownFilled, + ArrowUpOutlined, + ArrowDownOutlined, HistoryOutlined, - PlusCircleOutlined, - GiftOutlined, + WalletOutlined, + FileTextOutlined, + InfoCircleOutlined, } from "@ant-design/icons" +import { useNavigate } from "react-router-dom" +import PageHead from "@/components/layout/PageHead" import { usePointsStore } from "@/store/pointsStore" -import styles from "./Points.css" +import { PLAN_LABEL } from "@/api/subscription/types" +import "./Points.css" -const MEMBER_LABEL: Record = { - monthly: "月卡会员", - quarterly: "季卡会员", - yearly: "年卡会员", +const { Text } = Typography + +const SOURCE_NAME: Record = { + recharge: "充值", + task_reward: "任务奖励", + ai_voice: "AI 配音", + ai_digital_human: "AI 数字人", + ai_video: "智能混剪", + voice_clone_train: "声音克隆训练", + voice_clone_synth: "声音克隆合成", + douyin_extract: "抖音提取", + ai_rewrite: "AI 改写", + ai_title: "AI 标题", + ai_cover: "AI 封面", + sign_up: "注册赠送", + bind_phone: "绑定手机", + gift: "活动赠送", + admin: "管理员调整", } -const PointsCenterPage: React.FC = () => { +const sourceLabel = (src: string): string => { + if (src.startsWith("refund:")) return `${SOURCE_NAME[src.slice(7)] || src.slice(7)}退款` + return SOURCE_NAME[src] || src +} + +/** 会员标签:优先取 membership.member_type,降级 subscription.plan_id */ +const memberKey = ( + membership: { member_type: string | null } | null, + subscription: { plan_id: string } | null, +): string | null => + membership?.member_type ?? + (subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null) + +const memberLabel = ( + membership: { member_type: string | null } | null, + subscription: { plan_id: string; plan_name?: string } | null, +): string => { + const key = memberKey(membership, subscription) + if (!key) return "免费会员" + return PLAN_LABEL[key as keyof typeof PLAN_LABEL] || subscription?.plan_name || "付费会员" +} + +const PointsCenter: React.FC = () => { const navigate = useNavigate() - const { balance, dailyUsage, membership, subscription, rules, init } = usePointsStore() + const { balance, dailyUsage, membership, subscription, rules, init, loading } = usePointsStore() useEffect(() => { init() }, [init]) + const bal = membership?.points_balance ?? balance?.balance ?? 0 + const earned = balance?.total_earned ?? 0 + const spent = balance?.total_spent ?? 0 const isMember = membership?.is_member ?? balance?.is_member ?? false - const memberKey = - membership?.member_type ?? - (subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null) - const memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "" : "" - const freeLimit = dailyUsage?.free_clips_limit ?? 0 + + // 免费额度从 dailyUsage 取 const freeUsed = dailyUsage?.free_clips_used ?? 0 + const freeLimit = dailyUsage?.free_clips_limit ?? (isMember ? 0 : 3) const freeRemain = dailyUsage?.free_clips_remaining ?? 0 - const freePercent = freeLimit > 0 ? Math.round((freeUsed / freeLimit) * 100) : 0 - const freeMultiplier = rules?.free_user_multiplier ?? 1.15 + + // 最近流水 mock(后续可改为调用 getPointsTransactions(1,5)) + const recentTx = [ + { type: "deduct" as const, source: "ai_voice", amount: 1, time: "今天 10:30" }, + { type: "deduct" as const, source: "ai_video", amount: 3, time: "今天 09:15" }, + { type: "add" as const, source: "recharge", amount: 100, time: "昨天" }, + ] return ( -
-

积分中心

- - {/* 余额卡片 */} - -
- } - valueStyle={{ color: "#faad14", fontSize: 36, fontWeight: 700 }} - /> - {isMember && memberLabel && ( - } className={styles.memberTag}> - {memberLabel} - {membership?.max_resolution ? ` · ${membership.max_resolution}` : ""} - - )} -
- - - - - - {balance?.member_expires_at && isMember && ( -
- 会员到期时间:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")} -
- )} -
- - {/* 每日免费额度卡片 */} - - {!isMember && freeLimit > 0 ? ( - <> - `${freeUsed}/${freeLimit}`} - /> -
- 今日还剩 {freeRemain} 次免费生成额度 - {dailyUsage?.reset_at && ( - - ( - {new Date(dailyUsage.reset_at).toLocaleTimeString("zh-CN", { - hour: "2-digit", - minute: "2-digit", - })}{" "} - 重置) - - )} -
- - ) : isMember ? ( - - ) : ( - - )} -
- - {/* 快捷提示 */} - {!isMember && rules && ( - navigate("/subscription")}> - 开通会员 +
+ + - } - style={{ marginTop: 16 }} - /> - )} + + + } + /> - {/* 累计统计 */} - -
- - -
+ {/* 顶部大卡 */} + + +
+ + + 当前可用积分 + +
+ + {loading ? "…" : bal.toLocaleString()} +
+ + {isMember ? ( + } style={{ padding: "4px 10px" }}> + {memberLabel(membership, subscription)} + + ) : ( + + 免费会员 + + )} + {balance?.member_expires_at && isMember && ( + + 到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")} + + )} + {membership?.max_resolution && isMember && ( + + · {membership.max_resolution} + + )} + {!isMember && ( + + )} + +
+ + + {/* 今日免费额度 */} + {!isMember && freeLimit > 0 && ( +
+
+ 今日免费混剪 +
+ 0 ? Math.round((freeUsed / freeLimit) * 100) : 0} + strokeColor={{ "0%": "#f59e0b", "100%": "#ef4444" }} + format={() => `${freeUsed}/${freeLimit} 条`} + /> + + 剩余 {freeRemain} 条免费混剪,超出部分消耗积分 + {rules?.free_user_multiplier ? `(×${rules.free_user_multiplier} 倍率)` : ""} + {dailyUsage?.reset_at && ( + + {" "} + ·{" "} + {new Date(dailyUsage.reset_at).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + })} + 重置 + + )} + +
+ )} + + + + {/* 统计 */} + + + + } + valueStyle={{ color: "#10b981" }} + /> + + + + + } + valueStyle={{ color: "#ef4444" }} + /> + + + + + } + valueStyle={{ color: "#8b5cf6" }} + /> + + + + + } + valueStyle={{ color: "#f59e0b" }} + /> + + + + + {/* 快捷入口 & 最近流水 */} + + + + + 最近流水 + + } + extra={ navigate("/points/transactions")}>查看全部 →} + > + {recentTx.length === 0 ? ( + + ) : ( + ( + + : } + /> + } + title={sourceLabel(item.source)} + description={item.time} + /> +
+ {item.type === "add" ? "+" : "-"} + {item.amount} +
+
+ )} + /> + )} +
+ + + + + + + + + + + + ) } -export default PointsCenterPage +export default PointsCenter +export const Component = PointsCenter -- 2.54.0 From d3278de2fd98ae98e4713cc94b1715ad0696395c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:44:40 +0800 Subject: [PATCH 35/36] chore(points): fix lint - apps/web/src/store/pointsStore.ts -- 2.54.0 From e550e9b5c5567c17cca19d93bf24aeac02e162bc Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 16 Sep 2026 10:44:47 +0800 Subject: [PATCH 36/36] chore(points): fix lint - apps/web/src/pages/subscription/hooks/useSubscription.ts -- 2.54.0