feat: #1895 会员+积分系统前端(两档会员 + 积分充值/明细/扣费提示) #1920
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* 积分 & 会员 API 封装(v2 两档会员制)
|
||||
* 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
PointsBalance,
|
||||
PointsTransaction,
|
||||
PointsTransactionsParams,
|
||||
PointsTransactionsResponse,
|
||||
PointsPackagesResponse,
|
||||
PointsRechargeRequest,
|
||||
PointsOrder,
|
||||
PointsRulesResponse,
|
||||
PointsCheckRequest,
|
||||
PointsCheckResponse,
|
||||
SubscriptionCurrent,
|
||||
SubscribeRequest,
|
||||
SubscriptionPlan,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* 是否启用 mock 数据(后端 PR 合入前为 true;对接真实接口后改为 false)
|
||||
*/
|
||||
export const POINTS_API_MOCK = true
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
const MOCK_BALANCE: PointsBalance = {
|
||||
balance: 580,
|
||||
total_earned: 1200,
|
||||
total_spent: 620,
|
||||
is_member: false,
|
||||
member_type: null,
|
||||
member_expires_at: null,
|
||||
free_clips_used: 1,
|
||||
free_clips_limit: 2,
|
||||
free_clips_remaining: 1,
|
||||
}
|
||||
|
||||
const MOCK_PACKAGES: PointsPackagesResponse = {
|
||||
packages: [
|
||||
{
|
||||
id: "starter_pack",
|
||||
name: "体验包",
|
||||
points: 100,
|
||||
price: 990,
|
||||
discounted_price_for_free: 990,
|
||||
discounted_price_for_monthly: 891,
|
||||
discounted_price_for_quarterly: 861,
|
||||
discounted_price_for_yearly: 792,
|
||||
},
|
||||
{
|
||||
id: "basic_pack",
|
||||
name: "基础包",
|
||||
points: 500,
|
||||
price: 3900,
|
||||
discounted_price_for_free: 3900,
|
||||
discounted_price_for_monthly: 3510,
|
||||
discounted_price_for_quarterly: 3393,
|
||||
discounted_price_for_yearly: 3120,
|
||||
},
|
||||
{
|
||||
id: "pro_pack",
|
||||
name: "专业包",
|
||||
points: 2000,
|
||||
price: 12900,
|
||||
discounted_price_for_free: 12900,
|
||||
discounted_price_for_monthly: 11610,
|
||||
discounted_price_for_quarterly: 11223,
|
||||
discounted_price_for_yearly: 10320,
|
||||
},
|
||||
],
|
||||
user_member_type: "free",
|
||||
unit_price_yuan: 0.1,
|
||||
}
|
||||
|
||||
const MOCK_RULES: PointsRulesResponse = {
|
||||
rules: [
|
||||
{
|
||||
scene_key: "ai_voice",
|
||||
scene_name: "AI 配音",
|
||||
points_per_use: 1,
|
||||
unit: "分钟",
|
||||
description: "每生成 1 分钟配音",
|
||||
},
|
||||
{
|
||||
scene_key: "ai_video",
|
||||
scene_name: "智能混剪",
|
||||
points_per_use: 3,
|
||||
unit: "条",
|
||||
extra_per_30s: 1,
|
||||
description: "每条 ≤30s 3 积分,每加 30s +1",
|
||||
},
|
||||
{
|
||||
scene_key: "ai_digital_human",
|
||||
scene_name: "AI 数字人",
|
||||
points_per_use: 15,
|
||||
unit: "分钟",
|
||||
description: "每生成 1 分钟口播",
|
||||
},
|
||||
{
|
||||
scene_key: "voice_clone_train",
|
||||
scene_name: "声音克隆训练",
|
||||
points_per_use: 0,
|
||||
unit: "次",
|
||||
description: "训练免费",
|
||||
},
|
||||
{ scene_key: "voice_clone_synth", scene_name: "声音克隆合成", points_per_use: 1, unit: "分钟" },
|
||||
{ scene_key: "douyin_extract", scene_name: "抖音链接提取", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_rewrite", scene_name: "AI 改写文案", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_title", scene_name: "AI 标题生成", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_cover", scene_name: "AI 封面生成", points_per_use: 1, unit: "张" },
|
||||
],
|
||||
free_user_multiplier: 1.15,
|
||||
note: "免费用户消耗 = 会员消耗 × 1.15,向上取整",
|
||||
}
|
||||
|
||||
function genMockTransactions(): PointsTransactionsResponse {
|
||||
const now = new Date()
|
||||
const list = [
|
||||
{
|
||||
src: "ai_voice",
|
||||
name: "AI 配音",
|
||||
type: "spend" as const,
|
||||
amt: 1,
|
||||
desc: "生成配音 1 分钟",
|
||||
days: 0,
|
||||
hours: 0,
|
||||
mins: 30,
|
||||
},
|
||||
{
|
||||
src: "ai_video",
|
||||
name: "智能混剪",
|
||||
type: "spend" as const,
|
||||
amt: 5,
|
||||
desc: "生成 1 分钟视频(基础3+30s*2)",
|
||||
days: 0,
|
||||
hours: 1,
|
||||
mins: 15,
|
||||
},
|
||||
{
|
||||
src: "task_reward",
|
||||
name: "任务奖励",
|
||||
type: "earn" as const,
|
||||
amt: 20,
|
||||
desc: "首次生成视频奖励",
|
||||
days: 1,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "recharge",
|
||||
name: "充值",
|
||||
type: "earn" as const,
|
||||
amt: 500,
|
||||
desc: "基础包充值",
|
||||
days: 15,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "ai_rewrite",
|
||||
name: "AI 改写文案",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "免费用户价(1×1.15 向上取整)",
|
||||
days: 16,
|
||||
hours: 2,
|
||||
mins: 10,
|
||||
},
|
||||
{
|
||||
src: "ai_title",
|
||||
name: "AI 标题生成",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "免费用户价",
|
||||
days: 16,
|
||||
hours: 3,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "douyin_extract",
|
||||
name: "抖音链接提取",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "提取 3 分钟文案",
|
||||
days: 18,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "ai_digital_human",
|
||||
name: "AI 数字人",
|
||||
type: "spend" as const,
|
||||
amt: 18,
|
||||
desc: "数字人口播 1 分钟(免费用户价)",
|
||||
days: 20,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "task_reward",
|
||||
name: "任务奖励",
|
||||
type: "earn" as const,
|
||||
amt: 50,
|
||||
desc: "注册赠送",
|
||||
days: 30,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
]
|
||||
let bal = MOCK_BALANCE.balance
|
||||
const items = list
|
||||
.map((t, i) => {
|
||||
const signed = t.type === "earn" ? t.amt : -t.amt
|
||||
const balance_after = bal // 按时间倒序:earliest 先算
|
||||
// adjust running bal
|
||||
bal = t.type === "earn" ? bal - t.amt : bal + t.amt
|
||||
const d = new Date(now)
|
||||
d.setDate(d.getDate() - t.days)
|
||||
d.setHours(d.getHours() - t.hours)
|
||||
d.setMinutes(d.getMinutes() - t.mins)
|
||||
return {
|
||||
id: `tx_${i + 1}`,
|
||||
type: t.type,
|
||||
source: t.src as PointsBalance extends never ? never : string,
|
||||
source_name: t.name,
|
||||
amount: t.amt,
|
||||
signed_amount: signed,
|
||||
balance_after,
|
||||
description: t.desc,
|
||||
ref_id: null,
|
||||
created_at: d.toISOString(),
|
||||
}
|
||||
})
|
||||
.reverse()
|
||||
// Rebuild balance_after going forward
|
||||
let running = 50 + 0 // after registration gift
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const it = items[i] as PointsTransaction & { balance_after?: number }
|
||||
if (it.source === "task_reward" && it.description.includes("注册")) running = 50
|
||||
}
|
||||
running = 50
|
||||
const fwd = [...items].reverse() as Array<PointsTransaction & { balance_after?: number }>
|
||||
for (const it of fwd) {
|
||||
running += it.signed_amount
|
||||
it.balance_after = running
|
||||
}
|
||||
return { items: fwd, total: fwd.length, page: 1, page_size: 20 } as PointsTransactionsResponse
|
||||
}
|
||||
|
||||
// ==================== 真实 API ====================
|
||||
|
||||
/** 查询积分余额 + 会员状态 */
|
||||
export async function getPointsBalance(): Promise<PointsBalance> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_BALANCE }), 180))
|
||||
}
|
||||
const res = await apiClient.get("/points/balance")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分流水(分页) */
|
||||
export async function getPointsTransactions(
|
||||
params: PointsTransactionsParams = {},
|
||||
): Promise<PointsTransactionsResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r(genMockTransactions()), 200))
|
||||
}
|
||||
const res = await apiClient.get("/points/transactions", { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分包列表 */
|
||||
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_PACKAGES }), 150))
|
||||
}
|
||||
const res = await apiClient.get("/points/packages")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 创建积分充值订单(mock 阶段返回 "pending" 订单,前端弹"支付开发中") */
|
||||
export async function createPointsOrder(req: PointsRechargeRequest): Promise<PointsOrder> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const pkg = MOCK_PACKAGES.packages.find((p) => p.id === req.package_id)
|
||||
const mt = MOCK_PACKAGES.user_member_type
|
||||
type DiscountKey =
|
||||
| "discounted_price_for_free"
|
||||
| "discounted_price_for_monthly"
|
||||
| "discounted_price_for_quarterly"
|
||||
| "discounted_price_for_yearly"
|
||||
const discountKey = `discounted_price_for_${mt}` as DiscountKey
|
||||
const price = pkg?.[discountKey] ?? pkg?.price ?? 0
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
id: `mock_order_${Date.now()}`,
|
||||
package_id: req.package_id,
|
||||
package_name: pkg?.name ?? "",
|
||||
points_amount: pkg?.points ?? 0,
|
||||
price_cents: price,
|
||||
original_price_cents: pkg?.price ?? 0,
|
||||
discount: price / (pkg?.price || 1),
|
||||
currency: "CNY",
|
||||
status: "pending",
|
||||
payment_method: null,
|
||||
payment_id: null,
|
||||
paid_at: null,
|
||||
expire_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}),
|
||||
300,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.post("/points/recharge", req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分消耗规则 */
|
||||
export async function getPointsRules(): Promise<PointsRulesResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_RULES }), 120))
|
||||
}
|
||||
const res = await apiClient.get("/points/rules")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 消费前余额预检查 */
|
||||
export async function checkPoints(req: PointsCheckRequest): Promise<PointsCheckResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const rule = MOCK_RULES.rules.find((r) => r.scene_key === req.scene_key)
|
||||
if (!rule) {
|
||||
return {
|
||||
allowed: false,
|
||||
required_points: 0,
|
||||
current_balance: MOCK_BALANCE.balance,
|
||||
remaining_after: MOCK_BALANCE.balance,
|
||||
is_free_quota: false,
|
||||
code: "SCENE_NOT_FOUND",
|
||||
message: "未知场景",
|
||||
recharge_url: "/app/points",
|
||||
}
|
||||
}
|
||||
const units = req.units ?? 1
|
||||
let base = rule.points_per_use * units
|
||||
if (rule.extra_per_30s && units > 1) {
|
||||
// ai_video extra_per_30s: base already covers first 30s, subtract
|
||||
base = rule.points_per_use + rule.extra_per_30s * (units - 1)
|
||||
}
|
||||
const isFree =
|
||||
MOCK_BALANCE.is_member === false &&
|
||||
req.scene_key === "ai_video" &&
|
||||
(MOCK_BALANCE.free_clips_remaining ?? 0) > 0
|
||||
const needed = isFree
|
||||
? 0
|
||||
: MOCK_BALANCE.is_member
|
||||
? base
|
||||
: Math.ceil(base * MOCK_RULES.free_user_multiplier)
|
||||
const allowed = isFree || MOCK_BALANCE.balance >= needed
|
||||
return {
|
||||
allowed,
|
||||
required_points: needed,
|
||||
current_balance: MOCK_BALANCE.balance,
|
||||
remaining_after: MOCK_BALANCE.balance - needed,
|
||||
is_free_quota: isFree,
|
||||
code: allowed ? undefined : "INSUFFICIENT_POINTS",
|
||||
message: allowed
|
||||
? undefined
|
||||
: `积分不足,需要 ${needed} 积分,当前余额 ${MOCK_BALANCE.balance}`,
|
||||
recharge_url: "/app/points",
|
||||
}
|
||||
}
|
||||
const res = await apiClient.post("/points/check", req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ==================== 订阅相关 ====================
|
||||
|
||||
/** 订阅套餐(定价常量,前端硬编码;折扣由后端会员类型决定) */
|
||||
export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [
|
||||
{
|
||||
id: "monthly",
|
||||
name: "月卡",
|
||||
price_cents: 1990,
|
||||
price_yuan: 19.9,
|
||||
per_month_yuan: 19.9,
|
||||
billing_label: "/月",
|
||||
},
|
||||
{
|
||||
id: "quarterly",
|
||||
name: "季卡",
|
||||
price_cents: 3990,
|
||||
price_yuan: 39.9,
|
||||
per_month_yuan: 13.3,
|
||||
savings_percent: 33,
|
||||
recommended: true,
|
||||
billing_label: "/季",
|
||||
},
|
||||
{
|
||||
id: "yearly",
|
||||
name: "年卡",
|
||||
price_cents: 15900,
|
||||
price_yuan: 159,
|
||||
per_month_yuan: 13.25,
|
||||
savings_percent: 33,
|
||||
billing_label: "/年",
|
||||
},
|
||||
]
|
||||
|
||||
/** 查询当前订阅 */
|
||||
export async function getCurrentSubscription(): Promise<SubscriptionCurrent> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
is_member: MOCK_BALANCE.is_member,
|
||||
member_type: MOCK_BALANCE.member_type,
|
||||
member_type_name: MOCK_BALANCE.is_member ? "付费会员" : "免费会员",
|
||||
status: MOCK_BALANCE.is_member ? "active" : "none",
|
||||
current_period_start: null,
|
||||
current_period_end: MOCK_BALANCE.member_expires_at,
|
||||
auto_renew: false,
|
||||
points_discount: MOCK_BALANCE.is_member ? 0.9 : 1.0,
|
||||
}),
|
||||
150,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.get("/subscription/current")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 开通/续费会员 */
|
||||
export async function subscribe(req: SubscribeRequest): Promise<PointsOrder> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const plan = SUBSCRIPTION_PLANS.find((p) => p.id === req.member_type)!
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
id: `mock_sub_${Date.now()}`,
|
||||
package_id: plan.id,
|
||||
package_name: plan.name,
|
||||
points_amount: 0,
|
||||
price_cents: plan.price_cents,
|
||||
original_price_cents: plan.price_cents,
|
||||
discount: 1,
|
||||
currency: "CNY",
|
||||
status: "pending",
|
||||
payment_method: null,
|
||||
payment_id: null,
|
||||
paid_at: null,
|
||||
expire_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}),
|
||||
300,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.post("/subscription/subscribe", req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 取消自动续费 */
|
||||
export async function cancelAutoRenew(): Promise<{ success: boolean; message: string }> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) =>
|
||||
setTimeout(() => r({ success: true, message: "已取消自动续费" }), 200),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.post("/subscription/cancel")
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 积分 & 会员系统 API 类型定义(v2 两档会员制)
|
||||
* 接口契约对齐后端设计文档 membership-points-design-v2.md
|
||||
*/
|
||||
|
||||
/** 会员类型 */
|
||||
export type MemberType = "free" | "monthly" | "quarterly" | "yearly"
|
||||
|
||||
/** 积分流水类型 */
|
||||
export type PointsTxType = "earn" | "spend" | "refund"
|
||||
|
||||
/** 积分来源/消耗场景 */
|
||||
export type PointsSource =
|
||||
| "recharge" // 充值
|
||||
| "task_reward" // 任务奖励
|
||||
| "ai_voice" // AI 配音
|
||||
| "ai_digital_human" // AI 数字人
|
||||
| "ai_video" // 智能混剪
|
||||
| "voice_clone_train" // 声音克隆训练
|
||||
| "voice_clone_synth" // 声音克隆合成
|
||||
| "douyin_extract" // 抖音链接提取
|
||||
| "ai_rewrite" // AI 改写文案
|
||||
| "ai_title" // AI 标题生成
|
||||
| "ai_cover" // AI 封面生成
|
||||
| "subscription_bonus" // 会员赠送
|
||||
| "admin_adjust" // 管理员调整
|
||||
| "refund" // 失败退还
|
||||
|
||||
/** 会员 & 积分余额响应 */
|
||||
export interface PointsBalance {
|
||||
/** 当前可用积分 */
|
||||
balance: number
|
||||
/** 累计获得 */
|
||||
total_earned: number
|
||||
/** 累计消耗 */
|
||||
total_spent: number
|
||||
/** 是否付费会员(free 用户为 false) */
|
||||
is_member: boolean
|
||||
/** 会员类型:monthly / quarterly / yearly;free 用户为 null */
|
||||
member_type: Extract<MemberType, "monthly" | "quarterly" | "yearly"> | null
|
||||
/** 会员到期时间 ISO 字符串 */
|
||||
member_expires_at: string | null
|
||||
/** 今日免费混剪已用次数 */
|
||||
free_clips_used?: number
|
||||
/** 今日免费混剪额度上限 */
|
||||
free_clips_limit?: number
|
||||
/** 今日免费混剪剩余 */
|
||||
free_clips_remaining?: number
|
||||
}
|
||||
|
||||
/** 积分流水记录 */
|
||||
export interface PointsTransaction {
|
||||
id: string
|
||||
/** earn / spend / refund */
|
||||
type: PointsTxType
|
||||
/** 来源场景 */
|
||||
source: PointsSource
|
||||
/** 场景中文名称 */
|
||||
source_name: string
|
||||
/** 变动数量(正数) */
|
||||
amount: number
|
||||
/** 带符号的变动数(收入+,支出-) */
|
||||
signed_amount: number
|
||||
/** 变动后余额 */
|
||||
balance_after: number
|
||||
/** 备注描述 */
|
||||
description: string
|
||||
/** 关联业务 ID */
|
||||
ref_id: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 积分流水分页响应 */
|
||||
export interface PointsTransactionsResponse {
|
||||
items: PointsTransaction[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 积分流水查询参数 */
|
||||
export interface PointsTransactionsParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
type?: PointsTxType
|
||||
source?: PointsSource
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
/** 积分包 */
|
||||
export interface PointsPackage {
|
||||
id: "starter_pack" | "basic_pack" | "pro_pack" | string
|
||||
/** 中文名称 */
|
||||
name: string
|
||||
/** 积分数量 */
|
||||
points: number
|
||||
/** 原价(分) */
|
||||
price: number
|
||||
/** 各会员类型折扣价(分) */
|
||||
discounted_price_for_free: number
|
||||
discounted_price_for_monthly: number
|
||||
discounted_price_for_quarterly: number
|
||||
discounted_price_for_yearly: number
|
||||
}
|
||||
|
||||
/** 积分包列表响应 */
|
||||
export interface PointsPackagesResponse {
|
||||
packages: PointsPackage[]
|
||||
/** 当前用户会员类型,用于前端计算折后价 */
|
||||
user_member_type: MemberType
|
||||
/** 积分单价(元/积分,按会员价计) */
|
||||
unit_price_yuan: number
|
||||
}
|
||||
|
||||
/** 创建充值订单请求 */
|
||||
export interface PointsRechargeRequest {
|
||||
package_id: string
|
||||
payment_method?: "wechat_pay" | "alipay"
|
||||
}
|
||||
|
||||
/** 订单状态 */
|
||||
export type OrderStatus = "pending" | "paid" | "failed" | "refunded" | "expired"
|
||||
|
||||
/** 充值订单响应 */
|
||||
export interface PointsOrder {
|
||||
id: string
|
||||
package_id: string
|
||||
package_name: string
|
||||
points_amount: number
|
||||
price_cents: number
|
||||
original_price_cents: number
|
||||
discount: number
|
||||
currency: "CNY"
|
||||
status: OrderStatus
|
||||
payment_method: string | null
|
||||
payment_id: string | null
|
||||
paid_at: string | null
|
||||
expire_at: string | null
|
||||
created_at: string
|
||||
/** 微信/支付宝支付参数(mock 阶段前端自行处理) */
|
||||
pay_params?: Record<string, string>
|
||||
}
|
||||
|
||||
/** 订阅套餐(月/季/年) */
|
||||
export interface SubscriptionPlan {
|
||||
id: "monthly" | "quarterly" | "yearly"
|
||||
name: string
|
||||
price_cents: number
|
||||
price_yuan: number
|
||||
per_month_yuan: number
|
||||
savings_percent?: number
|
||||
recommended?: boolean
|
||||
billing_label: string
|
||||
}
|
||||
|
||||
/** 当前订阅详情 */
|
||||
export interface SubscriptionCurrent {
|
||||
is_member: boolean
|
||||
member_type: Extract<MemberType, "monthly" | "quarterly" | "yearly"> | null
|
||||
member_type_name: string
|
||||
status: "active" | "expired" | "cancelled" | "none"
|
||||
current_period_start: string | null
|
||||
current_period_end: string | null
|
||||
auto_renew: boolean
|
||||
/** 订阅会员对应的积分折扣 */
|
||||
points_discount: number
|
||||
}
|
||||
|
||||
/** 开通/续费订阅请求 */
|
||||
export interface SubscribeRequest {
|
||||
member_type: "monthly" | "quarterly" | "yearly"
|
||||
payment_method?: "wechat_pay" | "alipay"
|
||||
}
|
||||
|
||||
/** 积分消耗规则 */
|
||||
export interface PointsRule {
|
||||
scene_key: PointsSource
|
||||
scene_name: string
|
||||
/** 每次消耗基础积分(会员价) */
|
||||
points_per_use: number
|
||||
/** 计量单位:条/分钟/次/张 */
|
||||
unit: string
|
||||
/** 额外每 30s 加积分(ai_video 用) */
|
||||
extra_per_30s?: number
|
||||
/** 说明文案 */
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface PointsRulesResponse {
|
||||
rules: PointsRule[]
|
||||
/** 免费用户消耗倍率 */
|
||||
free_user_multiplier: number
|
||||
note: string
|
||||
}
|
||||
|
||||
/** 消费前余额检查请求 */
|
||||
export interface PointsCheckRequest {
|
||||
scene_key: PointsSource
|
||||
/** 单位数量(时长/条数),默认 1 */
|
||||
units?: number
|
||||
}
|
||||
|
||||
/** 消费前余额检查响应 */
|
||||
export interface PointsCheckResponse {
|
||||
allowed: boolean
|
||||
required_points: number
|
||||
current_balance: number
|
||||
remaining_after: number
|
||||
/** 是否走免费额度(混剪场景) */
|
||||
is_free_quota: boolean
|
||||
/** 拒绝原因代码 */
|
||||
code?: "INSUFFICIENT_POINTS" | "FREE_QUOTA_EXCEEDED" | "SCENE_NOT_FOUND"
|
||||
message?: string
|
||||
/** 充值页跳转 URL */
|
||||
recharge_url?: string
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* Header 积分徽章 & Popover 样式 */
|
||||
.xx-points-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-tertiary, #fafafa);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
line-height: 1;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.xx-points-badge:hover {
|
||||
border-color: var(--primary-color, #6366f1);
|
||||
color: var(--primary-color, #6366f1);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-points-badge.zero {
|
||||
color: #faad14;
|
||||
border-color: #ffe58f;
|
||||
background: #fffbe6;
|
||||
}
|
||||
|
||||
.xx-points-badge.low {
|
||||
color: #fa8c16;
|
||||
border-color: #ffd591;
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.xx-points-badge-icon {
|
||||
font-size: 14px;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.xx-points-badge.zero .xx-points-badge-icon,
|
||||
.xx-points-badge.low .xx-points-badge-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.xx-points-badge-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Popover */
|
||||
.xx-points-popover {
|
||||
width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-points-popover-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.xx-points-popover-balance {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-points-icon {
|
||||
color: #8b5cf6;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.xx-points-value {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #111827);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.xx-points-unit {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-points-warn {
|
||||
margin: 0 !important;
|
||||
padding: 8px 10px;
|
||||
background: #fffbe6;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-points-expire {
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-points-popover-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary, #fafafa);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
}
|
||||
|
||||
.xx-points-stat-val {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
margin-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Header 右上角的积分徽章(💎 580)
|
||||
* - 余额 <10 时橙色告警
|
||||
* - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口
|
||||
*/
|
||||
import React, { useEffect } from "react"
|
||||
import { Popover, Button, Tag, Space, Typography, Badge } from "antd"
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
CrownOutlined,
|
||||
RightOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import "./PointsBadge.css"
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
const PointsBadge: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { balance, init, loading } = usePointsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!balance) init()
|
||||
}, [balance, init])
|
||||
|
||||
const bal = balance?.balance ?? 0
|
||||
const lowBalance = bal > 0 && bal < 10
|
||||
const zero = bal === 0
|
||||
const isMember = !!balance?.is_member
|
||||
const memberLabel = isMember
|
||||
? balance?.member_type === "yearly"
|
||||
? "年卡会员"
|
||||
: balance?.member_type === "quarterly"
|
||||
? "季卡会员"
|
||||
: balance?.member_type === "monthly"
|
||||
? "月卡会员"
|
||||
: "付费会员"
|
||||
: "免费会员"
|
||||
|
||||
const popContent = (
|
||||
<div className="xx-points-popover">
|
||||
<div className="xx-points-popover-top">
|
||||
<div className="xx-points-popover-balance">
|
||||
<ThunderboltOutlined className="xx-points-icon" />
|
||||
<span className="xx-points-value">{loading ? "…" : bal}</span>
|
||||
<span className="xx-points-unit">积分</span>
|
||||
</div>
|
||||
<Tag color={isMember ? "gold" : "default"} icon={<CrownOutlined />}>
|
||||
{memberLabel}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{(zero || lowBalance) && (
|
||||
<Paragraph type="warning" className="xx-points-warn">
|
||||
<WarningOutlined /> 积分不足,充值后可继续使用 AI 功能
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{balance?.member_expires_at && (
|
||||
<Text type="secondary" className="xx-points-expire">
|
||||
会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="xx-points-popover-stats">
|
||||
<div>
|
||||
<Text type="secondary">累计获得</Text>
|
||||
<div className="xx-points-stat-val">+{balance?.total_earned ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">累计消耗</Text>
|
||||
<div className="xx-points-stat-val">-{balance?.total_spent ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={8}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
block
|
||||
onClick={() => navigate("/app/points")}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
<Button block onClick={() => navigate("/app/points/transactions")}>
|
||||
积分明细
|
||||
<RightOutlined />
|
||||
</Button>
|
||||
{!isMember && (
|
||||
<Button block type="link" onClick={() => navigate("/app/subscription")}>
|
||||
<CrownOutlined /> 升级会员享折扣
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover content={popContent} placement="bottomRight" trigger="click" arrow={false}>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-points-badge ${zero ? "zero" : lowBalance ? "low" : ""}`}
|
||||
>
|
||||
<Badge dot={zero} color={zero ? "#faad14" : undefined}>
|
||||
<ThunderboltOutlined className="xx-points-badge-icon" />
|
||||
</Badge>
|
||||
<span className="xx-points-badge-val">{loading ? "…" : bal}</span>
|
||||
</button>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsBadge
|
||||
@@ -0,0 +1,67 @@
|
||||
/* 功能按钮旁的积分消耗提示 */
|
||||
.xx-points-cost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-points-cost.free,
|
||||
.xx-points-cost.free-quota {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.xx-points-cost.insufficient {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.xx-points-gem {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-points-num {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.xx-points-unit-label {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
}
|
||||
|
||||
.xx-points-tag-free {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-points-free-user-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.xx-points-desc {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.xx-points-warn-icon {
|
||||
color: #ef4444;
|
||||
margin-left: 2px;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 功能操作按钮旁的"消耗积分"提示
|
||||
* 例:[生成配音] 💎 -1 积分
|
||||
* - 根据 scene_key 自动读取规则
|
||||
* - 免费用户自动计算 ×1.15 向上取整
|
||||
* - 余额不足时显示红色告警 + 充值提示
|
||||
*
|
||||
* 使用:<PointsCost scene="ai_voice" units={1} />
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import { WarningOutlined } from "@ant-design/icons"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import type { PointsSource } from "@/api/points/types"
|
||||
import { getPointsRules } from "@/api/points"
|
||||
import "./PointsCost.css"
|
||||
|
||||
interface Props {
|
||||
/** 消耗场景 key */
|
||||
scene: PointsSource
|
||||
/** 单位数(分钟数/条数/张数),默认 1 */
|
||||
units?: number
|
||||
/** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */
|
||||
compact?: boolean
|
||||
/** 余额不足时,是否显示充值提示 */
|
||||
showRechargeHint?: boolean
|
||||
/** 自定义 class */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 单位中文 */
|
||||
const UNIT_LABEL: Record<string, string> = {
|
||||
分钟: "分钟",
|
||||
条: "条",
|
||||
次: "次",
|
||||
张: "张",
|
||||
}
|
||||
|
||||
const PointsCost: React.FC<Props> = ({
|
||||
scene,
|
||||
units = 1,
|
||||
compact = false,
|
||||
showRechargeHint = true,
|
||||
className = "",
|
||||
}) => {
|
||||
const { balance, init } = usePointsStore()
|
||||
const [rules, setRules] = useState<Awaited<ReturnType<typeof getPointsRules>> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!balance) init()
|
||||
if (!rules) {
|
||||
getPointsRules()
|
||||
.then(setRules)
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [balance, init, rules])
|
||||
|
||||
const { cost, isFreeQuota, rule, isFreeUser, insufficient } = useMemo(() => {
|
||||
if (!rules || !balance) {
|
||||
return {
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule: null,
|
||||
isFreeUser: !balance?.is_member,
|
||||
insufficient: false,
|
||||
}
|
||||
}
|
||||
const rule = rules.rules.find((r) => r.scene_key === scene)
|
||||
if (!rule)
|
||||
return {
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule: null,
|
||||
isFreeUser: !balance.is_member,
|
||||
insufficient: false,
|
||||
}
|
||||
// 免费训练不扣费
|
||||
if (rule.points_per_use === 0) {
|
||||
return {
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule,
|
||||
isFreeUser: !balance.is_member,
|
||||
insufficient: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 智能混剪:首条30s=3分,每+30s +1
|
||||
let baseCost: number
|
||||
if (scene === "ai_video" && rule.extra_per_30s) {
|
||||
// units 当作"30s 段数"更简单;按分钟算:minutes 个 30s 段 - 1
|
||||
const segments = Math.max(1, Math.ceil(units * 2)) // 1min = 2 segments
|
||||
baseCost = rule.points_per_use + rule.extra_per_30s * (segments - 1)
|
||||
} else {
|
||||
baseCost = rule.points_per_use * Math.max(1, units)
|
||||
}
|
||||
|
||||
// 混剪 + 免费用户 + 今日有免费额度 → 免费
|
||||
const isFree =
|
||||
scene === "ai_video" && !balance.is_member && (balance.free_clips_remaining ?? 0) > 0
|
||||
|
||||
const isFreeUser = !balance.is_member
|
||||
const cost = isFree
|
||||
? 0
|
||||
: isFreeUser
|
||||
? Math.ceil(baseCost * rules.free_user_multiplier)
|
||||
: baseCost
|
||||
const insufficient = !isFree && balance.balance < cost
|
||||
return { cost, isFreeQuota: isFree, rule, isFreeUser, insufficient }
|
||||
}, [rules, balance, scene, units])
|
||||
|
||||
if (!rule || !balance) {
|
||||
return <span className={`xx-points-cost ${className}`} />
|
||||
}
|
||||
|
||||
if (rule.points_per_use === 0) {
|
||||
return (
|
||||
<span className={`xx-points-cost free ${className}`}>
|
||||
<span className="xx-points-tag-free">免费</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (isFreeQuota) {
|
||||
return (
|
||||
<Tooltip title={`今日免费额度剩余 ${balance.free_clips_remaining} 条,不扣积分`}>
|
||||
<span className={`xx-points-cost free-quota ${className}`}>
|
||||
<span className="xx-points-tag-free">免费</span>
|
||||
{!compact && (
|
||||
<span className="xx-points-desc">(今日剩余 {balance.free_clips_remaining} 条)</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const unitLabel = compact
|
||||
? ""
|
||||
: ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`xx-points-cost ${insufficient ? "insufficient" : ""} ${isFreeUser ? "free-user" : ""} ${className}`}
|
||||
>
|
||||
<span className="xx-points-gem">💎</span>
|
||||
<span className="xx-points-num">-{cost}</span>
|
||||
{unitLabel && <span className="xx-points-unit-label">积分{unitLabel}</span>}
|
||||
{isFreeUser && !compact && (
|
||||
<Tooltip title={`免费用户消耗 ×${rules?.free_user_multiplier ?? 1.15},开通会员享标准价`}>
|
||||
<span className="xx-points-free-user-tag">非会员</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{insufficient && showRechargeHint && (
|
||||
<Tooltip title={`积分不足,需 ${cost} 积分,当前余额 ${balance.balance}`}>
|
||||
<WarningOutlined className="xx-points-warn-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsCost
|
||||
@@ -1,4 +1,5 @@
|
||||
/* Phase 1 Header 样式 + 手机端适配 */
|
||||
/* Phase 1 Header 样式 + 手机端适配
|
||||
v2: 增加积分徽章、升级按钮、会员金冠 */
|
||||
.xx-top-nav {
|
||||
height: 68px;
|
||||
position: sticky;
|
||||
@@ -77,10 +78,40 @@
|
||||
.xx-right-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* v2: 顶部积分徽章 */
|
||||
.xx-header-points {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* v2: 升级会员按钮 */
|
||||
.xx-upgrade-btn {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.xx-upgrade-btn:hover {
|
||||
background: linear-gradient(135deg, #d97706, #dc2626) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* v2: 会员金冠按钮 */
|
||||
.xx-crown-btn {
|
||||
font-weight: 700;
|
||||
color: #d97706;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.xx-crown-btn:hover {
|
||||
background: rgba(245, 158, 11, 0.1) !important;
|
||||
color: #b45309 !important;
|
||||
}
|
||||
|
||||
.xx-user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -115,6 +146,29 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* v2: 手机端积分/会员行 */
|
||||
.xx-mobile-points-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 4px 16px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-mobile-member-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #d97706;
|
||||
background: #fef3c7;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.xx-mobile-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -157,6 +211,7 @@
|
||||
|
||||
.xx-top-nav-inner {
|
||||
padding: 0 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-brand-text {
|
||||
@@ -177,6 +232,19 @@
|
||||
.xx-username {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 手机端:缩小升级按钮文字 */
|
||||
.xx-upgrade-btn {
|
||||
padding: 0 8px !important;
|
||||
font-size: 12px !important;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.xx-crown-btn {
|
||||
padding: 0 4px !important;
|
||||
font-size: 12px !important;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕平板:导航文字缩小 */
|
||||
@@ -188,4 +256,14 @@
|
||||
.xx-nav-links button {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-header-points {
|
||||
transform: scale(0.9);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.xx-upgrade-btn {
|
||||
font-size: 12px !important;
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
/**
|
||||
* Phase 1 Header 重构
|
||||
* 扁平化导航菜单 + 手机端汉堡菜单
|
||||
* v2: 增加积分徽章、升级会员入口、我的积分入口
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { Avatar, Dropdown, Drawer, Space } from "antd"
|
||||
import { LogoutOutlined, SettingOutlined, UserOutlined, MenuOutlined } from "@ant-design/icons"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { Avatar, Badge, Button, Dropdown, Drawer, Space, Tooltip } from "antd"
|
||||
import {
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
MenuOutlined,
|
||||
CrownFilled,
|
||||
ThunderboltOutlined,
|
||||
HistoryOutlined,
|
||||
WalletOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { useLogout } from "@/hooks/useAuth"
|
||||
import type { MenuProps } from "antd"
|
||||
import { NAV_ITEMS } from "@/config/navigation"
|
||||
import PointsBadge from "@/components/common/PointsBadge"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import "./Header.css"
|
||||
|
||||
const Header: React.FC = () => {
|
||||
@@ -19,6 +31,18 @@ const Header: React.FC = () => {
|
||||
const logoutMutation = useLogout()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
// 积分与会员状态
|
||||
const { balance, init } = usePointsStore()
|
||||
useEffect(() => {
|
||||
init()
|
||||
// 每 60s 刷新一次(余额可能变化)
|
||||
const t = setInterval(() => init(), 60_000)
|
||||
return () => clearInterval(t)
|
||||
}, [init])
|
||||
|
||||
const isMember = !!balance?.is_member
|
||||
const memberType = balance?.member_type ?? null
|
||||
|
||||
/** 用户下拉菜单 */
|
||||
const menuItems: MenuProps["items"] = [
|
||||
{
|
||||
@@ -33,6 +57,30 @@ const Header: React.FC = () => {
|
||||
label: "订阅管理",
|
||||
onClick: () => navigate("/app/subscription"),
|
||||
},
|
||||
// v2: 我的积分入口
|
||||
{
|
||||
key: "points-center",
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: (
|
||||
<Space>
|
||||
我的积分
|
||||
{balance && <span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>}
|
||||
</Space>
|
||||
),
|
||||
onClick: () => navigate("/app/points"),
|
||||
},
|
||||
{
|
||||
key: "points-history",
|
||||
icon: <HistoryOutlined />,
|
||||
label: "积分明细",
|
||||
onClick: () => navigate("/app/points/transactions"),
|
||||
},
|
||||
{
|
||||
key: "recharge",
|
||||
icon: <WalletOutlined />,
|
||||
label: "充值积分",
|
||||
onClick: () => navigate("/app/points/recharge"),
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "logout",
|
||||
@@ -44,7 +92,6 @@ const Header: React.FC = () => {
|
||||
|
||||
/** 判断导航项是否激活 */
|
||||
const isActive = (path: string) => {
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return (
|
||||
location.pathname === "/" ||
|
||||
@@ -78,6 +125,47 @@ const Header: React.FC = () => {
|
||||
</nav>
|
||||
|
||||
<div className="xx-right-section">
|
||||
{/* v2: 积分徽章(顶部显示) */}
|
||||
<PointsBadge />
|
||||
|
||||
{/* v2: 升级会员入口(仅免费用户显示) */}
|
||||
{!isMember && (
|
||||
<Tooltip title="升级会员解锁无限混剪、批量导出,积分 8 折起">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<CrownFilled />}
|
||||
className="xx-upgrade-btn"
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
升级会员
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* v2: 付费会员显示金冠标识 */}
|
||||
{isMember && (
|
||||
<Tooltip
|
||||
title={`${memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"}会员${
|
||||
balance?.member_expires_at
|
||||
? ` · 到期 ${new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Badge dot color="gold">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CrownFilled style={{ color: "#f59e0b" }} />}
|
||||
className="xx-crown-btn"
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
{memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "会员"}
|
||||
</Button>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 手机端汉堡菜单按钮 */}
|
||||
<button className="xx-hamburger" type="button" onClick={() => setMobileMenuOpen(true)}>
|
||||
<MenuOutlined />
|
||||
@@ -103,6 +191,31 @@ const Header: React.FC = () => {
|
||||
width={260}
|
||||
className="xx-mobile-drawer"
|
||||
>
|
||||
{/* 手机端积分/会员入口 */}
|
||||
{balance && (
|
||||
<div className="xx-mobile-points-row">
|
||||
<PointsBadge />
|
||||
{!isMember ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CrownFilled />}
|
||||
onClick={() => {
|
||||
navigate("/app/subscription")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
) : (
|
||||
<span className="xx-mobile-member-tag">
|
||||
<CrownFilled style={{ color: "#f59e0b" }} />{" "}
|
||||
{memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="xx-mobile-nav">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ScanOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
ThunderboltOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons"
|
||||
@@ -105,6 +106,18 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/tasks",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "会员订阅",
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
]
|
||||
|
||||
/** 侧边栏导航分组(Sidebar 分组列表使用) */
|
||||
@@ -196,10 +209,16 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "订阅管理",
|
||||
label: "会员订阅",
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -28,13 +28,21 @@ import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { hasEnoughPoints } from "./hooks/pointsCost"
|
||||
import "./generate.css"
|
||||
import "./generate-points.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
/* ── 表单状态 ── */
|
||||
const formState = useGenerateFormState()
|
||||
/* ── 积分状态 ── */
|
||||
const { balance, init: initPoints } = usePointsStore()
|
||||
useEffect(() => {
|
||||
initPoints()
|
||||
}, [initPoints])
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
@@ -348,6 +356,13 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
|
||||
const handleConfirmGenerate = useCallback(async () => {
|
||||
// 积分预检查
|
||||
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const check = hasEnoughPoints(balance ?? null, units)
|
||||
if (!check.sufficient) {
|
||||
message.error(check.reason ?? "积分不足,请充值")
|
||||
return
|
||||
}
|
||||
if (isBatch) {
|
||||
if (selectedVariantIds.length === 0) {
|
||||
message.warning("请至少勾选一个视频")
|
||||
@@ -381,6 +396,7 @@ const GeneratePage: React.FC = () => {
|
||||
previewReady,
|
||||
handleGenerate,
|
||||
setCurrentStep,
|
||||
balance,
|
||||
])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
@@ -404,6 +420,14 @@ const GeneratePage: React.FC = () => {
|
||||
return "xx-generate-layout full-width"
|
||||
}, [currentStep])
|
||||
|
||||
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
|
||||
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const pointsEstimate = useMemo(
|
||||
() => hasEnoughPoints(balance ?? null, unitsForCost),
|
||||
[unitsForCost, balance],
|
||||
)
|
||||
const insufficientPoints = !pointsEstimate.sufficient
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
================================================================ */
|
||||
@@ -607,6 +631,11 @@ const GeneratePage: React.FC = () => {
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
selectedCount={isBatch ? selectedVariantIds.length : 1}
|
||||
estimatedCost={pointsEstimate.cost}
|
||||
freeClipsUsedThisTime={pointsEstimate.freeClipsUsed ?? 0}
|
||||
pointsInsufficient={insufficientPoints}
|
||||
insufficientReason={pointsEstimate.reason}
|
||||
onRecharge={() => navigate("/app/points/recharge")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
/**
|
||||
* GeneratePage 步骤底部操作按钮(#1899 简化为 5 步)
|
||||
* GeneratePage 步骤底部操作按钮(Issue #1677 修正:固定 6 步)
|
||||
* v2: 步骤4 按钮前显示本次积分消耗;积分不足时禁用按钮并提示充值
|
||||
*
|
||||
* 步骤 1~2:上一步 / 下一步
|
||||
* 步骤 3(选择标题):「✨ 确认生成视频 / 确认生成 N 个视频」→ 创建正式生成任务,成功后跳步骤4
|
||||
* 步骤 4(确认生成):渲染进度页,全部完成后「下一步:选择封面」;仅上一步
|
||||
* 步骤 5(选择封面):仅上一步
|
||||
* 步骤 1~3:上一步 / 下一步
|
||||
* 步骤 4(选择标题):「✨ 确认生成视频 / 确认生成 N 个视频」→ 创建正式生成任务,成功后跳步骤5
|
||||
* 步骤 5(确认生成):渲染进度页,全部完成后「下一步:选择封面」;仅上一步
|
||||
* 步骤 6(选择封面):仅上一步
|
||||
*/
|
||||
import React from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import { ThunderboltOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface GenerateStepActionsProps {
|
||||
currentStep: number
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
/** 步骤3:确认生成视频(校验 + 创建渲染任务) */
|
||||
/** 步骤4:确认生成视频(校验 + 创建渲染任务) */
|
||||
onConfirmGenerate: () => void | Promise<void>
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
/** 批量模式下勾选的视频数量(N=1 时为1) */
|
||||
selectedCount?: number
|
||||
// v2: 积分相关
|
||||
/** 本次预估消耗积分 */
|
||||
estimatedCost?: number
|
||||
/** 是否积分不足 */
|
||||
pointsInsufficient?: boolean
|
||||
/** 积分不足原因 */
|
||||
insufficientReason?: string
|
||||
/** 剩余免费混剪次数 */
|
||||
freeClipsUsedThisTime?: number
|
||||
/** 前往充值 */
|
||||
onRecharge?: () => void
|
||||
}
|
||||
|
||||
const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
@@ -30,10 +44,15 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
generated,
|
||||
generateError,
|
||||
selectedCount = 1,
|
||||
estimatedCost,
|
||||
pointsInsufficient,
|
||||
insufficientReason,
|
||||
freeClipsUsedThisTime,
|
||||
onRecharge,
|
||||
}) => {
|
||||
const renderPrimaryButton = () => {
|
||||
/* 步骤 1~2:上一步 / 下一步 */
|
||||
if (currentStep < 3) {
|
||||
/* 步骤 1~3:上一步 / 下一步 */
|
||||
if (currentStep < 4) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步 →
|
||||
@@ -41,8 +60,20 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 3:选择标题 — 确认生成 */
|
||||
if (currentStep === 3) {
|
||||
/* 步骤 4:选择标题 — 确认生成 */
|
||||
if (currentStep === 4) {
|
||||
const costTag =
|
||||
typeof estimatedCost === "number" && estimatedCost > 0 ? (
|
||||
<span className="xx-step-cost-tag">
|
||||
<ThunderboltOutlined />
|
||||
{estimatedCost}
|
||||
</span>
|
||||
) : typeof estimatedCost === "number" &&
|
||||
estimatedCost === 0 &&
|
||||
typeof freeClipsUsedThisTime === "number" ? (
|
||||
<span className="xx-step-cost-tag free">使用 {freeClipsUsedThisTime} 次免费额度</span>
|
||||
) : null
|
||||
|
||||
if (generating) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" disabled>
|
||||
@@ -54,18 +85,41 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
|
||||
🔄 重新生成
|
||||
{costTag}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (pointsInsufficient) {
|
||||
return (
|
||||
<Tooltip title={insufficientReason ?? "积分不足,请充值"}>
|
||||
<span>
|
||||
<button className="xx-btn xx-btn-primary" disabled>
|
||||
<ExclamationCircleOutlined /> 积分不足
|
||||
{costTag}
|
||||
</button>
|
||||
{onRecharge && (
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost"
|
||||
style={{ marginLeft: 8, color: "#7c3aed", borderColor: "#c4b5fd" }}
|
||||
onClick={onRecharge}
|
||||
>
|
||||
去充值 →
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
|
||||
{selectedCount > 1 ? `✨ 确认生成 ${selectedCount} 个视频` : "✨ 确认生成视频"}
|
||||
{costTag}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 4:确认生成进度页 — 全部完成后下一步进封面 */
|
||||
if (currentStep === 4) {
|
||||
/* 步骤 5:确认生成进度页 — 全部完成后下一步进封面 */
|
||||
if (currentStep === 5) {
|
||||
if (generated) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
@@ -80,7 +134,7 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 5(封面,最后一步):无主按钮 */
|
||||
/* 步骤 6(封面,最后一步):无主按钮 */
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* Points cost badge inside step actions button */
|
||||
.xx-step-cost-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.xx-step-cost-tag.free {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #d1fae5;
|
||||
}
|
||||
|
||||
.xx-step-cost-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 10px;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 生成前积分消耗估算与余额校验
|
||||
* 用于步骤4「确认生成」按钮前展示本次消耗,积分不足时禁用并提示充值
|
||||
*/
|
||||
import type { PointsBalance } from "@/api/points/types"
|
||||
|
||||
/** 生成单条视频消耗积分(基准) */
|
||||
export const BASE_VIDEO_POINTS = 3
|
||||
|
||||
/**
|
||||
* 估算生成任务的积分消耗
|
||||
* @param videoCount 视频条数(批量模式)
|
||||
* @param memberMultiplier 会员倍率(免费用户 1.15)
|
||||
*/
|
||||
export function estimateGenerateCost(videoCount: number, memberMultiplier = 1): number {
|
||||
const raw = BASE_VIDEO_POINTS * videoCount * memberMultiplier
|
||||
// 向上取整,避免小数
|
||||
return Math.ceil(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断积分是否充足(含每日免费额度)
|
||||
* @returns sufficient=true 表示可以继续生成;false 需要提示充值
|
||||
*/
|
||||
export function hasEnoughPoints(
|
||||
balance: PointsBalance | null,
|
||||
videoCount: number,
|
||||
): {
|
||||
sufficient: boolean
|
||||
cost: number
|
||||
reason?: string
|
||||
freeClipsUsed?: number
|
||||
freeClipsRemaining?: number
|
||||
} {
|
||||
if (!balance) {
|
||||
// 未登录或未加载:不拦截,后端会校验
|
||||
return { sufficient: true, cost: estimateGenerateCost(videoCount) }
|
||||
}
|
||||
|
||||
const isMember = balance.is_member
|
||||
const multiplier = isMember ? 1 : 1.15
|
||||
const cost = estimateGenerateCost(videoCount, multiplier)
|
||||
|
||||
// 免费用户优先使用每日免费额度
|
||||
if (!isMember && balance.free_clips_remaining && balance.free_clips_remaining > 0) {
|
||||
const freeUsed = Math.min(balance.free_clips_remaining, videoCount)
|
||||
const remainingAfterFree = videoCount - freeUsed
|
||||
const paidCost = estimateGenerateCost(remainingAfterFree, multiplier)
|
||||
if (paidCost === 0) {
|
||||
// 完全用免费额度
|
||||
return {
|
||||
sufficient: true,
|
||||
cost: 0,
|
||||
freeClipsUsed: freeUsed,
|
||||
freeClipsRemaining: balance.free_clips_remaining - freeUsed,
|
||||
}
|
||||
}
|
||||
if (balance.balance >= paidCost) {
|
||||
return {
|
||||
sufficient: true,
|
||||
cost: paidCost,
|
||||
freeClipsUsed: freeUsed,
|
||||
freeClipsRemaining: balance.free_clips_remaining - freeUsed,
|
||||
}
|
||||
}
|
||||
return {
|
||||
sufficient: false,
|
||||
cost: paidCost,
|
||||
reason: `积分不足:本次需 ${paidCost} 积分(使用 ${freeUsed} 次免费额度后),当前余额 ${balance.balance},还差 ${paidCost - balance.balance} 积分`,
|
||||
freeClipsUsed: freeUsed,
|
||||
}
|
||||
}
|
||||
|
||||
// 付费会员或免费额度用完
|
||||
if (balance.balance >= cost) {
|
||||
return { sufficient: true, cost }
|
||||
}
|
||||
return {
|
||||
sufficient: false,
|
||||
cost,
|
||||
reason: `积分不足:本次需 ${cost} 积分,当前余额 ${balance.balance},还差 ${cost - balance.balance} 积分`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 积分中心主页(/app/points)
|
||||
* 展示余额、会员信息、本月统计、快捷入口
|
||||
*/
|
||||
import React, { useEffect } from "react"
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Statistic,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Progress,
|
||||
List,
|
||||
Avatar,
|
||||
Empty,
|
||||
} from "antd"
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
CrownFilled,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
HistoryOutlined,
|
||||
WalletOutlined,
|
||||
FileTextOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import "./Points.css"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const SOURCE_NAME: Record<string, string> = {
|
||||
recharge: "充值",
|
||||
task_reward: "任务奖励",
|
||||
ai_voice: "AI 配音",
|
||||
ai_digital_human: "AI 数字人",
|
||||
ai_video: "智能混剪",
|
||||
voice_clone_train: "声音克隆训练",
|
||||
voice_clone_synth: "声音克隆合成",
|
||||
douyin_extract: "抖音提取",
|
||||
ai_rewrite: "AI 改写",
|
||||
ai_title: "AI 标题",
|
||||
ai_cover: "AI 封面",
|
||||
subscription_bonus: "会员赠送",
|
||||
admin_adjust: "管理员调整",
|
||||
refund: "失败退还",
|
||||
}
|
||||
|
||||
const PointsCenter: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { balance, subscription, init, loading } = usePointsStore()
|
||||
|
||||
useEffect(() => {
|
||||
init()
|
||||
}, [init])
|
||||
|
||||
const bal = balance?.balance ?? 0
|
||||
const earned = balance?.total_earned ?? 0
|
||||
const spent = balance?.total_spent ?? 0
|
||||
const isMember = !!balance?.is_member
|
||||
const freeUsed = balance?.free_clips_used ?? 0
|
||||
const freeLimit = balance?.free_clips_limit ?? 2
|
||||
const freeRemain = balance?.free_clips_remaining ?? (isMember ? 0 : 2)
|
||||
|
||||
// 近 5 条流水 mock(实际从 transactions 页加载)
|
||||
const recentTx = [
|
||||
{ type: "spend", source: "ai_voice", amount: 1, time: "今天 10:30" },
|
||||
{ type: "spend", source: "ai_video", amount: 3, time: "今天 09:15" },
|
||||
{ type: "earn", source: "task_reward", amount: 20, time: "昨天" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="xx-points-page">
|
||||
<PageHead
|
||||
title="积分中心"
|
||||
description="管理积分余额、查看流水、充值使用"
|
||||
|
||||
actions={
|
||||
<Space>
|
||||
<Button icon={<FileTextOutlined />} onClick={() => navigate("/app/points/rules")}>
|
||||
积分规则
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<WalletOutlined />}
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 顶部大卡 */}
|
||||
<Card className="xx-points-hero" bordered={false}>
|
||||
<Row gutter={24} align="middle">
|
||||
<Col xs={24} md={14}>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text type="secondary" style={{ color: "rgba(255,255,255,0.8)" }}>
|
||||
当前可用积分
|
||||
</Text>
|
||||
<div className="xx-points-hero-balance">
|
||||
<ThunderboltOutlined />
|
||||
<span>{loading ? "…" : bal.toLocaleString()}</span>
|
||||
</div>
|
||||
<Space size={8} wrap>
|
||||
{isMember ? (
|
||||
<Tag color="gold" icon={<CrownFilled />} style={{ padding: "4px 10px" }}>
|
||||
{subscription?.member_type === "yearly"
|
||||
? "年卡"
|
||||
: subscription?.member_type === "quarterly"
|
||||
? "季卡"
|
||||
: "月卡"}
|
||||
会员
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
}}
|
||||
>
|
||||
免费会员
|
||||
</Tag>
|
||||
)}
|
||||
{balance?.member_expires_at && (
|
||||
<Text style={{ color: "rgba(255,255,255,0.85)", fontSize: 12 }}>
|
||||
到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
|
||||
</Text>
|
||||
)}
|
||||
{!isMember && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
style={{ background: "#fff", color: "#7c3aed", borderColor: "#fff" }}
|
||||
>
|
||||
<CrownFilled /> 升级会员享 8 折
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} md={10}>
|
||||
{/* 今日免费额度 */}
|
||||
{!isMember && freeLimit > 0 && (
|
||||
<div className="xx-points-free-quota">
|
||||
<div className="xx-points-free-title">
|
||||
<InfoCircleOutlined /> 今日免费混剪
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((freeUsed / freeLimit) * 100)}
|
||||
strokeColor={{ "0%": "#f59e0b", "100%": "#ef4444" }}
|
||||
format={() => `${freeUsed}/${freeLimit} 条`}
|
||||
/>
|
||||
<Text style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
|
||||
剩余 {freeRemain} 条免费混剪,超出部分消耗积分
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 统计 */}
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="累计获得"
|
||||
value={earned}
|
||||
prefix={<ArrowUpOutlined style={{ color: "#10b981" }} />}
|
||||
valueStyle={{ color: "#10b981" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="累计消耗"
|
||||
value={spent}
|
||||
prefix={<ArrowDownOutlined style={{ color: "#ef4444" }} />}
|
||||
valueStyle={{ color: "#ef4444" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="可用积分"
|
||||
value={bal}
|
||||
prefix={<ThunderboltOutlined style={{ color: "#8b5cf6" }} />}
|
||||
valueStyle={{ color: "#8b5cf6" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={isMember ? "会员等级" : "今日免费剩余"}
|
||||
value={isMember ? (subscription?.member_type_name ?? "付费会员") : `${freeRemain} 条`}
|
||||
prefix={<CrownFilled style={{ color: "#f59e0b" }} />}
|
||||
valueStyle={{ color: "#f59e0b" }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 快捷入口 & 最近流水 */}
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 8 }}>
|
||||
<Col xs={24} md={14}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HistoryOutlined />
|
||||
最近流水
|
||||
</Space>
|
||||
}
|
||||
extra={<a onClick={() => navigate("/app/points/transactions")}>查看全部 →</a>}
|
||||
>
|
||||
{recentTx.length === 0 ? (
|
||||
<Empty description="暂无积分流水" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={recentTx}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar
|
||||
size="small"
|
||||
style={{
|
||||
background: item.type === "earn" ? "#d1fae5" : "#fee2e2",
|
||||
color: item.type === "earn" ? "#059669" : "#dc2626",
|
||||
}}
|
||||
icon={item.type === "earn" ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
/>
|
||||
}
|
||||
title={SOURCE_NAME[item.source] ?? item.source}
|
||||
description={item.time}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
color: item.type === "earn" ? "#10b981" : "#ef4444",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{item.type === "earn" ? "+" : "-"}
|
||||
{item.amount}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={10}>
|
||||
<Card title="快捷操作">
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={10}>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
type="primary"
|
||||
icon={<WalletOutlined />}
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
icon={<HistoryOutlined />}
|
||||
onClick={() => navigate("/app/points/transactions")}
|
||||
>
|
||||
积分明细
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
icon={<CrownFilled />}
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
{isMember ? "续费/升级会员" : "升级付费会员"}
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate("/app/points/rules")}
|
||||
>
|
||||
积分消耗规则
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsCenter
|
||||
export const Component = PointsCenter
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 积分充值页(/app/points/recharge)
|
||||
* 单独展示积分包,供入口直接跳转使用
|
||||
* 主 Plans 页面也有充值区,这里提供独立 URL 方便从"积分不足"弹窗跳转
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { Card, Col, Row, Button, Tag, Typography, Space, Modal, message, Tooltip } from "antd"
|
||||
import { ThunderboltOutlined, SafetyCertificateOutlined, CrownFilled } from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { createPointsOrder, getPointsPackages } from "@/api/points"
|
||||
import type { PointsPackage } from "@/api/points/types"
|
||||
import "./Points.css"
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
const PointsRecharge: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { balance, init } = usePointsStore()
|
||||
const [packages, setPackages] = useState<PointsPackage[]>([])
|
||||
const [buying, setBuying] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
init()
|
||||
getPointsPackages()
|
||||
.then((r) => setPackages(r.packages))
|
||||
.catch(() => {})
|
||||
}, [init])
|
||||
|
||||
const getPackPrice = (pkg: PointsPackage): number => {
|
||||
const mt = balance?.member_type ?? "free"
|
||||
type DiscountKey =
|
||||
| "discounted_price_for_free"
|
||||
| "discounted_price_for_monthly"
|
||||
| "discounted_price_for_quarterly"
|
||||
| "discounted_price_for_yearly"
|
||||
const key = `discounted_price_for_${mt}` as DiscountKey
|
||||
return pkg[key] ?? pkg.price
|
||||
}
|
||||
|
||||
const handleBuy = async (pkg: PointsPackage) => {
|
||||
try {
|
||||
setBuying(pkg.id)
|
||||
const order = await createPointsOrder({ package_id: pkg.id })
|
||||
Modal.info({
|
||||
title: "支付功能开发中",
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
<Paragraph>
|
||||
订单已创建({order.id.slice(0, 16)}…),金额{" "}
|
||||
<b>¥{(order.price_cents / 100).toFixed(2).replace(/\.00$/, "")}</b>。
|
||||
微信/支付宝支付正在接入中。
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
现阶段所有功能免费体验,积分仅为演示数据。
|
||||
</Paragraph>
|
||||
</div>
|
||||
),
|
||||
okText: "知道了",
|
||||
})
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
message.error(err?.message ?? "下单失败")
|
||||
} finally {
|
||||
setBuying(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-points-page">
|
||||
<PageHead
|
||||
title="积分充值"
|
||||
description="积分永久有效,可用于全部 AI 功能;付费会员享折扣"
|
||||
|
||||
actions={
|
||||
<Space>
|
||||
{!balance?.is_member && (
|
||||
<Button icon={<CrownFilled />} onClick={() => navigate("/app/subscription")}>
|
||||
升级会员 8 折起
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => navigate("/app/points/transactions")}>积分明细</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{balance && (
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ marginBottom: 16, background: "linear-gradient(135deg,#ede9fe,#fce7f3)" }}
|
||||
>
|
||||
<Space size="large">
|
||||
<div>
|
||||
<Text type="secondary">当前可用积分</Text>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: "#7c3aed" }}>
|
||||
<ThunderboltOutlined /> {balance.balance.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Title level={4}>选择积分包</Title>
|
||||
<Row gutter={[20, 20]}>
|
||||
{packages.map((pkg) => {
|
||||
const price = getPackPrice(pkg)
|
||||
const discount = price < pkg.price ? Math.round((1 - price / pkg.price) * 100) : 0
|
||||
const unit = price / pkg.points
|
||||
return (
|
||||
<Col xs={24} sm={12} md={8} key={pkg.id}>
|
||||
<Card
|
||||
className={`xx-pkg-card ${pkg.id === "basic_pack" ? "recommended" : ""} ${discount > 0 ? "has-discount" : ""}`}
|
||||
hoverable
|
||||
>
|
||||
{pkg.id === "basic_pack" && <div className="xx-pkg-badge">热门</div>}
|
||||
{discount > 0 && (
|
||||
<Tag color="gold" className="xx-pkg-discount">
|
||||
会员{10 - discount / 10}折
|
||||
</Tag>
|
||||
)}
|
||||
<div className="xx-pkg-name">{pkg.name}</div>
|
||||
<div className="xx-pkg-points">
|
||||
<ThunderboltOutlined /> {pkg.points.toLocaleString()} 积分
|
||||
</div>
|
||||
<div className="xx-pkg-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">
|
||||
{(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
|
||||
</span>
|
||||
{discount > 0 && (
|
||||
<span className="xx-pkg-origin">¥{(pkg.price / 100).toFixed(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分 · 永久有效</div>
|
||||
<Button
|
||||
block
|
||||
type={pkg.id === "basic_pack" ? "primary" : "default"}
|
||||
loading={buying === pkg.id}
|
||||
onClick={() => handleBuy(pkg)}
|
||||
size="large"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginTop: 24 }}>
|
||||
<Title level={5}>积分消耗说明</Title>
|
||||
<ul style={{ paddingLeft: 20, color: "var(--text-secondary)", lineHeight: 2 }}>
|
||||
<li>智能混剪:3 积分/条(≤30s),每加 30s +1 积分</li>
|
||||
<li>AI 配音 / 声音克隆合成:1 积分/分钟</li>
|
||||
<li>AI 数字人:15 积分/分钟</li>
|
||||
<li>抖音链接提取 / AI 改写 / AI 标题 / AI 封面:1~2 积分/次</li>
|
||||
<li>声音克隆训练:免费</li>
|
||||
<li>免费用户每日 2 条混剪免费,其余 AI 功能消耗为会员价 ×1.15</li>
|
||||
</ul>
|
||||
<Tooltip title="具体规则以系统实际计算为准">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<SafetyCertificateOutlined /> 所有规则以页面实际显示为准
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsRecharge
|
||||
export const Component = PointsRecharge
|
||||
@@ -0,0 +1,88 @@
|
||||
/* 积分中心 & 明细 & 规则页通用样式 */
|
||||
.xx-points-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
/* Hero 渐变卡 */
|
||||
.xx-points-hero {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #ec4899 50%, #f59e0b 100%);
|
||||
border-radius: 16px;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-points-hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -60px;
|
||||
top: -60px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.xx-points-hero .ant-card-body {
|
||||
padding: 28px 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-points-hero-balance {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
font-size: 52px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.xx-points-hero-balance .anticon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.xx-points-free-quota {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.xx-points-free-title {
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.xx-points-free-quota .ant-progress-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* 列表筛选 */
|
||||
.xx-points-filters {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-tx-earn {
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.xx-tx-spend {
|
||||
color: #ef4444;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.xx-tx-refund {
|
||||
color: #3b82f6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 规则页 */
|
||||
.xx-rules-table .ant-descriptions-item-label {
|
||||
width: 160px;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 积分规则页(/app/points/rules)
|
||||
* 静态展示积分获得、消耗、会员折扣等规则
|
||||
*/
|
||||
import React from "react"
|
||||
import { Card, Descriptions, Table, Tag, Typography, Divider, Space } from "antd"
|
||||
import { InfoCircleOutlined, CrownFilled, ThunderboltOutlined } from "@ant-design/icons"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import "./Points.css"
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
const costData = [
|
||||
{ key: "ai_video", scene: "智能混剪", unit: "条(≤30s)", cost: 3, remark: "每加 30s +1" },
|
||||
{ key: "ai_voice", scene: "AI 配音(含克隆合成)", unit: "分钟", cost: 1, remark: "" },
|
||||
{ key: "ai_digital_human", scene: "AI 数字人", unit: "分钟", cost: 15, remark: "" },
|
||||
{ key: "douyin_extract", scene: "抖音文案提取", unit: "次", cost: 1, remark: "" },
|
||||
{ key: "ai_rewrite", scene: "AI 文案改写", unit: "次", cost: 1, remark: "" },
|
||||
{ key: "ai_title", scene: "AI 标题生成", unit: "次", cost: 1, remark: "" },
|
||||
{ key: "ai_cover", scene: "AI 封面生成", unit: "次", cost: 2, remark: "" },
|
||||
{ key: "voice_clone_train", scene: "声音克隆训练", unit: "次", cost: 0, remark: "免费" },
|
||||
]
|
||||
|
||||
const earnData = [
|
||||
{ key: "new_user", channel: "注册奖励", points: "50~100", remark: "新人专享" },
|
||||
{ key: "recharge", channel: "充值购买", points: "100~2000/次", remark: "永久有效" },
|
||||
{ key: "subscribe", channel: "开通会员赠送", points: "见会员方案", remark: "季/年卡赠送更多" },
|
||||
{ key: "task", channel: "任务/活动奖励", points: "不定额", remark: "运营活动发放" },
|
||||
]
|
||||
|
||||
const PointsRules: React.FC = () => {
|
||||
return (
|
||||
<div className="xx-points-page">
|
||||
<PageHead title="积分规则" description="了解如何获得、使用与管理积分" />
|
||||
|
||||
<Card>
|
||||
<Title level={5}>
|
||||
<InfoCircleOutlined style={{ color: "#3b82f6" }} /> 什么是积分?
|
||||
</Title>
|
||||
<Paragraph>
|
||||
积分是小虾 SaaS 用于结算 AI 能力的虚拟货币。每次使用智能混剪、AI 配音、AI 数字人、AI
|
||||
改写等功能时,
|
||||
会根据使用量扣除相应积分。积分永久有效,可通过充值、任务奖励、注册礼包等方式获取。
|
||||
</Paragraph>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={5}>
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6" }} /> 积分消耗规则
|
||||
</Title>
|
||||
<Table
|
||||
className="xx-rules-table"
|
||||
dataSource={costData}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "功能场景", dataIndex: "scene", width: 200 },
|
||||
{ title: "计费单位", dataIndex: "unit", width: 140 },
|
||||
{
|
||||
title: "消耗积分",
|
||||
dataIndex: "cost",
|
||||
width: 110,
|
||||
render: (v: number) =>
|
||||
v === 0 ? (
|
||||
<Tag color="green">免费</Tag>
|
||||
) : (
|
||||
<Text strong style={{ color: "#ef4444" }}>
|
||||
{v}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ title: "备注", dataIndex: "remark" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Paragraph type="secondary" style={{ marginTop: 12, fontSize: 12 }}>
|
||||
免费用户每日享有 2 条免费混剪额度,超出后按上述标准 ×1.15 扣除积分;
|
||||
付费会员按上述标准原价扣除积分。
|
||||
</Paragraph>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={5} style={{ color: "#10b981" }}>
|
||||
↑ 积分获得方式
|
||||
</Title>
|
||||
<Table
|
||||
className="xx-rules-table"
|
||||
dataSource={earnData}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "渠道", dataIndex: "channel", width: 200 },
|
||||
{ title: "积分数量", dataIndex: "points", width: 180 },
|
||||
{ title: "备注", dataIndex: "remark" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={5}>
|
||||
<CrownFilled style={{ color: "#f59e0b" }} /> 会员折扣
|
||||
</Title>
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="免费用户">
|
||||
所有 AI 功能按标准价 ×1.15 消耗积分;每日 2 条免费混剪
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="付费会员(月/季/年卡)">
|
||||
所有 AI 功能按标准价消耗;积分购买享 8~9 折;解锁批量导出、多平台发布等高级功能
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="年卡会员">
|
||||
积分购买最低 8 折;开通即赠送 2000 积分
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="季卡会员">
|
||||
积分购买 8.5 折;开通即赠送 400 积分
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={5}>退款与有效期</Title>
|
||||
<ul style={{ paddingLeft: 20, lineHeight: 2 }}>
|
||||
<li>充值积分永久有效,不会过期</li>
|
||||
<li>AI 生成失败或服务异常时,已扣除的积分将自动退还</li>
|
||||
<li>会员为虚拟商品,一经开通不支持退款;到期后会员权益自动失效,积分余额不受影响</li>
|
||||
<li>如需发票请联系客服</li>
|
||||
</ul>
|
||||
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
规则更新时间:2026-09-15
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsRules
|
||||
export const Component = PointsRules
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 积分明细页(/app/points/transactions)
|
||||
* 分页展示积分流水,支持按类型/来源筛选
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback } from "react"
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Tag,
|
||||
Select,
|
||||
DatePicker,
|
||||
Space,
|
||||
Typography,
|
||||
Empty,
|
||||
Input,
|
||||
Spin,
|
||||
Button,
|
||||
} from "antd"
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import dayjs from "dayjs"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { getPointsTransactions } from "@/api/points"
|
||||
import type { PointsTransaction, PointsTxType } from "@/api/points/types"
|
||||
import "./Points.css"
|
||||
|
||||
const { Text } = Typography
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
const TYPE_LABEL: Record<PointsTxType, { text: string; color: string }> = {
|
||||
earn: { text: "获得", color: "green" },
|
||||
spend: { text: "消耗", color: "red" },
|
||||
refund: { text: "退还", color: "blue" },
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
recharge: "充值",
|
||||
task_reward: "任务奖励",
|
||||
ai_voice: "AI 配音",
|
||||
ai_digital_human: "AI 数字人",
|
||||
ai_video: "智能混剪",
|
||||
voice_clone_train: "声音克隆训练",
|
||||
voice_clone_synth: "克隆合成",
|
||||
douyin_extract: "抖音链接提取",
|
||||
ai_rewrite: "AI 文案改写",
|
||||
ai_title: "AI 标题生成",
|
||||
ai_cover: "AI 封面生成",
|
||||
subscription_bonus: "会员赠送",
|
||||
admin_adjust: "管理员调整",
|
||||
refund: "失败退还",
|
||||
}
|
||||
|
||||
const PointsTransactions: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<PointsTransaction[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [type, setType] = useState<PointsTxType | "all">("all")
|
||||
const [source, setSource] = useState<string>("all")
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null)
|
||||
const [keyword, setKeyword] = useState("")
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: Record<string, string | number> = { page, page_size: pageSize }
|
||||
if (type !== "all") params.type = type
|
||||
if (source !== "all") params.source = source
|
||||
if (dateRange && dateRange[0] && dateRange[1]) {
|
||||
params.start_date = dateRange[0].format("YYYY-MM-DD")
|
||||
params.end_date = dateRange[1].format("YYYY-MM-DD")
|
||||
}
|
||||
const res = await getPointsTransactions(params)
|
||||
let items = res.items
|
||||
if (keyword) {
|
||||
const k = keyword.toLowerCase()
|
||||
items = items.filter(
|
||||
(it) =>
|
||||
it.description.toLowerCase().includes(k) ||
|
||||
(SOURCE_LABEL[it.source] ?? it.source).includes(keyword),
|
||||
)
|
||||
}
|
||||
setData(items)
|
||||
setTotal(res.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, type, source, dateRange, keyword])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const columns: ColumnsType<PointsTransaction> = [
|
||||
{
|
||||
title: "时间",
|
||||
dataIndex: "created_at",
|
||||
width: 180,
|
||||
render: (v: string) => dayjs(v).format("YYYY-MM-DD HH:mm"),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
width: 90,
|
||||
render: (t: PointsTxType) => {
|
||||
const cfg = TYPE_LABEL[t]
|
||||
return <Tag color={cfg.color}>{cfg.text}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "来源/场景",
|
||||
dataIndex: "source",
|
||||
width: 150,
|
||||
render: (s: string, r: PointsTransaction) => (
|
||||
<Space>
|
||||
<Text>{r.source_name || SOURCE_LABEL[s] || s}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "说明",
|
||||
dataIndex: "description",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "变动",
|
||||
dataIndex: "signed_amount",
|
||||
width: 110,
|
||||
align: "right",
|
||||
render: (v: number, r: PointsTransaction) => (
|
||||
<span
|
||||
className={
|
||||
r.type === "earn" ? "xx-tx-earn" : r.type === "refund" ? "xx-tx-refund" : "xx-tx-spend"
|
||||
}
|
||||
>
|
||||
{v > 0 ? "+" : ""}
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "余额",
|
||||
dataIndex: "balance_after",
|
||||
width: 110,
|
||||
align: "right",
|
||||
render: (v: number) => (
|
||||
<Text strong style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{v}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="xx-points-page">
|
||||
<PageHead
|
||||
title="积分明细"
|
||||
description="查看积分收入、消耗与退还记录"
|
||||
|
||||
actions={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => navigate("/app/subscription")}>
|
||||
充值积分
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div className="xx-points-filters">
|
||||
<Space wrap size={12}>
|
||||
<Select
|
||||
value={type}
|
||||
onChange={(v) => {
|
||||
setType(v)
|
||||
setPage(1)
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "earn", label: "获得" },
|
||||
{ value: "spend", label: "消耗" },
|
||||
{ value: "refund", label: "退还" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={source}
|
||||
onChange={(v) => {
|
||||
setSource(v)
|
||||
setPage(1)
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
showSearch
|
||||
options={[
|
||||
{ value: "all", label: "全部来源" },
|
||||
...Object.entries(SOURCE_LABEL).map(([k, v]) => ({ value: k, label: v })),
|
||||
]}
|
||||
/>
|
||||
<RangePicker
|
||||
value={dateRange ?? undefined}
|
||||
onChange={(dates) => {
|
||||
setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null] | null)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索说明"
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setPage(1)
|
||||
load()
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Table<PointsTransaction>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
locale={{ emptyText: <Empty description="暂无积分流水,快去使用 AI 功能吧" /> }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsTransactions
|
||||
export const Component = PointsTransactions
|
||||
@@ -1,233 +1,286 @@
|
||||
/* V21 定价页面 - CSS变量体系 */
|
||||
@import "../../styles/global.css";
|
||||
|
||||
/* 会员订阅 & 积分充值页样式 v2 */
|
||||
.xx-plans-page {
|
||||
max-width: 1200px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-3xl) var(--space-lg);
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
/* 页面头部 */
|
||||
.xx-page-head {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-3xl);
|
||||
/* 当前状态卡片 */
|
||||
.xx-current-status {
|
||||
background: linear-gradient(135deg, #ede9fe 0%, #fce7f3 100%);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-page-head h2 {
|
||||
font-size: var(--font-size-5xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-md);
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
.xx-current-status .ant-card-body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.xx-page-head p {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* 定价卡片网格 - 4列 */
|
||||
.xx-plans-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-lg);
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.xx-plans-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-plans-grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* V21 定价卡片 */
|
||||
.xx-plan-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
position: relative;
|
||||
transition: var(--transition-base);
|
||||
.xx-current-balance {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.xx-plan-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 32px 90px color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
transform: translateY(-4px);
|
||||
.xx-current-balance-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 计费周期切换 */
|
||||
.xx-billing-switch {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-billing-btn {
|
||||
position: relative;
|
||||
border: 2px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-primary, #fff);
|
||||
border-radius: 12px;
|
||||
padding: 16px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-billing-btn:hover {
|
||||
border-color: var(--primary-soft, #c4b5fd);
|
||||
}
|
||||
|
||||
.xx-billing-btn.active {
|
||||
border-color: #7c3aed;
|
||||
background: #f5f3ff;
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
.xx-billing-name {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.xx-billing-price {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.xx-billing-yuan {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.xx-billing-amount {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #111827);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.xx-billing-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
}
|
||||
|
||||
.xx-billing-save {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 方案卡片 */
|
||||
.xx-plan-card {
|
||||
border-radius: 14px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.xx-plan-card.featured {
|
||||
border: 2px solid var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 4px color-mix(in srgb, var(--primary-color) 10%, transparent),
|
||||
0 32px 90px color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
transform: scale(1.02);
|
||||
border: 2px solid #7c3aed;
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
|
||||
.xx-plan-card.featured:hover {
|
||||
transform: scale(1.02) translateY(-4px);
|
||||
}
|
||||
|
||||
/* 推荐标签 */
|
||||
.xx-badge {
|
||||
.xx-plan-recommend-badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--gradient-primary);
|
||||
color: var(--text-inverse);
|
||||
padding: 6px 16px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
white-space: nowrap;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* 企业版定制标签 */
|
||||
.xx-badge.enterprise {
|
||||
background: linear-gradient(135deg, var(--accent-color), var(--accent-dark));
|
||||
}
|
||||
|
||||
/* 套餐名称 */
|
||||
.xx-plan-card h3 {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-lg);
|
||||
.xx-plan-head {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 价格 */
|
||||
.xx-plan-price {
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
margin: 12px 0 6px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-plan-price .currency {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-secondary);
|
||||
vertical-align: top;
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.xx-plan-price .amount {
|
||||
font-size: var(--font-size-5xl);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--primary-color);
|
||||
font-size: 44px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #111827);
|
||||
line-height: 1;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.xx-plan-card.featured .xx-plan-price .amount {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.xx-plan-price .period {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 企业版价格 */
|
||||
.xx-plan-price.custom .amount {
|
||||
font-size: var(--font-size-3xl);
|
||||
.xx-feature-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
/* 描述 */
|
||||
.xx-plan-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-base);
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding-bottom: var(--space-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* 功能列表 */
|
||||
.xx-features {
|
||||
margin-top: var(--space-sm);
|
||||
padding-top: var(--space-lg);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xx-feature {
|
||||
.xx-feature-list li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: var(--space-md);
|
||||
line-height: var(--line-height-base);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.xx-feature span:first-child {
|
||||
color: var(--secondary-color);
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
flex-shrink: 0;
|
||||
.xx-feature-list li.disabled {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* 订阅按钮 */
|
||||
.xx-subscribe-btn {
|
||||
width: 100%;
|
||||
margin-top: var(--space-lg);
|
||||
padding: var(--space-md) var(--space-lg) !important;
|
||||
font-size: var(--font-size-base) !important;
|
||||
font-weight: var(--font-weight-extrabold) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
transition: var(--transition-fast) !important;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
.xx-feature-no {
|
||||
width: 16px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary, #d1d5db);
|
||||
}
|
||||
|
||||
.xx-subscribe-btn.primary {
|
||||
background: var(--gradient-primary) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border: none !important;
|
||||
box-shadow: var(--shadow-primary) !important;
|
||||
.xx-plan-tip {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.xx-subscribe-btn.primary:hover {
|
||||
box-shadow: var(--shadow-hover) !important;
|
||||
transform: translateY(-1px);
|
||||
/* 积分包卡片 */
|
||||
.xx-pkg-card {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-subscribe-btn.ghost {
|
||||
background: var(--bg-primary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
color: var(--text-primary) !important;
|
||||
.xx-pkg-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 28px rgba(139, 92, 246, 0.18);
|
||||
}
|
||||
|
||||
.xx-subscribe-btn.ghost:hover {
|
||||
border-color: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
.xx-pkg-card.recommended {
|
||||
border: 2px solid #8b5cf6;
|
||||
}
|
||||
|
||||
/* 企业版按钮 */
|
||||
.xx-subscribe-btn.enterprise {
|
||||
background: linear-gradient(135deg, var(--accent-color), var(--accent-dark)) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 14px 26px color-mix(in srgb, var(--accent-color) 22%, transparent) !important;
|
||||
.xx-pkg-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
color: #fff;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.xx-plans-grid {
|
||||
.xx-pkg-discount {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-pkg-name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.xx-pkg-points {
|
||||
font-size: 16px;
|
||||
color: #8b5cf6;
|
||||
font-weight: 600;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.xx-pkg-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.xx-pkg-price .currency {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.xx-pkg-price .amount {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #111827);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-pkg-card.recommended .xx-pkg-price .amount {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.xx-pkg-origin {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
text-decoration: line-through;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-pkg-unit {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.xx-billing-switch {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-plan-card {
|
||||
padding: var(--space-lg);
|
||||
.xx-plan-price .amount {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +1,422 @@
|
||||
/**
|
||||
* 定价页面 - V21 UI 四级定价
|
||||
* 会员订阅 & 积分充值页
|
||||
* v2 两档会员制:免费 vs 付费
|
||||
* 付费三档:月¥19.9 / 季¥39.9(推荐)/ 年¥159
|
||||
* 积分包:100/¥9.9、500/¥39、2000/¥129
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { message } from "antd"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Tag,
|
||||
Divider,
|
||||
Space,
|
||||
Typography,
|
||||
Modal,
|
||||
message,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from "antd"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
CrownFilled,
|
||||
ThunderboltOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
VideoCameraOutlined,
|
||||
StarFilled,
|
||||
} from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { SUBSCRIPTION_PLANS, createPointsOrder } from "@/api/points"
|
||||
import type { PointsPackage } from "@/api/points/types"
|
||||
import { getPointsPackages } from "@/api/points"
|
||||
import "./Plans.css"
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
id: "free",
|
||||
name: "体验版",
|
||||
price: 0,
|
||||
description: "适合个人体验",
|
||||
recommended: false,
|
||||
features: ["3个视频成片", "基础标题模板", "基础配音音色", "720p导出"],
|
||||
},
|
||||
{
|
||||
id: "standard",
|
||||
name: "标准版",
|
||||
price: 99,
|
||||
description: "适合内容创作者",
|
||||
recommended: false,
|
||||
features: ["无限视频成片", "高级标题模板", "全部配音音色", "1080p导出", "API接口"],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "专业版",
|
||||
price: 299,
|
||||
description: "适合专业团队",
|
||||
recommended: true,
|
||||
features: ["无限视频成片", "自定义标题", "全部配音音色", "4K导出", "优先渲染队列", "专属客服"],
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "企业版",
|
||||
price: null,
|
||||
description: "适合企业用户",
|
||||
recommended: false,
|
||||
features: ["私有化部署", "自定义品牌形象", " SLA服务保障", "技术支持", "团队协作", "数据报表"],
|
||||
},
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
/** 免费会员权益 */
|
||||
const FREE_FEATURES = [
|
||||
{ include: true, text: "每日 2 条免费混剪" },
|
||||
{ include: true, text: "720p 导出分辨率" },
|
||||
{ include: true, text: "AI 配音(×1.15 积分)" },
|
||||
{ include: true, text: "AI 数字人(×1.15 积分)" },
|
||||
{ include: true, text: "声音克隆训练免费" },
|
||||
{ include: false, text: "批量导出" },
|
||||
{ include: false, text: "多平台一键发布" },
|
||||
{ include: false, text: "去重检测报告" },
|
||||
]
|
||||
|
||||
const Plans: React.FC = () => {
|
||||
const [subscribing, setSubscribing] = useState(false)
|
||||
/** 付费会员权益 */
|
||||
const PAID_FEATURES = [
|
||||
{ include: true, text: "无限次智能混剪" },
|
||||
{ include: true, text: "最高 1080p 导出" },
|
||||
{ include: true, text: "全部 AI 功能(标准积分价)" },
|
||||
{ include: true, text: "声音克隆训练免费" },
|
||||
{ include: true, text: "积分购买最低 8 折" },
|
||||
{ include: true, text: "批量导出" },
|
||||
{ include: true, text: "多平台一键发布" },
|
||||
{ include: true, text: "去重检测报告" },
|
||||
]
|
||||
|
||||
const handleSubscribe = async (_planId: string) => {
|
||||
const formatYuan = (cents: number) =>
|
||||
`¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}`
|
||||
|
||||
const Plans: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { balance, init } = usePointsStore()
|
||||
const [selectedBilling, setSelectedBilling] = useState<"monthly" | "quarterly" | "yearly">(
|
||||
"quarterly",
|
||||
)
|
||||
const [packages, setPackages] = useState<PointsPackage[]>([])
|
||||
const [subscribing] = useState(false)
|
||||
const [buying, setBuying] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
init()
|
||||
getPointsPackages()
|
||||
.then((r) => setPackages(r.packages))
|
||||
.catch(() => {})
|
||||
}, [init])
|
||||
|
||||
const isMember = !!balance?.is_member
|
||||
const memberType = balance?.member_type ?? null
|
||||
|
||||
/** 根据会员等级计算积分包折后价(分) */
|
||||
const getPackPrice = (pkg: PointsPackage): number => {
|
||||
const mt = memberType ?? "free"
|
||||
type DiscountKey =
|
||||
| "discounted_price_for_free"
|
||||
| "discounted_price_for_monthly"
|
||||
| "discounted_price_for_quarterly"
|
||||
| "discounted_price_for_yearly"
|
||||
const key = `discounted_price_for_${mt}` as DiscountKey
|
||||
return pkg[key] ?? pkg.price
|
||||
}
|
||||
|
||||
const selectedPlan = useMemo(
|
||||
() => SUBSCRIPTION_PLANS.find((p) => p.id === selectedBilling)!,
|
||||
[selectedBilling],
|
||||
)
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
Modal.confirm({
|
||||
title: "支付功能开发中",
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
content: "微信/支付宝支付正在接入中,完成后会第一时间通知。是否返回首页继续使用免费功能?",
|
||||
okText: "返回首页",
|
||||
cancelText: "留在此页",
|
||||
onOk: () => navigate("/app/dashboard"),
|
||||
})
|
||||
// 实际对接时:
|
||||
// try {
|
||||
// setSubscribing(true)
|
||||
// const order = await subscribe({ member_type: selectedBilling, payment_method: "wechat_pay" })
|
||||
// // 拉起支付...
|
||||
// } catch (e: any) {
|
||||
// message.error(e?.message ?? "订阅失败")
|
||||
// } finally {
|
||||
// setSubscribing(false)
|
||||
// }
|
||||
}
|
||||
|
||||
const handleBuyPoints = async (pkg: PointsPackage) => {
|
||||
try {
|
||||
setSubscribing(true)
|
||||
message.success("订阅成功")
|
||||
} catch (error: unknown) {
|
||||
message.error("订阅失败")
|
||||
setBuying(pkg.id)
|
||||
const order = await createPointsOrder({ package_id: pkg.id })
|
||||
Modal.info({
|
||||
title: "支付功能开发中",
|
||||
icon: <ThunderboltOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
<Paragraph>
|
||||
订单 <Text code>{order.id.slice(0, 16)}…</Text> 已创建,金额{" "}
|
||||
<b>{formatYuan(order.price_cents)}</b>,
|
||||
微信/支付宝支付正在接入中,正式上线后可直接付款。
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
现阶段所有功能均处于免费体验阶段,积分仅为演示数据。
|
||||
</Paragraph>
|
||||
</div>
|
||||
),
|
||||
okText: "知道了",
|
||||
})
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
message.error(err?.message ?? "创建订单失败")
|
||||
} finally {
|
||||
setSubscribing(false)
|
||||
setBuying(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-plans-page">
|
||||
<PageHead title="选择适合您的方案" description="灵活定价,满足不同规模的创作需求" />
|
||||
<PageHead
|
||||
title="会员与积分"
|
||||
description="开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
|
||||
|
||||
<div className="xx-plans-grid">
|
||||
{PLANS.map((plan) => (
|
||||
<div key={plan.id} className={`xx-plan-card ${plan.recommended ? "featured" : ""}`}>
|
||||
{plan.recommended && <div className="xx-badge">推荐</div>}
|
||||
{plan.id === "enterprise" && <div className="xx-badge enterprise">定制</div>}
|
||||
|
||||
<h3>{plan.name}</h3>
|
||||
|
||||
<div className={`xx-plan-price ${plan.price === null ? "custom" : ""}`}>
|
||||
{plan.price !== null ? (
|
||||
<>
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">{plan.price}</span>
|
||||
<span className="period">/月</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="amount">联系我们</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="xx-plan-description">{plan.description}</p>
|
||||
|
||||
<div className="xx-features">
|
||||
{plan.features.map((feature, idx) => (
|
||||
<div key={idx} className="xx-feature">
|
||||
<span>✓</span>
|
||||
<span>{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`xx-subscribe-btn ${
|
||||
plan.id === "enterprise" ? "enterprise" : plan.recommended ? "primary" : "ghost"
|
||||
}`}
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
disabled={subscribing}
|
||||
actions={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => navigate("/app/points/transactions")}
|
||||
>
|
||||
{subscribing ? "处理中..." : plan.price === null ? "联系我们" : "立即订阅"}
|
||||
</button>
|
||||
</div>
|
||||
积分明细
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 当前状态卡片 */}
|
||||
{balance && (
|
||||
<Card className="xx-current-status" bordered={false}>
|
||||
<Row align="middle" gutter={24}>
|
||||
<Col flex="auto">
|
||||
<Space size="large" wrap>
|
||||
<div>
|
||||
<Text type="secondary">当前身份</Text>
|
||||
<div>
|
||||
{isMember ? (
|
||||
<Tag
|
||||
color="gold"
|
||||
icon={<CrownFilled />}
|
||||
style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}
|
||||
>
|
||||
{memberType === "yearly"
|
||||
? "年卡"
|
||||
: memberType === "quarterly"
|
||||
? "季卡"
|
||||
: "月卡"}
|
||||
会员
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag style={{ marginTop: 4, fontSize: 14, padding: "4px 10px" }}>
|
||||
免费会员
|
||||
</Tag>
|
||||
)}
|
||||
{balance.member_expires_at && (
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">可用积分</Text>
|
||||
<div className="xx-current-balance">
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
|
||||
<span className="xx-current-balance-val">{balance.balance}</span>
|
||||
</div>
|
||||
</div>
|
||||
{balance.free_clips_limit ? (
|
||||
<div>
|
||||
<Text type="secondary">今日免费混剪</Text>
|
||||
<div>
|
||||
<Text strong>{balance.free_clips_used ?? 0}</Text>
|
||||
<Text type="secondary"> / {balance.free_clips_limit} 条</Text>
|
||||
{!isMember && (
|
||||
<Tag color="blue" style={{ marginLeft: 8 }}>
|
||||
剩余 {balance.free_clips_remaining ?? 0} 条
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => {
|
||||
const el = document.getElementById("points-packages")
|
||||
el?.scrollIntoView({ behavior: "smooth" })
|
||||
}}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 两档会员对比 */}
|
||||
<Title level={4} style={{ marginTop: 24 }}>
|
||||
<CrownFilled style={{ color: "#f59e0b", marginRight: 8 }} />
|
||||
选择适合您的方案
|
||||
</Title>
|
||||
|
||||
{/* 计费周期切换 */}
|
||||
<div className="xx-billing-switch">
|
||||
{SUBSCRIPTION_PLANS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`xx-billing-btn ${selectedBilling === p.id ? "active" : ""}`}
|
||||
onClick={() => setSelectedBilling(p.id)}
|
||||
>
|
||||
{p.recommended && (
|
||||
<Badge.Ribbon text="推荐" color="gold" style={{ top: -14, right: -22 }} />
|
||||
)}
|
||||
<div className="xx-billing-name">{p.name}</div>
|
||||
<div className="xx-billing-price">
|
||||
<span className="xx-billing-yuan">¥</span>
|
||||
<span className="xx-billing-amount">{p.price_yuan}</span>
|
||||
</div>
|
||||
<div className="xx-billing-sub">
|
||||
≈¥{p.per_month_yuan}/月
|
||||
{p.savings_percent ? (
|
||||
<span className="xx-billing-save">省{p.savings_percent}%</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Row gutter={[20, 20]} style={{ marginTop: 16 }}>
|
||||
{/* 免费会员 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card className="xx-plan-card" bordered>
|
||||
<div className="xx-plan-head">
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
免费会员
|
||||
</Title>
|
||||
<div className="xx-plan-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">0</span>
|
||||
<span className="period">/永久</span>
|
||||
</div>
|
||||
<Text type="secondary">体验 AI 剪辑的基础能力</Text>
|
||||
</div>
|
||||
<Divider style={{ margin: "16px 0" }} />
|
||||
<ul className="xx-feature-list">
|
||||
{FREE_FEATURES.map((f, i) => (
|
||||
<li key={i} className={f.include ? "" : "disabled"}>
|
||||
{f.include ? (
|
||||
<CheckCircleFilled style={{ color: "#10b981" }} />
|
||||
) : (
|
||||
<span className="xx-feature-no">—</span>
|
||||
)}
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button block size="large" disabled>
|
||||
当前方案
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 付费会员 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card className="xx-plan-card featured" bordered>
|
||||
<div className="xx-plan-recommend-badge">
|
||||
<StarFilled /> 推荐
|
||||
</div>
|
||||
<div className="xx-plan-head">
|
||||
<Title level={4} style={{ margin: 0, color: "#7c3aed" }}>
|
||||
<CrownFilled style={{ color: "#f59e0b" }} /> 付费会员
|
||||
</Title>
|
||||
<div className="xx-plan-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">{selectedPlan.price_yuan}</span>
|
||||
<span className="period">{selectedPlan.billing_label}</span>
|
||||
</div>
|
||||
<Text type="secondary">
|
||||
折合 ¥{selectedPlan.per_month_yuan}/月 · 解锁全部 AI 能力
|
||||
</Text>
|
||||
</div>
|
||||
<Divider style={{ margin: "16px 0" }} />
|
||||
<ul className="xx-feature-list">
|
||||
{PAID_FEATURES.map((f, i) => (
|
||||
<li key={i}>
|
||||
<CheckCircleFilled style={{ color: "#7c3aed" }} />
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
type="primary"
|
||||
loading={subscribing}
|
||||
disabled={isMember && memberType === selectedBilling}
|
||||
onClick={handleSubscribe}
|
||||
icon={<ThunderboltOutlined />}
|
||||
>
|
||||
{isMember && memberType === selectedBilling
|
||||
? "当前方案"
|
||||
: isMember
|
||||
? "续费/升级"
|
||||
: "立即开通"}
|
||||
</Button>
|
||||
<div className="xx-plan-tip">
|
||||
<VideoCameraOutlined /> 虚拟商品不支持退款 · 支付接入中先演示
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 积分充值 */}
|
||||
<div id="points-packages">
|
||||
<Title level={4} style={{ marginTop: 40 }}>
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
|
||||
积分充值
|
||||
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
|
||||
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
|
||||
(永久有效)
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Title>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{packages.map((pkg) => {
|
||||
const price = getPackPrice(pkg)
|
||||
const discount = price < pkg.price ? Math.round((1 - price / pkg.price) * 100) : 0
|
||||
const unit = price / pkg.points
|
||||
return (
|
||||
<Col xs={24} sm={8} key={pkg.id}>
|
||||
<Card
|
||||
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${pkg.id === "basic_pack" ? "recommended" : ""}`}
|
||||
hoverable
|
||||
>
|
||||
{pkg.id === "basic_pack" && <div className="xx-pkg-badge">热门</div>}
|
||||
{discount > 0 && (
|
||||
<Tag color="gold" className="xx-pkg-discount">
|
||||
会员{10 - discount / 10}折
|
||||
</Tag>
|
||||
)}
|
||||
<div className="xx-pkg-name">{pkg.name}</div>
|
||||
<div className="xx-pkg-points">
|
||||
<ThunderboltOutlined /> {pkg.points.toLocaleString()} 积分
|
||||
</div>
|
||||
<div className="xx-pkg-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">
|
||||
{(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
|
||||
</span>
|
||||
{discount > 0 && (
|
||||
<span className="xx-pkg-origin">¥{(pkg.price / 100).toFixed(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分</div>
|
||||
<Button
|
||||
block
|
||||
type={pkg.id === "basic_pack" ? "primary" : "default"}
|
||||
loading={buying === pkg.id}
|
||||
onClick={() => handleBuyPoints(pkg)}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,22 @@ const appChildren: RouteObject[] = [
|
||||
path: "subscription/billing",
|
||||
lazy: lazyRoute(() => import("@/pages/subscription/Billing")),
|
||||
},
|
||||
{
|
||||
path: "points",
|
||||
lazy: lazyRoute(() => import("@/pages/points/Center")),
|
||||
},
|
||||
{
|
||||
path: "points/transactions",
|
||||
lazy: lazyRoute(() => import("@/pages/points/Transactions")),
|
||||
},
|
||||
{
|
||||
path: "points/recharge",
|
||||
lazy: lazyRoute(() => import("@/pages/points/Packages")),
|
||||
},
|
||||
{
|
||||
path: "points/rules",
|
||||
lazy: lazyRoute(() => import("@/pages/points/Rules")),
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
lazy: lazyRoute(() => import("@/pages/profile/Settings")),
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 积分 & 会员状态管理(Zustand)
|
||||
* - 启动时拉取余额 & 订阅信息
|
||||
* - 提供刷新、余额扣减(乐观更新)等工具
|
||||
*/
|
||||
import { create } from "zustand"
|
||||
import type { PointsBalance, PointsRulesResponse, SubscriptionCurrent } from "@/api/points/types"
|
||||
import { getCurrentSubscription, getPointsBalance, getPointsRules } from "@/api/points"
|
||||
|
||||
interface PointsState {
|
||||
/** 积分余额 & 会员状态(来自 /points/balance) */
|
||||
balance: PointsBalance | null
|
||||
/** 订阅详情(来自 /subscription/current) */
|
||||
subscription: SubscriptionCurrent | null
|
||||
/** 积分消耗规则缓存 */
|
||||
rules: PointsRulesResponse | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
/** 初始化:拉取余额 + 订阅信息 + 规则 */
|
||||
init: () => Promise<void>
|
||||
/** 强制刷新余额 */
|
||||
refreshBalance: () => Promise<void>
|
||||
/** 乐观扣减:在支付/业务发起前调用,失败时用 refreshBalance 兜底 */
|
||||
optimisticDeduct: (points: number) => void
|
||||
/** 乐观增加(充值成功后调用) */
|
||||
optimisticAdd: (points: number) => void
|
||||
}
|
||||
|
||||
export const usePointsStore = create<PointsState>((set, get) => ({
|
||||
balance: null,
|
||||
subscription: null,
|
||||
rules: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
init: async () => {
|
||||
if (get().loading) return
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const [balance, sub, rules] = await Promise.all([
|
||||
getPointsBalance(),
|
||||
getCurrentSubscription(),
|
||||
getPointsRules(),
|
||||
])
|
||||
set({ balance, subscription: sub, rules, loading: false })
|
||||
} catch (e) {
|
||||
set({ error: (e as Error).message, loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
refreshBalance: async () => {
|
||||
try {
|
||||
const balance = await getPointsBalance()
|
||||
set({ balance })
|
||||
} catch (e) {
|
||||
set({ error: (e as Error).message })
|
||||
}
|
||||
},
|
||||
|
||||
optimisticDeduct: (points: number) => {
|
||||
const b = get().balance
|
||||
if (!b) return
|
||||
set({
|
||||
balance: {
|
||||
...b,
|
||||
balance: Math.max(0, b.balance - points),
|
||||
total_spent: b.total_spent + points,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
optimisticAdd: (points: number) => {
|
||||
const b = get().balance
|
||||
if (!b) return
|
||||
set({
|
||||
balance: {
|
||||
...b,
|
||||
balance: b.balance + points,
|
||||
total_earned: b.total_earned + points,
|
||||
},
|
||||
})
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user