From f497686649c448cd4cbea29d0acf8bd68a2eb080 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:37:40 +0800 Subject: [PATCH 01/62] feat(points): apps/web/src/api/points/types.ts --- apps/web/src/api/points/types.ts | 217 +++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 apps/web/src/api/points/types.ts diff --git a/apps/web/src/api/points/types.ts b/apps/web/src/api/points/types.ts new file mode 100644 index 000000000..577c26ef1 --- /dev/null +++ b/apps/web/src/api/points/types.ts @@ -0,0 +1,217 @@ +/** + * 积分 & 会员系统 API 类型定义(v2 两档会员制) + * 接口契约对齐后端设计文档 membership-points-design-v2.md + */ + +/** 会员类型 */ +export type MemberType = "free" | "monthly" | "quarterly" | "yearly" + +/** 积分流水类型 */ +export type PointsTxType = "earn" | "spend" | "refund" + +/** 积分来源/消耗场景 */ +export type PointsSource = + | "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" // 失败退还 + +/** 会员 & 积分余额响应 */ +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 +} + +/** 积分流水记录 */ +export interface PointsTransaction { + id: string + /** earn / spend / refund */ + type: PointsTxType + /** 来源场景 */ + source: PointsSource + /** 场景中文名称 */ + source_name: string + /** 变动数量(正数) */ + amount: number + /** 带符号的变动数(收入+,支出-) */ + signed_amount: number + /** 变动后余额 */ + balance_after: number + /** 备注描述 */ + description: string + /** 关联业务 ID */ + ref_id: string | null + created_at: string +} + +/** 积分流水分页响应 */ +export interface PointsTransactionsResponse { + items: PointsTransaction[] + total: number + page: number + 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 { + package_id: string + payment_method?: "wechat_pay" | "alipay" +} + +/** 订单状态 */ +export type OrderStatus = "pending" | "paid" | "failed" | "refunded" | "expired" + +/** 充值订单响应 */ +export interface PointsOrder { + id: string + package_id: string + package_name: string + 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 +} + +/** 订阅套餐(月/季/年) */ +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 +} + +/** 消费前余额检查请求 */ +export interface PointsCheckRequest { + scene_key: PointsSource + /** 单位数量(时长/条数),默认 1 */ + units?: 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 +} -- 2.54.0 From 05e56184d684cd30f7e9255ef643de74a0f9035c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:37:43 +0800 Subject: [PATCH 02/62] feat(points): apps/web/src/api/points/index.ts --- apps/web/src/api/points/index.ts | 471 +++++++++++++++++++++++++++++++ 1 file changed, 471 insertions(+) create mode 100644 apps/web/src/api/points/index.ts diff --git a/apps/web/src/api/points/index.ts b/apps/web/src/api/points/index.ts new file mode 100644 index 000000000..e1a49e44c --- /dev/null +++ b/apps/web/src/api/points/index.ts @@ -0,0 +1,471 @@ +/** + * 积分 & 会员 API 封装(v2 两档会员制) + * 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。 + */ +import apiClient from "../client" +import type { + PointsBalance, + PointsTransactionsParams, + PointsTransactionsResponse, + PointsPackagesResponse, + PointsRechargeRequest, + PointsOrder, + PointsRulesResponse, + PointsCheckRequest, + PointsCheckResponse, + SubscriptionCurrent, + SubscribeRequest, + SubscriptionPlan, +} from "./types" + +/** + * 是否启用 mock 数据(后端 PR 合入前为 true;对接真实接口后改为 false) + */ +export const POINTS_API_MOCK = true + +// ==================== Mock 数据 ==================== +const MOCK_BALANCE: PointsBalance = { + balance: 580, + total_earned: 1200, + total_spent: 620, + 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 分钟配音", + }, + { + scene_key: "ai_video", + scene_name: "智能混剪", + points_per_use: 3, + unit: "条", + extra_per_30s: 1, + description: "每条 ≤30s 3 积分,每加 30s +1", + }, + { + scene_key: "ai_digital_human", + scene_name: "AI 数字人", + points_per_use: 15, + unit: "分钟", + description: "每生成 1 分钟口播", + }, + { + scene_key: "voice_clone_train", + scene_name: "声音克隆训练", + points_per_use: 0, + unit: "次", + description: "训练免费", + }, + { 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 = [ + { + src: "ai_voice", + name: "AI 配音", + type: "spend" as const, + amt: 1, + desc: "生成配音 1 分钟", + days: 0, + hours: 0, + mins: 30, + }, + { + 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: any = items[i] + if (it.source === "task_reward" && it.description.includes("注册")) running = 50 + } + running = 50 + const fwd = [...items].reverse() + for (const it of fwd as any[]) { + running += it.signed_amount + it.balance_after = running + } + return { items: fwd as any, total: fwd.length, page: 1, page_size: 20 } +} + +// ==================== 真实 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 + const discountKey = `discounted_price_for_${mt}` as const + const price = (pkg as any)?.[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[] = [ + { + id: "monthly", + name: "月卡", + price_cents: 1990, + price_yuan: 19.9, + per_month_yuan: 19.9, + billing_label: "/月", + }, + { + id: "quarterly", + name: "季卡", + price_cents: 3990, + price_yuan: 39.9, + per_month_yuan: 13.3, + savings_percent: 33, + recommended: true, + billing_label: "/季", + }, + { + id: "yearly", + name: "年卡", + price_cents: 15900, + price_yuan: 159, + per_month_yuan: 13.25, + savings_percent: 33, + billing_label: "/年", + }, +] + +/** 查询当前订阅 */ +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 +} + +/** 开通/续费会员 */ +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 +} + +/** 取消自动续费 */ +export async function cancelAutoRenew(): Promise<{ success: boolean; message: string }> { + if (POINTS_API_MOCK) { + return new Promise((r) => + setTimeout(() => r({ success: true, message: "已取消自动续费" }), 200), + ) + } + const res = await apiClient.post("/subscription/cancel") + return res.data +} -- 2.54.0 From cd1c7bc26e98c51dae4558597283f741954d070b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:37:46 +0800 Subject: [PATCH 03/62] feat(points): apps/web/src/store/pointsStore.ts --- apps/web/src/store/pointsStore.ts | 83 +++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/web/src/store/pointsStore.ts diff --git a/apps/web/src/store/pointsStore.ts b/apps/web/src/store/pointsStore.ts new file mode 100644 index 000000000..9ce4d9cb0 --- /dev/null +++ b/apps/web/src/store/pointsStore.ts @@ -0,0 +1,83 @@ +/** + * 积分 & 会员状态管理(Zustand) + * - 启动时拉取余额 & 订阅信息 + * - 提供刷新、余额扣减(乐观更新)等工具 + */ +import { create } from "zustand" +import type { PointsBalance, PointsRulesResponse, SubscriptionCurrent } from "@/api/points/types" +import { getCurrentSubscription, getPointsBalance, getPointsRules } from "@/api/points" + +interface PointsState { + /** 积分余额 & 会员状态(来自 /points/balance) */ + balance: PointsBalance | null + /** 订阅详情(来自 /subscription/current) */ + subscription: SubscriptionCurrent | null + /** 积分消耗规则缓存 */ + rules: PointsRulesResponse | null + loading: boolean + error: string | null + /** 初始化:拉取余额 + 订阅信息 + 规则 */ + init: () => Promise + /** 强制刷新余额 */ + refreshBalance: () => Promise + /** 乐观扣减:在支付/业务发起前调用,失败时用 refreshBalance 兜底 */ + optimisticDeduct: (points: number) => void + /** 乐观增加(充值成功后调用) */ + optimisticAdd: (points: number) => void +} + +export const usePointsStore = create((set, get) => ({ + balance: null, + subscription: null, + rules: null, + loading: false, + error: null, + + init: async () => { + if (get().loading) return + set({ loading: true, error: null }) + try { + const [balance, sub, rules] = await Promise.all([ + getPointsBalance(), + getCurrentSubscription(), + getPointsRules(), + ]) + set({ balance, subscription: sub, rules, loading: false }) + } catch (e) { + set({ error: (e as Error).message, loading: false }) + } + }, + + refreshBalance: async () => { + try { + const balance = await getPointsBalance() + set({ balance }) + } catch (e) { + set({ error: (e as Error).message }) + } + }, + + optimisticDeduct: (points: number) => { + const b = get().balance + if (!b) return + set({ + balance: { + ...b, + balance: Math.max(0, b.balance - points), + total_spent: b.total_spent + points, + }, + }) + }, + + optimisticAdd: (points: number) => { + const b = get().balance + if (!b) return + set({ + balance: { + ...b, + balance: b.balance + points, + total_earned: b.total_earned + points, + }, + }) + }, +})) -- 2.54.0 From a6cb79292e81cd4497645948518ecb3cf837ac0f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:37:48 +0800 Subject: [PATCH 04/62] feat(points): apps/web/src/components/common/PointsBadge/index.tsx --- .../components/common/PointsBadge/index.tsx | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 apps/web/src/components/common/PointsBadge/index.tsx diff --git a/apps/web/src/components/common/PointsBadge/index.tsx b/apps/web/src/components/common/PointsBadge/index.tsx new file mode 100644 index 000000000..4cbfff498 --- /dev/null +++ b/apps/web/src/components/common/PointsBadge/index.tsx @@ -0,0 +1,110 @@ +/** + * Header 右上角的积分徽章(💎 580) + * - 余额 <10 时橙色告警 + * - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口 + */ +import React, { useEffect } from "react" +import { Popover, Button, Tag, Space, Typography, Badge } from "antd" +import { DiamondOutlined, CrownOutlined, RightOutlined, WarningOutlined } from "@ant-design/icons" +import { useNavigate } from "react-router-dom" +import { usePointsStore } from "@/store/pointsStore" +import "./PointsBadge.css" + +const { Text, Paragraph } = Typography + +const PointsBadge: React.FC = () => { + const navigate = useNavigate() + const { balance, subscription, init, loading } = 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 + ? subscription?.member_type === "yearly" + ? "年卡会员" + : subscription?.member_type === "quarterly" + ? "季卡会员" + : subscription?.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 && ( + + )} + +
+ ) + + return ( + + + + ) +} + +export default PointsBadge -- 2.54.0 From 53baca6b5d92fcb848c6d44d7533f2246896c640 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:37:53 +0800 Subject: [PATCH 05/62] feat(points): apps/web/src/components/common/PointsBadge/PointsBadge.css --- .../common/PointsBadge/PointsBadge.css | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 apps/web/src/components/common/PointsBadge/PointsBadge.css diff --git a/apps/web/src/components/common/PointsBadge/PointsBadge.css b/apps/web/src/components/common/PointsBadge/PointsBadge.css new file mode 100644 index 000000000..fe6d7aa44 --- /dev/null +++ b/apps/web/src/components/common/PointsBadge/PointsBadge.css @@ -0,0 +1,116 @@ +/* Header 积分徽章 & Popover 样式 */ +.xx-points-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: 999px; + border: 1px solid var(--border-color, #e5e7eb); + background: var(--bg-tertiary, #fafafa); + color: var(--text-secondary, #6b7280); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + line-height: 1; + height: 32px; +} + +.xx-points-badge:hover { + border-color: var(--primary-color, #6366f1); + color: var(--primary-color, #6366f1); + background: var(--primary-soft, #eef2ff); +} + +.xx-points-badge.zero { + color: #faad14; + border-color: #ffe58f; + background: #fffbe6; +} + +.xx-points-badge.low { + color: #fa8c16; + border-color: #ffd591; + background: #fff7e6; +} + +.xx-points-badge-icon { + font-size: 14px; + color: #8b5cf6; +} + +.xx-points-badge.zero .xx-points-badge-icon, +.xx-points-badge.low .xx-points-badge-icon { + color: inherit; +} + +.xx-points-badge-val { + font-variant-numeric: tabular-nums; +} + +/* Popover */ +.xx-points-popover { + width: 260px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.xx-points-popover-top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.xx-points-popover-balance { + display: flex; + align-items: baseline; + gap: 6px; +} + +.xx-points-icon { + color: #8b5cf6; + font-size: 18px; +} + +.xx-points-value { + font-size: 22px; + font-weight: 800; + color: var(--text-primary, #111827); + font-variant-numeric: tabular-nums; +} + +.xx-points-unit { + color: var(--text-secondary, #6b7280); + font-size: 12px; +} + +.xx-points-warn { + margin: 0 !important; + padding: 8px 10px; + background: #fffbe6; + border-radius: var(--radius-sm, 6px); + font-size: 12px; +} + +.xx-points-expire { + font-size: 12px; + display: block; +} + +.xx-points-popover-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 10px; + background: var(--bg-tertiary, #fafafa); + border-radius: var(--radius-sm, 6px); +} + +.xx-points-stat-val { + font-size: 16px; + font-weight: 700; + color: var(--text-primary, #111827); + margin-top: 2px; + font-variant-numeric: tabular-nums; +} -- 2.54.0 From f0f74cef51e70aec32f5d8e29a1f2bccfabbf382 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:38:01 +0800 Subject: [PATCH 06/62] feat(points): apps/web/src/components/common/PointsCost/index.tsx --- .../components/common/PointsCost/index.tsx | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 apps/web/src/components/common/PointsCost/index.tsx diff --git a/apps/web/src/components/common/PointsCost/index.tsx b/apps/web/src/components/common/PointsCost/index.tsx new file mode 100644 index 000000000..9ee2f9304 --- /dev/null +++ b/apps/web/src/components/common/PointsCost/index.tsx @@ -0,0 +1,162 @@ +/** + * 功能操作按钮旁的"消耗积分"提示 + * 例:[生成配音] 💎 -1 积分 + * - 根据 scene_key 自动读取规则 + * - 免费用户自动计算 ×1.15 向上取整 + * - 余额不足时显示红色告警 + 充值提示 + * + * 使用: + */ +import React, { useEffect, useMemo, useState } 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, POINTS_API_MOCK } from "@/api/points" +import "./PointsCost.css" + +interface Props { + /** 消耗场景 key */ + scene: PointsSource + /** 单位数(分钟数/条数/张数),默认 1 */ + units?: number + /** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */ + compact?: boolean + /** 余额不足时,是否显示充值提示 */ + showRechargeHint?: boolean + /** 自定义 class */ + className?: string +} + +/** 单位中文 */ +const UNIT_LABEL: Record = { + 分钟: "分钟", + 条: "条", + 次: "次", + 张: "张", +} + +const PointsCost: React.FC = ({ + scene, + units = 1, + compact = false, + showRechargeHint = true, + className = "", +}) => { + const { balance, init } = usePointsStore() + const [rules, setRules] = useState> | null>(null) + + useEffect(() => { + if (!balance) init() + if (!rules) { + getPointsRules() + .then(setRules) + .catch(() => {}) + } + }, [balance, init, rules]) + + 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, + } + } + + // 智能混剪:首条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 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) { + return ( + + 免费 + + ) + } + + if (isFreeQuota) { + return ( + + + 免费 + {!compact && ( + (今日剩余 {balance.free_clips_remaining} 条) + )} + + + ) + } + + const unitLabel = compact + ? "" + : ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}` + + return ( + + 💎 + -{cost} + {unitLabel && 积分{unitLabel}} + {isFreeUser && !compact && ( + + 非会员 + + )} + {insufficient && showRechargeHint && ( + + + + )} + + ) +} + +export default PointsCost -- 2.54.0 From 5579a404686523c9fef15e0dde49cc7b0c60be7b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:38:05 +0800 Subject: [PATCH 07/62] feat(points): apps/web/src/components/common/PointsCost/PointsCost.css --- .../common/PointsCost/PointsCost.css | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 apps/web/src/components/common/PointsCost/PointsCost.css diff --git a/apps/web/src/components/common/PointsCost/PointsCost.css b/apps/web/src/components/common/PointsCost/PointsCost.css new file mode 100644 index 000000000..b0440be3e --- /dev/null +++ b/apps/web/src/components/common/PointsCost/PointsCost.css @@ -0,0 +1,67 @@ +/* 功能按钮旁的积分消耗提示 */ +.xx-points-cost { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-secondary, #6b7280); + font-weight: 500; + white-space: nowrap; +} + +.xx-points-cost.free, +.xx-points-cost.free-quota { + color: #10b981; +} + +.xx-points-cost.insufficient { + color: #ef4444; +} + +.xx-points-gem { + font-size: 12px; + line-height: 1; +} + +.xx-points-num { + font-weight: 700; + font-variant-numeric: tabular-nums; + color: inherit; +} + +.xx-points-unit-label { + color: var(--text-tertiary, #9ca3af); +} + +.xx-points-tag-free { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 4px; + background: #d1fae5; + color: #059669; + font-size: 11px; + font-weight: 600; +} + +.xx-points-free-user-tag { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 4px; + background: #fef3c7; + color: #d97706; + font-size: 10px; + font-weight: 600; + margin-left: 4px; +} + +.xx-points-desc { + color: var(--text-tertiary, #9ca3af); + font-size: 11px; +} + +.xx-points-warn-icon { + color: #ef4444; + margin-left: 2px; +} -- 2.54.0 From b7de70fa7d6f471184447e6f024d7ba4db91a607 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:38:09 +0800 Subject: [PATCH 08/62] feat(points): update apps/web/src/components/layout/Header.tsx --- apps/web/src/components/layout/Header.tsx | 133 +++++++++++++++++++++- 1 file changed, 129 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index a90377c4f..46ac740fd 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -1,15 +1,27 @@ /** * Phase 1 Header 重构 * 扁平化导航菜单 + 手机端汉堡菜单 + * v2: 增加积分徽章、升级会员入口、我的积分入口 */ -import React, { useState } from "react" -import { Avatar, Dropdown, Drawer, Space } from "antd" -import { LogoutOutlined, SettingOutlined, UserOutlined, MenuOutlined } from "@ant-design/icons" +import React, { useEffect, useState } from "react" +import { Avatar, Badge, Button, Dropdown, Drawer, Space, Tooltip } from "antd" +import { + LogoutOutlined, + SettingOutlined, + UserOutlined, + MenuOutlined, + CrownFilled, + DiamondOutlined, + HistoryOutlined, + WalletOutlined, +} from "@ant-design/icons" import { useLocation, useNavigate } from "react-router-dom" import { useAuthStore } from "@/store/authStore" import { useLogout } from "@/hooks/useAuth" import type { MenuProps } from "antd" import { NAV_ITEMS } from "@/config/navigation" +import PointsBadge from "@/components/common/PointsBadge" +import { usePointsStore } from "@/store/pointsStore" import "./Header.css" const Header: React.FC = () => { @@ -19,6 +31,18 @@ const Header: React.FC = () => { const logoutMutation = useLogout() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + // 积分与会员状态 + const { balance, init, loading } = usePointsStore() + useEffect(() => { + init() + // 每 60s 刷新一次(余额可能变化) + const t = setInterval(() => init(), 60_000) + return () => clearInterval(t) + }, [init]) + + const isMember = !!balance?.is_member + const memberType = balance?.member_type ?? null + /** 用户下拉菜单 */ const menuItems: MenuProps["items"] = [ { @@ -33,6 +57,30 @@ const Header: React.FC = () => { label: "订阅管理", onClick: () => navigate("/app/subscription"), }, + // v2: 我的积分入口 + { + key: "points-center", + icon: , + label: ( + + 我的积分 + {balance && {balance.balance}} + + ), + onClick: () => navigate("/app/points"), + }, + { + key: "points-history", + icon: , + label: "积分明细", + onClick: () => navigate("/app/points/transactions"), + }, + { + key: "recharge", + icon: , + label: "充值积分", + onClick: () => navigate("/app/points/recharge"), + }, { type: "divider" }, { key: "logout", @@ -44,7 +92,6 @@ const Header: React.FC = () => { /** 判断导航项是否激活 */ const isActive = (path: string) => { - // 首页特殊处理:/ 和 /app/dashboard 都算激活 if (path === "/app/dashboard") { return ( location.pathname === "/" || @@ -78,6 +125,53 @@ const Header: React.FC = () => {
+ {/* v2: 积分徽章(顶部显示) */} + {balance && ( + navigate("/app/points")} + className="xx-header-points" + /> + )} + + {/* v2: 升级会员入口(仅免费用户显示) */} + {!isMember && ( + + + + )} + + {/* v2: 付费会员显示金冠标识 */} + {isMember && ( + + + + + + )} + {/* 手机端汉堡菜单按钮 */} + ) : ( + + {" "} + {memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"} + + )} +
+ )} +
{NAV_ITEMS.map((item) => ( + + } + /> -
- {PLANS.map((plan) => ( -
- {plan.recommended &&
推荐
} - {plan.id === "enterprise" &&
定制
} - -

{plan.name}

- -
- {plan.price !== null ? ( - <> - ¥ - {plan.price} - /月 - - ) : ( - 联系我们 - )} -
- -

{plan.description}

- -
- {plan.features.map((feature, idx) => ( -
- ✓ - {feature} + {/* 当前状态卡片 */} + {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} + + + + + + + + )} - -
+ {/* 两档会员对比 */} + + <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} +
  • + ))} +
+ +
+ 虚拟商品不支持退款 · 支付接入中先演示 +
+
+ +
+ + {/* 积分充值 */} +
+ + <DiamondOutlined 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 + return ( + + 0 ? "has-discount" : ""} ${pkg.id === "basic_pack" ? "recommended" : ""}`} + hoverable + > + {pkg.id === "basic_pack" &&
热门
} + {discount > 0 && ( + + 会员{10 - discount / 10}折 + + )} +
{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)}/积分
+ +
+ + ) + })} +
+
) } -- 2.54.0 From e2ffd43b298873f2288867d4cd05273e6a1ec6d4 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:38:30 +0800 Subject: [PATCH 13/62] feat(points): update apps/web/src/pages/subscription/Plans.css --- apps/web/src/pages/subscription/Plans.css | 401 ++++++++++++---------- 1 file changed, 227 insertions(+), 174 deletions(-) diff --git a/apps/web/src/pages/subscription/Plans.css b/apps/web/src/pages/subscription/Plans.css index a1ebdfeb9..fdcf43633 100644 --- a/apps/web/src/pages/subscription/Plans.css +++ b/apps/web/src/pages/subscription/Plans.css @@ -1,233 +1,286 @@ -/* V21 定价页面 - CSS变量体系 */ -@import "../../styles/global.css"; - +/* 会员订阅 & 积分充值页样式 v2 */ .xx-plans-page { - max-width: 1200px; + max-width: 1100px; margin: 0 auto; - padding: var(--space-3xl) var(--space-lg); + padding: 24px 0; } -/* 页面头部 */ -.xx-page-head { - text-align: center; - margin-bottom: var(--space-3xl); +/* 当前状态卡片 */ +.xx-current-status { + background: linear-gradient(135deg, #ede9fe 0%, #fce7f3 100%); + border-radius: 14px; + margin-bottom: 16px; } -.xx-page-head h2 { - font-size: var(--font-size-5xl); - font-weight: var(--font-weight-extrabold); - color: var(--text-primary); - margin: 0 0 var(--space-md); - letter-spacing: var(--letter-spacing-tight); +.xx-current-status .ant-card-body { + padding: 20px 24px; } -.xx-page-head p { - font-size: var(--font-size-lg); - color: var(--text-secondary); - margin: 0; - max-width: 560px; - margin-left: auto; - margin-right: auto; -} - -/* 定价卡片网格 - 4列 */ -.xx-plans-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: var(--space-lg); - margin-top: var(--space-xl); -} - -@media (max-width: 1200px) { - .xx-plans-grid { - grid-template-columns: repeat(2, 1fr); - } -} - -@media (max-width: 768px) { - .xx-plans-grid { - grid-template-columns: 1fr; - max-width: 400px; - margin-left: auto; - margin-right: auto; - } -} - -/* V21 定价卡片 */ -.xx-plan-card { - background: var(--bg-elevated); - border: 1px solid var(--border-color); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-card); - padding: var(--space-xl) var(--space-lg); - position: relative; - transition: var(--transition-base); +.xx-current-balance { display: flex; - flex-direction: column; + align-items: center; + gap: 6px; + font-size: 20px; + font-weight: 800; + color: var(--text-primary, #111827); } -.xx-plan-card:hover { - border-color: var(--primary-color); - box-shadow: 0 32px 90px color-mix(in srgb, var(--primary-color) 15%, transparent); - transform: translateY(-4px); +.xx-current-balance-val { + font-variant-numeric: tabular-nums; +} + +/* 计费周期切换 */ +.xx-billing-switch { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-top: 12px; + position: relative; +} + +.xx-billing-btn { + position: relative; + border: 2px solid var(--border-color, #e5e7eb); + background: var(--bg-primary, #fff); + border-radius: 12px; + padding: 16px 12px; + cursor: pointer; + transition: all 0.15s; + text-align: center; +} + +.xx-billing-btn:hover { + border-color: var(--primary-soft, #c4b5fd); +} + +.xx-billing-btn.active { + border-color: #7c3aed; + background: #f5f3ff; + box-shadow: 0 4px 12px rgba(124, 58, 237, 0.15); +} + +.xx-billing-name { + font-weight: 700; + font-size: 15px; + color: var(--text-primary, #111827); +} + +.xx-billing-price { + margin: 6px 0; +} + +.xx-billing-yuan { + font-size: 14px; + color: var(--text-secondary, #6b7280); +} + +.xx-billing-amount { + font-size: 28px; + font-weight: 800; + color: var(--text-primary, #111827); + margin: 0 2px; +} + +.xx-billing-sub { + font-size: 12px; + color: var(--text-tertiary, #9ca3af); +} + +.xx-billing-save { + display: inline-block; + margin-left: 6px; + background: #fef3c7; + color: #d97706; + padding: 1px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +/* 方案卡片 */ +.xx-plan-card { + border-radius: 14px; + position: relative; + height: 100%; } .xx-plan-card.featured { - border: 2px solid var(--primary-color); - box-shadow: - 0 0 0 4px color-mix(in srgb, var(--primary-color) 10%, transparent), - 0 32px 90px color-mix(in srgb, var(--primary-color) 15%, transparent); - transform: scale(1.02); + border: 2px solid #7c3aed; + box-shadow: 0 8px 24px rgba(124, 58, 237, 0.12); } -.xx-plan-card.featured:hover { - transform: scale(1.02) translateY(-4px); -} - -/* 推荐标签 */ -.xx-badge { +.xx-plan-recommend-badge { position: absolute; top: -12px; - left: 50%; - transform: translateX(-50%); - background: var(--gradient-primary); - color: var(--text-inverse); - padding: 6px 16px; - border-radius: var(--radius-full); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-extrabold); - white-space: nowrap; + right: 20px; + background: linear-gradient(135deg, #f59e0b, #ef4444); + color: #fff; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3); } -/* 企业版定制标签 */ -.xx-badge.enterprise { - background: linear-gradient(135deg, var(--accent-color), var(--accent-dark)); -} - -/* 套餐名称 */ -.xx-plan-card h3 { - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-extrabold); - color: var(--text-primary); - margin: 0 0 var(--space-lg); +.xx-plan-head { text-align: center; } -/* 价格 */ .xx-plan-price { - text-align: center; - margin-bottom: 12px; + margin: 12px 0 6px; + display: flex; + align-items: baseline; + justify-content: center; } .xx-plan-price .currency { - font-size: var(--font-size-lg); - font-weight: var(--font-weight-bold); - color: var(--text-secondary); - vertical-align: top; + font-size: 18px; + color: var(--text-secondary, #6b7280); } .xx-plan-price .amount { - font-size: var(--font-size-5xl); - font-weight: var(--font-weight-extrabold); - color: var(--primary-color); + font-size: 44px; + font-weight: 800; + color: var(--text-primary, #111827); line-height: 1; + margin: 0 4px; +} + +.xx-plan-card.featured .xx-plan-price .amount { + color: #7c3aed; } .xx-plan-price .period { - font-size: var(--font-size-md); - color: var(--text-secondary); - font-weight: var(--font-weight-medium); + color: var(--text-secondary, #6b7280); + font-size: 14px; } -/* 企业版价格 */ -.xx-plan-price.custom .amount { - font-size: var(--font-size-3xl); +.xx-feature-list { + list-style: none; + padding: 0; + margin: 0 0 20px 0; } -/* 描述 */ -.xx-plan-description { - color: var(--text-secondary); - font-size: var(--font-size-base); - text-align: center; - margin-bottom: var(--space-lg); - padding-bottom: var(--space-lg); - border-bottom: 1px solid var(--border-color); -} - -/* 功能列表 */ -.xx-features { - margin-top: var(--space-sm); - padding-top: var(--space-lg); - flex: 1; -} - -.xx-feature { +.xx-feature-list li { display: flex; - gap: 12px; - align-items: flex-start; - color: var(--text-secondary); - font-size: var(--font-size-base); - margin-bottom: var(--space-md); - line-height: var(--line-height-base); + align-items: center; + gap: 10px; + padding: 7px 0; + font-size: 14px; + color: var(--text-primary, #111827); } -.xx-feature span:first-child { - color: var(--secondary-color); - font-weight: var(--font-weight-extrabold); - flex-shrink: 0; +.xx-feature-list li.disabled { + color: var(--text-tertiary, #9ca3af); + text-decoration: line-through; } -/* 订阅按钮 */ -.xx-subscribe-btn { - width: 100%; - margin-top: var(--space-lg); - padding: var(--space-md) var(--space-lg) !important; - font-size: var(--font-size-base) !important; - font-weight: var(--font-weight-extrabold) !important; - border-radius: var(--radius-md) !important; - transition: var(--transition-fast) !important; - cursor: pointer; - border: none; +.xx-feature-no { + width: 16px; + display: inline-flex; + justify-content: center; + color: var(--text-tertiary, #d1d5db); } -.xx-subscribe-btn.primary { - background: var(--gradient-primary) !important; - color: var(--text-inverse) !important; - border: none !important; - box-shadow: var(--shadow-primary) !important; +.xx-plan-tip { + text-align: center; + font-size: 12px; + color: var(--text-tertiary, #9ca3af); + margin-top: 10px; } -.xx-subscribe-btn.primary:hover { - box-shadow: var(--shadow-hover) !important; - transform: translateY(-1px); +/* 积分包卡片 */ +.xx-pkg-card { + position: relative; + border-radius: 14px; + text-align: center; + transition: all 0.2s; + overflow: hidden; } -.xx-subscribe-btn.ghost { - background: var(--bg-primary) !important; - border: 1px solid var(--border-color) !important; - color: var(--text-primary) !important; +.xx-pkg-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 28px rgba(139, 92, 246, 0.18); } -.xx-subscribe-btn.ghost:hover { - border-color: var(--primary-color) !important; - color: var(--primary-color) !important; +.xx-pkg-card.recommended { + border: 2px solid #8b5cf6; } -/* 企业版按钮 */ -.xx-subscribe-btn.enterprise { - background: linear-gradient(135deg, var(--accent-color), var(--accent-dark)) !important; - color: var(--text-inverse) !important; - border: none !important; - box-shadow: 0 14px 26px color-mix(in srgb, var(--accent-color) 22%, transparent) !important; +.xx-pkg-badge { + position: absolute; + top: 12px; + left: 12px; + background: linear-gradient(135deg, #8b5cf6, #ec4899); + color: #fff; + padding: 2px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; } -@media (max-width: 480px) { - .xx-plans-grid { +.xx-pkg-discount { + position: absolute; + top: 12px; + right: 12px; + margin: 0; +} + +.xx-pkg-name { + font-size: 18px; + font-weight: 700; + color: var(--text-primary, #111827); + margin-top: 8px; +} + +.xx-pkg-points { + font-size: 16px; + color: #8b5cf6; + font-weight: 600; + margin: 8px 0; +} + +.xx-pkg-price { + display: flex; + align-items: baseline; + justify-content: center; + gap: 6px; + margin-top: 12px; +} + +.xx-pkg-price .currency { + font-size: 16px; + color: var(--text-secondary, #6b7280); +} + +.xx-pkg-price .amount { + font-size: 32px; + font-weight: 800; + color: var(--text-primary, #111827); + line-height: 1; +} + +.xx-pkg-card.recommended .xx-pkg-price .amount { + color: #8b5cf6; +} + +.xx-pkg-origin { + color: var(--text-tertiary, #9ca3af); + text-decoration: line-through; + font-size: 13px; +} + +.xx-pkg-unit { + font-size: 12px; + color: var(--text-tertiary, #9ca3af); + margin-top: 4px; +} + +@media (max-width: 640px) { + .xx-billing-switch { grid-template-columns: 1fr; - gap: var(--space-md); } - - .xx-plan-card { - padding: var(--space-lg); + .xx-plan-price .amount { + font-size: 36px; } } -- 2.54.0 From de5f505e01b3cede26ec67d9e26d3e2e95e00dc2 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:38:39 +0800 Subject: [PATCH 14/62] feat(points): apps/web/src/pages/points/Center.tsx --- apps/web/src/pages/points/Center.tsx | 308 +++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 apps/web/src/pages/points/Center.tsx diff --git a/apps/web/src/pages/points/Center.tsx b/apps/web/src/pages/points/Center.tsx new file mode 100644 index 000000000..ce340f23b --- /dev/null +++ b/apps/web/src/pages/points/Center.tsx @@ -0,0 +1,308 @@ +/** + * 积分中心主页(/app/points) + * 展示余额、会员信息、本月统计、快捷入口 + */ +import React, { useEffect } from "react" +import { + Card, + Col, + Row, + Statistic, + Button, + Space, + Tag, + Typography, + Progress, + List, + Avatar, + Empty, +} from "antd" +import { + DiamondOutlined, + CrownFilled, + ArrowUpOutlined, + ArrowDownOutlined, + HistoryOutlined, + RechargeOutlined, + FileTextOutlined, + InfoCircleOutlined, +} from "@ant-design/icons" +import { useNavigate } from "react-router-dom" +import PageHead from "@/components/layout/PageHead" +import { usePointsStore } from "@/store/pointsStore" +import "./Points.css" + +const { Title, 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 PointsCenter: React.FC = () => { + const navigate = useNavigate() + const { balance, subscription, init, loading } = 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: "昨天" }, + ] + + return ( +
+ + + + + } + /> + + {/* 顶部大卡 */} + + + + + + 当前可用积分 + +
+ + {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: "#10b981" }} + /> + + + + + } + valueStyle={{ color: "#ef4444" }} + /> + + + + + } + valueStyle={{ color: "#8b5cf6" }} + /> + + + + + } + valueStyle={{ color: "#f59e0b" }} + /> + + + + + {/* 快捷入口 & 最近流水 */} + + + + + 最近流水 + + } + extra={ navigate("/app/points/transactions")}>查看全部 →} + > + {recentTx.length === 0 ? ( + + ) : ( + ( + + : } + /> + } + title={SOURCE_NAME[item.source] ?? item.source} + description={item.time} + /> +
+ {item.type === "earn" ? "+" : "-"} + {item.amount} +
+
+ )} + /> + )} +
+ + + + + + + + + + + +
+
+ ) +} + +export default PointsCenter +export const Component = PointsCenter -- 2.54.0 From cec11a1680cebf9cf2cccc7beaf47ff7894f8d63 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:39:02 +0800 Subject: [PATCH 15/62] feat(points): apps/web/src/pages/points/Transactions.tsx --- apps/web/src/pages/points/Transactions.tsx | 256 +++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 apps/web/src/pages/points/Transactions.tsx diff --git a/apps/web/src/pages/points/Transactions.tsx b/apps/web/src/pages/points/Transactions.tsx new file mode 100644 index 000000000..31f070a36 --- /dev/null +++ b/apps/web/src/pages/points/Transactions.tsx @@ -0,0 +1,256 @@ +/** + * 积分明细页(/app/points/transactions) + * 分页展示积分流水,支持按类型/来源/日期/关键字筛选 + * 日期筛选使用原生 Date,不引入额外依赖 + */ +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 type { Dayjs } from "dayjs" +import { useNavigate } from "react-router-dom" +import PageHead from "@/components/layout/PageHead" +import { getPointsTransactions } from "@/api/points" +import type { PointsTransaction, PointsTxType } from "@/api/points/types" +import "./Points.css" + +const { Text } = Typography +const { RangePicker } = DatePicker + +function formatDateTime(iso: string): string { + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, "0") + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +const TYPE_LABEL: Record = { + earn: { text: "获得", color: "green" }, + spend: { text: "消耗", color: "red" }, + refund: { text: "退还", color: "blue" }, +} + +const SOURCE_LABEL: 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 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 [pageSize, setPageSize] = useState(20) + const [type, setType] = useState("all") + const [source, setSource] = useState("all") + const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(null) + const [keyword, setKeyword] = useState("") + + const load = useCallback(async () => { + setLoading(true) + 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) + } finally { + setLoading(false) + } + }, [page, pageSize, type, source, dateRange, keyword]) + + useEffect(() => { + load() + }, [load]) + + const columns: ColumnsType = [ + { + title: "时间", + dataIndex: "created_at", + width: 180, + render: (v: string) => formatDateTime(v), + }, + { + title: "类型", + dataIndex: "type", + width: 90, + render: (t: PointsTxType) => { + const cfg = TYPE_LABEL[t] + return {cfg.text} + }, + }, + { + title: "来源/场景", + dataIndex: "source", + width: 160, + render: (s: string, r: PointsTransaction) => ( + {r.source_name || SOURCE_LABEL[s] || s} + ), + }, + { + title: "说明", + dataIndex: "description", + ellipsis: true, + }, + { + title: "变动", + dataIndex: "signed_amount", + width: 110, + align: "right", + render: (v: number, r: PointsTransaction) => ( + + {v > 0 ? "+" : ""} + {v} + + ), + }, + { + title: "余额", + dataIndex: "balance_after", + width: 110, + align: "right", + render: (v: number) => ( + + {v} + + ), + }, + ] + + return ( +
+ + + + + } + /> + + +
+ + { + setSource(v) + setPage(1) + }} + style={{ width: 170 }} + showSearch + options={[ + { value: "all", label: "全部来源" }, + ...Object.entries(SOURCE_LABEL).map(([k, v]) => ({ value: k, label: v })), + ]} + /> + { + setDateRange(v) + 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 PointsTransactions +export const Component = PointsTransactions -- 2.54.0 From c09b9d66a6ec02da154cabfecb3793a5c72c2f98 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:39:07 +0800 Subject: [PATCH 16/62] feat(points): apps/web/src/pages/points/Packages.tsx --- apps/web/src/pages/points/Packages.tsx | 167 +++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 apps/web/src/pages/points/Packages.tsx diff --git a/apps/web/src/pages/points/Packages.tsx b/apps/web/src/pages/points/Packages.tsx new file mode 100644 index 000000000..17ef5f025 --- /dev/null +++ b/apps/web/src/pages/points/Packages.tsx @@ -0,0 +1,167 @@ +/** + * 积分充值页(/app/points/recharge) + * 单独展示积分包,供入口直接跳转使用 + * 主 Plans 页面也有充值区,这里提供独立 URL 方便从"积分不足"弹窗跳转 + */ +import React, { useEffect, useState } from "react" +import { Card, Col, Row, Button, Tag, Typography, Space, Modal, message, Tooltip } from "antd" +import { DiamondOutlined, 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 "./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) + + useEffect(() => { + init() + getPointsPackages() + .then((r) => setPackages(r.packages)) + .catch(() => {}) + }, [init]) + + const getPackPrice = (pkg: PointsPackage): number => { + const mt = balance?.member_type ?? "free" + const key = `discounted_price_for_${mt}` as const + return (pkg as any)[key] ?? pkg.price + } + + const handleBuy = async (pkg: PointsPackage) => { + 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: any) { + message.error(e?.message ?? "下单失败") + } finally { + setBuying(null) + } + } + + return ( +
+ + {!balance?.is_member && ( + + )} + + + } + /> + + {balance && ( + + +
+ 当前可用积分 +
+ {balance.balance.toLocaleString()} +
+
+
+
+ )} + + 选择积分包 + + {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 + return ( + + 0 ? "has-discount" : ""}`} + hoverable + > + {pkg.id === "basic_pack" &&
热门
} + {discount > 0 && ( + + 会员{10 - discount / 10}折 + + )} +
{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)}/积分 · 永久有效
+ +
+ + ) + })} +
+ + + 积分消耗说明 +
    +
  • 智能混剪:3 积分/条(≤30s),每加 30s +1 积分
  • +
  • AI 配音 / 声音克隆合成:1 积分/分钟
  • +
  • AI 数字人:15 积分/分钟
  • +
  • 抖音链接提取 / AI 改写 / AI 标题 / AI 封面:1~2 积分/次
  • +
  • 声音克隆训练:免费
  • +
  • 免费用户每日 2 条混剪免费,其余 AI 功能消耗为会员价 ×1.15
  • +
+ + + 所有规则以页面实际显示为准 + + +
+
+ ) +} + +export default PointsRecharge +export const Component = PointsRecharge -- 2.54.0 From a47007ed23c3af548f10fc31420e65ba2dedab6a Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:39:09 +0800 Subject: [PATCH 17/62] feat(points): apps/web/src/pages/points/Rules.tsx --- apps/web/src/pages/points/Rules.tsx | 137 ++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 apps/web/src/pages/points/Rules.tsx diff --git a/apps/web/src/pages/points/Rules.tsx b/apps/web/src/pages/points/Rules.tsx new file mode 100644 index 000000000..c574520db --- /dev/null +++ b/apps/web/src/pages/points/Rules.tsx @@ -0,0 +1,137 @@ +/** + * 积分规则页(/app/points/rules) + * 静态展示积分获得、消耗、会员折扣等规则 + */ +import React from "react" +import { Card, Descriptions, Table, Tag, Typography, Divider, Space } from "antd" +import { InfoCircleOutlined, CrownFilled, DiamondOutlined } from "@ant-design/icons" +import PageHead from "@/components/layout/PageHead" +import "./Points.css" + +const { Title, Text, Paragraph } = Typography + +const costData = [ + { key: "ai_video", scene: "智能混剪", unit: "条(≤30s)", cost: 3, remark: "每加 30s +1" }, + { key: "ai_voice", scene: "AI 配音(含克隆合成)", unit: "分钟", cost: 1, remark: "" }, + { key: "ai_digital_human", scene: "AI 数字人", unit: "分钟", cost: 15, remark: "" }, + { key: "douyin_extract", scene: "抖音文案提取", unit: "次", cost: 1, remark: "" }, + { key: "ai_rewrite", scene: "AI 文案改写", unit: "次", cost: 1, remark: "" }, + { key: "ai_title", scene: "AI 标题生成", unit: "次", cost: 1, remark: "" }, + { key: "ai_cover", scene: "AI 封面生成", unit: "次", cost: 2, remark: "" }, + { key: "voice_clone_train", scene: "声音克隆训练", unit: "次", cost: 0, remark: "免费" }, +] + +const earnData = [ + { key: "new_user", channel: "注册奖励", points: "50~100", remark: "新人专享" }, + { key: "recharge", channel: "充值购买", points: "100~2000/次", remark: "永久有效" }, + { key: "subscribe", channel: "开通会员赠送", points: "见会员方案", remark: "季/年卡赠送更多" }, + { key: "task", channel: "任务/活动奖励", points: "不定额", remark: "运营活动发放" }, +] + +const PointsRules: React.FC = () => { + return ( +
+ + + + + <InfoCircleOutlined style={{ color: "#3b82f6" }} /> 什么是积分? + + + 积分是小虾 SaaS 用于结算 AI 能力的虚拟货币。每次使用智能混剪、AI 配音、AI 数字人、AI + 改写等功能时, + 会根据使用量扣除相应积分。积分永久有效,可通过充值、任务奖励、注册礼包等方式获取。 + + + + + + <DiamondOutlined style={{ color: "#8b5cf6" }} /> 积分消耗规则 + + + v === 0 ? ( + 免费 + ) : ( + + {v} + + ), + }, + { title: "备注", dataIndex: "remark" }, + ]} + /> + + + 免费用户每日享有 2 条免费混剪额度,超出后按上述标准 ×1.15 扣除积分; + 付费会员按上述标准原价扣除积分。 + + + + + + ↑ 积分获得方式 + +
+ + + + + <CrownFilled style={{ color: "#f59e0b" }} /> 会员折扣 + + + + 所有 AI 功能按标准价 ×1.15 消耗积分;每日 2 条免费混剪 + + + 所有 AI 功能按标准价消耗;积分购买享 8~9 折;解锁批量导出、多平台发布等高级功能 + + + 积分购买最低 8 折;开通即赠送 2000 积分 + + + 积分购买 8.5 折;开通即赠送 400 积分 + + + + + + 退款与有效期 +
    +
  • 充值积分永久有效,不会过期
  • +
  • AI 生成失败或服务异常时,已扣除的积分将自动退还
  • +
  • 会员为虚拟商品,一经开通不支持退款;到期后会员权益自动失效,积分余额不受影响
  • +
  • 如需发票请联系客服
  • +
+ + + + 规则更新时间:2026-09-15 + + + + + ) +} + +export default PointsRules +export const Component = PointsRules -- 2.54.0 From c35e211123c7b3fac4df4da1d57e1bd2aa1eb21e Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:39:12 +0800 Subject: [PATCH 18/62] feat(points): apps/web/src/pages/points/Points.css --- apps/web/src/pages/points/Points.css | 88 ++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 apps/web/src/pages/points/Points.css diff --git a/apps/web/src/pages/points/Points.css b/apps/web/src/pages/points/Points.css new file mode 100644 index 000000000..2b3ccda04 --- /dev/null +++ b/apps/web/src/pages/points/Points.css @@ -0,0 +1,88 @@ +/* 积分中心 & 明细 & 规则页通用样式 */ +.xx-points-page { + max-width: 1100px; + margin: 0 auto; + padding: 24px 0; +} + +/* Hero 渐变卡 */ +.xx-points-hero { + background: linear-gradient(135deg, #7c3aed 0%, #ec4899 50%, #f59e0b 100%); + border-radius: 16px; + color: #fff; + overflow: hidden; + position: relative; +} + +.xx-points-hero::before { + content: ""; + position: absolute; + right: -60px; + top: -60px; + width: 220px; + height: 220px; + background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%); + border-radius: 50%; +} + +.xx-points-hero .ant-card-body { + padding: 28px 32px; + position: relative; +} + +.xx-points-hero-balance { + display: flex; + align-items: baseline; + gap: 10px; + font-size: 52px; + font-weight: 800; + color: #fff; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.xx-points-hero-balance .anticon { + font-size: 32px; +} + +.xx-points-free-quota { + background: rgba(255, 255, 255, 0.15); + border-radius: 12px; + padding: 14px 16px; + backdrop-filter: blur(8px); +} + +.xx-points-free-title { + font-size: 13px; + margin-bottom: 8px; + color: rgba(255, 255, 255, 0.95); +} + +.xx-points-free-quota .ant-progress-text { + color: #fff !important; +} + +/* 列表筛选 */ +.xx-points-filters { + margin-bottom: 16px; +} + +.xx-tx-earn { + color: #10b981; + font-weight: 700; +} + +.xx-tx-spend { + color: #ef4444; + font-weight: 700; +} + +.xx-tx-refund { + color: #3b82f6; + font-weight: 700; +} + +/* 规则页 */ +.xx-rules-table .ant-descriptions-item-label { + width: 160px; +} -- 2.54.0 From 6ec432427d1f913b54faa846ddcb8868e850f825 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:39:14 +0800 Subject: [PATCH 19/62] feat(points): update apps/web/src/pages/generate/GeneratePage.tsx --- apps/web/src/pages/generate/GeneratePage.tsx | 154 +++++++++++++------ 1 file changed, 104 insertions(+), 50 deletions(-) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 2c1885c0e..ed1f84b73 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -1,8 +1,12 @@ /** - * 智能剪辑页面(#1899 简化为 5 步) - * 5 步向导:素材(1) → 配音(2) → 标题(3) → 确认生成(4) → 封面(5) - * 模板选择已删除,模板在后台自动加载/选择(后端 API 兼容)。 - * 新增片段数量输入(默认3),系统按片段数智能匹配素材。 + * 智能剪辑页面(Issue #1677 多视频批量生成,修正版) + * 固定 6 步向导:模板(弹数量) → 素材 → 配音 → 标题 → 确认生成 → 封面,单视频与批量完全一致 + * + * 架构: + * - 预览全部为纯前端 Canvas 实时播放(FrontendPreviewPlayer),不调任何后端渲染接口: + * N=1 单播放器;N>1 CanvasPreviewGrid(variantSeed 让素材排布/起始点不同,画面有差异) + * - 步骤5确认生成:正式生成接口(count + titles[]/voice_library_ids[]/cover_urls[]), + * 批量时逐任务独立进度/失败重试(BatchGenerationGrid) */ import React, { useMemo, useState, useEffect, useRef, useCallback } from "react" import { message } from "antd" @@ -28,19 +32,30 @@ import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview" import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters" import { getAssetsByKind } from "@/api/assets" import { previewTts } from "@/api/tts" +// v2: 积分校验 +import { usePointsStore } from "@/store/pointsStore" +import { hasEnoughPoints } from "./hooks/pointsCost" import "./generate.css" +import "./generate-points.css" const GeneratePage: React.FC = () => { const navigate = useNavigate() + /* ── v2: 积分余额 ── */ + const { balance: pointsBalance, init: initPoints } = usePointsStore() + useEffect(() => { + initPoints() + }, [initPoints]) + /* ── 表单状态 ── */ const formState = useGenerateFormState() const { currentStep, setCurrentStep, - clipCount, - setClipCount, selectedTemplate, + setSelectedTemplate, + userTemplates, + handleInvalidTemplate, selectedMaterials, setSelectedMaterials, materialMode, @@ -87,7 +102,9 @@ const GeneratePage: React.FC = () => { const isBatch = previewCount > 1 - /* ── 配音选择同步 ── */ + /* ── 配音选择同步:共用配音 ↔ 变体数组 ── */ + // 触发场景:①共用配音变化 ②批量模式进入/退出 ③独立→共用切换(需把所有变体刷成共用配音) + // 独立模式下:仅同步变体[0](其选择器绑定共用配音),用户单独选择的其他变体不覆盖 const prevVoiceSyncRef = useRef({ voice: selectedVoice, batch: isBatch, @@ -97,16 +114,14 @@ const GeneratePage: React.FC = () => { const prev = prevVoiceSyncRef.current const voiceChanged = prev.voice !== selectedVoice const modeChanged = prev.batch !== isBatch || prev.perVideo !== voiceModePerVideo - prevVoiceSyncRef.current = { - voice: selectedVoice, - batch: isBatch, - perVideo: voiceModePerVideo, - } + prevVoiceSyncRef.current = { voice: selectedVoice, batch: isBatch, perVideo: voiceModePerVideo } if (!voiceChanged && !modeChanged) return if (!isBatch) return if (!voiceModePerVideo) { + // 共用模式(含刚从独立切回):所有变体跟随共用配音,未选择的补默认值 setVoiceLibraryIds((prevIds) => (prevIds || []).map((id) => id || selectedVoice)) } else if (voiceChanged) { + // 独立模式下共用配音变化:仅同步变体[0](与共用选择器绑定),其余不覆盖 setVoiceLibraryIds((prevIds) => (prevIds || []).map((id, i) => (i === 0 ? selectedVoice : id)), ) @@ -130,9 +145,12 @@ const GeneratePage: React.FC = () => { const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState(null) const ttsAbortRef = useRef(null) + // TTS 试听文案:批量跟随变体0标题(仅取首项,避免编辑其他变体标题触发多余 TTS 请求) const variant0Title = isBatch ? previewTitles?.[0] || "" : "" useEffect(() => { + // #1750:批量模式每变体独立/共用配音由 useVariantVoicePreview 接管(含独立模式每卡片挂各自 URL), + // 本 effect 只服务单视频,批量模式直接跳过(避免共用 TTS 结果错误下发给所有变体) if (isBatch) return const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice) if (voiceAsset?.file_url) { @@ -140,6 +158,7 @@ const GeneratePage: React.FC = () => { return } + // 单视频 TTS 文案跟随主标题 const ttsTitle = titleSettings.title const voiceId = selectedClonedVoice || selectedVoice if (!voiceId || !ttsTitle) { @@ -193,16 +212,22 @@ const GeneratePage: React.FC = () => { [materialMode, smartSelectedIds, selectedMaterials], ) + /* ── 当前模板对象 ── */ + const currentTemplate = useMemo( + () => userTemplates.find((t) => t.id === selectedTemplate) || null, + [userTemplates, selectedTemplate], + ) + /* ── BGM 配置 ── */ const bgmConfig = useMemo( () => ({ enabled: bgm, - music_id: "", + music_id: currentTemplate?.bgm_config?.music_id || "", }), - [bgm], + [bgm, currentTemplate], ) - /* ── 加载素材详情 ── */ + /* ── 加载素材详情(供前端预览播放器使用) ── */ const previewAssetsEnabled = previewAssetIds.length > 0 const { assets: previewAssets, @@ -210,11 +235,13 @@ const GeneratePage: React.FC = () => { ensureAssets, } = usePreviewAssets(previewAssetIds, previewAssetsEnabled) - /* ── 预览就绪 ── */ - // #1899: 不再依赖 currentTemplate,素材加载完即可预览 - const previewReady = previewAssetsReady + /* ── 预览就绪:纯前端 Canvas 预览,素材详情加载完即可秒开(单视频/批量一致) ── */ + const previewReady = previewAssetsReady && !!currentTemplate - /* ── 批量变体真实片段(#1744) ── */ + /* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ── + 仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求; + 变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */ + // P0 fix:批量变体计划请求需携带配音参数,避免后端按"无配音"选片导致 clips 时长与配音错位 const batchVoiceLibraryId = voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || "" const { @@ -225,7 +252,7 @@ const GeneratePage: React.FC = () => { error: variantClipsError, retry: retryVariantClips, } = useBatchVariantPlans({ - enabled: isBatch && currentStep === 3 && previewAssetsReady, + enabled: isBatch && currentStep === 4 && previewAssetsReady, count: previewCount, templateId: selectedTemplate || "", assetIds: previewAssetIds, @@ -235,7 +262,8 @@ const GeneratePage: React.FC = () => { voiceModePerVideo, }) - /* ── 批量变体配音预览 URL ── */ + /* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条; + hook 内部自取配音素材库并做 TTS 缓存/串行/防竞态 */ const variantVoiceAudioUrls = useVariantVoicePreview({ enabled: isBatch, count: previewCount, @@ -246,7 +274,8 @@ const GeneratePage: React.FC = () => { titles: previewTitles || [], }) - /* ── 变体 clips 引用素材补拉 ── */ + /* ── 变体计划 clips 引用的素材可能不在已选列表中(#1750):差集补拉素材详情, + 保证播放器拿得到 file_url,而不是静默丢片段/用假数据冒充 */ const clipAssetIds = useMemo(() => { if (!isBatch || !variantClips?.length) return [] const ids = new Set() @@ -298,6 +327,7 @@ const GeneratePage: React.FC = () => { autoSubtitles, bgm, sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId, + // #1744:批量预览阶段后端为每个变体生成的独立 plan id,正式生成回传 → 预览即成片 variantPlanIds, previewTaskId, bgmConfig, @@ -313,11 +343,12 @@ const GeneratePage: React.FC = () => { }, }) - /* ── 数量弹窗确认 ── */ + /* ── 数量弹窗确认:设置数量 + 同步批量数组长度 + 进入步骤2 ── */ const handleCountConfirm = useCallback( (count: number) => { setPreviewCount(count) setCountModalOpen(false) + // 同步批量数组长度 setPreviewTitles((prev) => { const list = prev || [] const base = list[0] || titleSettings.title || "" @@ -346,8 +377,18 @@ const GeneratePage: React.FC = () => { ], ) - /* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */ + /* ── 步骤4「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤5看实时进展 ── */ const handleConfirmGenerate = useCallback(async () => { + // 标题校验:批量只校验已勾选的变体;单视频校验主标题 + const videoCount = isBatch ? selectedVariantIds.length : 1 + + // v2: 积分校验 + const pointsCheck = hasEnoughPoints(pointsBalance, videoCount) + if (!pointsCheck.sufficient) { + message.warning(pointsCheck.reason ?? "积分不足,请充值后再试") + return + } + if (isBatch) { if (selectedVariantIds.length === 0) { message.warning("请至少勾选一个视频") @@ -359,6 +400,8 @@ const GeneratePage: React.FC = () => { return } } else if (!titleSettings.title?.trim()) { + // 与 buildPayload.validateGenerateInputs 一致:AI 自动选标题模式(aiAutoSelect) + // 允许空标题由后端生成;手动模式必须填写,避免提交空标题 if (!titleSettings.aiAutoSelect) { message.warning("请先选择或输入标题") return @@ -370,7 +413,8 @@ const GeneratePage: React.FC = () => { } const ok = await handleGenerate() if (ok) { - setCurrentStep(4) + // 单视频与批量一致:任务创建成功后进入步骤5「确认生成」看实时渲染进展 + setCurrentStep(5) } }, [ isBatch, @@ -381,12 +425,26 @@ const GeneratePage: React.FC = () => { previewReady, handleGenerate, setCurrentStep, + pointsBalance, ]) + /* v2: 积分消耗估算(步骤4按钮展示用) */ + const pointsInfo = useMemo(() => { + const videoCount = isBatch ? selectedVariantIds.length : 1 + const check = hasEnoughPoints(pointsBalance, videoCount) + return { + cost: check.cost, + insufficient: !check.sufficient, + reason: check.reason, + freeUsed: check.freeClipsUsed, + } + }, [isBatch, selectedVariantIds.length, pointsBalance]) + /* ── 步骤导航 ── */ const { goNext, goPrev } = useStepNavigation({ currentStep, setCurrentStep, + selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, @@ -395,12 +453,12 @@ const GeneratePage: React.FC = () => { onOpenCountModal: () => setCountModalOpen(true), }) - /* ── 最终成片 ── */ + /* ── 最终成片(单视频右侧播放) ── */ const finalVideo = generatedVideos[0] - /* ── 布局 class ── */ + /* ── 布局 class:步骤4标题页=预览+标题侧栏两栏;其余步骤(含步骤5确认生成、步骤6封面)=整行宽 ── */ const layoutClassName = useMemo(() => { - if (currentStep === 3) return "xx-generate-layout step4-layout" + if (currentStep === 4) return "xx-generate-layout step4-layout" return "xx-generate-layout full-width" }, [currentStep]) @@ -415,10 +473,11 @@ const GeneratePage: React.FC = () => {
- {/* ════ 步骤3:左侧预览大区域 ════ */} - {currentStep === 3 && ( + {/* ════ 步骤4:左侧预览大区域(纯前端 Canvas 实时预览) ════ */} + {currentStep === 4 && !!currentTemplate && (
{!isBatch ? ( + /* 单视频:前端 Canvas 实时预览(与旧版一致,零回归) */ { onTitlePositionChange={styleUpdaters.updateTitlePosition} /> ) : ( + /* 批量:N 个前端 Canvas 预览网格(不调任何后端渲染接口,秒开) */

🎬 {previewCount} 个视频预览

- + 实时预览,勾选要生成的视频
@@ -474,19 +529,19 @@ const GeneratePage: React.FC = () => {
)} - {/* ════ 右侧:步骤1~2 表单 / 步骤3 标题边栏 / 步骤4 确认生成进度 / 步骤5 封面 ════ */} + {/* ════ 右侧:步骤1~3 表单 / 步骤4 标题边栏 / 步骤5 确认生成进度 / 步骤6 封面 ════ */}
{ selectedVoice={selectedVoice} onSelectedVoiceChange={setSelectedVoice} onServerClipsChange={setServerClips} + onTemplateInvalid={handleInvalidTemplate} generating={generating} generated={generated} generateError={generateError} @@ -527,8 +583,8 @@ const GeneratePage: React.FC = () => { selectedVariantIds={selectedVariantIds} /> - {/* ════ 步骤4(单视频):成片播放器 ════ */} - {currentStep === 4 && !isBatch && generated && finalVideo && ( + {/* ════ 步骤5(单视频):成片播放器置于按钮上方、居中展示 ════ */} + {currentStep === 5 && !isBatch && generated && finalVideo && (
{ }} poster={finalVideo.thumbnail_url || undefined} /> -
+
@@ -607,6 +656,11 @@ const GeneratePage: React.FC = () => { generated={generated} generateError={generateError} selectedCount={isBatch ? selectedVariantIds.length : 1} + estimatedCost={pointsInfo.cost} + pointsInsufficient={pointsInfo.insufficient} + insufficientReason={pointsInfo.reason} + freeClipsUsedThisTime={pointsInfo.freeUsed} + onRecharge={() => navigate("/app/points/recharge")} />
-- 2.54.0 From 9437761d8b99da1007c6e4f6dbd2199c8f0c7575 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 08:39:16 +0800 Subject: [PATCH 20/62] feat(points): update apps/web/src/pages/generate/components/GenerateStepActions.tsx --- .../components/GenerateStepActions.tsx | 80 ++++++++++++++++--- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/apps/web/src/pages/generate/components/GenerateStepActions.tsx b/apps/web/src/pages/generate/components/GenerateStepActions.tsx index 68b256fe9..85e20d29b 100644 --- a/apps/web/src/pages/generate/components/GenerateStepActions.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepActions.tsx @@ -1,24 +1,38 @@ /** - * GeneratePage 步骤底部操作按钮(#1899 简化为 5 步) + * GeneratePage 步骤底部操作按钮(Issue #1677 修正:固定 6 步) + * v2: 步骤4 按钮前显示本次积分消耗;积分不足时禁用按钮并提示充值 * - * 步骤 1~2:上一步 / 下一步 - * 步骤 3(选择标题):「✨ 确认生成视频 / 确认生成 N 个视频」→ 创建正式生成任务,成功后跳步骤4 - * 步骤 4(确认生成):渲染进度页,全部完成后「下一步:选择封面」;仅上一步 - * 步骤 5(选择封面):仅上一步 + * 步骤 1~3:上一步 / 下一步 + * 步骤 4(选择标题):「✨ 确认生成视频 / 确认生成 N 个视频」→ 创建正式生成任务,成功后跳步骤5 + * 步骤 5(确认生成):渲染进度页,全部完成后「下一步:选择封面」;仅上一步 + * 步骤 6(选择封面):仅上一步 */ import React from "react" +import { Button, Tooltip } from "antd" +import { DiamondOutlined, ExclamationCircleOutlined } from "@ant-design/icons" export interface GenerateStepActionsProps { currentStep: number onPrev: () => void onNext: () => void - /** 步骤3:确认生成视频(校验 + 创建渲染任务) */ + /** 步骤4:确认生成视频(校验 + 创建渲染任务) */ onConfirmGenerate: () => void | Promise generating: boolean generated: boolean generateError: string | null /** 批量模式下勾选的视频数量(N=1 时为1) */ selectedCount?: number + // v2: 积分相关 + /** 本次预估消耗积分 */ + estimatedCost?: number + /** 是否积分不足 */ + pointsInsufficient?: boolean + /** 积分不足原因 */ + insufficientReason?: string + /** 剩余免费混剪次数 */ + freeClipsUsedThisTime?: number + /** 前往充值 */ + onRecharge?: () => void } const GenerateStepActions: React.FC = ({ @@ -30,10 +44,15 @@ const GenerateStepActions: React.FC = ({ generated, generateError, selectedCount = 1, + estimatedCost, + pointsInsufficient, + insufficientReason, + freeClipsUsedThisTime, + onRecharge, }) => { const renderPrimaryButton = () => { - /* 步骤 1~2:上一步 / 下一步 */ - if (currentStep < 3) { + /* 步骤 1~3:上一步 / 下一步 */ + if (currentStep < 4) { return ( ) } + if (pointsInsufficient) { + return ( + + + + {onRecharge && ( + + )} + + + ) + } return ( ) } - /* 步骤 4:确认生成进度页 — 全部完成后下一步进封面 */ - if (currentStep === 4) { + /* 步骤 5:确认生成进度页 — 全部完成后下一步进封面 */ + if (currentStep === 5) { if (generated) { return ( -- 2.54.0 From 3e65acc396407511d98319369366b4357a307aad Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 09:34:00 +0800 Subject: [PATCH 35/62] fix(1895): components/common/PointsBadge/PointsBadge.css - TS/Lint fixes (ThunderboltOutlined/actions/typed/no-unused) -- 2.54.0 From 5857114e1f8e39348a08ebcccee4ef0a21b7b43a Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 09:34:06 +0800 Subject: [PATCH 36/62] fix(1895): components/common/PointsCost/index.tsx - TS/Lint fixes (ThunderboltOutlined/actions/typed/no-unused) -- 2.54.0 From 070e82845c81895041f0d42f34853d00b22d62a2 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 09:34:13 +0800 Subject: [PATCH 37/62] fix(1895): components/common/PointsCost/PointsCost.css - TS/Lint fixes (ThunderboltOutlined/actions/typed/no-unused) -- 2.54.0 From 49495e6b50bd910fe1410b6de1a39766408528ac Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 09:34:18 +0800 Subject: [PATCH 38/62] fix(1895): components/layout/Header.tsx - TS/Lint fixes (ThunderboltOutlined/actions/typed/no-unused) --- apps/web/src/components/layout/Header.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index fdead151a..ee23f3f68 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -11,7 +11,7 @@ import { UserOutlined, MenuOutlined, CrownFilled, - DiamondOutlined, + ThunderboltOutlined, HistoryOutlined, WalletOutlined, } from "@ant-design/icons" @@ -36,7 +36,7 @@ const Header: React.FC = () => { useEffect(() => { init() // 每 60s 刷新一次(余额可能变化) - const t = setInterval(() => init(), 60_000) + const t = setInterval(() => init(true), 60_000) return () => clearInterval(t) }, [init]) @@ -60,7 +60,7 @@ const Header: React.FC = () => { // v2: 我的积分入口 { key: "points-center", - icon: , + icon: , label: ( 我的积分 -- 2.54.0 From bc7edc2e15f28019beaaa532b14a7b68485213af Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 09:34:22 +0800 Subject: [PATCH 39/62] fix(1895): components/layout/Header.css - TS/Lint fixes (ThunderboltOutlined/actions/typed/no-unused) -- 2.54.0 From 1bc270dd8275f26dd721203f7bd745554b2d3354 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 09:34:26 +0800 Subject: [PATCH 40/62] fix(1895): config/navigation.ts - TS/Lint fixes (ThunderboltOutlined/actions/typed/no-unused) --- apps/web/src/config/navigation.ts | 57 ++++++++----------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index 1d783b97d..99bc5107d 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -1,7 +1,6 @@ /** * 统一导航配置 * Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复 - * v2: 增加积分中心 / 会员订阅入口(侧边栏) */ import React from "react" import { @@ -9,18 +8,16 @@ import { FileOutlined, FileTextOutlined, AudioOutlined, - AppstoreOutlined, EditOutlined, - FolderOutlined, VideoCameraOutlined, HistoryOutlined, TrophyOutlined, ScanOutlined, ControlOutlined, CrownOutlined, + ThunderboltOutlined, UnorderedListOutlined, UserOutlined, - DiamondOutlined, } from "@ant-design/icons" /** 导航项类型 */ @@ -40,7 +37,6 @@ export interface NavGroup { /** * 全量导航项(Header 扁平列表使用) * 所有路径统一使用 /app 前缀,与路由配置保持一致 - * 注:积分/会员入口放在 Header 右侧按钮区(升级会员+积分徽章),不占用主导航槽位 */ export const NAV_ITEMS: NavItem[] = [ { @@ -73,24 +69,6 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/voices", icon: React.createElement(AudioOutlined), }, - { - key: "templates", - label: "模板库", - path: "/app/templates", - icon: React.createElement(AppstoreOutlined), - }, - { - key: "editing-planner", - label: "剪辑模板", - path: "/app/editing-planner", - icon: React.createElement(EditOutlined), - }, - { - key: "my-templates", - label: "我的模板", - path: "/app/my-templates", - icon: React.createElement(FolderOutlined), - }, { key: "generate", @@ -128,6 +106,18 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/tasks", icon: React.createElement(UnorderedListOutlined), }, + { + key: "subscription", + label: "会员订阅", + path: "/app/subscription", + icon: React.createElement(CrownOutlined), + }, + { + key: "points", + label: "积分中心", + path: "/app/points", + icon: React.createElement(ThunderboltOutlined), + }, ] /** 侧边栏导航分组(Sidebar 分组列表使用) */ @@ -153,12 +143,6 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/ai-avatar", icon: React.createElement(UserOutlined), }, - { - key: "editing-planner", - label: "剪辑模板", - path: "/app/editing-planner", - icon: React.createElement(EditOutlined), - }, ], }, { @@ -194,18 +178,6 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/products", icon: React.createElement(TrophyOutlined), }, - { - key: "templates", - label: "模板库", - path: "/app/templates", - icon: React.createElement(AppstoreOutlined), - }, - { - key: "my-templates", - label: "我的模板", - path: "/app/my-templates", - icon: React.createElement(FolderOutlined), - }, ], }, { @@ -235,7 +207,6 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/admin", icon: React.createElement(ControlOutlined), }, - // v2: 会员与积分 { key: "subscription", label: "会员订阅", @@ -246,7 +217,7 @@ export const NAV_GROUPS: NavGroup[] = [ key: "points", label: "积分中心", path: "/app/points", - icon: React.createElement(DiamondOutlined), + icon: React.createElement(ThunderboltOutlined), }, ], }, -- 2.54.0 From e805f4159c79b99ae1994f5df544a46c5dfb595f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 09:34:33 +0800 Subject: [PATCH 41/62] fix(1895): router/appRoutes.tsx - TS/Lint fixes (ThunderboltOutlined/actions/typed/no-unused) --- apps/web/src/router/appRoutes.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/apps/web/src/router/appRoutes.tsx b/apps/web/src/router/appRoutes.tsx index b274814e8..fc4442f3f 100644 --- a/apps/web/src/router/appRoutes.tsx +++ b/apps/web/src/router/appRoutes.tsx @@ -6,7 +6,6 @@ import { lazyRoute } from "./lazyRoute" /** * 受保护的 /app 子路由 * 所有页面使用 lazy 懒加载 - * v2: 增加 /app/points/* 积分相关路由 */ const appChildren: RouteObject[] = [ { @@ -33,10 +32,6 @@ const appChildren: RouteObject[] = [ path: "voices", lazy: lazyRoute(() => import("@/pages/voices/VoiceLibrary")), }, - { - path: "templates", - lazy: lazyRoute(() => import("@/pages/templates/TemplateLibrary")), - }, { path: "generate", lazy: lazyRoute(() => import("@/pages/generate/GeneratePage")), @@ -57,18 +52,10 @@ const appChildren: RouteObject[] = [ path: "tasks", lazy: lazyRoute(() => import("@/pages/tasks/TaskCenter")), }, - { - path: "editing-planner", - lazy: lazyRoute(() => import("@/pages/editing-planner/EditingPlanner")), - }, { path: "ai-avatar", lazy: lazyRoute(() => import("@/pages/ai-avatar/AiAvatarPage")), }, - { - path: "my-templates", - lazy: lazyRoute(() => import("@/pages/my-templates/MyTemplates")), - }, { path: "voice-clone", lazy: lazyRoute(() => import("@/pages/voice-clone/VoiceClone")), @@ -109,7 +96,6 @@ const appChildren: RouteObject[] = [ path: "subscription/billing", lazy: lazyRoute(() => import("@/pages/subscription/Billing")), }, - // v2: 积分相关路由 { path: "points", lazy: lazyRoute(() => import("@/pages/points/Center")), -- 2.54.0 From 16699b4bdf06de45c1086d68638fcf850a514f65 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 09:34:38 +0800 Subject: [PATCH 42/62] fix(1895): pages/points/Center.tsx - TS/Lint fixes (ThunderboltOutlined/actions/typed/no-unused) --- apps/web/src/pages/points/Center.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/points/Center.tsx b/apps/web/src/pages/points/Center.tsx index fa274bf0f..4130f9e4f 100644 --- a/apps/web/src/pages/points/Center.tsx +++ b/apps/web/src/pages/points/Center.tsx @@ -18,7 +18,7 @@ import { Empty, } from "antd" import { - DiamondOutlined, + ThunderboltOutlined, CrownFilled, ArrowUpOutlined, ArrowDownOutlined, @@ -79,7 +79,8 @@ const PointsCenter: React.FC = () => {
Date: Tue, 15 Sep 2026 09:35:01 +0800 Subject: [PATCH 46/62] fix(1895): pages/points/Points.css - TS/Lint fixes (ThunderboltOutlined/actions/typed/no-unused) -- 2.54.0 From 81372084517c8ba00b19a8dc5755a16ab14729e6 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 09:35:06 +0800 Subject: [PATCH 47/62] fix(1895): pages/subscription/Plans.tsx - TS/Lint fixes (ThunderboltOutlined/actions/typed/no-unused) --- apps/web/src/pages/subscription/Plans.tsx | 27 +++++++++++++---------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/web/src/pages/subscription/Plans.tsx b/apps/web/src/pages/subscription/Plans.tsx index 83ac01a28..b5f0b8e0c 100644 --- a/apps/web/src/pages/subscription/Plans.tsx +++ b/apps/web/src/pages/subscription/Plans.tsx @@ -22,9 +22,8 @@ import { import { CheckCircleFilled, CrownFilled, - DiamondOutlined, - SafetyCertificateOutlined, ThunderboltOutlined, + SafetyCertificateOutlined, VideoCameraOutlined, StarFilled, } from "@ant-design/icons" @@ -72,7 +71,7 @@ const Plans: React.FC = () => { "quarterly", ) const [packages, setPackages] = useState([]) - const [subscribing] = useState(false) + const [subscribing, setSubscribing] = useState(false) const [buying, setBuying] = useState(null) useEffect(() => { @@ -88,8 +87,8 @@ const Plans: React.FC = () => { /** 根据会员等级计算积分包折后价(分) */ const getPackPrice = (pkg: PointsPackage): number => { const mt = memberType ?? "free" - const key = `discounted_price_for_${mt}` as const - return (pkg as PointsPackage & Record)[key] ?? pkg.price + const key = `discounted_price_for_${mt}` as keyof PointsPackage + return (pkg[key] as number | undefined) ?? pkg.price } const selectedPlan = useMemo( @@ -124,7 +123,7 @@ const Plans: React.FC = () => { const order = await createPointsOrder({ package_id: pkg.id }) Modal.info({ title: "支付功能开发中", - icon: , + icon: , content: (
@@ -152,9 +151,13 @@ const Plans: React.FC = () => { - @@ -198,7 +201,7 @@ const Plans: React.FC = () => {
可用积分
- + {balance.balance}
@@ -221,7 +224,7 @@ const Plans: React.FC = () => {