Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 652bbfe12b |
@@ -20,6 +20,10 @@ export type {
|
||||
// 素材诊断
|
||||
export { getAssetDiagnosis } from "./diagnosis"
|
||||
|
||||
// 素材余量/可用性判断
|
||||
export { isAssetUsable } from "./usage"
|
||||
export type { AssetUsageLike } from "./usage"
|
||||
|
||||
// 素材库
|
||||
export {
|
||||
getAssetLibraries,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user