diff --git a/apps/web/src/api/points/index.ts b/apps/web/src/api/points/index.ts new file mode 100644 index 000000000..8344b97af --- /dev/null +++ b/apps/web/src/api/points/index.ts @@ -0,0 +1,477 @@ +/** + * 积分 & 会员 API 封装(v2 两档会员制) + * 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。 + */ +import apiClient from "../client" +import type { + PointsBalance, + PointsTransaction, + 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 = 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 +} + +// ==================== 真实 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[] = [ + { + 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 +} 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 +} 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; +} 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..3c26fff21 --- /dev/null +++ b/apps/web/src/components/common/PointsBadge/index.tsx @@ -0,0 +1,115 @@ +/** + * Header 右上角的积分徽章(💎 580) + * - 余额 <10 时橙色告警 + * - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口 + */ +import React, { useEffect } from "react" +import { Popover, Button, Tag, Space, Typography, Badge } from "antd" +import { + ThunderboltOutlined, + CrownOutlined, + RightOutlined, + WarningOutlined, +} from "@ant-design/icons" +import { useNavigate } from "react-router-dom" +import { usePointsStore } from "@/store/pointsStore" +import "./PointsBadge.css" + +const { Text, Paragraph } = Typography + +const PointsBadge: React.FC = () => { + const navigate = useNavigate() + const { balance, 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 + ? balance?.member_type === "yearly" + ? "年卡会员" + : balance?.member_type === "quarterly" + ? "季卡会员" + : balance?.member_type === "monthly" + ? "月卡会员" + : "付费会员" + : "免费会员" + + const popContent = ( +
+
+
+ + {loading ? "…" : bal} + 积分 +
+ }> + {memberLabel} + +
+ + {(zero || lowBalance) && ( + + 积分不足,充值后可继续使用 AI 功能 + + )} + + {balance?.member_expires_at && ( + + 会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")} + + )} + +
+
+ 累计获得 +
+{balance?.total_earned ?? 0}
+
+
+ 累计消耗 +
-{balance?.total_spent ?? 0}
+
+
+ + + + + {!isMember && ( + + )} + +
+ ) + + return ( + + + + ) +} + +export default PointsBadge 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; +} 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..ba8fac4d5 --- /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 } 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 diff --git a/apps/web/src/components/layout/Header.css b/apps/web/src/components/layout/Header.css index e74730978..588847fb5 100644 --- a/apps/web/src/components/layout/Header.css +++ b/apps/web/src/components/layout/Header.css @@ -1,4 +1,5 @@ -/* Phase 1 Header 样式 + 手机端适配 */ +/* Phase 1 Header 样式 + 手机端适配 + v2: 增加积分徽章、升级按钮、会员金冠 */ .xx-top-nav { height: 68px; position: sticky; @@ -77,10 +78,40 @@ .xx-right-section { display: flex; align-items: center; - gap: 12px; + gap: 10px; flex-shrink: 0; } +/* v2: 顶部积分徽章 */ +.xx-header-points { + cursor: pointer; +} + +/* v2: 升级会员按钮 */ +.xx-upgrade-btn { + background: linear-gradient(135deg, #f59e0b, #ef4444); + border: none; + font-weight: 700; + box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3); +} + +.xx-upgrade-btn:hover { + background: linear-gradient(135deg, #d97706, #dc2626) !important; + color: #fff !important; +} + +/* v2: 会员金冠按钮 */ +.xx-crown-btn { + font-weight: 700; + color: #d97706; + padding: 2px 8px; +} + +.xx-crown-btn:hover { + background: rgba(245, 158, 11, 0.1) !important; + color: #b45309 !important; +} + .xx-user-menu { display: flex; align-items: center; @@ -115,6 +146,29 @@ gap: var(--space-xs); } +/* v2: 手机端积分/会员行 */ +.xx-mobile-points-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 4px 16px; + margin-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.xx-mobile-member-tag { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 700; + color: #d97706; + background: #fef3c7; + padding: 4px 10px; + border-radius: 999px; +} + .xx-mobile-nav-item { display: flex; align-items: center; @@ -157,6 +211,7 @@ .xx-top-nav-inner { padding: 0 12px; + gap: 6px; } .xx-brand-text { @@ -177,6 +232,19 @@ .xx-username { display: none; } + + /* 手机端:缩小升级按钮文字 */ + .xx-upgrade-btn { + padding: 0 8px !important; + font-size: 12px !important; + height: 28px; + } + + .xx-crown-btn { + padding: 0 4px !important; + font-size: 12px !important; + height: 28px; + } } /* 小屏幕平板:导航文字缩小 */ @@ -188,4 +256,14 @@ .xx-nav-links button { font-size: 13px; } + + .xx-header-points { + transform: scale(0.9); + transform-origin: center; + } + + .xx-upgrade-btn { + font-size: 12px !important; + padding: 0 10px !important; + } } diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index a90377c4f..301ee1a7c 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, + ThunderboltOutlined, + 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 } = 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,47 @@ const Header: React.FC = () => {
+ {/* v2: 积分徽章(顶部显示) */} + + + {/* v2: 升级会员入口(仅免费用户显示) */} + {!isMember && ( + + + + )} + + {/* v2: 付费会员显示金冠标识 */} + {isMember && ( + + + + + + )} + {/* 手机端汉堡菜单按钮 */} + ) : ( + + {" "} + {memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"} + + )} +
+ )} +
{NAV_ITEMS.map((item) => (
diff --git a/apps/web/src/pages/generate/components/GenerateStepActions.tsx b/apps/web/src/pages/generate/components/GenerateStepActions.tsx index 68b256fe9..2f436814a 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 { Tooltip } from "antd" +import { ThunderboltOutlined, 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 ( + + + } + /> + + {/* 顶部大卡 */} + + + + + + 当前可用积分 + +
+ + {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 diff --git a/apps/web/src/pages/points/Packages.tsx b/apps/web/src/pages/points/Packages.tsx new file mode 100644 index 000000000..a4d7e71ee --- /dev/null +++ b/apps/web/src/pages/points/Packages.tsx @@ -0,0 +1,174 @@ +/** + * 积分充值页(/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 { 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 "./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" + type DiscountKey = + | "discounted_price_for_free" + | "discounted_price_for_monthly" + | "discounted_price_for_quarterly" + | "discounted_price_for_yearly" + const key = `discounted_price_for_${mt}` as DiscountKey + return pkg[key] ?? pkg.price + } + + const handleBuy = async (pkg: PointsPackage) => { + try { + setBuying(pkg.id) + const order = await createPointsOrder({ package_id: pkg.id }) + Modal.info({ + title: "支付功能开发中", + icon: , + content: ( +
+ + 订单已创建({order.id.slice(0, 16)}…),金额{" "} + ¥{(order.price_cents / 100).toFixed(2).replace(/\.00$/, "")}。 + 微信/支付宝支付正在接入中。 + + + 现阶段所有功能免费体验,积分仅为演示数据。 + +
+ ), + okText: "知道了", + }) + } catch (e) { + const err = e as { message?: string } + message.error(err?.message ?? "下单失败") + } 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 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; +} diff --git a/apps/web/src/pages/points/Rules.tsx b/apps/web/src/pages/points/Rules.tsx new file mode 100644 index 000000000..1fbaaa2f5 --- /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, ThunderboltOutlined } 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 + 改写等功能时, + 会根据使用量扣除相应积分。积分永久有效,可通过充值、任务奖励、注册礼包等方式获取。 + + + + + + <ThunderboltOutlined 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 diff --git a/apps/web/src/pages/points/Transactions.tsx b/apps/web/src/pages/points/Transactions.tsx new file mode 100644 index 000000000..e58d66b24 --- /dev/null +++ b/apps/web/src/pages/points/Transactions.tsx @@ -0,0 +1,252 @@ +/** + * 积分明细页(/app/points/transactions) + * 分页展示积分流水,支持按类型/来源筛选 + */ +import React, { useEffect, useState, useCallback } from "react" +import { + Card, + Table, + Tag, + Select, + DatePicker, + Space, + Typography, + Empty, + Input, + Spin, + Button, +} from "antd" +import { SearchOutlined, ReloadOutlined } from "@ant-design/icons" +import type { ColumnsType } from "antd/es/table" +import { useNavigate } from "react-router-dom" +import dayjs from "dayjs" +import PageHead from "@/components/layout/PageHead" +import { getPointsTransactions } from "@/api/points" +import type { PointsTransaction, PointsTxType } from "@/api/points/types" +import "./Points.css" + +const { Text } = Typography +const { RangePicker } = DatePicker + +const TYPE_LABEL: Record = { + earn: { text: "获得", color: "green" }, + spend: { text: "消耗", color: "red" }, + refund: { text: "退还", color: "blue" }, +} + +const 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.Dayjs | null, dayjs.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) => dayjs(v).format("YYYY-MM-DD HH:mm"), + }, + { + title: "类型", + dataIndex: "type", + width: 90, + render: (t: PointsTxType) => { + const cfg = TYPE_LABEL[t] + return {cfg.text} + }, + }, + { + title: "来源/场景", + dataIndex: "source", + width: 150, + 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: 160 }} + showSearch + options={[ + { value: "all", label: "全部来源" }, + ...Object.entries(SOURCE_LABEL).map(([k, v]) => ({ value: k, label: v })), + ]} + /> + { + setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) + setPage(1) + }} + /> + } + allowClear + style={{ width: 200 }} + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onPressEnter={() => { + setPage(1) + load() + }} + /> + +
+ + + + rowKey="id" + columns={columns} + dataSource={data} + locale={{ emptyText: }} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条记录`, + onChange: (p, ps) => { + setPage(p) + setPageSize(ps) + }, + }} + /> + +
+
+ ) +} + +export default PointsTransactions +export const Component = PointsTransactions 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; } } diff --git a/apps/web/src/pages/subscription/Plans.tsx b/apps/web/src/pages/subscription/Plans.tsx index 4fc642653..d511ed08e 100644 --- a/apps/web/src/pages/subscription/Plans.tsx +++ b/apps/web/src/pages/subscription/Plans.tsx @@ -1,107 +1,422 @@ /** - * 定价页面 - V21 UI 四级定价 + * 会员订阅 & 积分充值页 + * v2 两档会员制:免费 vs 付费 + * 付费三档:月¥19.9 / 季¥39.9(推荐)/ 年¥159 + * 积分包:100/¥9.9、500/¥39、2000/¥129 */ -import React, { useState } from "react" -import { message } from "antd" +import React, { useEffect, useMemo, useState } from "react" +import { + Button, + Card, + Col, + Row, + Tag, + Divider, + Space, + Typography, + Modal, + message, + Tooltip, + Badge, +} from "antd" +import { + CheckCircleFilled, + CrownFilled, + ThunderboltOutlined, + SafetyCertificateOutlined, + VideoCameraOutlined, + StarFilled, +} from "@ant-design/icons" +import { useNavigate } from "react-router-dom" import PageHead from "@/components/layout/PageHead" +import { usePointsStore } from "@/store/pointsStore" +import { SUBSCRIPTION_PLANS, createPointsOrder } from "@/api/points" +import type { PointsPackage } from "@/api/points/types" +import { getPointsPackages } from "@/api/points" import "./Plans.css" -const PLANS = [ - { - id: "free", - name: "体验版", - price: 0, - description: "适合个人体验", - recommended: false, - features: ["3个视频成片", "基础标题模板", "基础配音音色", "720p导出"], - }, - { - id: "standard", - name: "标准版", - price: 99, - description: "适合内容创作者", - recommended: false, - features: ["无限视频成片", "高级标题模板", "全部配音音色", "1080p导出", "API接口"], - }, - { - id: "pro", - name: "专业版", - price: 299, - description: "适合专业团队", - recommended: true, - features: ["无限视频成片", "自定义标题", "全部配音音色", "4K导出", "优先渲染队列", "专属客服"], - }, - { - id: "enterprise", - name: "企业版", - price: null, - description: "适合企业用户", - recommended: false, - features: ["私有化部署", "自定义品牌形象", " SLA服务保障", "技术支持", "团队协作", "数据报表"], - }, +const { Title, Text, Paragraph } = Typography + +/** 免费会员权益 */ +const FREE_FEATURES = [ + { include: true, text: "每日 2 条免费混剪" }, + { include: true, text: "720p 导出分辨率" }, + { include: true, text: "AI 配音(×1.15 积分)" }, + { include: true, text: "AI 数字人(×1.15 积分)" }, + { include: true, text: "声音克隆训练免费" }, + { include: false, text: "批量导出" }, + { include: false, text: "多平台一键发布" }, + { include: false, text: "去重检测报告" }, ] -const Plans: React.FC = () => { - const [subscribing, setSubscribing] = useState(false) +/** 付费会员权益 */ +const PAID_FEATURES = [ + { include: true, text: "无限次智能混剪" }, + { include: true, text: "最高 1080p 导出" }, + { include: true, text: "全部 AI 功能(标准积分价)" }, + { include: true, text: "声音克隆训练免费" }, + { include: true, text: "积分购买最低 8 折" }, + { include: true, text: "批量导出" }, + { include: true, text: "多平台一键发布" }, + { include: true, text: "去重检测报告" }, +] - const handleSubscribe = async (_planId: string) => { +const formatYuan = (cents: number) => + `¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}` + +const Plans: React.FC = () => { + const navigate = useNavigate() + const { balance, init } = usePointsStore() + const [selectedBilling, setSelectedBilling] = useState<"monthly" | "quarterly" | "yearly">( + "quarterly", + ) + const [packages, setPackages] = useState([]) + const [subscribing] = useState(false) + const [buying, setBuying] = useState(null) + + useEffect(() => { + init() + getPointsPackages() + .then((r) => setPackages(r.packages)) + .catch(() => {}) + }, [init]) + + const isMember = !!balance?.is_member + const memberType = balance?.member_type ?? null + + /** 根据会员等级计算积分包折后价(分) */ + const getPackPrice = (pkg: PointsPackage): number => { + const mt = memberType ?? "free" + type DiscountKey = + | "discounted_price_for_free" + | "discounted_price_for_monthly" + | "discounted_price_for_quarterly" + | "discounted_price_for_yearly" + const key = `discounted_price_for_${mt}` as DiscountKey + return pkg[key] ?? pkg.price + } + + const selectedPlan = useMemo( + () => SUBSCRIPTION_PLANS.find((p) => p.id === selectedBilling)!, + [selectedBilling], + ) + + const handleSubscribe = async () => { + Modal.confirm({ + title: "支付功能开发中", + icon: , + content: "微信/支付宝支付正在接入中,完成后会第一时间通知。是否返回首页继续使用免费功能?", + okText: "返回首页", + cancelText: "留在此页", + onOk: () => navigate("/app/dashboard"), + }) + // 实际对接时: + // try { + // setSubscribing(true) + // const order = await subscribe({ member_type: selectedBilling, payment_method: "wechat_pay" }) + // // 拉起支付... + // } catch (e: any) { + // message.error(e?.message ?? "订阅失败") + // } finally { + // setSubscribing(false) + // } + } + + const handleBuyPoints = async (pkg: PointsPackage) => { try { - setSubscribing(true) - message.success("订阅成功") - } catch (error: unknown) { - message.error("订阅失败") + setBuying(pkg.id) + const order = await createPointsOrder({ package_id: pkg.id }) + Modal.info({ + title: "支付功能开发中", + icon: , + content: ( +
+ + 订单 {order.id.slice(0, 16)}… 已创建,金额{" "} + {formatYuan(order.price_cents)}, + 微信/支付宝支付正在接入中,正式上线后可直接付款。 + + + 现阶段所有功能均处于免费体验阶段,积分仅为演示数据。 + +
+ ), + okText: "知道了", + }) + } catch (e) { + const err = e as { message?: string } + message.error(err?.message ?? "创建订单失败") } finally { - setSubscribing(false) + setBuying(null) } } return (
- + - {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} +
  • + ))} +
+ +
+ 虚拟商品不支持退款 · 支付接入中先演示 +
+
+ + + + {/* 积分充值 */} +
+ + <ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} /> + 积分充值 + <Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣"> + <Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}> + (永久有效) + </Text> + </Tooltip> + + + + {packages.map((pkg) => { + const price = getPackPrice(pkg) + const discount = price < pkg.price ? Math.round((1 - price / pkg.price) * 100) : 0 + const unit = price / pkg.points + 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)}/积分
+ +
+ + ) + })} + + ) } diff --git a/apps/web/src/router/appRoutes.tsx b/apps/web/src/router/appRoutes.tsx index 2aa771347..fc4442f3f 100644 --- a/apps/web/src/router/appRoutes.tsx +++ b/apps/web/src/router/appRoutes.tsx @@ -96,6 +96,22 @@ const appChildren: RouteObject[] = [ path: "subscription/billing", lazy: lazyRoute(() => import("@/pages/subscription/Billing")), }, + { + path: "points", + lazy: lazyRoute(() => import("@/pages/points/Center")), + }, + { + path: "points/transactions", + lazy: lazyRoute(() => import("@/pages/points/Transactions")), + }, + { + path: "points/recharge", + lazy: lazyRoute(() => import("@/pages/points/Packages")), + }, + { + path: "points/rules", + lazy: lazyRoute(() => import("@/pages/points/Rules")), + }, { path: "profile", lazy: lazyRoute(() => import("@/pages/profile/Settings")), 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, + }, + }) + }, +}))