diff --git a/apps/web/src/components/common/PointsCost/index.tsx b/apps/web/src/components/common/PointsCost/index.tsx index 72021a42d..e9ec2edba 100644 --- a/apps/web/src/components/common/PointsCost/index.tsx +++ b/apps/web/src/components/common/PointsCost/index.tsx @@ -1,102 +1,168 @@ /** - * 积分消耗提示组件(创作页/按钮旁使用) + * 功能操作按钮旁的"消耗积分"提示 + * 例:[生成配音] 💎 -1 积分 + * - 根据 scene_key 自动读取规则(来自 store.rules) + * - 免费用户自动计算 ×free_user_multiplier 向上取整 + * - 会员 floor(base × points_discount) + * - 余额不足时显示红色告警 + 充值提示 * - * 展示规则: - * - 非会员 + 免费额度未用完:展示「消耗 X 积分(今日剩余 Y 次免费)」 - * - 非会员 + 免费额度用完:展示「消耗 X 积分(非会员 ×free_user_multiplier)」 - * - 会员:展示「消耗 X 积分(会员折扣)」 - * - * 兼容旧 props: - * - 旧 props.units 会自动映射为 quantity,保持现有调用方不报错 + * 字段对齐新契约: + * - rule.points_per_use → base_points + * - balance.free_clips_remaining → dailyUsage.free_clips_remaining + * - props.units → 保留兼容,新代码优先用 quantity */ import React, { useMemo } from "react" -import { Tooltip, Tag } from "antd" -import { ThunderboltOutlined, InfoCircleOutlined } from "@ant-design/icons" +import { Tooltip } from "antd" +import { WarningOutlined } from "@ant-design/icons" import { usePointsStore } from "@/store/pointsStore" -import styles from "./PointsCost.module.css" import type { PointsSource } from "@/api/points/types" +import "./PointsCost.css" interface Props { - /** 积分场景键 */ - source: PointsSource - /** 消耗数量(新字段) */ + /** 消耗场景 key */ + scene: PointsSource + /** 数量(新字段),默认 1 */ quantity?: number /** 预计时长(分钟),可选 */ durationMinutes?: number - /** 简化显示(只显示图标 + 数字) */ + /** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */ compact?: boolean + /** 余额不足时,是否显示充值提示 */ + showRechargeHint?: boolean + /** 自定义 class */ + className?: string /** - * @deprecated 已改名为 quantity,保留旧字段做兼容映射 + * @deprecated 旧字段保留兼容,内部映射为 quantity */ units?: number } +/** 单位中文 */ +const UNIT_LABEL: Record = { + 分钟: "分钟", + 条: "条", + 次: "次", + 张: "张", +} + const PointsCost: React.FC = ({ - source, + scene, quantity, units, durationMinutes, compact = false, + showRechargeHint = true, + className = "", }) => { - const { balance, rules, dailyUsage, membership } = usePointsStore() + const { balance, dailyUsage, rules, membership } = usePointsStore() const qty = quantity ?? units ?? 1 - const rule = rules?.rules.find((r) => r.scene_key === source) - const freeMultiplier = rules?.free_user_multiplier ?? 1.15 - const pointsDiscount = membership?.is_member - ? 1 // 会员折扣可通过 subscription.plans.points_discount 读取,这里简化用 1 占位 - : freeMultiplier - const cost = useMemo(() => { - if (!rule) return 0 - const extra = - durationMinutes && durationMinutes > 0.5 && rule.extra_per_30s - ? Math.ceil((durationMinutes * 60 - 30) / 30) * rule.extra_per_30s - : 0 - const base = (rule.base_points + extra) * qty - return membership?.is_member - ? Math.floor(base * pointsDiscount) - : Math.ceil(base * pointsDiscount) - }, [rule, qty, durationMinutes, pointsDiscount, membership?.is_member]) + const { cost, isFreeQuota, rule, isFreeUser, insufficient, freeRemain } = useMemo(() => { + const isMem = membership?.is_member ?? balance?.is_member ?? false + if (!rules || !balance) { + return { + cost: 0, + isFreeQuota: false, + rule: null, + isFreeUser: !isMem, + insufficient: false, + freeRemain: 0, + } + } + const rule = rules.rules.find((r) => r.scene_key === scene) + if (!rule) + return { + cost: 0, + isFreeQuota: false, + rule: null, + isFreeUser: !isMem, + insufficient: false, + freeRemain: 0, + } + // 免费训练不扣费 + if (rule.base_points === 0) { + return { + cost: 0, + isFreeQuota: false, + rule, + isFreeUser: !isMem, + insufficient: false, + freeRemain: 0, + } + } - if (!rule) return null + // 计算 base + let baseCost: number + if (scene === "ai_video" && rule.extra_per_30s) { + const minutes = durationMinutes ?? qty + const segments = Math.max(1, Math.ceil(minutes * 2)) + baseCost = rule.base_points + rule.extra_per_30s * (segments - 1) + } else { + baseCost = rule.base_points * Math.max(1, qty) + } - const freeRemain = dailyUsage?.free_clips_remaining ?? 0 - const isFreeQuota = !membership?.is_member && freeRemain > 0 + // 非会员 + 今日免费额度 → 免费 + const freeRemain = dailyUsage?.free_clips_remaining ?? 0 + const isFree = scene === "ai_video" && !isMem && freeRemain > 0 - if (compact) { + const multiplier = rules.free_user_multiplier ?? 1.15 + const cost = isFree ? 0 : isMem ? Math.floor(baseCost) : Math.ceil(baseCost * multiplier) + const insufficient = !isFree && balance.balance < cost + return { + cost, + isFreeQuota: isFree, + rule, + isFreeUser: !isMem, + insufficient, + freeRemain, + } + }, [rules, balance, dailyUsage, membership, scene, qty, durationMinutes]) + + if (!rule || !balance) { + return + } + + if (rule.base_points === 0) { return ( - - - {cost} - + + 免费 + + ) + } + + if (isFreeQuota) { + return ( + + + 免费 + {!compact && (今日剩余 {freeRemain} 条)} + ) } + const unitLabel = compact + ? "" + : `/${qty > 1 ? `${qty}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}` + return ( -
- - 消耗 {cost} 积分 - {isFreeQuota ? ( - - 今日剩 {freeRemain} 次免费 - - ) : ( - !membership?.is_member && ( - - - 非会员 ×{freeMultiplier} - - - - ) + + 💎 + -{cost} + {unitLabel && 积分{unitLabel}} + {isFreeUser && !compact && ( + + 非会员 + )} - {balance && balance.balance < cost && ( - - 余额不足 - + {insufficient && showRechargeHint && ( + + + )} -
+
) }