chore(points): align types/api with final backend contract - apps/web/src/components/common/PointsCost/index.tsx
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m22s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m22s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m28s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m11s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m59s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 8m50s
CI/CD Pipeline / CI Gate (pull_request) Failing after 2s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m34s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 4h53m6s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 5h1m58s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 5h1m54s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 5h1m59s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 5h2m0s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 5h1m3s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 5h1m3s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 5h1m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 5h1m6s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 5h1m6s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 4h52m16s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 4h52m16s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 5h1m0s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 4h52m16s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 4h52m13s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 5h1m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 5h1m2s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 5h1m7s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 5h1m10s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 5h36m55s

This commit is contained in:
2026-09-16 10:20:44 +08:00
parent b37ca41207
commit 8dbc8b48f3
@@ -1,161 +1,102 @@
/**
* 功能操作按钮旁的"消耗积分"提示
* 例:[生成配音] 💎 -1 积分
* - 根据 scene_key 自动读取规则
* - 免费用户自动计算 ×1.15 向上取整
* - 余额不足时显示红色告警 + 充值提示
* 积分消耗提示组件(创作页/按钮旁使用)
*
* 使用:<PointsCost scene="ai_voice" units={1} />
* 展示规则:
* - 非会员 + 免费额度未用完:展示「消耗 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<string, string> = {
分钟: "分钟",
条: "条",
次: "次",
张: "张",
/**
* @deprecated 已改名为 quantity,保留旧字段做兼容映射
*/
units?: number
}
const PointsCost: React.FC<Props> = ({
scene,
units = 1,
source,
quantity,
units,
durationMinutes,
compact = false,
showRechargeHint = true,
className = "",
}) => {
const { balance, init } = usePointsStore()
const [rules, setRules] = useState<Awaited<ReturnType<typeof getPointsRules>> | 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 <span className={`xx-points-cost ${className}`} />
}
if (rule.points_per_use === 0) {
if (compact) {
return (
<span className={`xx-points-cost free ${className}`}>
<span className="xx-points-tag-free">免费</span>
</span>
)
}
if (isFreeQuota) {
return (
<Tooltip title={`今日免费额度剩余 ${balance.free_clips_remaining} 条,不扣积分`}>
<span className={`xx-points-cost free-quota ${className}`}>
<span className="xx-points-tag-free">免费</span>
{!compact && (
<span className="xx-points-desc">(今日剩余 {balance.free_clips_remaining} 条)</span>
)}
</span>
<Tooltip title={rule.name}>
<Tag color="gold" className={styles.compatTag}>
<ThunderboltOutlined /> {cost}
</Tag>
</Tooltip>
)
}
const unitLabel = compact
? ""
: ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
return (
<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>
<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>
)
)}
{insufficient && showRechargeHint && (
<Tooltip title={`积分不足,需 ${cost} 积分,当前余额 ${balance.balance}`}>
<WarningOutlined className="xx-points-warn-icon" />
</Tooltip>
{balance && balance.balance < cost && (
<Tag color="red" className={styles.tag}>
余额不足
</Tag>
)}
</span>
</div>
)
}