Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia 652bbfe12b feat(assets): 素材余量角标 + Step2 过滤已用尽素材 (#1548)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m26s
CI/CD Pipeline / Check push changed paths (push) Successful in 4m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m58s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m5s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 6m9s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m19s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m42s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m4s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 45s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m9s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m22s
AI Code Review / AI Code Review (pull_request) Successful in 8m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m17s
CI/CD Pipeline / Integration Tests (push) Successful in 5m10s
CI/CD Pipeline / Unit Tests (push) Successful in 16m2s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m29s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 23s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 01:26:00 +08:00
13 changed files with 333 additions and 110 deletions
+4
View File
@@ -20,6 +20,10 @@ export type {
// 素材诊断
export { getAssetDiagnosis } from "./diagnosis"
// 素材余量/可用性判断
export { isAssetUsable } from "./usage"
export type { AssetUsageLike } from "./usage"
// 素材库
export {
getAssetLibraries,
+4
View File
@@ -40,6 +40,10 @@ export interface AssetItem {
thumbnail_url?: string
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
duration?: number
/** 已切片段占用时长占比(0~1,后端片段重复率控制机制返回;字段缺失视为未统计) */
used_ratio?: number | null
/** 是否已彻底用尽(无新区间且历史区间复用次数均达上限);false 的素材不参与生成选片 */
usable?: boolean | null
status?: string
classification_status?: AssetClassificationStatus | null
quality_score?: number | null
+18
View File
@@ -0,0 +1,18 @@
/**
* 素材余量/可用性判断
* 后端片段重复率控制机制(任意两条成片画面重复率 ≤15%)上线后,
* 素材列表会附加 usable / used_ratio 字段。字段未上线前一律按可用处理。
*/
/** 仅依赖素材余量相关字段的最小结构,api 层与 pages 层 AssetItem 均可传入 */
export interface AssetUsageLike {
usable?: boolean | null
used_ratio?: number | null
}
/**
* 素材是否仍可参与生成选片。
* usable === false 表示已彻底用尽(无新区间且复用次数全部达上限);
* 字段缺失(undefined/null)时降级为可用,保证后端字段上线前零影响。
*/
export const isAssetUsable = (asset: AssetUsageLike): boolean => asset.usable !== false
+38
View File
@@ -402,6 +402,44 @@
margin-bottom: var(--space-sm);
}
/* 状态标签 + 余量角标行 */
.xx-asset-meta-left {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
}
/* 视频素材余量角标(仅状态展示,不影响卡片操作) */
.xx-asset-usage-badge {
display: inline-flex;
align-items: center;
padding: var(--space-xxs) var(--space-sm);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
line-height: 1.5;
white-space: nowrap;
}
/* 已用尽:红色实心 */
.xx-asset-usage-badge-exhausted {
background: var(--error-color);
color: var(--text-inverse);
}
/* 即将用尽:红色软底 */
.xx-asset-usage-badge-warning {
background: var(--error-soft);
color: var(--error-color);
}
/* 已用 xx%:橙色软底 */
.xx-asset-usage-badge-ratio {
background: var(--warning-soft);
color: var(--warning-color);
}
/* 诊断按钮 */
.xx-asset-diagnose-btn {
width: 100%;
@@ -8,7 +8,7 @@ import {
CloseCircleOutlined,
} from "@ant-design/icons"
import { Popconfirm } from "antd"
import type { AssetItem } from "@/pages/assets/types"
import { getUsageBadge, type AssetItem } from "@/pages/assets/types"
import { thumbGradient } from "@/pages/assets/utils/asset"
import { kindIcon } from "@/pages/assets/utils/kindIcon"
import { StatusPill } from "./AssetSkeleton"
@@ -34,95 +34,106 @@ const AssetCard: React.FC<AssetCardProps> = ({
onDiagnose,
onPlay,
onDelete,
}) => (
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
{/* 缩略图区 */}
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
{asset.thumbUrl ? (
<img src={asset.thumbUrl} alt={asset.name} />
) : (
<span className="xx-asset-thumb-placeholder">
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
</span>
)}
}) => {
// 视频素材余量角标(已用尽/即将用尽/已用 xx%);非视频或字段缺失返回 null
const usageBadge = getUsageBadge(asset)
return (
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
{/* 缩略图区 */}
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
{asset.thumbUrl ? (
<img src={asset.thumbUrl} alt={asset.name} />
) : (
<span className="xx-asset-thumb-placeholder">
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
</span>
)}
{/* 处理中遮罩 */}
{asset.loading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span></span>
{/* 处理中遮罩 */}
{asset.loading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span></span>
</div>
)}
{/* 失败状态标识 */}
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
<CloseCircleOutlined />
<span></span>
</div>
)}
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
<span
className="xx-asset-play"
onClick={(e) => {
e.stopPropagation()
onPlay()
}}
>
<PlayCircleOutlined />
</span>
)}
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation()
onDelete()
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div>
{/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<span className="xx-asset-meta-left">
<StatusPill status={asset.status} label={asset.statusLabel} />
{usageBadge && (
<span className={`xx-asset-usage-badge xx-asset-usage-badge-${usageBadge.variant}`}>
{usageBadge.label}
</span>
)}
</span>
{asset.duration && <span>{asset.duration}</span>}
</div>
)}
{/* 失败状态标识 */}
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
<CloseCircleOutlined />
<span></span>
</div>
)}
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
<span
className="xx-asset-play"
<button
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
disabled={diagnosing || asset.loading || asset.status === "bad"}
onClick={(e) => {
e.stopPropagation()
onPlay()
onDiagnose()
}}
>
<PlayCircleOutlined />
</span>
)}
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation()
onDelete()
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div>
{/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<StatusPill status={asset.status} label={asset.statusLabel} />
{asset.duration && <span>{asset.duration}</span>}
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
{diagnosing ? "诊断中..." : "诊断"}
</button>
</div>
<button
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
disabled={diagnosing || asset.loading || asset.status === "bad"}
onClick={(e) => {
e.stopPropagation()
onDiagnose()
}}
>
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
{diagnosing ? "诊断中..." : "诊断"}
</button>
</div>
</div>
)
)
}
export default AssetCard
+32
View File
@@ -27,6 +27,36 @@ export interface AssetItem {
duration?: string
size: number
createdAt: string
/** 已切片段占用时长占比(0~1),后端字段缺失时为 undefined */
usedRatio?: number
/** 是否已彻底用尽(false 的素材不参与生成选片),字段缺失时视为可用 */
usable?: boolean
}
/** 素材余量角标状态(仅视频素材) */
export interface UsageBadge {
/** 角标文案 */
label: string
/** 样式变体:exhausted=红色实心,warning=红色软底,ratio=橙色软底 */
variant: "exhausted" | "warning" | "ratio"
}
/**
* 根据后端余量字段计算视频素材的余量角标;
* 非视频、字段缺失或已用占比 <50% 时不显示(返回 null)。
*/
export const getUsageBadge = (asset: {
kind?: AssetKind
usable?: boolean
usedRatio?: number
}): UsageBadge | null => {
if (asset.kind && asset.kind !== "video") return null
if (asset.usable === false) return { label: "已用尽", variant: "exhausted" }
const ratio = asset.usedRatio
if (ratio == null) return null
if (ratio >= 0.85) return { label: "即将用尽", variant: "warning" }
if (ratio >= 0.5) return { label: `已用 ${Math.round(ratio * 100)}%`, variant: "ratio" }
return null
}
/** 根据 mime_type 推断前端 AssetKind */
@@ -111,5 +141,7 @@ export const mapAsset = (item: ApiAssetItem): AssetItem => {
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
usedRatio: item.used_ratio ?? undefined,
usable: item.usable ?? undefined,
}
}
@@ -62,8 +62,9 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
</div>
<ManualMaterialList
materials={m.materials}
materials={m.selectableMaterials}
materialsLoading={m.materialsLoading}
allExhausted={m.allMaterialsExhausted}
selectedMaterials={m.selectedMaterials}
onToggle={m.handleToggleMaterial}
/>
@@ -77,7 +78,7 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
onMatch={m.handleSmartMatch}
hasMatched={m.hasMatched}
onRefresh={m.handleRefreshMatch}
materialsCount={m.materials.items.length}
materialsCount={m.selectableMaterials.items.length}
loading={m.materialsLoading}
/>
@@ -11,6 +11,8 @@ const { Text } = Typography
interface ManualMaterialListProps {
materials: { items: AssetItem[]; total: number }
materialsLoading: boolean
/** 库内有素材但全部已用尽(usable === false),用于区分空状态文案 */
allExhausted?: boolean
selectedMaterials: string[]
onToggle: (materialId: string) => void
}
@@ -247,6 +249,7 @@ const MaterialCard: React.FC<{
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
materials,
materialsLoading,
allExhausted,
selectedMaterials,
onToggle,
}) => {
@@ -256,7 +259,9 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}></Text>
) : materials.items.length === 0 ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
{allExhausted
? "暂无可选素材(素材可能已用尽,请先上传新素材)"
: "暂无素材,请先在视频库中上传"}
</Text>
) : (
<div
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"
import { useState, useEffect, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { getAssets, getAssetLibraries } from "@/api/assets"
import { getAssets, getAssetLibraries, isAssetUsable } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
/**
@@ -31,11 +31,26 @@ export function useMaterialLibrary() {
enabled: !!selectedLibraryId,
})
// 生成选片只展示仍可切出不重复片段的素材(usable !== false);
// 后端字段未上线时 isAssetUsable 恒为 true,过滤为 no-op
const selectableMaterials = useMemo(
() => ({
items: materials.items.filter(isAssetUsable),
total: materials.total,
}),
[materials],
)
// 库内有素材但全部已用尽(用于区分空状态文案)
const allMaterialsExhausted = materials.items.length > 0 && selectableMaterials.items.length === 0
return {
libraries,
selectedLibraryId,
setSelectedLibraryId,
materials,
selectableMaterials,
allMaterialsExhausted,
materialsLoading,
}
}
@@ -1,7 +1,7 @@
import { useState, useCallback, useMemo } from "react"
import { message } from "antd"
import type { AssetItem } from "@/api/assets"
import { smartMatchAssets } from "@/api/assets"
import { smartMatchAssets, isAssetUsable } from "@/api/assets"
interface UseSmartMatchOptions {
libraryId: string
@@ -32,38 +32,41 @@ export function useSmartMatch({
return
}
if (materials.items.length === 0) {
message.warning("当前视频库暂无素材")
// 已用尽素材(usable === false)不参与智能匹配;
// 后端字段未上线时 isAssetUsable 恒为 true,过滤为 no-op
const usableItems = materials.items.filter(isAssetUsable)
if (usableItems.length === 0) {
message.warning(
materials.items.length === 0 ? "当前视频库暂无素材" : "素材可能已用尽,请先上传新素材",
)
return
}
setSmartMatching(true)
try {
// 调用后端智能匹配 API
// 调用后端智能匹配 API(后端也会排除已用尽素材,这里前端兜底过滤)
const result = await smartMatchAssets(libraryId)
const matchedIds = result.items?.map((a: AssetItem) => a.id) ?? []
const matched = (result.items ?? []).filter(isAssetUsable)
const matchedIds = matched.map((a: AssetItem) => a.id)
if (matchedIds.length > 0) {
onSmartSelectedIdsChange(matchedIds)
// 保存 API 返回的完整素材列表
const resolved = result.items?.length
? result.items
: materials.items.filter((a) => matchedIds.includes(a.id))
setSmartMatchedResults(resolved)
setSmartMatchedResults(matched)
setHasMatched(true)
message.success(`AI 已为你选择 ${matchedIds.length} 个素材`)
} else {
// 后端返回空结果,回退到全选
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
setSmartMatchedResults(materials.items)
// 后端返回空结果,回退到全选可用素材
onSmartSelectedIdsChange(usableItems.map((a) => a.id))
setSmartMatchedResults(usableItems)
setHasMatched(true)
message.info("AI 暂未找到匹配素材,已全选当前库素材")
}
} catch {
// 后端 API 尚未就绪时,回退到全选当前库素材
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
setSmartMatchedResults(materials.items)
// 后端 API 尚未就绪时,回退到全选当前库可用素材
onSmartSelectedIdsChange(usableItems.map((a) => a.id))
setSmartMatchedResults(usableItems)
setHasMatched(true)
message.info("已为你全选当前库素材(智能匹配功能即将上线)")
} finally {
@@ -77,7 +80,7 @@ export function useSmartMatch({
}, [handleSmartMatch])
const handleSelectAllMatched = useCallback(() => {
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
onSmartSelectedIdsChange(materials.items.filter(isAssetUsable).map((a) => a.id))
}, [materials.items, onSmartSelectedIdsChange])
const handleClearSmartSelect = useCallback(() => {
@@ -99,7 +102,7 @@ export function useSmartMatch({
const smartSelectedTotalDuration = useMemo(
() =>
materials.items
.filter((a) => smartSelectedIds.includes(a.id))
.filter((a) => isAssetUsable(a) && smartSelectedIds.includes(a.id))
.reduce((sum, a) => sum + (a.duration || 0), 0),
[materials.items, smartSelectedIds],
)
@@ -38,12 +38,19 @@ export function useStep2Materials({
templateSegments,
onServerClipsChange,
}: UseStep2MaterialsProps) {
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
useMaterialLibrary()
const {
libraries,
selectedLibraryId,
setSelectedLibraryId,
materials,
selectableMaterials,
allMaterialsExhausted,
materialsLoading,
} = useMaterialLibrary()
const smartMatch = useSmartMatch({
libraryId: selectedLibraryId,
materials,
materials: selectableMaterials,
smartSelectedIds,
onSmartSelectedIdsChange,
})
@@ -59,13 +66,19 @@ export function useStep2Materials({
}
if (!selectedLibraryId) return
if (materialsLoading) return
if (materials.items.length === 0) return
if (selectableMaterials.items.length === 0) return
// 防止同一视频库重复触发
if (autoTriggeredRef.current === selectedLibraryId) return
autoTriggeredRef.current = selectedLibraryId
handleSmartMatch()
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
}, [
selectedLibraryId,
materialMode,
materialsLoading,
selectableMaterials.items,
handleSmartMatch,
])
/* ── Step2 选择素材后自动保存草稿 asset_ids(防抖 500ms,失败静默) ── */
const { scheduleSave } = useDraftAutoSave(selectedTemplate)
@@ -169,6 +182,8 @@ export function useStep2Materials({
selectedLibraryId,
setSelectedLibraryId,
materials,
selectableMaterials,
allMaterialsExhausted,
materialsLoading,
// 模式
materialMode,
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest"
import { isAssetUsable } from "@/api/assets"
describe("isAssetUsable", () => {
it("usable 字段缺失时降级为可用(后端字段未上线零影响)", () => {
expect(isAssetUsable({})).toBe(true)
expect(isAssetUsable({ usable: undefined })).toBe(true)
expect(isAssetUsable({ usable: null })).toBe(true)
})
it("usable === true 时可用", () => {
expect(isAssetUsable({ usable: true })).toBe(true)
})
it("usable === false 时不可用(已彻底用尽)", () => {
expect(isAssetUsable({ usable: false })).toBe(false)
expect(isAssetUsable({ usable: false, used_ratio: 1 })).toBe(false)
})
it("used_ratio 不影响可用性判断(只影响角标展示)", () => {
expect(isAssetUsable({ used_ratio: 0.99 })).toBe(true)
expect(isAssetUsable({ used_ratio: 0 })).toBe(true)
})
})
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest"
import { getUsageBadge } from "@/pages/assets/types"
describe("getUsageBadge", () => {
it("非视频素材不显示角标", () => {
expect(getUsageBadge({ kind: "voice", usable: false })).toBeNull()
expect(getUsageBadge({ kind: "image", usable: false })).toBeNull()
})
it("字段缺失时不显示角标(降级零影响)", () => {
expect(getUsageBadge({ kind: "video" })).toBeNull()
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: undefined })).toBeNull()
})
it("usable === false 显示红色实心「已用尽」", () => {
expect(getUsageBadge({ kind: "video", usable: false, usedRatio: 1 })).toEqual({
label: "已用尽",
variant: "exhausted",
})
// usable === false 优先级最高,即使 usedRatio 字段缺失
expect(getUsageBadge({ kind: "video", usable: false })).toEqual({
label: "已用尽",
variant: "exhausted",
})
})
it("used_ratio >= 0.85 显示红色「即将用尽」", () => {
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.85 })).toEqual({
label: "即将用尽",
variant: "warning",
})
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.97 })).toEqual({
label: "即将用尽",
variant: "warning",
})
})
it("used_ratio >= 0.5 显示橙色「已用 xx%」", () => {
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.5 })).toEqual({
label: "已用 50%",
variant: "ratio",
})
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.84 })).toEqual({
label: "已用 84%",
variant: "ratio",
})
})
it("used_ratio < 0.5 不显示角标", () => {
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.49 })).toBeNull()
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0 })).toBeNull()
})
})