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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user