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