feat(points): 对齐后端积分/订阅 API 最终契约(types + API 层改造) #1956

Merged
auto-approve-bot merged 36 commits from fix/points-api-contract-v2 into develop 2026-09-16 10:53:58 +08:00
18 changed files with 1649 additions and 1127 deletions
+290 -422
View File
@@ -1,477 +1,345 @@
/**
* 积分 & 会员 API 封装(v2 两档会员制)
* 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。
* 积分系统 API 封装
* 对齐后端 staging 实测最终契约(2026-09-16)
*
* 当前 POINTS_API_MOCK=true:使用 MOCK_* 常量 + setTimeout 模拟延迟,
* 等后端 P0(支付通道接入、change-plan 校验)稳定后切 false 联调。
*
* 会员/订阅 API 在 @/api/subscription 中定义,避免重复封装。
*/
import apiClient from "../client"
import type {
PointsBalance,
PointsTransaction,
PointsTransactionsParams,
PointsTransactionsResponse,
PointsPackagesResponse,
PointsRechargeRequest,
PointsOrder,
PointsRulesResponse,
PointsPackagesResponse,
PointsTransaction,
PointsTransactionsResponse,
PointsCheckRequest,
PointsCheckResponse,
SubscriptionCurrent,
SubscribeRequest,
SubscriptionPlan,
CreateRechargeOrderRequest,
CreateRechargeOrderResponse,
DailyUsage,
MembershipResponse,
} from "./types"
/**
* 是否启用 mock 数据(后端 PR 合入前为 true;对接真实接口后改为 false)
*/
export const POINTS_API_MOCK = true
/** 模拟网络延迟(ms) */
const MOCK_DELAY = 500
// ==================== Mock 数据 ====================
/* ================================================================
* Mock 数据
* ================================================================ */
/** mock 余额(无 free_clips_* 字段,已拆分到 dailyUsage) */
const MOCK_BALANCE: PointsBalance = {
balance: 580,
total_earned: 1200,
total_spent: 620,
balance: 258,
total_earned: 500,
total_spent: 242,
is_member: false,
member_type: null,
member_expires_at: null,
free_clips_used: 1,
free_clips_limit: 2,
free_clips_remaining: 1,
}
const MOCK_PACKAGES: PointsPackagesResponse = {
packages: [
{
id: "starter_pack",
name: "体验包",
points: 100,
price: 990,
discounted_price_for_free: 990,
discounted_price_for_monthly: 891,
discounted_price_for_quarterly: 861,
discounted_price_for_yearly: 792,
},
{
id: "basic_pack",
name: "基础包",
points: 500,
price: 3900,
discounted_price_for_free: 3900,
discounted_price_for_monthly: 3510,
discounted_price_for_quarterly: 3393,
discounted_price_for_yearly: 3120,
},
{
id: "pro_pack",
name: "专业包",
points: 2000,
price: 12900,
discounted_price_for_free: 12900,
discounted_price_for_monthly: 11610,
discounted_price_for_quarterly: 11223,
discounted_price_for_yearly: 10320,
},
],
user_member_type: "free",
unit_price_yuan: 0.1,
}
const MOCK_RULES: PointsRulesResponse = {
rules: [
{
scene_key: "ai_voice",
scene_name: "AI 配音",
points_per_use: 1,
unit: "分钟",
description: "每生成 1 分钟配音",
name: "AI 配音",
base_points: 2,
unit: "次",
description: "单次配音消耗 2 积分,超 30 秒每 30 秒 +1 积分",
extra_per_30s: 1,
},
{
scene_key: "ai_video",
scene_name: "智能混剪",
points_per_use: 3,
name: "AI 视频生成",
base_points: 8,
unit: "条",
extra_per_30s: 1,
description: "每条 ≤30s 3 积分,每加 30s +1",
description: "单条视频 8 积分起,按视频时长加收",
extra_per_30s: 3,
},
{
scene_key: "ai_digital_human",
scene_name: "AI 数字人",
points_per_use: 15,
unit: "分钟",
description: "每生成 1 分钟口播",
name: "AI 数字人",
base_points: 15,
unit: "次",
description: "数字人生成 15 积分起",
extra_per_30s: 5,
},
{
scene_key: "voice_clone_train",
scene_name: "声音克隆训练",
points_per_use: 0,
name: "声音克隆训练",
base_points: 20,
unit: "次",
description: "训练免费",
description: "声音模型训练一次性消耗 20 积分",
},
{
scene_key: "voice_clone_synth",
name: "声音克隆合成",
base_points: 3,
unit: "次",
description: "使用克隆声音合成音频每次 3 积分",
},
{
scene_key: "douyin_extract",
name: "抖音文案提取",
base_points: 1,
unit: "次",
description: "提取抖音视频文案每次 1 积分",
},
{
scene_key: "ai_rewrite",
name: "AI 文案改写",
base_points: 2,
unit: "次",
description: "AI 改写文案每次 2 积分",
},
{
scene_key: "ai_title",
name: "AI 标题生成",
base_points: 1,
unit: "次",
description: "AI 生成标题每次 1 积分,一次生成多条",
},
{
scene_key: "ai_cover",
name: "AI 封面生成",
base_points: 3,
unit: "次",
description: "AI 生成封面每次 3 积分",
},
{ scene_key: "voice_clone_synth", scene_name: "声音克隆合成", points_per_use: 1, unit: "分钟" },
{ scene_key: "douyin_extract", scene_name: "抖音链接提取", points_per_use: 1, unit: "次" },
{ scene_key: "ai_rewrite", scene_name: "AI 改写文案", points_per_use: 1, unit: "次" },
{ scene_key: "ai_title", scene_name: "AI 标题生成", points_per_use: 1, unit: "次" },
{ scene_key: "ai_cover", scene_name: "AI 封面生成", points_per_use: 1, unit: "张" },
],
free_user_multiplier: 1.15,
note: "免费用户消耗 = 会员消耗 × 1.15,向上取整",
}
function genMockTransactions(): PointsTransactionsResponse {
const now = new Date()
const list = [
const MOCK_PACKAGES: PointsPackagesResponse = {
packages: [
{ code: "points_100", name: "100 积分", points: 100, price_cents: 990, unit_price: 0.099 },
{ code: "points_500", name: "500 积分", points: 500, price_cents: 4490, unit_price: 0.0898 },
{ code: "points_1000", name: "1000 积分", points: 1000, price_cents: 7990, unit_price: 0.0799 },
{
src: "ai_voice",
name: "AI 配音",
type: "spend" as const,
amt: 1,
desc: "生成配音 1 分钟",
days: 0,
hours: 0,
mins: 30,
code: "points_3000",
name: "3000 积分",
points: 3000,
price_cents: 19900,
unit_price: 0.0663,
},
{
src: "ai_video",
name: "智能混剪",
type: "spend" as const,
amt: 5,
desc: "生成 1 分钟视频(基础3+30s*2)",
days: 0,
hours: 1,
mins: 15,
},
{
src: "task_reward",
name: "任务奖励",
type: "earn" as const,
amt: 20,
desc: "首次生成视频奖励",
days: 1,
hours: 0,
mins: 0,
},
{
src: "recharge",
name: "充值",
type: "earn" as const,
amt: 500,
desc: "基础包充值",
days: 15,
hours: 0,
mins: 0,
},
{
src: "ai_rewrite",
name: "AI 改写文案",
type: "spend" as const,
amt: 2,
desc: "免费用户价(1×1.15 向上取整)",
days: 16,
hours: 2,
mins: 10,
},
{
src: "ai_title",
name: "AI 标题生成",
type: "spend" as const,
amt: 2,
desc: "免费用户价",
days: 16,
hours: 3,
mins: 0,
},
{
src: "douyin_extract",
name: "抖音链接提取",
type: "spend" as const,
amt: 2,
desc: "提取 3 分钟文案",
days: 18,
hours: 0,
mins: 0,
},
{
src: "ai_digital_human",
name: "AI 数字人",
type: "spend" as const,
amt: 18,
desc: "数字人口播 1 分钟(免费用户价)",
days: 20,
hours: 0,
mins: 0,
},
{
src: "task_reward",
name: "任务奖励",
type: "earn" as const,
amt: 50,
desc: "注册赠送",
days: 30,
hours: 0,
mins: 0,
},
]
let bal = MOCK_BALANCE.balance
const items = list
.map((t, i) => {
const signed = t.type === "earn" ? t.amt : -t.amt
const balance_after = bal // 按时间倒序:earliest 先算
// adjust running bal
bal = t.type === "earn" ? bal - t.amt : bal + t.amt
const d = new Date(now)
d.setDate(d.getDate() - t.days)
d.setHours(d.getHours() - t.hours)
d.setMinutes(d.getMinutes() - t.mins)
return {
id: `tx_${i + 1}`,
type: t.type,
source: t.src as PointsBalance extends never ? never : string,
source_name: t.name,
amount: t.amt,
signed_amount: signed,
balance_after,
description: t.desc,
ref_id: null,
created_at: d.toISOString(),
}
})
.reverse()
// Rebuild balance_after going forward
let running = 50 + 0 // after registration gift
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i] as PointsTransaction & { balance_after?: number }
if (it.source === "task_reward" && it.description.includes("注册")) running = 50
}
running = 50
const fwd = [...items].reverse() as Array<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
],
user_discount: null,
}
// ==================== 真实 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[] = [
const MOCK_TRANSACTIONS: PointsTransaction[] = [
{
id: "monthly",
name: "月卡",
price_cents: 1990,
price_yuan: 19.9,
per_month_yuan: 19.9,
billing_label: "/月",
id: 1,
type: "deduct",
source: "ai_video",
amount: 10,
balance_after: 248,
description: "AI 视频生成 ×1(非会员倍率)",
ref_id: "task_abc123",
created_at: "2026-09-16T08:30:00Z",
},
{
id: "quarterly",
name: "季卡",
price_cents: 3990,
price_yuan: 39.9,
per_month_yuan: 13.3,
savings_percent: 33,
recommended: true,
billing_label: "/季",
id: 2,
type: "add",
source: "recharge",
amount: 100,
balance_after: 258,
description: "充值 100 积分",
ref_id: "order_xyz789",
created_at: "2026-09-15T14:20:00Z",
},
{
id: "yearly",
name: "年卡",
price_cents: 15900,
price_yuan: 159,
per_month_yuan: 13.25,
savings_percent: 33,
billing_label: "/年",
id: 3,
type: "deduct",
source: "ai_voice",
amount: 3,
balance_after: 158,
description: "AI 配音 ×1(45s 加收)",
ref_id: "",
created_at: "2026-09-15T10:15:00Z",
},
{
id: 4,
type: "add",
source: "sign_up",
amount: 60,
balance_after: 161,
description: "新用户注册赠送",
ref_id: "",
created_at: "2026-09-10T09:00:00Z",
},
{
id: 5,
type: "deduct",
source: "ai_title",
amount: 1,
balance_after: 101,
description: "AI 标题生成 ×1",
ref_id: "",
created_at: "2026-09-14T16:45:00Z",
},
]
/** 查询当前订阅 */
export async function getCurrentSubscription(): Promise<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
const MOCK_DAILY_USAGE: DailyUsage = {
free_clips_used: 1,
free_clips_limit: 3,
free_clips_remaining: 2,
reset_at: new Date(Date.now() + 8 * 3600_000).toISOString(),
}
/** 开通/续费会员 */
export async function subscribe(req: SubscribeRequest): Promise<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
const MOCK_MEMBERSHIP: MembershipResponse = {
is_member: false,
member_type: null,
member_expires_at: null,
points_balance: 258,
max_resolution: "720p",
}
/** 取消自动续费 */
export async function cancelAutoRenew(): Promise<{ success: boolean; message: string }> {
if (POINTS_API_MOCK) {
return new Promise((r) =>
setTimeout(() => r({ success: true, message: "已取消自动续费" }), 200),
)
/* ================================================================
* 积分 API
* ================================================================ */
/** 获取积分余额 */
export async function getPointsBalance(): Promise<PointsBalance> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_BALANCE }
}
const res = await apiClient.post("/subscription/cancel")
return res.data
const { data } = await apiClient.get(`/points/balance`)
return data
}
/** 获取积分消耗规则 */
export async function getPointsRules(): Promise<PointsRulesResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { rules: [...MOCK_RULES.rules], free_user_multiplier: MOCK_RULES.free_user_multiplier }
}
const { data } = await apiClient.get(`/points/rules`)
return data
}
/** 获取充值包列表 */
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { packages: MOCK_PACKAGES.packages.map((p) => ({ ...p })), user_discount: null }
}
const { data } = await apiClient.get(`/points/packages`)
return data
}
/**
* 获取积分流水(分页)
*/
export async function getPointsTransactions(
page = 1,
pageSize = 20,
): Promise<PointsTransactionsResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
const start = (page - 1) * pageSize
const items = MOCK_TRANSACTIONS.slice(start, start + pageSize)
return {
items: items.map((t) => ({ ...t })),
total: MOCK_TRANSACTIONS.length,
page,
page_size: pageSize,
}
}
const { data } = await apiClient.get(`/points/transactions`, {
params: { page, page_size: pageSize },
})
return data
}
/**
* 创建充值订单
* 注意:当前 pay_params 返回空对象 {}(支付通道未接入),
* 前端可以完成订单创建 UI,但无法发起真实支付,待后续支付通道接入后联调。
*/
export async function createPointsOrder(
data: CreateRechargeOrderRequest,
): Promise<CreateRechargeOrderResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY * 2))
const pkg = MOCK_PACKAGES.packages.find((p) => p.code === data.package_id)
if (!pkg) throw new Error("充值包不存在")
return {
id: `mock_order_${Date.now()}`,
order_type: "points_recharge",
product_code: pkg.code,
amount_cents: pkg.price_cents,
points_amount: pkg.points,
status: "pending",
pay_params: {},
expire_at: new Date(Date.now() + 30 * 60_000).toISOString(),
created_at: new Date().toISOString(),
}
}
const { data: d } = await apiClient.post(`/points/recharge`, data)
return d
}
/**
* 积分预检查(消耗前调用)
*/
export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheckResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
const rule = MOCK_RULES.rules.find((r) => r.scene_key === data.scene_key)
if (!rule) {
throw {
error: {
code: 400,
message: `未知场景:${data.scene_key}`,
valid_scenes: MOCK_RULES.rules.map((r) => r.scene_key),
},
}
}
const durationExtra =
data.duration_minutes && data.duration_minutes > 0.5 && rule.extra_per_30s
? Math.ceil((data.duration_minutes * 60 - 30) / 30) * rule.extra_per_30s
: 0
const base = (rule.base_points + durationExtra) * data.quantity
const balance = MOCK_BALANCE.balance
const multiplier = MOCK_BALANCE.is_member ? 1 : MOCK_RULES.free_user_multiplier
const required = Math.ceil(base * multiplier)
// 免费额度抵扣
const isFreeQuota = !MOCK_BALANCE.is_member && MOCK_DAILY_USAGE.free_clips_remaining > 0
const finalRequired = isFreeQuota ? 0 : required
return {
allowed: balance >= finalRequired,
required_points: finalRequired,
current_balance: balance,
remaining_after: balance - finalRequired,
is_free_quota: isFreeQuota,
}
}
const { data: d2 } = await apiClient.post(`/points/check`, data)
return d2
}
/* ================================================================
* 每日免费额度 + 会员聚合信息(新接口)
* ================================================================ */
/** 获取每日免费额度使用情况 */
export async function getDailyUsage(): Promise<DailyUsage> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_DAILY_USAGE }
}
const { data } = await apiClient.get(`/usage/daily`)
return data
}
/** 获取会员聚合信息(创作页可用来判断 max_resolution) */
export async function getMembership(): Promise<MembershipResponse> {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_MEMBERSHIP }
}
const { data } = await apiClient.get(`/points/subscription/membership`)
return data
}
+175 -165
View File
@@ -1,76 +1,134 @@
/**
* 积分 & 会员系统 API 类型定义(v2 两档会员制)
* 接口契约对齐后端设计文档 membership-points-design-v2.md
* 积分系统类型定义
* 对齐后端 staging 实测最终契约(2026-09-16)
*
* Base path: /api/v1/
* 会员/订阅相关类型请从 @/api/subscription/types 引入,本文件仅保留积分核心类型。
*/
/** 会员类型 */
export type MemberType = "free" | "monthly" | "quarterly" | "yearly"
/** 积分流水类型 */
export type PointsTxType = "earn" | "spend" | "refund"
/** 积分来源/消耗场景 */
/* ================================================================
* 场景键
* ================================================================ */
/**
* 积分消耗场景键(9 个)
* - ai_script 已拆分为 douyin_extract / ai_rewrite / ai_title,前端禁止再传 ai_script
*/
export type PointsSource =
| "recharge" // 充值
| "task_reward" // 任务奖励
| "ai_voice" // AI 配音
| "ai_video" // AI 视频生成
| "ai_digital_human" // AI 数字人
| "ai_video" // 智能混剪
| "voice_clone_train" // 声音克隆训练
| "voice_clone_synth" // 声音克隆合成
| "douyin_extract" // 抖音链接提取
| "ai_rewrite" // AI 改写文案
| "douyin_extract" // 抖音提取文案
| "ai_rewrite" // AI 文案改写
| "ai_title" // AI 标题生成
| "ai_cover" // AI 封面生成
| "subscription_bonus" // 会员赠送
| "admin_adjust" // 管理员调整
| "refund" // 失败退还
/** 会员 & 积分余额响应 */
/** 非消耗场景 source 前缀(用于流水 source 字段) */
export type PointsSourceExtra =
PointsSource | `refund:${string}` | "recharge" | "sign_up" | "bind_phone" | "gift" | "admin"
/* ================================================================
* 通用
* ================================================================ */
/** ISO 8601 时间字符串 */
export type ISODate = string
/* ================================================================
* 积分余额(GET /points/balance)
* ================================================================ */
export interface PointsBalance {
/** 当前可用积分 */
balance: number
/** 累计获得 */
/** 累计获得积分 */
total_earned: number
/** 累计消耗 */
/** 累计消耗积分 */
total_spent: number
/** 是否付费会员(free 用户为 false) */
/** 是否为付费会员 */
is_member: boolean
/** 会员类型:monthly / quarterly / yearly;free 用户为 null */
member_type: Extract<MemberType, "monthly" | "quarterly" | "yearly"> | null
/** 会员到期时间 ISO 字符串 */
member_expires_at: string | null
/** 今日免费混剪已用次数 */
free_clips_used?: number
/** 今日免费混剪额度上限 */
free_clips_limit?: number
/** 今日免费混剪剩余 */
free_clips_remaining?: number
/** 会员类型(monthly/quarterly/yearly,非会员 null)。推荐使用 /subscription/current 的 plan_id+billing_cycle 做判断 */
member_type: "monthly" | "quarterly" | "yearly" | null
/** 会员到期时间 */
member_expires_at: ISODate | null
}
/** 积分流水记录 */
/* ================================================================
* 积分规则(GET /points/rules)
* ================================================================ */
export interface PointsRule {
scene_key: PointsSource
/** 场景中文名 */
name: string
/** 基准消耗积分(points_per_use 改名) */
base_points: number
/** 单位描述,如「次」「分钟」「个」 */
unit: string
/** 超过30秒后每30秒额外积分(视频/语音类) */
extra_per_30s?: number
/** 场景说明(后端已补回) */
description?: string
}
export interface PointsRulesResponse {
rules: PointsRule[]
/** 非会员消耗倍率(如 1.15) */
free_user_multiplier: number
}
/* ================================================================
* 充值包(GET /points/packages)
* ================================================================ */
export interface PointsPackage {
/** 包编码(id 改名) */
code: string
name: string
points: number
/** 原价,单位分 */
price_cents: number
/** 每积分单价(元),展示用 */
unit_price: number
}
export interface PointsPackagesResponse {
packages: PointsPackage[]
/** 当前用户折扣(会员折扣或活动折扣),null 表示无折扣 */
user_discount: number | null
}
/**
* 充值包前端展示辅助:折后价(分)
* 后端废弃 4 档 discounted_price_for_*,前端按 price_cents * (user_discount ?? 1) 计算。
*/
export function getDiscountPriceCents(pkg: PointsPackage, userDiscount: number | null): number {
return Math.round(pkg.price_cents * (userDiscount ?? 1))
}
/* ================================================================
* 积分流水(GET /points/transactions)
* ================================================================ */
export type PointsTxType = "add" | "deduct"
export interface PointsTransaction {
id: string
/** earn / spend / refund */
id: number
/** 流水类型:add=获得/退款,deduct=消耗 */
type: PointsTxType
/** 来源场景 */
source: PointsSource
/** 场景中文名称 */
source_name: string
/** 变动数量(正数) */
/**
* 消耗/获得来源:
* - 消耗场景直接用 PointsSource 值
* - 充值/退款/赠送使用 recharge / refund:<source> / sign_up / bind_phone / gift / admin
*/
source: string
/** 变动数量(绝对值,正负由 type 决定) */
amount: number
/** 带符号的变动数(收入+,支出-) */
signed_amount: number
/** 变动后余额 */
balance_after: number
/** 备注描述 */
/** 中文描述 */
description: string
/** 关联业务 ID */
ref_id: string | null
created_at: string
/** 关联订单/任务 ID,空字符串 "" 表示无关联(不是 null) */
ref_id: string
created_at: ISODate
}
/** 积分流水分页响应 */
export interface PointsTransactionsResponse {
items: PointsTransaction[]
total: number
@@ -78,140 +136,92 @@ export interface PointsTransactionsResponse {
page_size: number
}
/** 积分流水查询参数 */
export interface PointsTransactionsParams {
page?: number
page_size?: number
type?: PointsTxType
source?: PointsSource
start_date?: string
end_date?: string
}
/** 积分包 */
export interface PointsPackage {
id: "starter_pack" | "basic_pack" | "pro_pack" | string
/** 中文名称 */
name: string
/** 积分数量 */
points: number
/** 原价(分) */
price: number
/** 各会员类型折扣价(分) */
discounted_price_for_free: number
discounted_price_for_monthly: number
discounted_price_for_quarterly: number
discounted_price_for_yearly: number
}
/** 积分包列表响应 */
export interface PointsPackagesResponse {
packages: PointsPackage[]
/** 当前用户会员类型,用于前端计算折后价 */
user_member_type: MemberType
/** 积分单价(元/积分,按会员价计) */
unit_price_yuan: number
}
/** 创建充值订单请求 */
export interface PointsRechargeRequest {
/* ================================================================
* 创建充值订单(POST /points/recharge)
* ================================================================ */
export interface CreateRechargeOrderRequest {
/** 充值包 code(字段名保留 package_id 与后端一致) */
package_id: string
payment_method?: "wechat_pay" | "alipay"
}
/** 订单状态 */
export type OrderStatus = "pending" | "paid" | "failed" | "refunded" | "expired"
/** 充值订单响应 */
export interface PointsOrder {
export interface CreateRechargeOrderResponse {
id: string
package_id: string
package_name: string
order_type: string
product_code: string
/** 订单金额(分) */
amount_cents: number
/** 充值积分数量 */
points_amount: number
price_cents: number
original_price_cents: number
discount: number
currency: "CNY"
status: OrderStatus
payment_method: string | null
payment_id: string | null
paid_at: string | null
expire_at: string | null
created_at: string
/** 微信/支付宝支付参数(mock 阶段前端自行处理) */
pay_params?: Record<string, string>
status: string
/**
* 支付参数(支付通道未接入时返回空对象 {},前端可透传)
*/
pay_params: Record<string, unknown>
/** 订单过期时间 */
expire_at: ISODate
created_at: ISODate
}
/** 订阅套餐(月/季/年) */
export interface SubscriptionPlan {
id: "monthly" | "quarterly" | "yearly"
name: string
price_cents: number
price_yuan: number
per_month_yuan: number
savings_percent?: number
recommended?: boolean
billing_label: string
}
/** 当前订阅详情 */
export interface SubscriptionCurrent {
is_member: boolean
member_type: Extract<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
}
/** 消费前余额检查请求 */
/* ================================================================
* 积分预检查(POST /points/check)
* ================================================================ */
export interface PointsCheckRequest {
scene_key: PointsSource
/** 单位数量(时长/条数),默认 1 */
units?: number
/** 数量(units 改名) */
quantity: number
/** 预计时长(分钟),可选 */
duration_minutes?: number
}
/** 消费前余额检查响应 */
export interface PointsCheckResponse {
/** 是否可以执行 */
allowed: boolean
/** 需要消耗积分 */
required_points: number
/** 当前余额 */
current_balance: number
/** 扣除后剩余 */
remaining_after: number
/** 是否走免费额度(混剪场景) */
/** 是否走免费额度 */
is_free_quota: boolean
/** 拒绝原因代码 */
code?: "INSUFFICIENT_POINTS" | "FREE_QUOTA_EXCEEDED" | "SCENE_NOT_FOUND"
message?: string
/** 充值页跳转 URL */
recharge_url?: string
}
/* ================================================================
* 每日使用情况(GET /usage/daily,新接口)
* ================================================================ */
export interface DailyUsage {
/** 今日已用免费次数 */
free_clips_used: number
/** 每日免费次数上限 */
free_clips_limit: number
/** 今日剩余免费次数 */
free_clips_remaining: number
/** 额度重置时间 */
reset_at: ISODate
}
/* ================================================================
* 会员聚合信息(GET /points/subscription/membership,新接口)
* ================================================================ */
export interface MembershipResponse {
is_member: boolean
/** 会员类型(monthly/quarterly/yearly,非会员 null) */
member_type: "monthly" | "quarterly" | "yearly" | null
member_expires_at: ISODate | null
/** 当前积分余额(冗余,可与 balance 互校) */
points_balance: number
/** 最大分辨率,如 "720p" / "1080p" / "4k" */
max_resolution: string
}
/* ================================================================
* 错误响应(统一格式 {error:{code,message}})
* ================================================================ */
export interface ApiError {
error: {
code: number
message: string
/** 部分场景会返回,如 unknown scene_key */
valid_scenes?: PointsSource[]
}
}
+8 -2
View File
@@ -1,24 +1,30 @@
/**
* 订阅 API — 目录化入口
* 保持与原 subscription.ts 相同导出,向后兼容
* 对齐后端 staging 最终契约(2026-09-16)
*/
// 类型
export type {
PlanId,
PlanType,
SubscriptionStatus,
BillingStatus,
BillingCycle,
Plan,
SubscriptionInfo,
SubscriptionPlan,
SubscriptionPlansResponse,
BillingRecord,
ChangePlanRequest,
ChangePlanResponse,
ToggleAutoRenewRequest,
} from "./types"
export { PLAN_LABEL, BILLING_CYCLE_LABEL } from "./types"
// API 函数
export {
getCurrentSubscription,
getSubscriptionPlans,
getBillingRecords,
changePlan,
cancelSubscription,
+130 -23
View File
@@ -1,47 +1,154 @@
/**
* 订阅相关 API 函数
* 订阅/会员 API 封装
* 对齐后端 staging 实测最终契约(2026-09-16)
*
* Base path: /api/v1/
* 所有请求走 apiClient(已配置 baseURL=/api/v1 和 token 拦截器)。
*/
import apiClient from "../client"
import type {
SubscriptionInfo,
SubscriptionPlan,
SubscriptionPlansResponse,
BillingRecord,
ChangePlanRequest,
ChangePlanResponse,
SubscriptionInfo,
ToggleAutoRenewRequest,
} from "./types"
/** 获取当前订阅信息 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
const response = await apiClient.get("/subscription/current")
return response.data
const MOCK_DELAY = 500
const MOCK_SUBSCRIPTION: SubscriptionInfo = {
id: "sub_mock_001",
plan_id: "free",
plan_name: "免费版",
status: "active",
billing_cycle: "monthly",
current_period_start: new Date(Date.now() - 30 * 86400_000).toISOString(),
current_period_end: new Date(Date.now() + 30 * 86400_000).toISOString(),
amount: 0,
auto_renew: false,
created_at: new Date(Date.now() - 30 * 86400_000).toISOString(),
}
/** 获取账单记录列表 */
const MOCK_PLANS: SubscriptionPlan[] = [
{
plan_id: "free",
name: "免费版",
price_cents: 0,
monthly_price_cents: 0,
duration_days: 0,
points_discount: 1,
features: { max_resolution: "720p", free_clips_daily: 3 },
},
{
plan_id: "monthly",
name: "月度会员",
price_cents: 1990,
monthly_price_cents: 1990,
duration_days: 30,
points_discount: 0.9,
features: { max_resolution: "1080p", free_clips_daily: 10 },
},
{
plan_id: "quarterly",
name: "季度会员",
price_cents: 3990,
monthly_price_cents: 1330,
duration_days: 90,
points_discount: 0.85,
features: { max_resolution: "1080p", free_clips_daily: 15 },
},
{
plan_id: "yearly",
name: "年度会员",
price_cents: 15900,
monthly_price_cents: 1325,
duration_days: 365,
points_discount: 0.8,
features: { max_resolution: "4k", free_clips_daily: 30 },
},
]
const MOCK_BILLING: BillingRecord[] = []
const isMock = () => (process.env.POINTS_API_MOCK as string | undefined) === "true"
/** 获取当前订阅 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY))
return { ...MOCK_SUBSCRIPTION }
}
const { data } = await apiClient.get("/subscription/current")
return data
}
/** 获取所有订阅档位 */
export const getSubscriptionPlans = async (): Promise<SubscriptionPlansResponse> => {
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY))
return { plans: MOCK_PLANS.map((p) => ({ ...p, features: { ...p.features } })) }
}
const { data } = await apiClient.get("/subscription/plans")
return data
}
/** 获取账单记录 */
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
const response = await apiClient.get("/subscription/billing-records")
return response.data
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY))
return MOCK_BILLING.map((r) => ({ ...r }))
}
const { data } = await apiClient.get("/subscription/billing-records")
return data
}
/** 升级/降级套餐 */
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
const response = await apiClient.post("/subscription/change-plan", request)
return response.data
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY * 2))
const plan = MOCK_PLANS.find((p) => p.plan_id === request.target_plan_id)
if (!plan) return { success: false, message: "套餐不存在" }
const newSub: SubscriptionInfo = {
...MOCK_SUBSCRIPTION,
plan_id: plan.plan_id,
plan_name: plan.name,
billing_cycle: request.billing_cycle,
amount: plan.price_cents,
status: "pending",
current_period_start: new Date().toISOString(),
current_period_end: new Date(Date.now() + plan.duration_days * 86400_000).toISOString(),
auto_renew: true,
}
return {
success: true,
message: "订阅变更成功(mock,支付通道待接入)",
new_subscription: newSub,
}
}
const { data } = await apiClient.post("/subscription/change-plan", request)
return data
}
/** 取消订阅 */
export const cancelSubscription = async (): Promise<{
success: boolean
message: string
}> => {
const response = await apiClient.post("/subscription/cancel")
return response.data
/** 取消订阅(到期后失效) */
export const cancelSubscription = async (): Promise<{ success: boolean; message: string }> => {
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY))
return { success: true, message: "已取消订阅,到期后将不再续费" }
}
const { data } = await apiClient.post("/subscription/cancel")
return data
}
/** 切换自动续费 */
export const toggleAutoRenew = async (
enabled: boolean,
req: ToggleAutoRenewRequest,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post("/subscription/toggle-auto-renew", {
enabled,
})
return response.data
if (isMock()) {
await new Promise((r) => setTimeout(r, MOCK_DELAY))
return { success: true, message: req.enabled ? "已开启自动续费" : "已关闭自动续费" }
}
const { data } = await apiClient.post("/subscription/toggle-auto-renew", req)
return data
}
+80 -30
View File
@@ -1,65 +1,115 @@
/**
* 订阅相关类型定义
* 订阅/会员类型定义
* 对齐后端 staging 实测最终契约(2026-09-16)
*
* Base path: /api/v1/
*/
/** 套餐类型 */
export type PlanType = "free" | "standard" | "pro" | "enterprise"
/** 订阅状态 */
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial"
/** 账单状态 */
export type BillingStatus = "paid" | "pending" | "failed" | "refunded"
/** 订阅计划 ID */
export type PlanId = "free" | "monthly" | "quarterly" | "yearly"
/** 计费周期 */
export type BillingCycle = "monthly" | "yearly"
/** 套餐信息 */
export interface Plan {
id: PlanType
name: string
price: number | null
yearly_price?: number | null
description: string
recommended: boolean
features: string[]
}
/** 订阅状态 */
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "pending"
/** 当前订阅信息 */
/** 账单状态 */
export type BillingStatus = "paid" | "pending" | "failed" | "refunded"
/* ================================================================
* 当前订阅(GET /subscription/current)
* ================================================================ */
export interface SubscriptionInfo {
id: string
plan_id: PlanType
plan_id: PlanId
plan_name: string
status: SubscriptionStatus
/** 当前计费周期:monthly 对月卡/季卡按自然月续费;yearly 对年卡 */
billing_cycle: BillingCycle
current_period_start: string
current_period_end: string
/** 本期金额(分) */
amount: number
auto_renew: boolean
created_at: string
}
/** 账单记录 */
/* ================================================================
* 订阅计划(GET /subscription/plans)
* ================================================================ */
export interface SubscriptionPlan {
plan_id: PlanId
/** 中文名 */
name: string
/** 价格(分),年卡/季卡为总价 */
price_cents: number
/** 折算月价(分),对比用 */
monthly_price_cents: number
/** 时长(天) */
duration_days: number
/** 积分折扣(0.9 = 9折,1 = 无折扣) */
points_discount: number
features: {
max_resolution: string
free_clips_daily: number
[key: string]: unknown
}
}
export interface SubscriptionPlansResponse {
plans: SubscriptionPlan[]
}
/* ================================================================
* 账单(GET /subscription/billing-records)
* ================================================================ */
export interface BillingRecord {
id: string
plan_name: string
amount: number
billing_cycle: BillingCycle
/** 订单类型:subscribe/renew/upgrade/refund */
order_type: string
plan_id: PlanId
/** 金额(分) */
amount_cents: number
status: BillingStatus
payment_method: string
created_at: string
invoice_url?: string
paid_at?: string
}
/** 升级/降级请求 */
/* ================================================================
* 变更/取消/开关自动续费
* ================================================================ */
export interface ChangePlanRequest {
target_plan_id: PlanType
target_plan_id: PlanId
billing_cycle: BillingCycle
}
/** 升级/降级响应 */
export interface ChangePlanResponse {
success: boolean
message: string
new_subscription?: SubscriptionInfo
}
export interface ToggleAutoRenewRequest {
enabled: boolean
}
/* ================================================================
* 中文标签映射
* ================================================================ */
export const PLAN_LABEL: Record<PlanId, string> = {
free: "免费版",
monthly: "月度会员",
quarterly: "季度会员",
yearly: "年度会员",
}
export const BILLING_CYCLE_LABEL: Record<BillingCycle, string> = {
monthly: "月付",
yearly: "年付",
}
/**
* @deprecated 旧命名保留别名,新代码请直接用 PlanId
*/
export type PlanType = PlanId
@@ -1,7 +1,11 @@
/**
* Header 右上角的积分徽章(💎 580)
* Header 右上角积分徽章
* - 余额 <10 时橙色告警
* - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口
*
* 字段对齐新契约:
* - balance.is_member / balance.member_type 保留但降级;推荐用 membership.member_type
* - 免费额度、会员 max_resolution 在 popover 展示
*/
import React, { useEffect } from "react"
import { Popover, Button, Tag, Space, Typography, Badge } from "antd"
@@ -17,27 +21,32 @@ import "./PointsBadge.css"
const { Text, Paragraph } = Typography
const MEMBER_LABEL: Record<string, string> = {
monthly: "月卡会员",
quarterly: "季卡会员",
yearly: "年卡会员",
}
const PointsBadge: React.FC = () => {
const navigate = useNavigate()
const { balance, init, loading } = usePointsStore()
const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore()
useEffect(() => {
if (!balance) init()
}, [balance, init])
const bal = balance?.balance ?? 0
// 余额:优先用 membership.points_balance(冗余字段),降级 balance.balance
const bal = membership?.points_balance ?? balance?.balance ?? 0
const lowBalance = bal > 0 && bal < 10
const zero = bal === 0
const isMember = !!balance?.is_member
const memberLabel = isMember
? balance?.member_type === "yearly"
? "年卡会员"
: balance?.member_type === "quarterly"
? "季卡会员"
: balance?.member_type === "monthly"
? "月卡会员"
: "付费会员"
: "免费会员"
const isMember = membership?.is_member ?? balance?.is_member ?? false
const memberKey =
membership?.member_type ??
(subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null)
const memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "付费会员" : "免费会员"
const maxRes = membership?.max_resolution
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
const popContent = (
<div className="xx-points-popover">
@@ -58,9 +67,16 @@ const PointsBadge: React.FC = () => {
</Paragraph>
)}
{balance?.member_expires_at && (
{!isMember && dailyUsage && freeRemain > 0 && (
<Text type="secondary" className="xx-points-expire">
今日剩余免费次数:{freeRemain}/{dailyUsage.free_clips_limit}
</Text>
)}
{balance?.member_expires_at && isMember && (
<Text type="secondary" className="xx-points-expire">
会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
{maxRes ? ` · ${maxRes}` : ""}
</Text>
)}
@@ -80,16 +96,16 @@ const PointsBadge: React.FC = () => {
type="primary"
icon={<ThunderboltOutlined />}
block
onClick={() => navigate("/app/points")}
onClick={() => navigate("/points/recharge")}
>
充值积分
</Button>
<Button block onClick={() => navigate("/app/points/transactions")}>
<Button block onClick={() => navigate("/points/transactions")}>
积分明细
<RightOutlined />
</Button>
{!isMember && (
<Button block type="link" onClick={() => navigate("/app/subscription")}>
<Button block type="link" onClick={() => navigate("/subscription")}>
<CrownOutlined /> 升级会员享折扣
</Button>
)}
@@ -1,31 +1,40 @@
/**
* 功能操作按钮旁的"消耗积分"提示
* 例:[生成配音] 💎 -1 积分
* - 根据 scene_key 自动读取规则
* - 免费用户自动计算 ×1.15 向上取整
* - 根据 scene_key 自动读取规则(来自 store.rules)
* - 免费用户自动计算 ×free_user_multiplier 向上取整
* - 会员 floor(base × points_discount)
* - 余额不足时显示红色告警 + 充值提示
*
* 使用:<PointsCost scene="ai_voice" units={1} />
* 字段对齐新契约:
* - rule.points_per_use → base_points
* - balance.free_clips_remaining → dailyUsage.free_clips_remaining
* - props.units → 保留兼容,新代码优先用 quantity
*/
import React, { useEffect, useMemo, useState } from "react"
import React, { useMemo } from "react"
import { Tooltip } from "antd"
import { WarningOutlined } from "@ant-design/icons"
import { usePointsStore } from "@/store/pointsStore"
import type { PointsSource } from "@/api/points/types"
import { getPointsRules } from "@/api/points"
import "./PointsCost.css"
interface Props {
/** 消耗场景 key */
scene: PointsSource
/** 单位数(分钟数/条数/张数),默认 1 */
units?: number
/** 数量(新字段),默认 1 */
quantity?: number
/** 预计时长(分钟),可选 */
durationMinutes?: number
/** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */
compact?: boolean
/** 余额不足时,是否显示充值提示 */
showRechargeHint?: boolean
/** 自定义 class */
className?: string
/**
* @deprecated 旧字段保留兼容,内部映射为 quantity
*/
units?: number
}
/** 单位中文 */
@@ -38,31 +47,26 @@ const UNIT_LABEL: Record<string, string> = {
const PointsCost: React.FC<Props> = ({
scene,
units = 1,
quantity,
units,
durationMinutes,
compact = false,
showRechargeHint = true,
className = "",
}) => {
const { balance, init } = usePointsStore()
const [rules, setRules] = useState<Awaited<ReturnType<typeof getPointsRules>> | null>(null)
const { balance, dailyUsage, rules, membership } = usePointsStore()
const qty = quantity ?? units ?? 1
useEffect(() => {
if (!balance) init()
if (!rules) {
getPointsRules()
.then(setRules)
.catch(() => {})
}
}, [balance, init, rules])
const { cost, isFreeQuota, rule, isFreeUser, insufficient } = useMemo(() => {
const { cost, isFreeQuota, rule, isFreeUser, insufficient, freeRemain } = useMemo(() => {
const isMem = membership?.is_member ?? balance?.is_member ?? false
if (!rules || !balance) {
return {
cost: 0,
isFreeQuota: false,
rule: null,
isFreeUser: !balance?.is_member,
isFreeUser: !isMem,
insufficient: false,
freeRemain: 0,
}
}
const rule = rules.rules.find((r) => r.scene_key === scene)
@@ -71,49 +75,54 @@ const PointsCost: React.FC<Props> = ({
cost: 0,
isFreeQuota: false,
rule: null,
isFreeUser: !balance.is_member,
isFreeUser: !isMem,
insufficient: false,
freeRemain: 0,
}
// 免费训练不扣费
if (rule.points_per_use === 0) {
if (rule.base_points === 0) {
return {
cost: 0,
isFreeQuota: false,
rule,
isFreeUser: !balance.is_member,
isFreeUser: !isMem,
insufficient: false,
freeRemain: 0,
}
}
// 智能混剪:首条30s=3分,每+30s +1
// 计算 base
let baseCost: number
if (scene === "ai_video" && rule.extra_per_30s) {
// units 当作"30s 段数"更简单;按分钟算:minutes 个 30s 段 - 1
const segments = Math.max(1, Math.ceil(units * 2)) // 1min = 2 segments
baseCost = rule.points_per_use + rule.extra_per_30s * (segments - 1)
const minutes = durationMinutes ?? qty
const segments = Math.max(1, Math.ceil(minutes * 2))
baseCost = rule.base_points + rule.extra_per_30s * (segments - 1)
} else {
baseCost = rule.points_per_use * Math.max(1, units)
baseCost = rule.base_points * Math.max(1, qty)
}
// 混剪 + 免费用户 + 今日有免费额度 → 免费
const isFree =
scene === "ai_video" && !balance.is_member && (balance.free_clips_remaining ?? 0) > 0
// 非会员 + 今日免费额度 → 免费
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
const isFree = scene === "ai_video" && !isMem && freeRemain > 0
const isFreeUser = !balance.is_member
const cost = isFree
? 0
: isFreeUser
? Math.ceil(baseCost * rules.free_user_multiplier)
: baseCost
const multiplier = rules.free_user_multiplier ?? 1.15
const cost = isFree ? 0 : isMem ? Math.floor(baseCost) : Math.ceil(baseCost * multiplier)
const insufficient = !isFree && balance.balance < cost
return { cost, isFreeQuota: isFree, rule, isFreeUser, insufficient }
}, [rules, balance, scene, units])
return {
cost,
isFreeQuota: isFree,
rule,
isFreeUser: !isMem,
insufficient,
freeRemain,
}
}, [rules, balance, dailyUsage, membership, scene, qty, durationMinutes])
if (!rule || !balance) {
return <span className={`xx-points-cost ${className}`} />
}
if (rule.points_per_use === 0) {
if (rule.base_points === 0) {
return (
<span className={`xx-points-cost free ${className}`}>
<span className="xx-points-tag-free">免费</span>
@@ -123,12 +132,10 @@ const PointsCost: React.FC<Props> = ({
if (isFreeQuota) {
return (
<Tooltip title={`今日免费额度剩余 ${balance.free_clips_remaining} 条,不扣积分`}>
<Tooltip title={`今日免费额度剩余 ${freeRemain} 条,不扣积分`}>
<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>
)}
{!compact && <span className="xx-points-desc">(今日剩余 {freeRemain} 条)</span>}
</span>
</Tooltip>
)
@@ -136,7 +143,7 @@ const PointsCost: React.FC<Props> = ({
const unitLabel = compact
? ""
: ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
: `/${qty > 1 ? `${qty}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
return (
<span
+21 -4
View File
@@ -39,7 +39,7 @@ const GeneratePage: React.FC = () => {
/* ── 表单状态 ── */
const formState = useGenerateFormState()
/* ── 积分状态 ── */
const { balance, init: initPoints } = usePointsStore()
const { balance, dailyUsage, rules, init: initPoints } = usePointsStore()
useEffect(() => {
initPoints()
}, [initPoints])
@@ -358,7 +358,14 @@ const GeneratePage: React.FC = () => {
const handleConfirmGenerate = useCallback(async () => {
// 积分预检查
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const check = hasEnoughPoints(balance ?? null, units)
const check = hasEnoughPoints(
balance ?? null,
units,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
)
if (!check.sufficient) {
message.error(check.reason ?? "积分不足,请充值")
return
@@ -397,6 +404,8 @@ const GeneratePage: React.FC = () => {
handleGenerate,
setCurrentStep,
balance,
dailyUsage,
rules,
])
/* ── 步骤导航 ── */
@@ -423,8 +432,16 @@ const GeneratePage: React.FC = () => {
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const pointsEstimate = useMemo(
() => hasEnoughPoints(balance ?? null, unitsForCost),
[unitsForCost, balance],
() =>
hasEnoughPoints(
balance ?? null,
unitsForCost,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
),
[unitsForCost, balance, dailyUsage, rules],
)
const insufficientPoints = !pointsEstimate.sufficient
+60 -40
View File
@@ -1,58 +1,74 @@
/**
* 生成前积分消耗估算与余额校验
* 用于步骤4「确认生成」按钮前展示本次消耗,积分不足时禁用并提示充值
* 用于步骤「确认生成」按钮前展示本次消耗,积分不足时禁用并提示充值
*
* 契约对齐(2026-09-16 最终版):
* - balance 不再包含 free_clips_* 字段,免费额度通过 dailyUsage 传入
* - 乘数逻辑:非会员 ceil(base × free_user_multiplier),会员 floor(base × points_discount)
* - points_discount 从 subscription.plans.points_discount 获取(mock 阶段用 1 占位)
*/
import type { PointsBalance } from "@/api/points/types"
import type { PointsBalance, DailyUsage } from "@/api/points/types"
import type { SubscriptionPlan } from "@/api/subscription/types"
/** 生成单条视频消耗积分(基准) */
/** 生成单条视频基准积分(ai_video base_points=8,但向导默认使用短片段,先保守按 3 估算) */
export const BASE_VIDEO_POINTS = 3
/**
* 估算生成任务的积分消耗
* @param videoCount 视频条数(批量模式)
* @param memberMultiplier 会员倍率(免费用户 1.15)
*/
export function estimateGenerateCost(videoCount: number, memberMultiplier = 1): number {
const raw = BASE_VIDEO_POINTS * videoCount * memberMultiplier
// 向上取整,避免小数
return Math.ceil(raw)
}
/** 默认免费用户倍率(后端 free_user_multiplier,mock 默认 1.15) */
const DEFAULT_FREE_MULTIPLIER = 1.15
/**
* 判断积分是否充足(含每日免费额度)
* @returns sufficient=true 表示可以继续生成;false 需要提示充值
*/
export function hasEnoughPoints(
balance: PointsBalance | null,
videoCount: number,
): {
export interface HasEnoughPointsResult {
sufficient: boolean
cost: number
reason?: string
freeClipsUsed?: number
freeClipsRemaining?: number
} {
}
/**
* 判断积分是否充足(含每日免费额度)
* @param balance 积分余额
* @param videoCount 视频条数(批量模式下为 variant 数)
* @param dailyUsage 每日免费额度(可选;不传视为 0)
* @param plans 当前可用订阅计划列表(用于计算会员积分折扣;mock 传 [])
* @param currentPlanId 当前用户 plan_id(free/monthly/quarterly/yearly)
* @param freeMultiplier 免费用户倍率,默认 1.15
*/
export function hasEnoughPoints(
balance: PointsBalance | null,
videoCount: number,
dailyUsage?: DailyUsage | null,
plans: SubscriptionPlan[] = [],
currentPlanId: string = "free",
freeMultiplier: number = DEFAULT_FREE_MULTIPLIER,
): HasEnoughPointsResult {
if (!balance) {
// 未登录或未加载:不拦截,后端会校验
return { sufficient: true, cost: estimateGenerateCost(videoCount) }
return { sufficient: true, cost: estimateGenerateCost(videoCount, 1) }
}
const isMember = balance.is_member
const multiplier = isMember ? 1 : 1.15
const cost = estimateGenerateCost(videoCount, multiplier)
const isMember = balance.is_member && currentPlanId !== "free"
const plan = plans.find((p) => p.plan_id === currentPlanId)
const multiplier = isMember ? (plan?.points_discount ?? 1) : freeMultiplier
const raw = BASE_VIDEO_POINTS * videoCount * multiplier
const cost = isMember ? Math.floor(raw) : Math.ceil(raw)
// 免费用户优先使用每日免费额度
if (!isMember && balance.free_clips_remaining && balance.free_clips_remaining > 0) {
const freeUsed = Math.min(balance.free_clips_remaining, videoCount)
const remainingAfterFree = videoCount - freeUsed
const paidCost = estimateGenerateCost(remainingAfterFree, multiplier)
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
// 非会员优先用每日免费额度
if (!isMember && freeRemain > 0) {
const freeUsed = Math.min(freeRemain, videoCount)
const afterFree = videoCount - freeUsed
const paidCost =
afterFree === 0
? 0
: isMember
? Math.floor(BASE_VIDEO_POINTS * afterFree * multiplier)
: Math.ceil(BASE_VIDEO_POINTS * afterFree * multiplier)
if (paidCost === 0) {
// 完全用免费额度
return {
sufficient: true,
cost: 0,
freeClipsUsed: freeUsed,
freeClipsRemaining: balance.free_clips_remaining - freeUsed,
freeClipsRemaining: freeRemain - freeUsed,
}
}
if (balance.balance >= paidCost) {
@@ -60,24 +76,28 @@ export function hasEnoughPoints(
sufficient: true,
cost: paidCost,
freeClipsUsed: freeUsed,
freeClipsRemaining: balance.free_clips_remaining - freeUsed,
freeClipsRemaining: freeRemain - freeUsed,
}
}
return {
sufficient: false,
cost: paidCost,
reason: `积分不足:本次需 ${paidCost} 积分(使用 ${freeUsed} 次免费额度后),当前余额 ${balance.balance},还差 ${paidCost - balance.balance} 积分`,
reason: `积分不足:本次需 ${paidCost} 积分(已用 ${freeUsed} 次免费额度),当前余额 ${balance.balance},还差 ${paidCost - balance.balance} 积分`,
freeClipsUsed: freeUsed,
}
}
// 付费会员或免费额度用完
if (balance.balance >= cost) {
return { sufficient: true, cost }
}
if (balance.balance >= cost) return { sufficient: true, cost }
return {
sufficient: false,
cost,
reason: `积分不足:本次需 ${cost} 积分,当前余额 ${balance.balance},还差 ${cost - balance.balance} 积分`,
}
}
/**
* 估算生成任务的积分消耗(导出给 UI 直接使用)
*/
export function estimateGenerateCost(videoCount: number, multiplier = 1): number {
return Math.ceil(BASE_VIDEO_POINTS * videoCount * multiplier)
}
+82 -40
View File
@@ -1,6 +1,11 @@
/**
* 积分中心主页(/app/points)
* 展示余额、会员信息、本月统计、快捷入口
* 积分中心主页(/points 或 /app/points)
* 展示余额、会员信息、免费额度、快捷入口、最近流水
*
* 字段对齐新契约(2026-09-16):
* - balance 不含 free_clips_*,从 dailyUsage 取
* - subscription.member_type → plan_id(free/monthly/quarterly/yearly)
* - subscription.member_type_name → 前端 PLAN_LABEL 映射
*/
import React, { useEffect } from "react"
import {
@@ -30,6 +35,7 @@ import {
import { useNavigate } from "react-router-dom"
import PageHead from "@/components/layout/PageHead"
import { usePointsStore } from "@/store/pointsStore"
import { PLAN_LABEL } from "@/api/subscription/types"
import "./Points.css"
const { Text } = Typography
@@ -46,32 +52,57 @@ const SOURCE_NAME: Record<string, string> = {
ai_rewrite: "AI 改写",
ai_title: "AI 标题",
ai_cover: "AI 封面",
subscription_bonus: "会员赠送",
admin_adjust: "管理员调整",
refund: "失败退还",
sign_up: "注册赠送",
bind_phone: "绑定手机",
gift: "活动赠送",
admin: "管理员调整",
}
const sourceLabel = (src: string): string => {
if (src.startsWith("refund:")) return `${SOURCE_NAME[src.slice(7)] || src.slice(7)}退款`
return SOURCE_NAME[src] || src
}
/** 会员标签:优先取 membership.member_type,降级 subscription.plan_id */
const memberKey = (
membership: { member_type: string | null } | null,
subscription: { plan_id: string } | null,
): string | null =>
membership?.member_type ??
(subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null)
const memberLabel = (
membership: { member_type: string | null } | null,
subscription: { plan_id: string; plan_name?: string } | null,
): string => {
const key = memberKey(membership, subscription)
if (!key) return "免费会员"
return PLAN_LABEL[key as keyof typeof PLAN_LABEL] || subscription?.plan_name || "付费会员"
}
const PointsCenter: React.FC = () => {
const navigate = useNavigate()
const { balance, subscription, init, loading } = usePointsStore()
const { balance, dailyUsage, membership, subscription, rules, init, loading } = usePointsStore()
useEffect(() => {
init()
}, [init])
const bal = balance?.balance ?? 0
const bal = membership?.points_balance ?? balance?.balance ?? 0
const earned = balance?.total_earned ?? 0
const spent = balance?.total_spent ?? 0
const isMember = !!balance?.is_member
const freeUsed = balance?.free_clips_used ?? 0
const freeLimit = balance?.free_clips_limit ?? 2
const freeRemain = balance?.free_clips_remaining ?? (isMember ? 0 : 2)
const isMember = membership?.is_member ?? balance?.is_member ?? false
// 近 5 条流水 mock(实际从 transactions 页加载)
// 免费额度从 dailyUsage 取
const freeUsed = dailyUsage?.free_clips_used ?? 0
const freeLimit = dailyUsage?.free_clips_limit ?? (isMember ? 0 : 3)
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
// 最近流水 mock(后续可改为调用 getPointsTransactions(1,5))
const recentTx = [
{ type: "spend", source: "ai_voice", amount: 1, time: "今天 10:30" },
{ type: "spend", source: "ai_video", amount: 3, time: "今天 09:15" },
{ type: "earn", source: "task_reward", amount: 20, time: "昨天" },
{ type: "deduct" as const, source: "ai_voice", amount: 1, time: "今天 10:30" },
{ type: "deduct" as const, source: "ai_video", amount: 3, time: "今天 09:15" },
{ type: "add" as const, source: "recharge", amount: 100, time: "昨天" },
]
return (
@@ -79,16 +110,15 @@ const PointsCenter: React.FC = () => {
<PageHead
title="积分中心"
description="管理积分余额、查看流水、充值使用"
actions={
<Space>
<Button icon={<FileTextOutlined />} onClick={() => navigate("/app/points/rules")}>
<Button icon={<FileTextOutlined />} onClick={() => navigate("/points/rules")}>
积分规则
</Button>
<Button
type="primary"
icon={<WalletOutlined />}
onClick={() => navigate("/app/subscription")}
onClick={() => navigate("/points/recharge")}
>
充值积分
</Button>
@@ -111,12 +141,7 @@ const PointsCenter: React.FC = () => {
<Space size={8} wrap>
{isMember ? (
<Tag color="gold" icon={<CrownFilled />} style={{ padding: "4px 10px" }}>
{subscription?.member_type === "yearly"
? "年卡"
: subscription?.member_type === "quarterly"
? "季卡"
: "月卡"}
会员
{memberLabel(membership, subscription)}
</Tag>
) : (
<Tag
@@ -130,19 +155,24 @@ const PointsCenter: React.FC = () => {
免费会员
</Tag>
)}
{balance?.member_expires_at && (
{balance?.member_expires_at && isMember && (
<Text style={{ color: "rgba(255,255,255,0.85)", fontSize: 12 }}>
到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</Text>
)}
{membership?.max_resolution && isMember && (
<Text style={{ color: "rgba(255,255,255,0.85)", fontSize: 12 }}>
· {membership.max_resolution}
</Text>
)}
{!isMember && (
<Button
size="small"
type="primary"
onClick={() => navigate("/app/subscription")}
onClick={() => navigate("/subscription")}
style={{ background: "#fff", color: "#7c3aed", borderColor: "#fff" }}
>
<CrownFilled /> 升级会员享 8 折
<CrownFilled /> 升级会员享折扣
</Button>
)}
</Space>
@@ -156,12 +186,24 @@ const PointsCenter: React.FC = () => {
<InfoCircleOutlined /> 今日免费混剪
</div>
<Progress
percent={Math.round((freeUsed / freeLimit) * 100)}
percent={freeLimit > 0 ? Math.round((freeUsed / freeLimit) * 100) : 0}
strokeColor={{ "0%": "#f59e0b", "100%": "#ef4444" }}
format={() => `${freeUsed}/${freeLimit} 条`}
/>
<Text style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
剩余 {freeRemain} 条免费混剪,超出部分消耗积分
{rules?.free_user_multiplier ? `(×${rules.free_user_multiplier} 倍率)` : ""}
{dailyUsage?.reset_at && (
<span>
{" "}
·{" "}
{new Date(dailyUsage.reset_at).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
重置
</span>
)}
</Text>
</div>
)}
@@ -205,7 +247,7 @@ const PointsCenter: React.FC = () => {
<Card>
<Statistic
title={isMember ? "会员等级" : "今日免费剩余"}
value={isMember ? (subscription?.member_type_name ?? "付费会员") : `${freeRemain} 条`}
value={isMember ? memberLabel(membership, subscription) : `${freeRemain} 条`}
prefix={<CrownFilled style={{ color: "#f59e0b" }} />}
valueStyle={{ color: "#f59e0b" }}
/>
@@ -223,7 +265,7 @@ const PointsCenter: React.FC = () => {
最近流水
</Space>
}
extra={<a onClick={() => navigate("/app/points/transactions")}>查看全部 →</a>}
extra={<a onClick={() => navigate("/points/transactions")}>查看全部 →</a>}
>
{recentTx.length === 0 ? (
<Empty description="暂无积分流水" />
@@ -237,22 +279,22 @@ const PointsCenter: React.FC = () => {
<Avatar
size="small"
style={{
background: item.type === "earn" ? "#d1fae5" : "#fee2e2",
color: item.type === "earn" ? "#059669" : "#dc2626",
background: item.type === "add" ? "#d1fae5" : "#fee2e2",
color: item.type === "add" ? "#059669" : "#dc2626",
}}
icon={item.type === "earn" ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
icon={item.type === "add" ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
/>
}
title={SOURCE_NAME[item.source] ?? item.source}
title={sourceLabel(item.source)}
description={item.time}
/>
<div
style={{
color: item.type === "earn" ? "#10b981" : "#ef4444",
color: item.type === "add" ? "#10b981" : "#ef4444",
fontWeight: 700,
}}
>
{item.type === "earn" ? "+" : "-"}
{item.type === "add" ? "+" : "-"}
{item.amount}
</div>
</List.Item>
@@ -269,7 +311,7 @@ const PointsCenter: React.FC = () => {
size="large"
type="primary"
icon={<WalletOutlined />}
onClick={() => navigate("/app/subscription")}
onClick={() => navigate("/points/recharge")}
>
充值积分
</Button>
@@ -277,7 +319,7 @@ const PointsCenter: React.FC = () => {
block
size="large"
icon={<HistoryOutlined />}
onClick={() => navigate("/app/points/transactions")}
onClick={() => navigate("/points/transactions")}
>
积分明细
</Button>
@@ -285,7 +327,7 @@ const PointsCenter: React.FC = () => {
block
size="large"
icon={<CrownFilled />}
onClick={() => navigate("/app/subscription")}
onClick={() => navigate("/subscription")}
>
{isMember ? "续费/升级会员" : "升级付费会员"}
</Button>
@@ -293,7 +335,7 @@ const PointsCenter: React.FC = () => {
block
size="large"
icon={<FileTextOutlined />}
onClick={() => navigate("/app/points/rules")}
onClick={() => navigate("/points/rules")}
>
积分消耗规则
</Button>
+85 -57
View File
@@ -1,56 +1,72 @@
/**
* 积分充值页(/app/points/recharge)
* 积分充值页(/points/recharge)
* 单独展示积分包,供入口直接跳转使用
* 主 Plans 页面也有充值区,这里提供独立 URL 方便从"积分不足"弹窗跳转
*
* 字段对齐新契约(2026-09-16):
* - package 用 code 做唯一键(替代 id)
* - 价格单位为 cents,折后价 = price_cents × (user_discount ?? 1)
* - 下单接口返回 pay_params(当前为 {},支付通道未接入)
* - 余额优先取 membership.points_balance,降级 balance.balance
*/
import React, { useEffect, useState } from "react"
import { Card, Col, Row, Button, Tag, Typography, Space, Modal, message, Tooltip } from "antd"
import {
Card,
Col,
Row,
Button,
Tag,
Typography,
Space,
Modal,
message,
Tooltip,
Alert,
} from "antd"
import { ThunderboltOutlined, SafetyCertificateOutlined, CrownFilled } from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import PageHead from "@/components/layout/PageHead"
import { usePointsStore } from "@/store/pointsStore"
import { createPointsOrder, getPointsPackages } from "@/api/points"
import type { PointsPackage } from "@/api/points/types"
import type { PointsPackage, PointsPackagesResponse } from "@/api/points/types"
import { getDiscountPriceCents } from "@/api/points/types"
import "./Points.css"
const { Title, Text, Paragraph } = Typography
const PointsRecharge: React.FC = () => {
const navigate = useNavigate()
const { balance, init } = usePointsStore()
const [packages, setPackages] = useState<PointsPackage[]>([])
const { balance, membership, init } = usePointsStore()
const [packagesResp, setPackagesResp] = useState<PointsPackagesResponse | null>(null)
const [buying, setBuying] = useState<string | null>(null)
useEffect(() => {
init()
getPointsPackages()
.then((r) => setPackages(r.packages))
.then((r) => setPackagesResp(r))
.catch(() => {})
}, [init])
const getPackPrice = (pkg: PointsPackage): number => {
const mt = balance?.member_type ?? "free"
type DiscountKey =
| "discounted_price_for_free"
| "discounted_price_for_monthly"
| "discounted_price_for_quarterly"
| "discounted_price_for_yearly"
const key = `discounted_price_for_${mt}` as DiscountKey
return pkg[key] ?? pkg.price
}
const packages = packagesResp?.packages ?? []
const userDiscount = packagesResp?.user_discount ?? null
const isMember = membership?.is_member ?? balance?.is_member ?? false
const currentBalance = membership?.points_balance ?? balance?.balance ?? 0
const handleBuy = async (pkg: PointsPackage) => {
try {
setBuying(pkg.id)
const order = await createPointsOrder({ package_id: pkg.id })
setBuying(pkg.code)
const order = await createPointsOrder({ package_id: pkg.code })
Modal.info({
title: "支付功能开发中",
icon: <SafetyCertificateOutlined />,
content: (
<div>
<Paragraph>
订单已创建({order.id.slice(0, 16)}…),金额{" "}
<b>¥{(order.price_cents / 100).toFixed(2).replace(/\.00$/, "")}</b>。
订单已创建({order.id.slice(0, 16)}…),购买 {pkg.points} 积分,金额{" "}
<b>¥{(order.amount_cents / 100).toFixed(2).replace(/\.00$/, "")}</b>。
{order.points_amount !== undefined && ` 到账 ${order.points_amount} 积分。`}
{order.expire_at && (
<span> 有效期至 {new Date(order.expire_at).toLocaleDateString("zh-CN")}。</span>
)}
微信/支付宝支付正在接入中。
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
@@ -61,8 +77,9 @@ const PointsRecharge: React.FC = () => {
okText: "知道了",
})
} catch (e) {
const err = e as { message?: string }
message.error(err?.message ?? "下单失败")
const err = e as { response?: { data?: { error?: { message?: string } } }; message?: string }
const msg = err?.response?.data?.error?.message || err?.message || "下单失败"
message.error(msg)
} finally {
setBuying(null)
}
@@ -73,51 +90,61 @@ const PointsRecharge: React.FC = () => {
<PageHead
title="积分充值"
description="积分永久有效,可用于全部 AI 功能;付费会员享折扣"
actions={
<Space>
{!balance?.is_member && (
<Button icon={<CrownFilled />} onClick={() => navigate("/app/subscription")}>
升级会员 8 折起
{!isMember && (
<Button icon={<CrownFilled />} onClick={() => navigate("/subscription")}>
升级会员享折扣
</Button>
)}
<Button onClick={() => navigate("/app/points/transactions")}>积分明细</Button>
<Button onClick={() => navigate("/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>
<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 /> {currentBalance.toLocaleString()}
</div>
</Space>
</Card>
</div>
</Space>
</Card>
{userDiscount !== null && userDiscount < 1 && (
<Alert
type="success"
showIcon
style={{ marginBottom: 16 }}
message={`您当前享 ${Math.round(userDiscount * 100) / 10} 折优惠`}
description="会员/优惠已自动应用到下方价格"
/>
)}
<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
const priceCents = getDiscountPriceCents(pkg, userDiscount)
const originalCents = pkg.price_cents
const discount =
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
const unit = priceCents / 100 / pkg.points
const isHot = pkg.unit_price < 0.1 // 单价低于 0.1 元/分视为热门
return (
<Col xs={24} sm={12} md={8} key={pkg.id}>
<Col xs={24} sm={12} md={8} key={pkg.code}>
<Card
className={`xx-pkg-card ${pkg.id === "basic_pack" ? "recommended" : ""} ${discount > 0 ? "has-discount" : ""}`}
className={`xx-pkg-card ${isHot ? "recommended" : ""} ${discount > 0 ? "has-discount" : ""}`}
hoverable
>
{pkg.id === "basic_pack" && <div className="xx-pkg-badge">热门</div>}
{isHot && <div className="xx-pkg-badge">热门</div>}
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
会员{10 - discount / 10}折
{Math.round((priceCents / originalCents) * 10) / 1}折
</Tag>
)}
<div className="xx-pkg-name">{pkg.name}</div>
@@ -127,17 +154,17 @@ const PointsRecharge: React.FC = () => {
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
{(priceCents / 100).toFixed(priceCents % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
</span>
{discount > 0 && (
<span className="xx-pkg-origin">¥{(pkg.price / 100).toFixed(0)}</span>
<span className="xx-pkg-origin">¥{(originalCents / 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}
type={isHot ? "primary" : "default"}
loading={buying === pkg.code}
onClick={() => handleBuy(pkg)}
size="large"
style={{ marginTop: 16 }}
@@ -153,12 +180,13 @@ const PointsRecharge: React.FC = () => {
<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>智能混剪:基础积分/条(≤30s),每加 30s 额外消耗</li>
<li>AI 配音 / 声音克隆合成:按分钟消耗积分</li>
<li>AI 数字人:按分钟消耗积分</li>
<li>抖音链接提取 / AI 改写 / AI 标题 / AI 封面:按次消耗</li>
<li>声音克隆训练:免费</li>
<li>免费用户每日 2 条混剪免费,其余 AI 功能消耗为会员价 ×1.15</li>
<li>免费用户每日若干条免费混剪,其余 AI 功能按会员价 ×1.15 消耗</li>
<li>会员享积分折扣(具体档位见会员中心)</li>
</ul>
<Tooltip title="具体规则以系统实际计算为准">
<Text type="secondary" style={{ fontSize: 12 }}>
+64 -45
View File
@@ -1,6 +1,13 @@
/**
* 积分明细页(/app/points/transactions)
* 积分明细页(/points/transactions)
* 分页展示积分流水,支持按类型/来源筛选
*
* 字段对齐新契约(2026-09-16):
* - 分页接口返回 {items, total, page, page_size}
* - type 仅 add/deduct;refund 通过 source=refund:xxx 前缀体现
* - source_name 字段已移除,中文名在前端 SOURCE_LABEL 映射
* - signed_amount 字段已移除,根据 type 显示 +/-
* - ref_id 在新契约中为 string(不再是 number)
*/
import React, { useEffect, useState, useCallback } from "react"
import {
@@ -29,14 +36,12 @@ 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" },
add: { text: "获得", color: "green" },
deduct: { text: "消耗", color: "red" },
}
const SOURCE_LABEL: Record<string, string> = {
recharge: "充值",
task_reward: "任务奖励",
ai_voice: "AI 配音",
ai_digital_human: "AI 数字人",
ai_video: "智能混剪",
@@ -46,9 +51,18 @@ const SOURCE_LABEL: Record<string, string> = {
ai_rewrite: "AI 文案改写",
ai_title: "AI 标题生成",
ai_cover: "AI 封面生成",
subscription_bonus: "会员赠送",
admin_adjust: "管理员调整",
refund: "失败退还",
sign_up: "注册赠送",
bind_phone: "绑定手机",
gift: "活动赠送",
admin: "管理员调整",
}
const sourceLabel = (s: string): { label: string; isRefund: boolean } => {
if (s.startsWith("refund:")) {
const inner = s.slice(7)
return { label: `${SOURCE_LABEL[inner] || inner}(退款)`, isRefund: true }
}
return { label: SOURCE_LABEL[s] || s, isRefund: false }
}
const PointsTransactions: React.FC = () => {
@@ -66,22 +80,29 @@ const PointsTransactions: React.FC = () => {
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)
// 新契约后端暂不支持 type/source/date/keyword 过滤参数,先前端过滤
const res = await getPointsTransactions(page, pageSize)
let items = res.items
if (type !== "all") {
items = items.filter((it) => it.type === type)
}
if (source !== "all") {
items = items.filter((it) => it.source === source || it.source === `refund:${source}`)
}
if (dateRange && dateRange[0] && dateRange[1]) {
const start = dateRange[0].startOf("day")
const end = dateRange[1].endOf("day")
items = items.filter((it) => {
const t = dayjs(it.created_at)
return t.isAfter(start) && t.isBefore(end)
})
}
if (keyword) {
const k = keyword.toLowerCase()
items = items.filter(
(it) =>
it.description.toLowerCase().includes(k) ||
(SOURCE_LABEL[it.source] ?? it.source).includes(keyword),
)
items = items.filter((it) => {
const sl = sourceLabel(it.source).label
return (it.description || "").toLowerCase().includes(k) || sl.toLowerCase().includes(k)
})
}
setData(items)
setTotal(res.total)
@@ -105,41 +126,41 @@ const PointsTransactions: React.FC = () => {
title: "类型",
dataIndex: "type",
width: 90,
render: (t: PointsTxType) => {
render: (t: PointsTxType, r: PointsTransaction) => {
if (r.source.startsWith("refund:")) {
return <Tag color="blue">退还</Tag>
}
const cfg = TYPE_LABEL[t]
return <Tag color={cfg.color}>{cfg.text}</Tag>
return <Tag color={cfg?.color || "default"}>{cfg?.text || t}</Tag>
},
},
{
title: "来源/场景",
dataIndex: "source",
width: 150,
render: (s: string, r: PointsTransaction) => (
<Space>
<Text>{r.source_name || SOURCE_LABEL[s] || s}</Text>
</Space>
),
width: 180,
render: (s: string) => <Text>{sourceLabel(s).label}</Text>,
},
{
title: "说明",
dataIndex: "description",
ellipsis: true,
render: (v: string) => v || "-",
},
{
title: "变动",
dataIndex: "signed_amount",
dataIndex: "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>
),
render: (v: number, r: PointsTransaction) => {
const isRefund = r.source.startsWith("refund:")
const positive = r.type === "add" || isRefund
return (
<span className={positive ? "xx-tx-earn" : "xx-tx-spend"}>
{positive ? "+" : "-"}
{v}
</span>
)
},
},
{
title: "余额",
@@ -159,13 +180,12 @@ const PointsTransactions: React.FC = () => {
<PageHead
title="积分明细"
description="查看积分收入、消耗与退还记录"
actions={
<Space>
<Button icon={<ReloadOutlined />} onClick={load}>
刷新
</Button>
<Button type="primary" onClick={() => navigate("/app/subscription")}>
<Button type="primary" onClick={() => navigate("/points/recharge")}>
充值积分
</Button>
</Space>
@@ -184,9 +204,8 @@ const PointsTransactions: React.FC = () => {
style={{ width: 120 }}
options={[
{ value: "all", label: "全部类型" },
{ value: "earn", label: "获得" },
{ value: "spend", label: "消耗" },
{ value: "refund", label: "退还" },
{ value: "add", label: "获得" },
{ value: "deduct", label: "消耗" },
]}
/>
<Select
+171 -12
View File
@@ -1,15 +1,28 @@
/**
* 账单管理页面
* 展示当前订阅信息 + 自动续费开关
* P1-3: antd Switch→自定义ToggleSwitch, antd Spin→自定义Spinner
* 展示当前订阅信息 + 自动续费开关 + 账单历史
*
* 字段对齐新契约(2026-09-16):
* - toggleAutoRenew 参数改为 {enabled} 对象
* - billing_cycle 仅 monthly/yearly(季卡走 monthly 周期 + 3 个月时长)
* - 新增账单历史表格:order_type / amount_cents / status / created_at
*/
import React, { useState, useEffect } from "react"
import { message } from "antd"
import { getCurrentSubscription, toggleAutoRenew } from "@/api/subscription"
import type { SubscriptionInfo } from "@/api/subscription"
import { message, Table, Tag, Card, Space, Button, Modal, Typography } from "antd"
import type { ColumnsType } from "antd/es/table"
import {
getCurrentSubscription,
toggleAutoRenew,
cancelSubscription,
getBillingRecords,
} from "@/api/subscription"
import type { SubscriptionInfo, BillingRecord } from "@/api/subscription/types"
import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types"
import PageHead from "@/components/layout/PageHead"
import "./Billing.css"
const { Text } = Typography
const formatDate = (iso: string): string => {
const d = new Date(iso)
return d.toLocaleDateString("zh-CN", {
@@ -51,21 +64,40 @@ const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) =>
</div>
)
const ORDER_TYPE_LABEL: Record<string, string> = {
subscribe: "新购",
renew: "续费",
upgrade: "升级",
downgrade: "降级",
refund: "退款",
}
const BILLING_STATUS_TAG: Record<string, { color: string; text: string }> = {
paid: { color: "green", text: "已支付" },
pending: { color: "orange", text: "待支付" },
failed: { color: "red", text: "支付失败" },
refunded: { color: "blue", text: "已退款" },
cancelled: { color: "default", text: "已取消" },
}
const Billing: React.FC = () => {
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
const [billingRecords, setBillingRecords] = useState<BillingRecord[]>([])
const [loading, setLoading] = useState(true)
const [recordsLoading, setRecordsLoading] = useState(false)
const [autoRenewChecked, setAutoRenewChecked] = useState(false)
const [autoRenewLoading, setAutoRenewLoading] = useState(false)
useEffect(() => {
loadData()
loadRecords()
}, [])
const loadData = async () => {
try {
const data = await getCurrentSubscription()
setSubscription(data)
setAutoRenewChecked(data.auto_renew)
setAutoRenewChecked(!!data.auto_renew)
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("加载订阅数据失败")
} finally {
@@ -73,22 +105,106 @@ const Billing: React.FC = () => {
}
}
const loadRecords = async () => {
try {
setRecordsLoading(true)
const list = await getBillingRecords()
setBillingRecords(Array.isArray(list) ? list : [])
} catch {
// 账单加载失败不阻塞主流程
setBillingRecords([])
} finally {
setRecordsLoading(false)
}
}
const handleToggleAutoRenew = async (checked: boolean) => {
setAutoRenewLoading(true)
try {
const res = await toggleAutoRenew(checked)
message.success(res.message)
const res = await toggleAutoRenew({ enabled: checked })
message.success(res?.message ?? (checked ? "已开启自动续费" : "已关闭自动续费"))
setAutoRenewChecked(checked)
if (subscription) {
setSubscription({ ...subscription, auto_renew: checked })
}
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
const e = err as { response?: { data?: { error?: { message?: string } } }; message?: string }
message.error(e?.response?.data?.error?.message || e?.message || "操作失败")
} finally {
setAutoRenewLoading(false)
}
}
const handleCancelSubscription = () => {
Modal.confirm({
title: "确认取消订阅?",
content: "取消后,当前周期结束时将不再自动续费。您仍可使用当前会员权益至到期日。",
okText: "确认取消",
okType: "danger",
cancelText: "我再想想",
onOk: async () => {
try {
await cancelSubscription()
message.success("已取消订阅,到期后不再续费")
await loadData()
} catch (err: unknown) {
const e = err as {
response?: { data?: { error?: { message?: string } } }
message?: string
}
message.error(e?.response?.data?.error?.message || e?.message || "取消失败")
}
},
})
}
const columns: ColumnsType<BillingRecord> = [
{
title: "时间",
dataIndex: "created_at",
width: 170,
render: (v: string) => formatDate(v),
},
{
title: "类型",
dataIndex: "order_type",
width: 100,
render: (v: string) => ORDER_TYPE_LABEL[v] || v || "-",
},
{
title: "套餐",
dataIndex: "plan_id",
width: 120,
render: (v: string) => (v ? PLAN_LABEL[v as keyof typeof PLAN_LABEL] || v : "-"),
},
{
title: "金额",
dataIndex: "amount_cents",
width: 110,
align: "right",
render: (v: number) => (
<Text strong style={{ fontVariantNumeric: "tabular-nums" }}>
¥{((v ?? 0) / 100).toFixed(2)}
</Text>
),
},
{
title: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => {
const cfg = BILLING_STATUS_TAG[v]
return <Tag color={cfg?.color || "default"}>{cfg?.text || v || "-"}</Tag>
},
},
{
title: "支付时间",
dataIndex: "paid_at",
width: 170,
render: (v?: string) => (v ? formatDate(v) : <Text type="secondary">—</Text>),
},
]
if (loading) {
return (
<div className="xx-billing-page">
@@ -109,19 +225,42 @@ const Billing: React.FC = () => {
<div className="xx-overview-details">
<div className="xx-overview-item">
<span className="xx-label">套餐</span>
<span className="xx-value">{subscription.plan_name}</span>
<span className="xx-value">
{subscription.plan_name ||
PLAN_LABEL[subscription.plan_id as keyof typeof PLAN_LABEL] ||
"-"}
</span>
</div>
<div className="xx-overview-item">
<span className="xx-label">计费周期</span>
<span className="xx-value">
{subscription.billing_cycle === "monthly" ? "月付" : "年付"}
{BILLING_CYCLE_LABEL[
subscription.billing_cycle as keyof typeof BILLING_CYCLE_LABEL
] ||
subscription.billing_cycle ||
"-"}
</span>
</div>
<div className="xx-overview-item">
<span className="xx-label">下次扣费</span>
<span className="xx-label">本期金额</span>
<span className="xx-value">¥{((subscription.amount ?? 0) / 100).toFixed(2)}</span>
</div>
<div className="xx-overview-item">
<span className="xx-label">周期开始</span>
<span className="xx-value">{formatDate(subscription.current_period_start)}</span>
</div>
<div className="xx-overview-item">
<span className="xx-label">下次扣费/到期</span>
<span className="xx-value">{formatDate(subscription.current_period_end)}</span>
</div>
</div>
{subscription.plan_id !== "free" && (
<Space style={{ marginTop: 16 }}>
<Button danger onClick={handleCancelSubscription}>
取消订阅
</Button>
</Space>
)}
</div>
{/* 自动续费 */}
@@ -145,6 +284,26 @@ const Billing: React.FC = () => {
</div>
</>
)}
{/* 账单历史 */}
<Card
title="账单历史"
style={{ marginTop: 16 }}
extra={
<Button size="small" onClick={loadRecords} loading={recordsLoading}>
刷新
</Button>
}
>
<Table<BillingRecord>
rowKey="id"
columns={columns}
dataSource={billingRecords}
loading={recordsLoading}
pagination={{ pageSize: 10, showSizeChanger: false }}
locale={{ emptyText: "暂无账单记录" }}
/>
</Card>
</div>
)
}
+250 -143
View File
@@ -1,8 +1,10 @@
/**
* 会员订阅 & 积分充值页
* v2 两档会员制:免费 vs 付费
* 付费三档:月¥19.9 / 季¥39.9(推荐)/ 年¥159
* 积分包:100/¥9.9、500/¥39、2000/¥129
* v3: 对齐后端最终契约(2026-09-16)
* - 订阅计划走 GET /subscription/plans(4 档:free/monthly/quarterly/yearly)
* - 积分包走 GET /points/packages,折后价 = price_cents × (user_discount ?? 1)
* - 当前身份/余额优先从 membership + dailyUsage 取,降级 balance
* - 暂保留 SUBSCRIPTION_PLANS_FALLBACK 常量,API 失败时降级
*/
import React, { useEffect, useMemo, useState } from "react"
import {
@@ -30,16 +32,19 @@ import {
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 { createPointsOrder, getPointsPackages } from "@/api/points"
import { getSubscriptionPlans, changePlan } from "@/api/subscription"
import type { PointsPackage, PointsPackagesResponse } from "@/api/points/types"
import { getDiscountPriceCents } from "@/api/points/types"
import type { SubscriptionPlan } from "@/api/subscription/types"
import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types"
import "./Plans.css"
const { Title, Text, Paragraph } = Typography
/** 免费会员权益 */
/** 免费会员权益(根据后端 features 动态展示,这里保留兜底) */
const FREE_FEATURES = [
{ include: true, text: "每日 2 条免费混剪" },
{ include: true, text: "每日免费混剪额度" },
{ include: true, text: "720p 导出分辨率" },
{ include: true, text: "AI 配音(×1.15 积分)" },
{ include: true, text: "AI 数字人(×1.15 积分)" },
@@ -49,91 +54,181 @@ const FREE_FEATURES = [
{ include: false, text: "去重检测报告" },
]
/** 付费会员权益 */
/** 付费会员权益(兜底) */
const PAID_FEATURES = [
{ include: true, text: "无限次智能混剪" },
{ include: true, text: "智能混剪按会员折扣积分" },
{ include: true, text: "最高 1080p 导出" },
{ include: true, text: "全部 AI 功能(标准积分价)" },
{ include: true, text: "全部 AI 功能(会员折扣积分)" },
{ include: true, text: "声音克隆训练免费" },
{ include: true, text: "积分购买最低 8 折" },
{ include: true, text: "积分购买最低折扣" },
{ include: true, text: "批量导出" },
{ include: true, text: "多平台一键发布" },
{ include: true, text: "去重检测报告" },
]
/** 旧 SUBSCRIPTION_PLANS 兜底(API 不可用时) */
const SUBSCRIPTION_PLANS_FALLBACK = [
{
id: "monthly" as const,
name: "月卡",
price_cents: 1990,
per_month_yuan: "19.9",
savings_percent: 0,
recommended: false,
billing_label: "/月",
billing_cycle: "monthly" as const,
},
{
id: "quarterly" as const,
name: "季卡",
price_cents: 3990,
per_month_yuan: "13.3",
savings_percent: 33,
recommended: true,
billing_label: "/季",
billing_cycle: "monthly" as const,
},
{
id: "yearly" as const,
name: "年卡",
price_cents: 15900,
per_month_yuan: "13.25",
savings_percent: 34,
recommended: false,
billing_label: "/年",
billing_cycle: "yearly" as const,
},
]
const formatYuan = (cents: number) =>
`¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}`
const Plans: React.FC = () => {
const navigate = useNavigate()
const { balance, init } = usePointsStore()
const { balance, dailyUsage, membership, subscription, init } = usePointsStore()
const [plans, setPlans] = useState<SubscriptionPlan[]>([])
const [packagesResp, setPackagesResp] = useState<PointsPackagesResponse | null>(null)
const [subscribing, setSubscribing] = useState(false)
const [buying, setBuying] = useState<string | null>(null)
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()
// 拉取订阅计划
getSubscriptionPlans()
.then((r) => {
const paid = r.plans.filter((p) => p.plan_id !== "free")
setPlans(paid)
// 默认选季卡,没有就选第一个
const hasQuarterly = paid.some((p) => p.plan_id === "quarterly")
if (!hasQuarterly && paid.length > 0) setSelectedBilling(paid[0].plan_id as "monthly")
})
.catch(() => {
// 降级
})
getPointsPackages()
.then((r) => setPackages(r.packages))
.then((r) => setPackagesResp(r))
.catch(() => {})
}, [init])
const isMember = !!balance?.is_member
const memberType = balance?.member_type ?? null
const packages = packagesResp?.packages ?? []
const userDiscount = packagesResp?.user_discount ?? 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 isMember = membership?.is_member ?? balance?.is_member ?? false
// 当前会员档位:优先 membership.member_type,降级 subscription.plan_id
const memberPlanId =
membership?.member_type ??
(subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null)
const bal = membership?.points_balance ?? balance?.balance ?? 0
const freeUsed = dailyUsage?.free_clips_used ?? 0
const freeLimit = dailyUsage?.free_clips_limit ?? (isMember ? 0 : 3)
const freeRemain = dailyUsage?.free_clips_remaining ?? (isMember ? 0 : freeLimit - freeUsed)
/** 统一的可选付费档位(API 返回 + 兜底) */
const billingOptions = useMemo(() => {
if (plans.length > 0) {
return plans.map((p) => {
const id = p.plan_id as "monthly" | "quarterly" | "yearly"
const perMonth =
p.duration_days > 0
? (p.price_cents / 100 / (p.duration_days / 30)).toFixed(1)
: (p.monthly_price_cents / 100).toFixed(1)
const monthlyCents = p.monthly_price_cents || p.price_cents
const savings =
p.price_cents > 0 && monthlyCents > 0
? Math.max(
0,
Math.round((1 - p.price_cents / (monthlyCents * (p.duration_days / 30))) * 100),
)
: 0
return {
id,
name: p.name,
price_cents: p.price_cents,
per_month_yuan: perMonth,
savings_percent: savings,
recommended: id === "quarterly",
billing_label: id === "yearly" ? "/年" : id === "quarterly" ? "/季" : "/月",
billing_cycle: (id === "yearly" ? "yearly" : "monthly") as "monthly" | "yearly",
}
})
}
return SUBSCRIPTION_PLANS_FALLBACK
}, [plans])
const selectedPlan = useMemo(
() => SUBSCRIPTION_PLANS.find((p) => p.id === selectedBilling)!,
[selectedBilling],
() => billingOptions.find((p) => p.id === selectedBilling) ?? billingOptions[0],
[billingOptions, 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)
// }
if (!selectedPlan) return
try {
setSubscribing(true)
await changePlan({
target_plan_id: selectedPlan.id,
billing_cycle: selectedPlan.billing_cycle,
})
Modal.success({
title: "订阅已提交",
icon: <SafetyCertificateOutlined />,
content: `已为您切换到 ${selectedPlan.name},${BILLING_CYCLE_LABEL[selectedPlan.billing_cycle]} ${formatYuan(selectedPlan.price_cents)}。支付通道接入中,正式上线后会自动扣费。`,
okText: "知道了",
})
} catch (e) {
const err = e as { response?: { data?: { error?: { message?: string } } }; message?: string }
// 支付未接入阶段,保持演示体验
Modal.confirm({
title: "支付功能开发中",
icon: <SafetyCertificateOutlined />,
content:
err?.response?.data?.error?.message ||
"微信/支付宝支付正在接入中,完成后会第一时间通知。是否返回首页继续使用免费功能?",
okText: "返回首页",
cancelText: "留在此页",
onOk: () => navigate("/app/dashboard"),
})
} finally {
setSubscribing(false)
}
}
const handleBuyPoints = async (pkg: PointsPackage) => {
try {
setBuying(pkg.id)
const order = await createPointsOrder({ package_id: pkg.id })
setBuying(pkg.code)
const order = await createPointsOrder({ package_id: pkg.code })
Modal.info({
title: "支付功能开发中",
icon: <ThunderboltOutlined />,
content: (
<div>
<Paragraph>
订单 <Text code>{order.id.slice(0, 16)}…</Text> 已创建,金额{" "}
<b>{formatYuan(order.price_cents)}</b>,
订单 <Text code>{order.id.slice(0, 16)}…</Text> 已创建,购买 {pkg.points} 积分,金额{" "}
<b>{formatYuan(order.amount_cents)}</b>,
{order.points_amount !== undefined && `到账 ${order.points_amount} 积分。`}
微信/支付宝支付正在接入中,正式上线后可直接付款。
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
@@ -144,8 +239,8 @@ const Plans: React.FC = () => {
okText: "知道了",
})
} catch (e) {
const err = e as { message?: string }
message.error(err?.message ?? "创建订单失败")
const err = e as { response?: { data?: { error?: { message?: string } } }; message?: string }
message.error(err?.response?.data?.error?.message || err?.message || "创建订单失败")
} finally {
setBuying(null)
}
@@ -156,7 +251,6 @@ const Plans: React.FC = () => {
<PageHead
title="会员与积分"
description="开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
actions={
<Space>
<Button
@@ -170,77 +264,75 @@ const Plans: React.FC = () => {
/>
{/* 当前状态卡片 */}
{balance && (
<Card className="xx-current-status" bordered={false}>
<Row align="middle" gutter={24}>
<Col flex="auto">
<Space size="large" wrap>
<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>
<Text type="secondary">当前身份</Text>
{isMember ? (
<Tag
color="gold"
icon={<CrownFilled />}
style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}
>
{memberPlanId
? PLAN_LABEL[memberPlanId as keyof typeof PLAN_LABEL] || "付费会员"
: "付费会员"}
</Tag>
) : (
<Tag style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}>免费会员</Tag>
)}
{balance?.member_expires_at && isMember && (
<Text type="secondary" style={{ marginLeft: 8 }}>
到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</Text>
)}
{membership?.max_resolution && (
<Text type="secondary" style={{ marginLeft: 8 }}>
· 最高 {membership.max_resolution}
</Text>
)}
</div>
</div>
<div>
<Text type="secondary">可用积分</Text>
<div className="xx-current-balance">
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
<span className="xx-current-balance-val">{bal}</span>
</div>
</div>
{!isMember && freeLimit > 0 && (
<div>
<Text type="secondary">今日免费混剪</Text>
<div>
{isMember ? (
<Tag
color="gold"
icon={<CrownFilled />}
style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}
>
{memberType === "yearly"
? "年卡"
: memberType === "quarterly"
? "季卡"
: "月卡"}
会员
<Text strong>{freeUsed}</Text>
<Text type="secondary"> / {freeLimit} 条</Text>
{!isMember && (
<Tag color="blue" style={{ marginLeft: 8 }}>
剩余 {freeRemain} 条
</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>
)}
)}
</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 }}>
@@ -250,7 +342,7 @@ const Plans: React.FC = () => {
{/* 计费周期切换 */}
<div className="xx-billing-switch">
{SUBSCRIPTION_PLANS.map((p) => (
{billingOptions.map((p) => (
<button
key={p.id}
type="button"
@@ -263,7 +355,11 @@ const Plans: React.FC = () => {
<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>
<span className="xx-billing-amount">
{(p.price_cents / 100)
.toFixed(p.price_cents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")}
</span>
</div>
<div className="xx-billing-sub">
≈¥{p.per_month_yuan}/月
@@ -303,8 +399,8 @@ const Plans: React.FC = () => {
</li>
))}
</ul>
<Button block size="large" disabled>
当前方案
<Button block size="large" disabled={!isMember ? false : true}>
{!isMember ? "当前方案" : "免费方案"}
</Button>
</Card>
</Col>
@@ -317,15 +413,21 @@ const Plans: React.FC = () => {
</div>
<div className="xx-plan-head">
<Title level={4} style={{ margin: 0, color: "#7c3aed" }}>
<CrownFilled style={{ color: "#f59e0b" }} /> 付费会员
<CrownFilled style={{ color: "#f59e0b" }} /> {selectedPlan?.name || "付费会员"}
</Title>
<div className="xx-plan-price">
<span className="currency">¥</span>
<span className="amount">{selectedPlan.price_yuan}</span>
<span className="period">{selectedPlan.billing_label}</span>
<span className="amount">
{selectedPlan
? (selectedPlan.price_cents / 100)
.toFixed(selectedPlan.price_cents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")
: "—"}
</span>
<span className="period">{selectedPlan?.billing_label || ""}</span>
</div>
<Text type="secondary">
折合 ¥{selectedPlan.per_month_yuan}/月 · 解锁全部 AI 能力
折合 ¥{selectedPlan?.per_month_yuan}/月 · 解锁全部 AI 能力
</Text>
</div>
<Divider style={{ margin: "16px 0" }} />
@@ -342,11 +444,11 @@ const Plans: React.FC = () => {
size="large"
type="primary"
loading={subscribing}
disabled={isMember && memberType === selectedBilling}
disabled={isMember && memberPlanId === selectedBilling}
onClick={handleSubscribe}
icon={<ThunderboltOutlined />}
>
{isMember && memberType === selectedBilling
{isMember && memberPlanId === selectedBilling
? "当前方案"
: isMember
? "续费/升级"
@@ -373,19 +475,22 @@ const Plans: React.FC = () => {
<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
const priceCents = getDiscountPriceCents(pkg, userDiscount)
const originalCents = pkg.price_cents
const discount =
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
const unit = priceCents / 100 / pkg.points
const isHot = pkg.unit_price < 0.1
return (
<Col xs={24} sm={8} key={pkg.id}>
<Col xs={24} sm={8} key={pkg.code}>
<Card
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${pkg.id === "basic_pack" ? "recommended" : ""}`}
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`}
hoverable
>
{pkg.id === "basic_pack" && <div className="xx-pkg-badge">热门</div>}
{isHot && <div className="xx-pkg-badge">热门</div>}
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
会员{10 - discount / 10}折
{Math.round((priceCents / originalCents) * 10) / 1}折
</Tag>
)}
<div className="xx-pkg-name">{pkg.name}</div>
@@ -395,17 +500,19 @@ const Plans: React.FC = () => {
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
{(priceCents / 100)
.toFixed(priceCents % 100 === 0 ? 0 : 1)
.replace(/\.0$/, "")}
</span>
{discount > 0 && (
<span className="xx-pkg-origin">¥{(pkg.price / 100).toFixed(0)}</span>
<span className="xx-pkg-origin">¥{(originalCents / 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}
type={isHot ? "primary" : "default"}
loading={buying === pkg.code}
onClick={() => handleBuyPoints(pkg)}
style={{ marginTop: 12 }}
>
@@ -1,11 +1,10 @@
/**
* 升级/降级/续费页面
* P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件
*/
import React from "react"
import { Modal } from "@/components/ui"
import { useNavigate } from "react-router-dom"
import type { PlanType } from "@/api/subscription"
import type { PlanId } from "@/api/subscription/types"
import PageHead from "@/components/layout/PageHead"
import { Button } from "@/components/ui"
import { PLANS_META, getPlanName, getPlanPrice } from "./constants"
@@ -13,6 +12,9 @@ import { BillingCycleSwitch, Spinner } from "./components/SubscriptionUI"
import { useSubscription } from "./hooks/useSubscription"
import "./UpgradeSubscription.css"
/** 可选择的付费档位(不含 free) */
const PAID_PLANS: PlanId[] = ["monthly", "quarterly", "yearly"]
const UpgradeSubscription: React.FC = () => {
const navigate = useNavigate()
const {
@@ -49,12 +51,12 @@ const UpgradeSubscription: React.FC = () => {
const handleCancelClick = () => {
Modal.confirm({
title: "确认取消订阅",
content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
content: "取消后,当前周期结束前仍可正常使用,到期后降级为免费版。",
okText: "确认取消",
cancelText: "再想想",
onOk: async () => {
const ok = await handleCancel()
if (ok) navigate("/app/subscription")
if (ok) navigate("/subscription")
},
})
}
@@ -74,14 +76,22 @@ const UpgradeSubscription: React.FC = () => {
<PageHead title="变更订阅方案" description={`当前套餐:${getPlanName(currentPlan)}`} />
<div className="xx-upgrade-plans">
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
{PAID_PLANS.map((planId) => {
const plan = PLANS_META[planId]
const isCurrent = planId === currentPlan
// 年卡显示年价,其他显示月价
const monthlyPrice = plan.priceYuan
const yearlyPrice = plan.yearlyPriceYuan || plan.priceYuan * 12
// 选中季卡时默认切到月付周期;年卡切到年付
const resolvedCycle: "monthly" | "yearly" = planId === "yearly" ? "yearly" : "monthly"
return (
<div
key={planId}
className={`xx-upgrade-card ${isCurrent ? "current" : ""} ${selectedPlan === planId ? "selected" : ""}`}
onClick={() => setSelectedPlan(planId)}
onClick={() => {
setSelectedPlan(planId)
setBillingCycle(resolvedCycle)
}}
>
{isCurrent && <div className="xx-current-badge">当前</div>}
<h3>{plan.name}</h3>
@@ -89,8 +99,8 @@ const UpgradeSubscription: React.FC = () => {
<BillingCycleSwitch
value={billingCycle}
onChange={setBillingCycle}
monthlyPrice={plan.price}
yearlyPrice={plan.yearlyPrice}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
/>
</div>
</div>
+26 -10
View File
@@ -1,16 +1,32 @@
import type { PlanType, BillingCycle } from "@/api/subscription"
/**
* 订阅套餐元数据
* 价格硬编码兜底,真实价格以 GET /subscription/plans 为准
*/
import type { PlanId, BillingCycle } from "@/api/subscription/types"
export const PLANS_META: Record<string, { name: string; price: number; yearlyPrice: number }> = {
free: { name: "体验版", price: 0, yearlyPrice: 0 },
standard: { name: "标准版", price: 99, yearlyPrice: 990 },
pro: { name: "专业版", price: 299, yearlyPrice: 2990 },
enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
export const PLANS_META: Record<
PlanId,
{ name: string; priceYuan: number; yearlyPriceYuan: number }
> = {
free: { name: "免费版", priceYuan: 0, yearlyPriceYuan: 0 },
monthly: { name: "月度会员", priceYuan: 19.9, yearlyPriceYuan: 0 },
quarterly: { name: "季度会员", priceYuan: 39.9, yearlyPriceYuan: 0 },
yearly: { name: "年度会员", priceYuan: 0, yearlyPriceYuan: 159 },
}
export const getPlanName = (planId: PlanType | string) => PLANS_META[planId]?.name ?? "体验版"
export const getPlanName = (planId: PlanId | string): string =>
PLANS_META[planId as PlanId]?.name ?? "免费版"
export const getPlanPrice = (planId: PlanType | string, cycle: BillingCycle) => {
const plan = PLANS_META[planId]
/**
* 获取展示价格(元)
* - monthly 周期:月卡/季卡按自身价格,年卡按月折算
* - yearly 周期:年卡按年价,其他按年价 * 12
*/
export const getPlanPrice = (planId: PlanId | string, cycle: BillingCycle): number => {
const plan = PLANS_META[planId as PlanId]
if (!plan) return 0
return cycle === "yearly" ? plan.yearlyPrice : plan.price
if (cycle === "yearly") {
return plan.yearlyPriceYuan > 0 ? plan.yearlyPriceYuan : plan.priceYuan * 12
}
return plan.priceYuan
}
@@ -1,3 +1,8 @@
/**
* 订阅管理 Hook
* 封装订阅信息查询、套餐变更、自动续费切换、取消订阅等逻辑
* 对齐最终契约(plan_id=free/monthly/quarterly/yearly + billing_cycle=monthly/yearly)
*/
import { useState, useEffect, useCallback } from "react"
import { message } from "antd"
import {
@@ -5,20 +10,14 @@ import {
changePlan,
toggleAutoRenew,
cancelSubscription,
type SubscriptionInfo,
type PlanType,
type BillingCycle,
} from "@/api/subscription"
import type { SubscriptionInfo, PlanId, BillingCycle } from "@/api/subscription/types"
/**
* 订阅管理 Hook
* 封装订阅信息查询、套餐变更、自动续费切换、取消订阅等逻辑
*/
export function useSubscription() {
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [selectedPlan, setSelectedPlan] = useState<PlanType>("standard")
const [selectedPlan, setSelectedPlan] = useState<PlanId>("monthly")
const [billingCycle, setBillingCycle] = useState<BillingCycle>("monthly")
const loadSubscription = useCallback(async () => {
@@ -26,6 +25,7 @@ export function useSubscription() {
const data = await getCurrentSubscription()
setSubscription(data)
setSelectedPlan(data.plan_id)
setBillingCycle(data.billing_cycle)
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("获取订阅信息失败")
} finally {
@@ -37,45 +37,41 @@ export function useSubscription() {
loadSubscription()
}, [loadSubscription])
const handleUpgrade = useCallback(async () => {
if (!subscription) return
const executeChangePlan = useCallback(async () => {
if (!subscription) return false
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
message.info("当前已是该套餐")
return
return false
}
// 由调用方决定是否弹确认框
}, [subscription, selectedPlan, billingCycle])
const executeChangePlan = useCallback(async () => {
try {
setSubmitting(true)
const res = await changePlan({
target_plan_id: selectedPlan,
billing_cycle: billingCycle,
})
const res = await changePlan({ target_plan_id: selectedPlan, billing_cycle: billingCycle })
if (res.success) {
message.success(res.message)
setSubscription(res.new_subscription ?? null)
return true
} else {
message.error(res.message)
return false
}
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("套餐变更失败,请重试")
return false
} finally {
setSubmitting(false)
}
}, [selectedPlan, billingCycle])
}, [subscription, selectedPlan, billingCycle])
const handleToggleAutoRenew = useCallback(
async (enabled: boolean) => {
try {
const res = await toggleAutoRenew(enabled)
const res = await toggleAutoRenew({ enabled })
message.success(res.message)
if (subscription) {
setSubscription({ ...subscription, auto_renew: enabled })
}
if (subscription) setSubscription({ ...subscription, auto_renew: enabled })
return true
} catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
return false
}
},
[subscription],
@@ -93,7 +89,6 @@ export function useSubscription() {
}, [])
return {
// 状态
subscription,
loading,
submitting,
@@ -101,9 +96,7 @@ export function useSubscription() {
billingCycle,
setSelectedPlan,
setBillingCycle,
// 操作
loadSubscription,
handleUpgrade,
executeChangePlan,
handleToggleAutoRenew,
handleCancel,
+83 -36
View File
@@ -1,83 +1,130 @@
/**
* 积分 & 会员状态管理(Zustand)
* - 启动时拉取余额 & 订阅信息
* - 提供刷新、余额扣减(乐观更新)等工具
* 积分 & 会员状态管理
* 对齐后端 staging 最终契约(2026-09-16):
* - balance: GET /points/balance(无 free_clips_*)
* - dailyUsage: GET /usage/daily(每日免费额度)
* - membership: GET /points/subscription/membership(聚合会员信息)
* - rules: GET /points/rules(base_points + free_user_multiplier)
* - subscription: GET /subscription/current(plan_id + billing_cycle)
*/
import { create } from "zustand"
import type { PointsBalance, PointsRulesResponse, SubscriptionCurrent } from "@/api/points/types"
import { getCurrentSubscription, getPointsBalance, getPointsRules } from "@/api/points"
import { getPointsBalance, getPointsRules, getDailyUsage, getMembership } from "@/api/points"
import { getCurrentSubscription } from "@/api/subscription"
import type {
PointsBalance,
PointsRulesResponse,
DailyUsage,
MembershipResponse,
} from "@/api/points/types"
import type { SubscriptionInfo } from "@/api/subscription/types"
interface PointsState {
/** 积分余额 & 会员状态(来自 /points/balance) */
balance: PointsBalance | null
/** 订阅详情(来自 /subscription/current) */
subscription: SubscriptionCurrent | null
/** 积分消耗规则缓存 */
dailyUsage: DailyUsage | null
membership: MembershipResponse | null
rules: PointsRulesResponse | null
subscription: SubscriptionInfo | null
loading: boolean
error: string | null
/** 初始化:拉取余额 + 订阅信息 + 规则 */
/** 初始化:并行拉取 balance / rules / subscription / dailyUsage / membership */
init: () => Promise<void>
/** 强制刷新余额 */
/** 刷新余额(充值/消费后调用) */
refreshBalance: () => Promise<void>
/** 乐观扣减:在支付/业务发起前调用,失败时用 refreshBalance 兜底 */
/** 乐观扣减 */
optimisticDeduct: (points: number) => void
/** 乐观增加(充值成功后调用) */
/** 乐观增加 */
optimisticAdd: (points: number) => void
/** 清除积分状态(退出登录) */
reset: () => void
}
export const usePointsStore = create<PointsState>((set, get) => ({
balance: null,
subscription: null,
dailyUsage: null,
membership: null,
rules: null,
subscription: null,
loading: false,
error: null,
init: async () => {
if (get().loading) return
// 已加载过不重复拉取
if (get().balance && get().rules && get().subscription) return
set({ loading: true, error: null })
try {
const [balance, sub, rules] = await Promise.all([
getPointsBalance(),
getCurrentSubscription(),
getPointsRules(),
const [balance, rules, subscription, dailyUsage, membership] = await Promise.all([
getPointsBalance().catch(() => null),
getPointsRules().catch(() => null),
getCurrentSubscription().catch(() => null),
getDailyUsage().catch(() => null),
getMembership().catch(() => null),
])
set({ balance, subscription: sub, rules, loading: false })
} catch (e) {
set({ error: (e as Error).message, loading: false })
set({
balance,
rules,
subscription,
dailyUsage,
membership,
loading: false,
})
} catch (err) {
set({ error: (err as Error).message || "加载积分信息失败", loading: false })
}
},
refreshBalance: async () => {
try {
const balance = await getPointsBalance()
set({ balance })
} catch (e) {
set({ error: (e as Error).message })
const [balance, dailyUsage, membership, subscription] = await Promise.all([
getPointsBalance(),
getDailyUsage().catch(() => null),
getMembership().catch(() => null),
getCurrentSubscription().catch(() => get().subscription),
])
set({ balance, dailyUsage, membership, subscription })
} catch (err) {
set({ error: (err as Error).message || "刷新积分失败" })
}
},
optimisticDeduct: (points: number) => {
const b = get().balance
if (!b) return
const { balance, membership } = get()
if (!balance) return
set({
balance: {
...b,
balance: Math.max(0, b.balance - points),
total_spent: b.total_spent + points,
...balance,
balance: Math.max(0, balance.balance - points),
total_spent: balance.total_spent + points,
},
membership: membership
? { ...membership, points_balance: Math.max(0, membership.points_balance - points) }
: null,
})
},
optimisticAdd: (points: number) => {
const b = get().balance
if (!b) return
const { balance, membership } = get()
if (!balance) return
set({
balance: {
...b,
balance: b.balance + points,
total_earned: b.total_earned + points,
...balance,
balance: balance.balance + points,
total_earned: balance.total_earned + points,
},
membership: membership
? { ...membership, points_balance: membership.points_balance + points }
: null,
})
},
reset: () => {
set({
balance: null,
dailyUsage: null,
membership: null,
rules: null,
subscription: null,
loading: false,
error: null,
})
},
}))