feat: #1895 会员+积分系统前端(两档会员 + 积分充值/明细/扣费提示) #1920

Merged
auto-approve-bot merged 62 commits from feature/1895-membership-points-web into develop 2026-09-15 10:05:45 +08:00
22 changed files with 3267 additions and 279 deletions
+477
View File
@@ -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<PointsTransaction & { balance_after?: number }>
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<PointsBalance> {
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<PointsTransactionsResponse> {
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<PointsPackagesResponse> {
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<PointsOrder> {
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<PointsRulesResponse> {
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<PointsCheckResponse> {
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<SubscriptionCurrent> {
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<PointsOrder> {
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
}
+217
View File
@@ -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<MemberType, "monthly" | "quarterly" | "yearly"> | 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<string, string>
}
/** 订阅套餐(月/季/年) */
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<MemberType, "monthly" | "quarterly" | "yearly"> | 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
}
@@ -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;
}
@@ -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 = (
<div className="xx-points-popover">
<div className="xx-points-popover-top">
<div className="xx-points-popover-balance">
<ThunderboltOutlined className="xx-points-icon" />
<span className="xx-points-value">{loading ? "…" : bal}</span>
<span className="xx-points-unit">积分</span>
</div>
<Tag color={isMember ? "gold" : "default"} icon={<CrownOutlined />}>
{memberLabel}
</Tag>
</div>
{(zero || lowBalance) && (
<Paragraph type="warning" className="xx-points-warn">
<WarningOutlined /> 积分不足,充值后可继续使用 AI 功能
</Paragraph>
)}
{balance?.member_expires_at && (
<Text type="secondary" className="xx-points-expire">
会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</Text>
)}
<div className="xx-points-popover-stats">
<div>
<Text type="secondary">累计获得</Text>
<div className="xx-points-stat-val">+{balance?.total_earned ?? 0}</div>
</div>
<div>
<Text type="secondary">累计消耗</Text>
<div className="xx-points-stat-val">-{balance?.total_spent ?? 0}</div>
</div>
</div>
<Space direction="vertical" style={{ width: "100%" }} size={8}>
<Button
type="primary"
icon={<ThunderboltOutlined />}
block
onClick={() => navigate("/app/points")}
>
充值积分
</Button>
<Button block onClick={() => navigate("/app/points/transactions")}>
积分明细
<RightOutlined />
</Button>
{!isMember && (
<Button block type="link" onClick={() => navigate("/app/subscription")}>
<CrownOutlined /> 升级会员享折扣
</Button>
)}
</Space>
</div>
)
return (
<Popover content={popContent} placement="bottomRight" trigger="click" arrow={false}>
<button
type="button"
className={`xx-points-badge ${zero ? "zero" : lowBalance ? "low" : ""}`}
>
<Badge dot={zero} color={zero ? "#faad14" : undefined}>
<ThunderboltOutlined className="xx-points-badge-icon" />
</Badge>
<span className="xx-points-badge-val">{loading ? "…" : bal}</span>
</button>
</Popover>
)
}
export default PointsBadge
@@ -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;
}
@@ -0,0 +1,162 @@
/**
* 功能操作按钮旁的"消耗积分"提示
* 例:[生成配音] 💎 -1 积分
* - 根据 scene_key 自动读取规则
* - 免费用户自动计算 ×1.15 向上取整
* - 余额不足时显示红色告警 + 充值提示
*
* 使用:<PointsCost scene="ai_voice" units={1} />
*/
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<string, string> = {
分钟: "分钟",
条: "条",
次: "次",
张: "张",
}
const PointsCost: React.FC<Props> = ({
scene,
units = 1,
compact = false,
showRechargeHint = true,
className = "",
}) => {
const { balance, init } = usePointsStore()
const [rules, setRules] = useState<Awaited<ReturnType<typeof getPointsRules>> | 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 <span className={`xx-points-cost ${className}`} />
}
if (rule.points_per_use === 0) {
return (
<span className={`xx-points-cost free ${className}`}>
<span className="xx-points-tag-free">免费</span>
</span>
)
}
if (isFreeQuota) {
return (
<Tooltip title={`今日免费额度剩余 ${balance.free_clips_remaining} 条,不扣积分`}>
<span className={`xx-points-cost free-quota ${className}`}>
<span className="xx-points-tag-free">免费</span>
{!compact && (
<span className="xx-points-desc">(今日剩余 {balance.free_clips_remaining} 条)</span>
)}
</span>
</Tooltip>
)
}
const unitLabel = compact
? ""
: ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
return (
<span
className={`xx-points-cost ${insufficient ? "insufficient" : ""} ${isFreeUser ? "free-user" : ""} ${className}`}
>
<span className="xx-points-gem">💎</span>
<span className="xx-points-num">-{cost}</span>
{unitLabel && <span className="xx-points-unit-label">积分{unitLabel}</span>}
{isFreeUser && !compact && (
<Tooltip title={`免费用户消耗 ×${rules?.free_user_multiplier ?? 1.15},开通会员享标准价`}>
<span className="xx-points-free-user-tag">非会员</span>
</Tooltip>
)}
{insufficient && showRechargeHint && (
<Tooltip title={`积分不足,需 ${cost} 积分,当前余额 ${balance.balance}`}>
<WarningOutlined className="xx-points-warn-icon" />
</Tooltip>
)}
</span>
)
}
export default PointsCost
+80 -2
View File
@@ -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;
}
}
+117 -4
View File
@@ -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: <ThunderboltOutlined />,
label: (
<Space>
我的积分
{balance && <span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>}
</Space>
),
onClick: () => navigate("/app/points"),
},
{
key: "points-history",
icon: <HistoryOutlined />,
label: "积分明细",
onClick: () => navigate("/app/points/transactions"),
},
{
key: "recharge",
icon: <WalletOutlined />,
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 = () => {
</nav>
<div className="xx-right-section">
{/* v2: 积分徽章(顶部显示) */}
<PointsBadge />
{/* v2: 升级会员入口(仅免费用户显示) */}
{!isMember && (
<Tooltip title="升级会员解锁无限混剪、批量导出,积分 8 折起">
<Button
type="primary"
size="small"
icon={<CrownFilled />}
className="xx-upgrade-btn"
onClick={() => navigate("/app/subscription")}
>
升级会员
</Button>
</Tooltip>
)}
{/* v2: 付费会员显示金冠标识 */}
{isMember && (
<Tooltip
title={`${memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"}会员${
balance?.member_expires_at
? ` · 到期 ${new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}`
: ""
}`}
>
<Badge dot color="gold">
<Button
type="text"
size="small"
icon={<CrownFilled style={{ color: "#f59e0b" }} />}
className="xx-crown-btn"
onClick={() => navigate("/app/subscription")}
>
{memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "会员"}
</Button>
</Badge>
</Tooltip>
)}
{/* 手机端汉堡菜单按钮 */}
<button className="xx-hamburger" type="button" onClick={() => setMobileMenuOpen(true)}>
<MenuOutlined />
@@ -103,6 +191,31 @@ const Header: React.FC = () => {
width={260}
className="xx-mobile-drawer"
>
{/* 手机端积分/会员入口 */}
{balance && (
<div className="xx-mobile-points-row">
<PointsBadge />
{!isMember ? (
<Button
size="small"
type="primary"
icon={<CrownFilled />}
onClick={() => {
navigate("/app/subscription")
setMobileMenuOpen(false)
}}
>
升级
</Button>
) : (
<span className="xx-mobile-member-tag">
<CrownFilled style={{ color: "#f59e0b" }} />{" "}
{memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"}
</span>
)}
</div>
)}
<div className="xx-mobile-nav">
{NAV_ITEMS.map((item) => (
<button
+20 -1
View File
@@ -15,6 +15,7 @@ import {
ScanOutlined,
ControlOutlined,
CrownOutlined,
ThunderboltOutlined,
UnorderedListOutlined,
UserOutlined,
} from "@ant-design/icons"
@@ -105,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 分组列表使用) */
@@ -196,10 +209,16 @@ export const NAV_GROUPS: NavGroup[] = [
},
{
key: "subscription",
label: "订阅管理",
label: "会员订阅",
path: "/app/subscription",
icon: React.createElement(CrownOutlined),
},
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
],
},
]
@@ -28,13 +28,21 @@ import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts"
import { usePointsStore } from "@/store/pointsStore"
import { hasEnoughPoints } from "./hooks/pointsCost"
import "./generate.css"
import "./generate-points.css"
const GeneratePage: React.FC = () => {
const navigate = useNavigate()
/* ── 表单状态 ── */
const formState = useGenerateFormState()
/* ── 积分状态 ── */
const { balance, init: initPoints } = usePointsStore()
useEffect(() => {
initPoints()
}, [initPoints])
const {
currentStep,
setCurrentStep,
@@ -348,6 +356,13 @@ const GeneratePage: React.FC = () => {
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
const handleConfirmGenerate = useCallback(async () => {
// 积分预检查
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const check = hasEnoughPoints(balance ?? null, units)
if (!check.sufficient) {
message.error(check.reason ?? "积分不足,请充值")
return
}
if (isBatch) {
if (selectedVariantIds.length === 0) {
message.warning("请至少勾选一个视频")
@@ -381,6 +396,7 @@ const GeneratePage: React.FC = () => {
previewReady,
handleGenerate,
setCurrentStep,
balance,
])
/* ── 步骤导航 ── */
@@ -404,6 +420,14 @@ const GeneratePage: React.FC = () => {
return "xx-generate-layout full-width"
}, [currentStep])
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const pointsEstimate = useMemo(
() => hasEnoughPoints(balance ?? null, unitsForCost),
[unitsForCost, balance],
)
const insufficientPoints = !pointsEstimate.sufficient
/* ================================================================
渲染
================================================================ */
@@ -607,6 +631,11 @@ const GeneratePage: React.FC = () => {
generated={generated}
generateError={generateError}
selectedCount={isBatch ? selectedVariantIds.length : 1}
estimatedCost={pointsEstimate.cost}
freeClipsUsedThisTime={pointsEstimate.freeClipsUsed ?? 0}
pointsInsufficient={insufficientPoints}
insufficientReason={pointsEstimate.reason}
onRecharge={() => navigate("/app/points/recharge")}
/>
</div>
</div>
@@ -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<void>
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<GenerateStepActionsProps> = ({
@@ -30,10 +44,15 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
generated,
generateError,
selectedCount = 1,
estimatedCost,
pointsInsufficient,
insufficientReason,
freeClipsUsedThisTime,
onRecharge,
}) => {
const renderPrimaryButton = () => {
/* 步骤 1~2:上一步 / 下一步 */
if (currentStep < 3) {
/* 步骤 1~3:上一步 / 下一步 */
if (currentStep < 4) {
return (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
下一步 →
@@ -41,8 +60,20 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
)
}
/* 步骤 3:选择标题 — 确认生成 */
if (currentStep === 3) {
/* 步骤 4:选择标题 — 确认生成 */
if (currentStep === 4) {
const costTag =
typeof estimatedCost === "number" && estimatedCost > 0 ? (
<span className="xx-step-cost-tag">
<ThunderboltOutlined />
{estimatedCost}
</span>
) : typeof estimatedCost === "number" &&
estimatedCost === 0 &&
typeof freeClipsUsedThisTime === "number" ? (
<span className="xx-step-cost-tag free">使用 {freeClipsUsedThisTime} 次免费额度</span>
) : null
if (generating) {
return (
<button className="xx-btn xx-btn-primary" disabled>
@@ -54,18 +85,41 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
return (
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
🔄 重新生成
{costTag}
</button>
)
}
if (pointsInsufficient) {
return (
<Tooltip title={insufficientReason ?? "积分不足,请充值"}>
<span>
<button className="xx-btn xx-btn-primary" disabled>
<ExclamationCircleOutlined /> 积分不足
{costTag}
</button>
{onRecharge && (
<button
className="xx-btn xx-btn-ghost"
style={{ marginLeft: 8, color: "#7c3aed", borderColor: "#c4b5fd" }}
onClick={onRecharge}
>
去充值 →
</button>
)}
</span>
</Tooltip>
)
}
return (
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
{selectedCount > 1 ? `✨ 确认生成 ${selectedCount} 个视频` : "✨ 确认生成视频"}
{costTag}
</button>
)
}
/* 步骤 4:确认生成进度页 — 全部完成后下一步进封面 */
if (currentStep === 4) {
/* 步骤 5:确认生成进度页 — 全部完成后下一步进封面 */
if (currentStep === 5) {
if (generated) {
return (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
@@ -80,7 +134,7 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
)
}
/* 步骤 5(封面,最后一步):无主按钮 */
/* 步骤 6(封面,最后一步):无主按钮 */
return null
}
@@ -0,0 +1,31 @@
/* Points cost badge inside step actions button */
.xx-step-cost-tag {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: 999px;
font-size: 12px;
font-weight: 600;
vertical-align: middle;
}
.xx-step-cost-tag.free {
background: rgba(16, 185, 129, 0.2);
color: #d1fae5;
}
.xx-step-cost-warning {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
background: #fef3c7;
border: 1px solid #fde68a;
border-radius: 10px;
color: #92400e;
font-size: 13px;
margin-bottom: 10px;
}
@@ -0,0 +1,83 @@
/**
* 生成前积分消耗估算与余额校验
* 用于步骤4「确认生成」按钮前展示本次消耗,积分不足时禁用并提示充值
*/
import type { PointsBalance } from "@/api/points/types"
/** 生成单条视频消耗积分(基准) */
export const BASE_VIDEO_POINTS = 3
/**
* 估算生成任务的积分消耗
* @param videoCount 视频条数(批量模式)
* @param memberMultiplier 会员倍率(免费用户 1.15)
*/
export function estimateGenerateCost(videoCount: number, memberMultiplier = 1): number {
const raw = BASE_VIDEO_POINTS * videoCount * memberMultiplier
// 向上取整,避免小数
return Math.ceil(raw)
}
/**
* 判断积分是否充足(含每日免费额度)
* @returns sufficient=true 表示可以继续生成;false 需要提示充值
*/
export function hasEnoughPoints(
balance: PointsBalance | null,
videoCount: number,
): {
sufficient: boolean
cost: number
reason?: string
freeClipsUsed?: number
freeClipsRemaining?: number
} {
if (!balance) {
// 未登录或未加载:不拦截,后端会校验
return { sufficient: true, cost: estimateGenerateCost(videoCount) }
}
const isMember = balance.is_member
const multiplier = isMember ? 1 : 1.15
const cost = estimateGenerateCost(videoCount, multiplier)
// 免费用户优先使用每日免费额度
if (!isMember && balance.free_clips_remaining && balance.free_clips_remaining > 0) {
const freeUsed = Math.min(balance.free_clips_remaining, videoCount)
const remainingAfterFree = videoCount - freeUsed
const paidCost = estimateGenerateCost(remainingAfterFree, multiplier)
if (paidCost === 0) {
// 完全用免费额度
return {
sufficient: true,
cost: 0,
freeClipsUsed: freeUsed,
freeClipsRemaining: balance.free_clips_remaining - freeUsed,
}
}
if (balance.balance >= paidCost) {
return {
sufficient: true,
cost: paidCost,
freeClipsUsed: freeUsed,
freeClipsRemaining: balance.free_clips_remaining - freeUsed,
}
}
return {
sufficient: false,
cost: paidCost,
reason: `积分不足:本次需 ${paidCost} 积分(使用 ${freeUsed} 次免费额度后),当前余额 ${balance.balance},还差 ${paidCost - balance.balance} 积分`,
freeClipsUsed: freeUsed,
}
}
// 付费会员或免费额度用完
if (balance.balance >= cost) {
return { sufficient: true, cost }
}
return {
sufficient: false,
cost,
reason: `积分不足:本次需 ${cost} 积分,当前余额 ${balance.balance},还差 ${cost - balance.balance} 积分`,
}
}
+309
View File
@@ -0,0 +1,309 @@
/**
* 积分中心主页(/app/points)
* 展示余额、会员信息、本月统计、快捷入口
*/
import React, { useEffect } from "react"
import {
Card,
Col,
Row,
Statistic,
Button,
Space,
Tag,
Typography,
Progress,
List,
Avatar,
Empty,
} from "antd"
import {
ThunderboltOutlined,
CrownFilled,
ArrowUpOutlined,
ArrowDownOutlined,
HistoryOutlined,
WalletOutlined,
FileTextOutlined,
InfoCircleOutlined,
} from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import PageHead from "@/components/layout/PageHead"
import { usePointsStore } from "@/store/pointsStore"
import "./Points.css"
const { Text } = Typography
const SOURCE_NAME: Record<string, string> = {
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 (
<div className="xx-points-page">
<PageHead
title="积分中心"
description="管理积分余额、查看流水、充值使用"
actions={
<Space>
<Button icon={<FileTextOutlined />} onClick={() => navigate("/app/points/rules")}>
积分规则
</Button>
<Button
type="primary"
icon={<WalletOutlined />}
onClick={() => navigate("/app/subscription")}
>
充值积分
</Button>
</Space>
}
/>
{/* 顶部大卡 */}
<Card className="xx-points-hero" bordered={false}>
<Row gutter={24} align="middle">
<Col xs={24} md={14}>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text type="secondary" style={{ color: "rgba(255,255,255,0.8)" }}>
当前可用积分
</Text>
<div className="xx-points-hero-balance">
<ThunderboltOutlined />
<span>{loading ? "…" : bal.toLocaleString()}</span>
</div>
<Space size={8} wrap>
{isMember ? (
<Tag color="gold" icon={<CrownFilled />} style={{ padding: "4px 10px" }}>
{subscription?.member_type === "yearly"
? "年卡"
: subscription?.member_type === "quarterly"
? "季卡"
: "月卡"}
会员
</Tag>
) : (
<Tag
style={{
padding: "4px 10px",
background: "rgba(255,255,255,0.2)",
color: "#fff",
border: "none",
}}
>
免费会员
</Tag>
)}
{balance?.member_expires_at && (
<Text style={{ color: "rgba(255,255,255,0.85)", fontSize: 12 }}>
到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</Text>
)}
{!isMember && (
<Button
size="small"
type="primary"
onClick={() => navigate("/app/subscription")}
style={{ background: "#fff", color: "#7c3aed", borderColor: "#fff" }}
>
<CrownFilled /> 升级会员享 8 折
</Button>
)}
</Space>
</Space>
</Col>
<Col xs={24} md={10}>
{/* 今日免费额度 */}
{!isMember && freeLimit > 0 && (
<div className="xx-points-free-quota">
<div className="xx-points-free-title">
<InfoCircleOutlined /> 今日免费混剪
</div>
<Progress
percent={Math.round((freeUsed / freeLimit) * 100)}
strokeColor={{ "0%": "#f59e0b", "100%": "#ef4444" }}
format={() => `${freeUsed}/${freeLimit} 条`}
/>
<Text style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
剩余 {freeRemain} 条免费混剪,超出部分消耗积分
</Text>
</div>
)}
</Col>
</Row>
</Card>
{/* 统计 */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={12} md={6}>
<Card>
<Statistic
title="累计获得"
value={earned}
prefix={<ArrowUpOutlined style={{ color: "#10b981" }} />}
valueStyle={{ color: "#10b981" }}
/>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Statistic
title="累计消耗"
value={spent}
prefix={<ArrowDownOutlined style={{ color: "#ef4444" }} />}
valueStyle={{ color: "#ef4444" }}
/>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Statistic
title="可用积分"
value={bal}
prefix={<ThunderboltOutlined style={{ color: "#8b5cf6" }} />}
valueStyle={{ color: "#8b5cf6" }}
/>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Statistic
title={isMember ? "会员等级" : "今日免费剩余"}
value={isMember ? (subscription?.member_type_name ?? "付费会员") : `${freeRemain} 条`}
prefix={<CrownFilled style={{ color: "#f59e0b" }} />}
valueStyle={{ color: "#f59e0b" }}
/>
</Card>
</Col>
</Row>
{/* 快捷入口 & 最近流水 */}
<Row gutter={[16, 16]} style={{ marginTop: 8 }}>
<Col xs={24} md={14}>
<Card
title={
<Space>
<HistoryOutlined />
最近流水
</Space>
}
extra={<a onClick={() => navigate("/app/points/transactions")}>查看全部 →</a>}
>
{recentTx.length === 0 ? (
<Empty description="暂无积分流水" />
) : (
<List
dataSource={recentTx}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={
<Avatar
size="small"
style={{
background: item.type === "earn" ? "#d1fae5" : "#fee2e2",
color: item.type === "earn" ? "#059669" : "#dc2626",
}}
icon={item.type === "earn" ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
/>
}
title={SOURCE_NAME[item.source] ?? item.source}
description={item.time}
/>
<div
style={{
color: item.type === "earn" ? "#10b981" : "#ef4444",
fontWeight: 700,
}}
>
{item.type === "earn" ? "+" : "-"}
{item.amount}
</div>
</List.Item>
)}
/>
)}
</Card>
</Col>
<Col xs={24} md={10}>
<Card title="快捷操作">
<Space direction="vertical" style={{ width: "100%" }} size={10}>
<Button
block
size="large"
type="primary"
icon={<WalletOutlined />}
onClick={() => navigate("/app/subscription")}
>
充值积分
</Button>
<Button
block
size="large"
icon={<HistoryOutlined />}
onClick={() => navigate("/app/points/transactions")}
>
积分明细
</Button>
<Button
block
size="large"
icon={<CrownFilled />}
onClick={() => navigate("/app/subscription")}
>
{isMember ? "续费/升级会员" : "升级付费会员"}
</Button>
<Button
block
size="large"
icon={<FileTextOutlined />}
onClick={() => navigate("/app/points/rules")}
>
积分消耗规则
</Button>
</Space>
</Card>
</Col>
</Row>
</div>
)
}
export default PointsCenter
export const Component = PointsCenter
+174
View File
@@ -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<PointsPackage[]>([])
const [buying, setBuying] = useState<string | null>(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: <SafetyCertificateOutlined />,
content: (
<div>
<Paragraph>
订单已创建({order.id.slice(0, 16)}…),金额{" "}
<b>¥{(order.price_cents / 100).toFixed(2).replace(/\.00$/, "")}</b>。
微信/支付宝支付正在接入中。
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
现阶段所有功能免费体验,积分仅为演示数据。
</Paragraph>
</div>
),
okText: "知道了",
})
} catch (e) {
const err = e as { message?: string }
message.error(err?.message ?? "下单失败")
} finally {
setBuying(null)
}
}
return (
<div className="xx-points-page">
<PageHead
title="积分充值"
description="积分永久有效,可用于全部 AI 功能;付费会员享折扣"
actions={
<Space>
{!balance?.is_member && (
<Button icon={<CrownFilled />} onClick={() => navigate("/app/subscription")}>
升级会员 8 折起
</Button>
)}
<Button onClick={() => navigate("/app/points/transactions")}>积分明细</Button>
</Space>
}
/>
{balance && (
<Card
bordered={false}
style={{ marginBottom: 16, background: "linear-gradient(135deg,#ede9fe,#fce7f3)" }}
>
<Space size="large">
<div>
<Text type="secondary">当前可用积分</Text>
<div style={{ fontSize: 28, fontWeight: 800, color: "#7c3aed" }}>
<ThunderboltOutlined /> {balance.balance.toLocaleString()}
</div>
</div>
</Space>
</Card>
)}
<Title level={4}>选择积分包</Title>
<Row gutter={[20, 20]}>
{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 (
<Col xs={24} sm={12} md={8} key={pkg.id}>
<Card
className={`xx-pkg-card ${pkg.id === "basic_pack" ? "recommended" : ""} ${discount > 0 ? "has-discount" : ""}`}
hoverable
>
{pkg.id === "basic_pack" && <div className="xx-pkg-badge">热门</div>}
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
会员{10 - discount / 10}折
</Tag>
)}
<div className="xx-pkg-name">{pkg.name}</div>
<div className="xx-pkg-points">
<ThunderboltOutlined /> {pkg.points.toLocaleString()} 积分
</div>
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
</span>
{discount > 0 && (
<span className="xx-pkg-origin">¥{(pkg.price / 100).toFixed(0)}</span>
)}
</div>
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分 · 永久有效</div>
<Button
block
type={pkg.id === "basic_pack" ? "primary" : "default"}
loading={buying === pkg.id}
onClick={() => handleBuy(pkg)}
size="large"
style={{ marginTop: 16 }}
>
立即购买
</Button>
</Card>
</Col>
)
})}
</Row>
<Card style={{ marginTop: 24 }}>
<Title level={5}>积分消耗说明</Title>
<ul style={{ paddingLeft: 20, color: "var(--text-secondary)", lineHeight: 2 }}>
<li>智能混剪:3 积分/条(≤30s),每加 30s +1 积分</li>
<li>AI 配音 / 声音克隆合成:1 积分/分钟</li>
<li>AI 数字人:15 积分/分钟</li>
<li>抖音链接提取 / AI 改写 / AI 标题 / AI 封面:1~2 积分/次</li>
<li>声音克隆训练:免费</li>
<li>免费用户每日 2 条混剪免费,其余 AI 功能消耗为会员价 ×1.15</li>
</ul>
<Tooltip title="具体规则以系统实际计算为准">
<Text type="secondary" style={{ fontSize: 12 }}>
<SafetyCertificateOutlined /> 所有规则以页面实际显示为准
</Text>
</Tooltip>
</Card>
</div>
)
}
export default PointsRecharge
export const Component = PointsRecharge
+88
View File
@@ -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;
}
+137
View File
@@ -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 (
<div className="xx-points-page">
<PageHead title="积分规则" description="了解如何获得、使用与管理积分" />
<Card>
<Title level={5}>
<InfoCircleOutlined style={{ color: "#3b82f6" }} /> 什么是积分?
</Title>
<Paragraph>
积分是小虾 SaaS 用于结算 AI 能力的虚拟货币。每次使用智能混剪、AI 配音、AI 数字人、AI
改写等功能时,
会根据使用量扣除相应积分。积分永久有效,可通过充值、任务奖励、注册礼包等方式获取。
</Paragraph>
<Divider />
<Title level={5}>
<ThunderboltOutlined style={{ color: "#8b5cf6" }} /> 积分消耗规则
</Title>
<Table
className="xx-rules-table"
dataSource={costData}
pagination={false}
columns={[
{ title: "功能场景", dataIndex: "scene", width: 200 },
{ title: "计费单位", dataIndex: "unit", width: 140 },
{
title: "消耗积分",
dataIndex: "cost",
width: 110,
render: (v: number) =>
v === 0 ? (
<Tag color="green">免费</Tag>
) : (
<Text strong style={{ color: "#ef4444" }}>
{v}
</Text>
),
},
{ title: "备注", dataIndex: "remark" },
]}
/>
<Paragraph type="secondary" style={{ marginTop: 12, fontSize: 12 }}>
免费用户每日享有 2 条免费混剪额度,超出后按上述标准 ×1.15 扣除积分;
付费会员按上述标准原价扣除积分。
</Paragraph>
<Divider />
<Title level={5} style={{ color: "#10b981" }}>
↑ 积分获得方式
</Title>
<Table
className="xx-rules-table"
dataSource={earnData}
pagination={false}
columns={[
{ title: "渠道", dataIndex: "channel", width: 200 },
{ title: "积分数量", dataIndex: "points", width: 180 },
{ title: "备注", dataIndex: "remark" },
]}
/>
<Divider />
<Title level={5}>
<CrownFilled style={{ color: "#f59e0b" }} /> 会员折扣
</Title>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="免费用户">
所有 AI 功能按标准价 ×1.15 消耗积分;每日 2 条免费混剪
</Descriptions.Item>
<Descriptions.Item label="付费会员(月/季/年卡)">
所有 AI 功能按标准价消耗;积分购买享 8~9 折;解锁批量导出、多平台发布等高级功能
</Descriptions.Item>
<Descriptions.Item label="年卡会员">
积分购买最低 8 折;开通即赠送 2000 积分
</Descriptions.Item>
<Descriptions.Item label="季卡会员">
积分购买 8.5 折;开通即赠送 400 积分
</Descriptions.Item>
</Descriptions>
<Divider />
<Title level={5}>退款与有效期</Title>
<ul style={{ paddingLeft: 20, lineHeight: 2 }}>
<li>充值积分永久有效,不会过期</li>
<li>AI 生成失败或服务异常时,已扣除的积分将自动退还</li>
<li>会员为虚拟商品,一经开通不支持退款;到期后会员权益自动失效,积分余额不受影响</li>
<li>如需发票请联系客服</li>
</ul>
<Space style={{ marginTop: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
规则更新时间:2026-09-15
</Text>
</Space>
</Card>
</div>
)
}
export default PointsRules
export const Component = PointsRules
+252
View File
@@ -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<PointsTxType, { text: string; color: string }> = {
earn: { text: "获得", color: "green" },
spend: { text: "消耗", color: "red" },
refund: { text: "退还", color: "blue" },
}
const SOURCE_LABEL: Record<string, string> = {
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<PointsTransaction[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [type, setType] = useState<PointsTxType | "all">("all")
const [source, setSource] = useState<string>("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<string, string | number> = { 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<PointsTransaction> = [
{
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 <Tag color={cfg.color}>{cfg.text}</Tag>
},
},
{
title: "来源/场景",
dataIndex: "source",
width: 150,
render: (s: string, r: PointsTransaction) => (
<Space>
<Text>{r.source_name || SOURCE_LABEL[s] || s}</Text>
</Space>
),
},
{
title: "说明",
dataIndex: "description",
ellipsis: true,
},
{
title: "变动",
dataIndex: "signed_amount",
width: 110,
align: "right",
render: (v: number, r: PointsTransaction) => (
<span
className={
r.type === "earn" ? "xx-tx-earn" : r.type === "refund" ? "xx-tx-refund" : "xx-tx-spend"
}
>
{v > 0 ? "+" : ""}
{v}
</span>
),
},
{
title: "余额",
dataIndex: "balance_after",
width: 110,
align: "right",
render: (v: number) => (
<Text strong style={{ fontVariantNumeric: "tabular-nums" }}>
{v}
</Text>
),
},
]
return (
<div className="xx-points-page">
<PageHead
title="积分明细"
description="查看积分收入、消耗与退还记录"
actions={
<Space>
<Button icon={<ReloadOutlined />} onClick={load}>
刷新
</Button>
<Button type="primary" onClick={() => navigate("/app/subscription")}>
充值积分
</Button>
</Space>
}
/>
<Card>
<div className="xx-points-filters">
<Space wrap size={12}>
<Select
value={type}
onChange={(v) => {
setType(v)
setPage(1)
}}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部类型" },
{ value: "earn", label: "获得" },
{ value: "spend", label: "消耗" },
{ value: "refund", label: "退还" },
]}
/>
<Select
value={source}
onChange={(v) => {
setSource(v)
setPage(1)
}}
style={{ width: 160 }}
showSearch
options={[
{ value: "all", label: "全部来源" },
...Object.entries(SOURCE_LABEL).map(([k, v]) => ({ value: k, label: v })),
]}
/>
<RangePicker
value={dateRange ?? undefined}
onChange={(dates) => {
setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null] | null)
setPage(1)
}}
/>
<Input
placeholder="搜索说明"
prefix={<SearchOutlined />}
allowClear
style={{ width: 200 }}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => {
setPage(1)
load()
}}
/>
</Space>
</div>
<Spin spinning={loading}>
<Table<PointsTransaction>
rowKey="id"
columns={columns}
dataSource={data}
locale={{ emptyText: <Empty description="暂无积分流水,快去使用 AI 功能吧" /> }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条记录`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}}
/>
</Spin>
</Card>
</div>
)
}
export default PointsTransactions
export const Component = PointsTransactions
+227 -174
View File
@@ -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;
}
}
+400 -85
View File
@@ -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<PointsPackage[]>([])
const [subscribing] = useState(false)
const [buying, setBuying] = useState<string | null>(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: <SafetyCertificateOutlined />,
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: <ThunderboltOutlined />,
content: (
<div>
<Paragraph>
订单 <Text code>{order.id.slice(0, 16)}…</Text> 已创建,金额{" "}
<b>{formatYuan(order.price_cents)}</b>,
微信/支付宝支付正在接入中,正式上线后可直接付款。
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
现阶段所有功能均处于免费体验阶段,积分仅为演示数据。
</Paragraph>
</div>
),
okText: "知道了",
})
} catch (e) {
const err = e as { message?: string }
message.error(err?.message ?? "创建订单失败")
} finally {
setSubscribing(false)
setBuying(null)
}
}
return (
<div className="xx-plans-page">
<PageHead title="选择适合您的方案" description="灵活定价,满足不同规模的创作需求" />
<PageHead
title="会员与积分"
description="开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
<div className="xx-plans-grid">
{PLANS.map((plan) => (
<div key={plan.id} className={`xx-plan-card ${plan.recommended ? "featured" : ""}`}>
{plan.recommended && <div className="xx-badge">推荐</div>}
{plan.id === "enterprise" && <div className="xx-badge enterprise">定制</div>}
<h3>{plan.name}</h3>
<div className={`xx-plan-price ${plan.price === null ? "custom" : ""}`}>
{plan.price !== null ? (
<>
<span className="currency">¥</span>
<span className="amount">{plan.price}</span>
<span className="period">/月</span>
</>
) : (
<span className="amount">联系我们</span>
)}
</div>
<p className="xx-plan-description">{plan.description}</p>
<div className="xx-features">
{plan.features.map((feature, idx) => (
<div key={idx} className="xx-feature">
<span>✓</span>
<span>{feature}</span>
</div>
))}
</div>
<button
className={`xx-subscribe-btn ${
plan.id === "enterprise" ? "enterprise" : plan.recommended ? "primary" : "ghost"
}`}
onClick={() => handleSubscribe(plan.id)}
disabled={subscribing}
actions={
<Space>
<Button
icon={<ThunderboltOutlined />}
onClick={() => navigate("/app/points/transactions")}
>
{subscribing ? "处理中..." : plan.price === null ? "联系我们" : "立即订阅"}
</button>
</div>
积分明细
</Button>
</Space>
}
/>
{/* 当前状态卡片 */}
{balance && (
<Card className="xx-current-status" bordered={false}>
<Row align="middle" gutter={24}>
<Col flex="auto">
<Space size="large" wrap>
<div>
<Text type="secondary">当前身份</Text>
<div>
{isMember ? (
<Tag
color="gold"
icon={<CrownFilled />}
style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}
>
{memberType === "yearly"
? "年卡"
: memberType === "quarterly"
? "季卡"
: "月卡"}
会员
</Tag>
) : (
<Tag style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}>
免费会员
</Tag>
)}
{balance.member_expires_at && (
<Text type="secondary" style={{ marginLeft: 8 }}>
到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</Text>
)}
</div>
</div>
<div>
<Text type="secondary">可用积分</Text>
<div className="xx-current-balance">
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
<span className="xx-current-balance-val">{balance.balance}</span>
</div>
</div>
{balance.free_clips_limit ? (
<div>
<Text type="secondary">今日免费混剪</Text>
<div>
<Text strong>{balance.free_clips_used ?? 0}</Text>
<Text type="secondary"> / {balance.free_clips_limit} 条</Text>
{!isMember && (
<Tag color="blue" style={{ marginLeft: 8 }}>
剩余 {balance.free_clips_remaining ?? 0} 条
</Tag>
)}
</div>
</div>
) : null}
</Space>
</Col>
<Col>
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={() => {
const el = document.getElementById("points-packages")
el?.scrollIntoView({ behavior: "smooth" })
}}
>
充值积分
</Button>
</Col>
</Row>
</Card>
)}
{/* 两档会员对比 */}
<Title level={4} style={{ marginTop: 24 }}>
<CrownFilled style={{ color: "#f59e0b", marginRight: 8 }} />
选择适合您的方案
</Title>
{/* 计费周期切换 */}
<div className="xx-billing-switch">
{SUBSCRIPTION_PLANS.map((p) => (
<button
key={p.id}
type="button"
className={`xx-billing-btn ${selectedBilling === p.id ? "active" : ""}`}
onClick={() => setSelectedBilling(p.id)}
>
{p.recommended && (
<Badge.Ribbon text="推荐" color="gold" style={{ top: -14, right: -22 }} />
)}
<div className="xx-billing-name">{p.name}</div>
<div className="xx-billing-price">
<span className="xx-billing-yuan">¥</span>
<span className="xx-billing-amount">{p.price_yuan}</span>
</div>
<div className="xx-billing-sub">
≈¥{p.per_month_yuan}/月
{p.savings_percent ? (
<span className="xx-billing-save">省{p.savings_percent}%</span>
) : null}
</div>
</button>
))}
</div>
<Row gutter={[20, 20]} style={{ marginTop: 16 }}>
{/* 免费会员 */}
<Col xs={24} md={12}>
<Card className="xx-plan-card" bordered>
<div className="xx-plan-head">
<Title level={4} style={{ margin: 0 }}>
免费会员
</Title>
<div className="xx-plan-price">
<span className="currency">¥</span>
<span className="amount">0</span>
<span className="period">/永久</span>
</div>
<Text type="secondary">体验 AI 剪辑的基础能力</Text>
</div>
<Divider style={{ margin: "16px 0" }} />
<ul className="xx-feature-list">
{FREE_FEATURES.map((f, i) => (
<li key={i} className={f.include ? "" : "disabled"}>
{f.include ? (
<CheckCircleFilled style={{ color: "#10b981" }} />
) : (
<span className="xx-feature-no">—</span>
)}
<span>{f.text}</span>
</li>
))}
</ul>
<Button block size="large" disabled>
当前方案
</Button>
</Card>
</Col>
{/* 付费会员 */}
<Col xs={24} md={12}>
<Card className="xx-plan-card featured" bordered>
<div className="xx-plan-recommend-badge">
<StarFilled /> 推荐
</div>
<div className="xx-plan-head">
<Title level={4} style={{ margin: 0, color: "#7c3aed" }}>
<CrownFilled style={{ color: "#f59e0b" }} /> 付费会员
</Title>
<div className="xx-plan-price">
<span className="currency">¥</span>
<span className="amount">{selectedPlan.price_yuan}</span>
<span className="period">{selectedPlan.billing_label}</span>
</div>
<Text type="secondary">
折合 ¥{selectedPlan.per_month_yuan}/月 · 解锁全部 AI 能力
</Text>
</div>
<Divider style={{ margin: "16px 0" }} />
<ul className="xx-feature-list">
{PAID_FEATURES.map((f, i) => (
<li key={i}>
<CheckCircleFilled style={{ color: "#7c3aed" }} />
<span>{f.text}</span>
</li>
))}
</ul>
<Button
block
size="large"
type="primary"
loading={subscribing}
disabled={isMember && memberType === selectedBilling}
onClick={handleSubscribe}
icon={<ThunderboltOutlined />}
>
{isMember && memberType === selectedBilling
? "当前方案"
: isMember
? "续费/升级"
: "立即开通"}
</Button>
<div className="xx-plan-tip">
<VideoCameraOutlined /> 虚拟商品不支持退款 · 支付接入中先演示
</div>
</Card>
</Col>
</Row>
{/* 积分充值 */}
<div id="points-packages">
<Title level={4} style={{ marginTop: 40 }}>
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
积分充值
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
(永久有效)
</Text>
</Tooltip>
</Title>
<Row gutter={[16, 16]}>
{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 (
<Col xs={24} sm={8} key={pkg.id}>
<Card
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${pkg.id === "basic_pack" ? "recommended" : ""}`}
hoverable
>
{pkg.id === "basic_pack" && <div className="xx-pkg-badge">热门</div>}
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
会员{10 - discount / 10}折
</Tag>
)}
<div className="xx-pkg-name">{pkg.name}</div>
<div className="xx-pkg-points">
<ThunderboltOutlined /> {pkg.points.toLocaleString()} 积分
</div>
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
</span>
{discount > 0 && (
<span className="xx-pkg-origin">¥{(pkg.price / 100).toFixed(0)}</span>
)}
</div>
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分</div>
<Button
block
type={pkg.id === "basic_pack" ? "primary" : "default"}
loading={buying === pkg.id}
onClick={() => handleBuyPoints(pkg)}
style={{ marginTop: 12 }}
>
立即购买
</Button>
</Card>
</Col>
)
})}
</Row>
</div>
</div>
)
}
+16
View File
@@ -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")),
+83
View File
@@ -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<void>
/** 强制刷新余额 */
refreshBalance: () => Promise<void>
/** 乐观扣减:在支付/业务发起前调用,失败时用 refreshBalance 兜底 */
optimisticDeduct: (points: number) => void
/** 乐观增加(充值成功后调用) */
optimisticAdd: (points: number) => void
}
export const usePointsStore = create<PointsState>((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,
},
})
},
}))