diff --git a/apps/web/src/components/common/PointsCost/index.tsx b/apps/web/src/components/common/PointsCost/index.tsx new file mode 100644 index 000000000..9ee2f9304 --- /dev/null +++ b/apps/web/src/components/common/PointsCost/index.tsx @@ -0,0 +1,162 @@ +/** + * 功能操作按钮旁的"消耗积分"提示 + * 例:[生成配音] 💎 -1 积分 + * - 根据 scene_key 自动读取规则 + * - 免费用户自动计算 ×1.15 向上取整 + * - 余额不足时显示红色告警 + 充值提示 + * + * 使用: + */ +import React, { useEffect, useMemo, useState } from "react" +import { Tooltip } from "antd" +import { WarningOutlined } from "@ant-design/icons" +import { usePointsStore } from "@/store/pointsStore" +import type { PointsSource } from "@/api/points/types" +import { getPointsRules, 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 = { + 分钟: "分钟", + 条: "条", + 次: "次", + 张: "张", +} + +const PointsCost: React.FC = ({ + scene, + units = 1, + compact = false, + showRechargeHint = true, + className = "", +}) => { + const { balance, init } = usePointsStore() + const [rules, setRules] = useState> | null>(null) + + useEffect(() => { + if (!balance) init() + if (!rules) { + getPointsRules() + .then(setRules) + .catch(() => {}) + } + }, [balance, init, rules]) + + const { cost, isFreeQuota, rule, isFreeUser, insufficient } = useMemo(() => { + if (!rules || !balance) { + return { + cost: 0, + isFreeQuota: false, + rule: null, + isFreeUser: !balance?.is_member, + insufficient: false, + } + } + const rule = rules.rules.find((r) => r.scene_key === scene) + if (!rule) + return { + cost: 0, + isFreeQuota: false, + rule: null, + isFreeUser: !balance.is_member, + insufficient: false, + } + // 免费训练不扣费 + if (rule.points_per_use === 0) { + return { + cost: 0, + isFreeQuota: false, + rule, + isFreeUser: !balance.is_member, + insufficient: false, + } + } + + // 智能混剪:首条30s=3分,每+30s +1 + let baseCost: number + if (scene === "ai_video" && rule.extra_per_30s) { + // units 当作"30s 段数"更简单;按分钟算:minutes 个 30s 段 - 1 + const segments = Math.max(1, Math.ceil(units * 2)) // 1min = 2 segments + baseCost = rule.points_per_use + rule.extra_per_30s * (segments - 1) + } else { + baseCost = rule.points_per_use * Math.max(1, units) + } + + // 混剪 + 免费用户 + 今日有免费额度 → 免费 + const isFree = + scene === "ai_video" && !balance.is_member && (balance.free_clips_remaining ?? 0) > 0 + + const isFreeUser = !balance.is_member + const cost = isFree + ? 0 + : isFreeUser + ? Math.ceil(baseCost * rules.free_user_multiplier) + : baseCost + const insufficient = !isFree && balance.balance < cost + return { cost, isFreeQuota: isFree, rule, isFreeUser, insufficient } + }, [rules, balance, scene, units]) + + if (!rule || !balance) { + return + } + + if (rule.points_per_use === 0) { + return ( + + 免费 + + ) + } + + if (isFreeQuota) { + return ( + + + 免费 + {!compact && ( + (今日剩余 {balance.free_clips_remaining} 条) + )} + + + ) + } + + const unitLabel = compact + ? "" + : ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}` + + return ( + + 💎 + -{cost} + {unitLabel && 积分{unitLabel}} + {isFreeUser && !compact && ( + + 非会员 + + )} + {insufficient && showRechargeHint && ( + + + + )} + + ) +} + +export default PointsCost