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