feat(points): apps/web/src/components/common/PointsCost/index.tsx

This commit is contained in:
2026-09-15 08:38:01 +08:00
parent 53baca6b5d
commit f0f74cef51
@@ -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, POINTS_API_MOCK } from "@/api/points"
import "./PointsCost.css"
interface Props {
/** 消耗场景 key */
scene: PointsSource
/** 单位数(分钟数/条数/张数),默认 1 */
units?: number
/** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */
compact?: boolean
/** 余额不足时,是否显示充值提示 */
showRechargeHint?: boolean
/** 自定义 class */
className?: string
}
/** 单位中文 */
const UNIT_LABEL: Record<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