chore(points): fix lint - apps/web/src/components/common/PointsCost/index.tsx
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled

This commit is contained in:
2026-09-16 10:44:06 +08:00
parent 984df6be47
commit 0905799681
@@ -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<string, string> = {
分钟: "分钟",
条: "条",
次: "次",
张: "张",
}
const PointsCost: React.FC<Props> = ({
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 <span className={`xx-points-cost ${className}`} />
}
if (rule.base_points === 0) {
return (
<Tooltip title={rule.name}>
<Tag color="gold" className={styles.compatTag}>
<ThunderboltOutlined /> {cost}
</Tag>
<span className={`xx-points-cost free ${className}`}>
<span className="xx-points-tag-free">免费</span>
</span>
)
}
if (isFreeQuota) {
return (
<Tooltip title={`今日免费额度剩余 ${freeRemain} 条,不扣积分`}>
<span className={`xx-points-cost free-quota ${className}`}>
<span className="xx-points-tag-free">免费</span>
{!compact && <span className="xx-points-desc">(今日剩余 {freeRemain} 条)</span>}
</span>
</Tooltip>
)
}
const unitLabel = compact
? ""
: `/${qty > 1 ? `${qty}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
return (
<div className={styles.pointsCost}>
<ThunderboltOutlined className={styles.icon} />
<span>消耗 {cost} 积分</span>
{isFreeQuota ? (
<Tag color="success" className={styles.tag}>
今日剩 {freeRemain} 次免费
</Tag>
) : (
!membership?.is_member && (
<Tooltip title={`非会员消耗倍率 ×${freeMultiplier}`}>
<Tag color="default" className={styles.tag}>
非会员 ×{freeMultiplier}
<InfoCircleOutlined style={{ marginLeft: 4 }} />
</Tag>
</Tooltip>
)
<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>
)}
{balance && balance.balance < cost && (
<Tag color="red" className={styles.tag}>
余额不足
</Tag>
{insufficient && showRechargeHint && (
<Tooltip title={`积分不足,需 ${cost} 积分,当前余额 ${balance.balance}`}>
<WarningOutlined className="xx-points-warn-icon" />
</Tooltip>
)}
</div>
</span>
)
}