diff --git a/apps/web/src/api/assets/index.ts b/apps/web/src/api/assets/index.ts index 5c536f2c2..f9cf446ac 100644 --- a/apps/web/src/api/assets/index.ts +++ b/apps/web/src/api/assets/index.ts @@ -20,6 +20,10 @@ export type { // 素材诊断 export { getAssetDiagnosis } from "./diagnosis" +// 素材余量/可用性判断 +export { isAssetUsable } from "./usage" +export type { AssetUsageLike } from "./usage" + // 素材库 export { getAssetLibraries, diff --git a/apps/web/src/api/assets/types.ts b/apps/web/src/api/assets/types.ts index e98274a18..bb99baa5d 100644 --- a/apps/web/src/api/assets/types.ts +++ b/apps/web/src/api/assets/types.ts @@ -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 diff --git a/apps/web/src/api/assets/usage.ts b/apps/web/src/api/assets/usage.ts new file mode 100644 index 000000000..d0f48b265 --- /dev/null +++ b/apps/web/src/api/assets/usage.ts @@ -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 diff --git a/apps/web/src/pages/assets/assets.css b/apps/web/src/pages/assets/assets.css index 8fa5f34ba..8c8f45876 100644 --- a/apps/web/src/pages/assets/assets.css +++ b/apps/web/src/pages/assets/assets.css @@ -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%; diff --git a/apps/web/src/pages/assets/components/AssetCard.tsx b/apps/web/src/pages/assets/components/AssetCard.tsx index 6f83b573d..22ab9c0fe 100644 --- a/apps/web/src/pages/assets/components/AssetCard.tsx +++ b/apps/web/src/pages/assets/components/AssetCard.tsx @@ -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 = ({ onDiagnose, onPlay, onDelete, -}) => ( -
- {/* 缩略图区 */} -
- {asset.thumbUrl ? ( - {asset.name} - ) : ( - - {asset.loading ? : kindIcon(asset.kind)} - - )} +}) => { + // 视频素材余量角标(已用尽/即将用尽/已用 xx%);非视频或字段缺失返回 null + const usageBadge = getUsageBadge(asset) + return ( +
+ {/* 缩略图区 */} +
+ {asset.thumbUrl ? ( + {asset.name} + ) : ( + + {asset.loading ? : kindIcon(asset.kind)} + + )} - {/* 处理中遮罩 */} - {asset.loading && ( -
- - 处理中 + {/* 处理中遮罩 */} + {asset.loading && ( +
+ + 处理中 +
+ )} + + {/* 失败状态标识 */} + {asset.status === "bad" && asset.statusLabel === "处理失败" && ( +
+ + 处理失败 +
+ )} + + {/* 视频/配音类显示播放按钮(处理中/失败不显示) */} + {asset.kind === "video" && !asset.loading && asset.status !== "bad" && ( + { + e.stopPropagation() + onPlay() + }} + > + + + )} + + {/* 删除按钮 */} + { + e?.stopPropagation() + onDelete() + }} + onCancel={(e) => e?.stopPropagation()} + okText="删除" + cancelText="取消" + okButtonProps={{ danger: true }} + > + e.stopPropagation()}> + + + + + {/* 选中态勾选 */} + {selected && ( + + + + )} +
+ + {/* 信息区 */} +
+

+ {asset.name} +

+
+ + + {usageBadge && ( + + {usageBadge.label} + + )} + + {asset.duration && {asset.duration}}
- )} - - {/* 失败状态标识 */} - {asset.status === "bad" && asset.statusLabel === "处理失败" && ( -
- - 处理失败 -
- )} - - {/* 视频/配音类显示播放按钮(处理中/失败不显示) */} - {asset.kind === "video" && !asset.loading && asset.status !== "bad" && ( - { e.stopPropagation() - onPlay() + onDiagnose() }} > - - - )} - - {/* 删除按钮 */} - { - e?.stopPropagation() - onDelete() - }} - onCancel={(e) => e?.stopPropagation()} - okText="删除" - cancelText="取消" - okButtonProps={{ danger: true }} - > - e.stopPropagation()}> - - - - - {/* 选中态勾选 */} - {selected && ( - - - - )} -
- - {/* 信息区 */} -
-

- {asset.name} -

-
- - {asset.duration && {asset.duration}} + {diagnosing ? : } + {diagnosing ? "诊断中..." : "诊断"} +
-
-
-) + ) +} export default AssetCard diff --git a/apps/web/src/pages/assets/types.ts b/apps/web/src/pages/assets/types.ts index d500644cc..5b7ed7fd0 100644 --- a/apps/web/src/pages/assets/types.ts +++ b/apps/web/src/pages/assets/types.ts @@ -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, } } diff --git a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx index 5b34ab333..6e99582d8 100644 --- a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx +++ b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx @@ -62,8 +62,9 @@ const Step2MaterialSelect: React.FC = (props) => {
@@ -77,7 +78,7 @@ const Step2MaterialSelect: React.FC = (props) => { onMatch={m.handleSmartMatch} hasMatched={m.hasMatched} onRefresh={m.handleRefreshMatch} - materialsCount={m.materials.items.length} + materialsCount={m.selectableMaterials.items.length} loading={m.materialsLoading} /> diff --git a/apps/web/src/pages/generate/components/material/ManualMaterialList.tsx b/apps/web/src/pages/generate/components/material/ManualMaterialList.tsx index 1baed4ddb..a929cbe53 100644 --- a/apps/web/src/pages/generate/components/material/ManualMaterialList.tsx +++ b/apps/web/src/pages/generate/components/material/ManualMaterialList.tsx @@ -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 = ({ materials, materialsLoading, + allExhausted, selectedMaterials, onToggle, }) => { @@ -256,7 +259,9 @@ const ManualMaterialList: React.FC = ({ 加载素材中… ) : materials.items.length === 0 ? ( - 暂无素材,请先在视频库中上传 + {allExhausted + ? "暂无可选素材(素材可能已用尽,请先上传新素材)" + : "暂无素材,请先在视频库中上传"} ) : (
({ + 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, } } diff --git a/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts b/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts index a3feb5f55..4a4d265ce 100755 --- a/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts +++ b/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts @@ -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], ) diff --git a/apps/web/src/pages/generate/hooks/useStep2Materials.ts b/apps/web/src/pages/generate/hooks/useStep2Materials.ts index 20b9c2e09..2aeb62e11 100644 --- a/apps/web/src/pages/generate/hooks/useStep2Materials.ts +++ b/apps/web/src/pages/generate/hooks/useStep2Materials.ts @@ -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, diff --git a/apps/web/src/test/api/asset-usage.test.ts b/apps/web/src/test/api/asset-usage.test.ts new file mode 100644 index 000000000..e79be6cca --- /dev/null +++ b/apps/web/src/test/api/asset-usage.test.ts @@ -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) + }) +}) diff --git a/apps/web/src/test/pages/assets/usage-badge.test.ts b/apps/web/src/test/pages/assets/usage-badge.test.ts new file mode 100644 index 000000000..c7d92f459 --- /dev/null +++ b/apps/web/src/test/pages/assets/usage-badge.test.ts @@ -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() + }) +})