diff --git a/apps/web/src/components/common/PointsCost/index.tsx b/apps/web/src/components/common/PointsCost/index.tsx index ba8fac4d5..72021a42d 100644 --- a/apps/web/src/components/common/PointsCost/index.tsx +++ b/apps/web/src/components/common/PointsCost/index.tsx @@ -1,161 +1,102 @@ /** - * 功能操作按钮旁的"消耗积分"提示 - * 例:[生成配音] 💎 -1 积分 - * - 根据 scene_key 自动读取规则 - * - 免费用户自动计算 ×1.15 向上取整 - * - 余额不足时显示红色告警 + 充值提示 + * 积分消耗提示组件(创作页/按钮旁使用) * - * 使用: + * 展示规则: + * - 非会员 + 免费额度未用完:展示「消耗 X 积分(今日剩余 Y 次免费)」 + * - 非会员 + 免费额度用完:展示「消耗 X 积分(非会员 ×free_user_multiplier)」 + * - 会员:展示「消耗 X 积分(会员折扣)」 + * + * 兼容旧 props: + * - 旧 props.units 会自动映射为 quantity,保持现有调用方不报错 */ -import React, { useEffect, useMemo, useState } from "react" -import { Tooltip } from "antd" -import { WarningOutlined } from "@ant-design/icons" +import React, { useMemo } from "react" +import { Tooltip, Tag } from "antd" +import { ThunderboltOutlined, InfoCircleOutlined } from "@ant-design/icons" import { usePointsStore } from "@/store/pointsStore" +import styles from "./PointsCost.module.css" import type { PointsSource } from "@/api/points/types" -import { getPointsRules } from "@/api/points" -import "./PointsCost.css" interface Props { - /** 消耗场景 key */ - scene: PointsSource - /** 单位数(分钟数/条数/张数),默认 1 */ - units?: number - /** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */ + /** 积分场景键 */ + source: PointsSource + /** 消耗数量(新字段) */ + quantity?: number + /** 预计时长(分钟),可选 */ + durationMinutes?: number + /** 简化显示(只显示图标 + 数字) */ compact?: boolean - /** 余额不足时,是否显示充值提示 */ - showRechargeHint?: boolean - /** 自定义 class */ - className?: string -} - -/** 单位中文 */ -const UNIT_LABEL: Record = { - 分钟: "分钟", - 条: "条", - 次: "次", - 张: "张", + /** + * @deprecated 已改名为 quantity,保留旧字段做兼容映射 + */ + units?: number } const PointsCost: React.FC = ({ - scene, - units = 1, + source, + quantity, + units, + durationMinutes, compact = false, - showRechargeHint = true, - className = "", }) => { - const { balance, init } = usePointsStore() - const [rules, setRules] = useState> | null>(null) + const { balance, rules, dailyUsage, 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 - useEffect(() => { - if (!balance) init() - if (!rules) { - getPointsRules() - .then(setRules) - .catch(() => {}) - } - }, [balance, init, rules]) + 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 } = 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, - } - } + if (!rule) return null - // 智能混剪:首条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 freeRemain = dailyUsage?.free_clips_remaining ?? 0 + const isFreeQuota = !membership?.is_member && freeRemain > 0 - // 混剪 + 免费用户 + 今日有免费额度 → 免费 - 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) { + if (compact) { return ( - - 免费 - - ) - } - - if (isFreeQuota) { - return ( - - - 免费 - {!compact && ( - (今日剩余 {balance.free_clips_remaining} 条) - )} - + + + {cost} + ) } - const unitLabel = compact - ? "" - : ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}` - return ( - - 💎 - -{cost} - {unitLabel && 积分{unitLabel}} - {isFreeUser && !compact && ( - - 非会员 - +
+ + 消耗 {cost} 积分 + {isFreeQuota ? ( + + 今日剩 {freeRemain} 次免费 + + ) : ( + !membership?.is_member && ( + + + 非会员 ×{freeMultiplier} + + + + ) )} - {insufficient && showRechargeHint && ( - - - + {balance && balance.balance < cost && ( + + 余额不足 + )} - +
) }