diff --git a/apps/web/src/api/points/index.ts b/apps/web/src/api/points/index.ts index 8344b97af..116e2b863 100644 --- a/apps/web/src/api/points/index.ts +++ b/apps/web/src/api/points/index.ts @@ -1,477 +1,345 @@ /** - * 积分 & 会员 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, PointsRulesResponse, + 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 } 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[] + } } 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, 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 } 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 diff --git a/apps/web/src/components/common/PointsBadge/index.tsx b/apps/web/src/components/common/PointsBadge/index.tsx index 3c26fff21..3ac88e25f 100644 --- a/apps/web/src/components/common/PointsBadge/index.tsx +++ b/apps/web/src/components/common/PointsBadge/index.tsx @@ -1,7 +1,11 @@ /** - * Header 右上角的积分徽章(💎 580) + * Header 右上角积分徽章 * - 余额 <10 时橙色告警 * - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口 + * + * 字段对齐新契约: + * - balance.is_member / balance.member_type 保留但降级;推荐用 membership.member_type + * - 免费额度、会员 max_resolution 在 popover 展示 */ import React, { useEffect } from "react" import { Popover, Button, Tag, Space, Typography, Badge } from "antd" @@ -17,27 +21,32 @@ import "./PointsBadge.css" const { Text, Paragraph } = Typography +const MEMBER_LABEL: Record = { + monthly: "月卡会员", + quarterly: "季卡会员", + yearly: "年卡会员", +} + const PointsBadge: React.FC = () => { const navigate = useNavigate() - const { balance, init, loading } = usePointsStore() + const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore() useEffect(() => { if (!balance) init() }, [balance, init]) - const bal = balance?.balance ?? 0 + // 余额:优先用 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 = !!balance?.is_member - const memberLabel = isMember - ? balance?.member_type === "yearly" - ? "年卡会员" - : balance?.member_type === "quarterly" - ? "季卡会员" - : balance?.member_type === "monthly" - ? "月卡会员" - : "付费会员" - : "免费会员" + 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 maxRes = membership?.max_resolution + + const freeRemain = dailyUsage?.free_clips_remaining ?? 0 const popContent = (
@@ -58,9 +67,16 @@ const PointsBadge: React.FC = () => { )} - {balance?.member_expires_at && ( + {!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}` : ""} )} @@ -80,16 +96,16 @@ const PointsBadge: React.FC = () => { type="primary" icon={} block - onClick={() => navigate("/app/points")} + onClick={() => navigate("/points/recharge")} > 充值积分 - {!isMember && ( - )} diff --git a/apps/web/src/components/common/PointsCost/index.tsx b/apps/web/src/components/common/PointsCost/index.tsx index ba8fac4d5..e9ec2edba 100644 --- a/apps/web/src/components/common/PointsCost/index.tsx +++ b/apps/web/src/components/common/PointsCost/index.tsx @@ -1,31 +1,40 @@ /** * 功能操作按钮旁的"消耗积分"提示 * 例:[生成配音] 💎 -1 积分 - * - 根据 scene_key 自动读取规则 - * - 免费用户自动计算 ×1.15 向上取整 + * - 根据 scene_key 自动读取规则(来自 store.rules) + * - 免费用户自动计算 ×free_user_multiplier 向上取整 + * - 会员 floor(base × points_discount) * - 余额不足时显示红色告警 + 充值提示 * - * 使用: + * 字段对齐新契约: + * - rule.points_per_use → base_points + * - balance.free_clips_remaining → dailyUsage.free_clips_remaining + * - props.units → 保留兼容,新代码优先用 quantity */ -import React, { useEffect, useMemo, useState } from "react" +import React, { useMemo } from "react" import { Tooltip } from "antd" import { WarningOutlined } from "@ant-design/icons" import { usePointsStore } from "@/store/pointsStore" import type { PointsSource } from "@/api/points/types" -import { getPointsRules } from "@/api/points" import "./PointsCost.css" interface Props { /** 消耗场景 key */ scene: PointsSource - /** 单位数(分钟数/条数/张数),默认 1 */ - units?: number + /** 数量(新字段),默认 1 */ + quantity?: number + /** 预计时长(分钟),可选 */ + durationMinutes?: number /** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */ compact?: boolean /** 余额不足时,是否显示充值提示 */ showRechargeHint?: boolean /** 自定义 class */ className?: string + /** + * @deprecated 旧字段保留兼容,内部映射为 quantity + */ + units?: number } /** 单位中文 */ @@ -38,31 +47,26 @@ const UNIT_LABEL: Record = { const PointsCost: React.FC = ({ scene, - units = 1, + quantity, + units, + durationMinutes, compact = false, showRechargeHint = true, className = "", }) => { - const { balance, init } = usePointsStore() - const [rules, setRules] = useState> | null>(null) + const { balance, dailyUsage, rules, membership } = usePointsStore() + const qty = quantity ?? units ?? 1 - useEffect(() => { - if (!balance) init() - if (!rules) { - getPointsRules() - .then(setRules) - .catch(() => {}) - } - }, [balance, init, rules]) - - const { cost, isFreeQuota, rule, isFreeUser, insufficient } = useMemo(() => { + 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: !balance?.is_member, + isFreeUser: !isMem, insufficient: false, + freeRemain: 0, } } const rule = rules.rules.find((r) => r.scene_key === scene) @@ -71,49 +75,54 @@ const PointsCost: React.FC = ({ cost: 0, isFreeQuota: false, rule: null, - isFreeUser: !balance.is_member, + isFreeUser: !isMem, insufficient: false, + freeRemain: 0, } // 免费训练不扣费 - if (rule.points_per_use === 0) { + if (rule.base_points === 0) { return { cost: 0, isFreeQuota: false, rule, - isFreeUser: !balance.is_member, + isFreeUser: !isMem, insufficient: false, + freeRemain: 0, } } - // 智能混剪:首条30s=3分,每+30s +1 + // 计算 base 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) + 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.points_per_use * Math.max(1, units) + baseCost = rule.base_points * Math.max(1, qty) } - // 混剪 + 免费用户 + 今日有免费额度 → 免费 - const isFree = - scene === "ai_video" && !balance.is_member && (balance.free_clips_remaining ?? 0) > 0 + // 非会员 + 今日免费额度 → 免费 + const freeRemain = dailyUsage?.free_clips_remaining ?? 0 + const isFree = scene === "ai_video" && !isMem && freeRemain > 0 - const isFreeUser = !balance.is_member - const cost = isFree - ? 0 - : isFreeUser - ? Math.ceil(baseCost * rules.free_user_multiplier) - : baseCost + 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, insufficient } - }, [rules, balance, scene, units]) + return { + cost, + isFreeQuota: isFree, + rule, + isFreeUser: !isMem, + insufficient, + freeRemain, + } + }, [rules, balance, dailyUsage, membership, scene, qty, durationMinutes]) if (!rule || !balance) { return } - if (rule.points_per_use === 0) { + if (rule.base_points === 0) { return ( 免费 @@ -123,12 +132,10 @@ const PointsCost: React.FC = ({ if (isFreeQuota) { return ( - + 免费 - {!compact && ( - (今日剩余 {balance.free_clips_remaining} 条) - )} + {!compact && (今日剩余 {freeRemain} 条)} ) @@ -136,7 +143,7 @@ const PointsCost: React.FC = ({ const unitLabel = compact ? "" - : ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}` + : `/${qty > 1 ? `${qty}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}` return ( { /* ── 表单状态 ── */ 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 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) +} diff --git a/apps/web/src/pages/points/Center.tsx b/apps/web/src/pages/points/Center.tsx index 0e0d844a5..546fbfb6d 100644 --- a/apps/web/src/pages/points/Center.tsx +++ b/apps/web/src/pages/points/Center.tsx @@ -1,6 +1,11 @@ /** - * 积分中心主页(/app/points) - * 展示余额、会员信息、本月统计、快捷入口 + * 积分中心主页(/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 { @@ -30,6 +35,7 @@ import { import { useNavigate } from "react-router-dom" import PageHead from "@/components/layout/PageHead" import { usePointsStore } from "@/store/pointsStore" +import { PLAN_LABEL } from "@/api/subscription/types" import "./Points.css" const { Text } = Typography @@ -46,32 +52,57 @@ const SOURCE_NAME: Record = { ai_rewrite: "AI 改写", ai_title: "AI 标题", ai_cover: "AI 封面", - subscription_bonus: "会员赠送", - admin_adjust: "管理员调整", - refund: "失败退还", + sign_up: "注册赠送", + bind_phone: "绑定手机", + gift: "活动赠送", + admin: "管理员调整", +} + +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, subscription, init, loading } = usePointsStore() + const { balance, dailyUsage, membership, subscription, rules, init, loading } = usePointsStore() useEffect(() => { init() }, [init]) - const bal = balance?.balance ?? 0 + const bal = membership?.points_balance ?? 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) + const isMember = membership?.is_member ?? balance?.is_member ?? false - // 近 5 条流水 mock(实际从 transactions 页加载) + // 免费额度从 dailyUsage 取 + const freeUsed = dailyUsage?.free_clips_used ?? 0 + const freeLimit = dailyUsage?.free_clips_limit ?? (isMember ? 0 : 3) + const freeRemain = dailyUsage?.free_clips_remaining ?? 0 + + // 最近流水 mock(后续可改为调用 getPointsTransactions(1,5)) 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: "昨天" }, + { 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 ( @@ -79,16 +110,15 @@ const PointsCenter: React.FC = () => { - @@ -111,12 +141,7 @@ const PointsCenter: React.FC = () => { {isMember ? ( } style={{ padding: "4px 10px" }}> - {subscription?.member_type === "yearly" - ? "年卡" - : subscription?.member_type === "quarterly" - ? "季卡" - : "月卡"} - 会员 + {memberLabel(membership, subscription)} ) : ( { 免费会员 )} - {balance?.member_expires_at && ( + {balance?.member_expires_at && isMember && ( 到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")} )} + {membership?.max_resolution && isMember && ( + + · {membership.max_resolution} + + )} {!isMember && ( )} @@ -156,12 +186,24 @@ const PointsCenter: React.FC = () => { 今日免费混剪
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", + })} + 重置 + + )} )} @@ -205,7 +247,7 @@ const PointsCenter: React.FC = () => { } valueStyle={{ color: "#f59e0b" }} /> @@ -223,7 +265,7 @@ const PointsCenter: React.FC = () => { 最近流水 } - extra={ navigate("/app/points/transactions")}>查看全部 →} + extra={ navigate("/points/transactions")}>查看全部 →} > {recentTx.length === 0 ? ( @@ -237,22 +279,22 @@ const PointsCenter: React.FC = () => { : } + icon={item.type === "add" ? : } /> } - title={SOURCE_NAME[item.source] ?? item.source} + title={sourceLabel(item.source)} description={item.time} />
- {item.type === "earn" ? "+" : "-"} + {item.type === "add" ? "+" : "-"} {item.amount}
@@ -269,7 +311,7 @@ const PointsCenter: React.FC = () => { size="large" type="primary" icon={} - onClick={() => navigate("/app/subscription")} + onClick={() => navigate("/points/recharge")} > 充值积分 @@ -277,7 +319,7 @@ const PointsCenter: React.FC = () => { block size="large" icon={} - onClick={() => navigate("/app/points/transactions")} + onClick={() => navigate("/points/transactions")} > 积分明细 @@ -285,7 +327,7 @@ const PointsCenter: React.FC = () => { block size="large" icon={} - onClick={() => navigate("/app/subscription")} + onClick={() => navigate("/subscription")} > {isMember ? "续费/升级会员" : "升级付费会员"} @@ -293,7 +335,7 @@ const PointsCenter: React.FC = () => { block size="large" icon={} - onClick={() => navigate("/app/points/rules")} + onClick={() => navigate("/points/rules")} > 积分消耗规则 diff --git a/apps/web/src/pages/points/Packages.tsx b/apps/web/src/pages/points/Packages.tsx index a4d7e71ee..c3ef1ecd4 100644 --- a/apps/web/src/pages/points/Packages.tsx +++ b/apps/web/src/pages/points/Packages.tsx @@ -1,56 +1,72 @@ /** - * 积分充值页(/app/points/recharge) + * 积分充值页(/points/recharge) * 单独展示积分包,供入口直接跳转使用 - * 主 Plans 页面也有充值区,这里提供独立 URL 方便从"积分不足"弹窗跳转 + * + * 字段对齐新契约(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, Col, Row, Button, Tag, Typography, Space, Modal, message, Tooltip } from "antd" +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 { createPointsOrder, getPointsPackages } from "@/api/points" -import type { PointsPackage } from "@/api/points/types" +import type { PointsPackage, PointsPackagesResponse } from "@/api/points/types" +import { getDiscountPriceCents } from "@/api/points/types" import "./Points.css" const { Title, Text, Paragraph } = Typography const PointsRecharge: React.FC = () => { const navigate = useNavigate() - const { balance, init } = usePointsStore() - const [packages, setPackages] = useState([]) + const { balance, membership, init } = usePointsStore() + const [packagesResp, setPackagesResp] = useState(null) const [buying, setBuying] = useState(null) useEffect(() => { init() getPointsPackages() - .then((r) => setPackages(r.packages)) + .then((r) => setPackagesResp(r)) .catch(() => {}) }, [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 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 handleBuy = async (pkg: PointsPackage) => { try { - setBuying(pkg.id) - const order = await createPointsOrder({ package_id: pkg.id }) + setBuying(pkg.code) + const order = await createPointsOrder({ package_id: pkg.code }) Modal.info({ title: "支付功能开发中", icon: , content: (
- 订单已创建({order.id.slice(0, 16)}…),金额{" "} - ¥{(order.price_cents / 100).toFixed(2).replace(/\.00$/, "")}。 + 订单已创建({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")}。 + )} 微信/支付宝支付正在接入中。 @@ -61,8 +77,9 @@ const PointsRecharge: React.FC = () => { okText: "知道了", }) } catch (e) { - const err = e as { message?: string } - message.error(err?.message ?? "下单失败") + const err = e as { response?: { data?: { error?: { message?: string } } }; message?: string } + const msg = err?.response?.data?.error?.message || err?.message || "下单失败" + message.error(msg) } finally { setBuying(null) } @@ -73,51 +90,61 @@ const PointsRecharge: React.FC = () => { - {!balance?.is_member && ( - )} - + } /> - {balance && ( - - -
- 当前可用积分 -
- {balance.balance.toLocaleString()} -
+ + +
+ 当前可用积分 +
+ {currentBalance.toLocaleString()}
- - +
+
+
+ + {userDiscount !== null && userDiscount < 1 && ( + )} 选择积分包 {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 + 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 ( - + 0 ? "has-discount" : ""}`} + className={`xx-pkg-card ${isHot ? "recommended" : ""} ${discount > 0 ? "has-discount" : ""}`} hoverable > - {pkg.id === "basic_pack" &&
热门
} + {isHot &&
热门
} {discount > 0 && ( - 会员{10 - discount / 10}折 + {Math.round((priceCents / originalCents) * 10) / 1}折 )}
{pkg.name}
@@ -127,17 +154,17 @@ const PointsRecharge: React.FC = () => {
¥ - {(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")} + {(priceCents / 100).toFixed(priceCents % 100 === 0 ? 0 : 1).replace(/\.0$/, "")} {discount > 0 && ( - ¥{(pkg.price / 100).toFixed(0)} + ¥{(originalCents / 100).toFixed(0)} )}
≈¥{unit.toFixed(3)}/积分 · 永久有效
- @@ -184,9 +204,8 @@ const PointsTransactions: React.FC = () => { style={{ width: 120 }} options={[ { value: "all", label: "全部类型" }, - { value: "earn", label: "获得" }, - { value: "spend", label: "消耗" }, - { value: "refund", label: "退还" }, + { value: "add", label: "获得" }, + { value: "deduct", label: "消耗" }, ]} />