diff --git a/apps/web/src/api/assets/assets.ts b/apps/web/src/api/assets/assets.ts index c436295c5..96a5b0840 100644 --- a/apps/web/src/api/assets/assets.ts +++ b/apps/web/src/api/assets/assets.ts @@ -52,12 +52,37 @@ export const getAssetsByKind = async ( /** * 智能匹配素材(后端 AI 选素材) * 调用后端 smart-match 端点,由后端根据素材库内容智能选择素材 + * + * 后端返回 items 元素兼容两种结构(过渡期): + * - 扁平结构:AssetItem 本身(id 在顶层) + * - 包装结构:{ asset: AssetItem, score, breakdown }(id 需从 .asset 取) + * 这里统一归一化为 AssetItem[],调用方无需关心包装层。 */ -export const smartMatchAssets = async (libraryId: string): Promise<{ items: AssetItem[] }> => { +export interface SmartMatchResult { + items: AssetItem[] +} + +interface SmartMatchWrappedItem { + asset?: AssetItem + id?: string + score?: number + breakdown?: unknown +} + +export const smartMatchAssets = async (libraryId: string): Promise => { const response = await apiClient.post("/assets/smart-match", { library_id: libraryId, }) - return response.data + const rawItems: SmartMatchWrappedItem[] = response.data?.items ?? [] + const items = rawItems + .map((it) => + // 包装结构 { asset: {...} } 优先解包;否则视其本身为扁平 AssetItem + it?.asset && typeof it.asset === "object" && "id" in it.asset + ? it.asset + : (it as unknown as AssetItem), + ) + .filter((it): it is AssetItem => !!it && typeof it.id === "string" && it.id.length > 0) + return { items } } /** 更新素材(名称、metadata 等) */ diff --git a/apps/web/src/pages/assets/assets.css b/apps/web/src/pages/assets/assets.css index ca9c0b019..f237ae738 100644 --- a/apps/web/src/pages/assets/assets.css +++ b/apps/web/src/pages/assets/assets.css @@ -147,44 +147,52 @@ /* ============================================================ 上传区域 ============================================================ */ -.xx-asset-upload-zone { - border: 2px dashed var(--border-color); - border-radius: var(--radius-lg); - padding: var(--space-2xl) var(--space-xl); - text-align: center; - background: var(--bg-secondary); - cursor: pointer; +.xx-asset-upload-entry { + display: flex; + align-items: center; + gap: var(--space-md); + flex-wrap: wrap; + padding: var(--space-sm) var(--space-md); + border: 1px dashed transparent; + border-radius: var(--radius-md); transition: var(--transition-all); } -.xx-asset-upload-zone:hover { +.xx-asset-upload-entry-dragover { border-color: var(--primary-color); background: var(--primary-soft); } -.xx-asset-upload-zone:active { - border-style: solid; - transform: scale(0.99); - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.06); -} - -.xx-asset-upload-icon { - font-size: 40px; - margin-bottom: var(--space-sm); - color: var(--primary-color); -} - -.xx-asset-upload-text { - font-size: var(--font-size-base) !important; - color: var(--text-primary) !important; - margin: 0 0 var(--space-xs) !important; +.xx-asset-upload-btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: 6px 16px; + font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); + color: var(--text-inverse); + background: var(--primary-color); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition-all); + white-space: nowrap; } -.xx-asset-upload-hint { - font-size: var(--font-size-sm) !important; - color: var(--text-tertiary) !important; - margin: 0 !important; +.xx-asset-upload-btn:hover { + opacity: 0.9; +} + +.xx-asset-upload-btn:active { + transform: scale(0.98); +} + +.xx-asset-upload-status { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-size: var(--font-size-xs); + color: var(--text-tertiary); } /* ============================================================ @@ -397,26 +405,46 @@ display: flex; justify-content: space-between; align-items: center; + gap: var(--space-xs); font-size: var(--font-size-xs); color: var(--text-secondary); - margin-bottom: var(--space-sm); + margin-bottom: 4px; + min-width: 0; } -/* 状态标签 + 余量角标行 */ -.xx-asset-meta-left { +/* 状态标签行:标签过长省略 */ +.xx-asset-meta-status { display: inline-flex; align-items: center; - gap: var(--space-xs); min-width: 0; } +.xx-asset-meta-status .xx-status-pill { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.xx-asset-meta-duration { + flex-shrink: 0; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +/* 余量标签独占一行 */ +.xx-asset-meta-usage { + justify-content: flex-start; + margin-bottom: var(--space-xs); +} + /* 视频素材余量角标(仅状态展示,不影响卡片操作) */ .xx-asset-usage-badge { display: inline-flex; align-items: center; - padding: var(--space-xxs) var(--space-sm); + padding: 1px 6px; border-radius: var(--radius-full); - font-size: var(--font-size-xs); + font-size: 10px; font-weight: var(--font-weight-medium); line-height: 1.5; white-space: nowrap; @@ -481,12 +509,16 @@ .xx-status-pill { display: inline-flex; align-items: center; - gap: var(--space-xs); - padding: var(--space-xxs) var(--space-sm); + gap: 2px; + padding: 1px 6px; border-radius: var(--radius-full); - font-size: var(--font-size-xs); + font-size: 10px; font-weight: var(--font-weight-medium); line-height: 1.5; + white-space: nowrap; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; } .xx-status-pill-ok { diff --git a/apps/web/src/pages/assets/components/AssetCard.tsx b/apps/web/src/pages/assets/components/AssetCard.tsx index a89293330..b0b0ea21c 100644 --- a/apps/web/src/pages/assets/components/AssetCard.tsx +++ b/apps/web/src/pages/assets/components/AssetCard.tsx @@ -128,16 +128,18 @@ const AssetCard: React.FC = ({ {asset.name}

- + - {usageBadge && ( - - {usageBadge.label} - - )} - {asset.duration && {asset.duration}} + {asset.duration && {asset.duration}}
+ {usageBadge && ( +
+ + {usageBadge.label} + +
+ )} + + {uploading ? ( + <> + + 上传中…(进行 {activeCount} 个{pendingCount > 0 ? `,排队 ${pendingCount} 个` : ""}) + + ) : ( + "视频、图片均可,单文件不超过 2GB;也可直接拖拽文件到此区域" + )} + + { + pickFiles(e.target.files) + // 允许连续选择同一文件 + e.target.value = "" + }} + /> + ) } diff --git a/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx b/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx index bab81fd51..166dc4eed 100644 --- a/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx +++ b/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx @@ -27,6 +27,13 @@ const getFileSize = (item: AssetItem): number => { return item.file_size ?? (item.metadata?.file_size as number) ?? 0 } +/** 是否为 AI 音色(克隆/预置音色模型:无固定时长、无实体音频文件,按脚本实时合成) */ +const isAiVoice = (item: AssetItem): boolean => { + const duration = getDuration(item) + const size = getFileSize(item) + return (!duration || duration <= 0) && (!size || size <= 0) +} + const formatDuration = (seconds?: number): string => { if (!seconds || seconds <= 0) return "00:00" const m = Math.floor(seconds / 60) @@ -96,10 +103,10 @@ const Step5VoiceSelect: React.FC = ({ /** 选中素材(含时长校验) */ const handleSelect = useCallback( (id: string) => { - // 如果启用了时长校验,且配音时长不足 + // 如果启用了时长校验,且配音时长不足(AI 音色按脚本实时合成,不参与时长校验) if (totalVideoDuration > 0) { const material = materials.find((m) => m.id === id) - if (material && getDuration(material) < totalVideoDuration) { + if (material && !isAiVoice(material) && getDuration(material) < totalVideoDuration) { setPendingVoiceId(id) setDurationWarningOpen(true) return @@ -280,25 +287,29 @@ const Step5VoiceSelect: React.FC = ({ alignItems: "center", }} > - - {formatDuration(getDuration(item))} - {totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && ( - - - 时长不足 - - )} - - {formatFileSize(getFileSize(item))} + {isAiVoice(item) ? ( + AI 音色 + ) : ( + + {formatDuration(getDuration(item))} + {totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && ( + + + 时长不足 + + )} + + )} + {isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))} ) 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 997b4f61d..bf1733d33 100755 --- a/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts +++ b/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts @@ -43,9 +43,11 @@ export function useSmartMatch({ try { // 调用后端智能匹配 API(后端也会排除已用尽素材,这里前端兜底过滤) + // items 已在 API 层归一化(兼容后端 {asset, score} 包装结构); + // 这里再过滤一遍无 id/已用尽项,杜绝 undefined id 流入预览链路 const result = await smartMatchAssets(libraryId) - const matched = (result.items ?? []).filter(isAssetUsable) - const matchedIds = matched.map((a: AssetItem) => a.id) + const matched = (result.items ?? []).filter((a) => !!a?.id && isAssetUsable(a)) + const matchedIds = matched.map((a) => a.id) if (matchedIds.length > 0) { onSmartSelectedIdsChange(matchedIds) diff --git a/apps/web/src/pages/generate/hooks/usePreviewAssets.ts b/apps/web/src/pages/generate/hooks/usePreviewAssets.ts index 69d221a26..7bf8e801e 100644 --- a/apps/web/src/pages/generate/hooks/usePreviewAssets.ts +++ b/apps/web/src/pages/generate/hooks/usePreviewAssets.ts @@ -15,12 +15,14 @@ import type { AxiosResponse } from "axios" * 使用 Promise.allSettled 确保单个失败不影响整体 */ async function fetchAssetsByIds(ids: string[]): Promise { - if (!ids.length) return [] + // 防御:过滤空值/undefined/非字符串 id,避免发出 /assets/undefined 请求 + const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0) + if (!validIds.length) return [] try { const { default: apiClient } = await import("@/api/client") const results = await Promise.allSettled( - ids.map((id) => apiClient.get(`/assets/${id}`)), + validIds.map((id) => apiClient.get(`/assets/${id}`)), ) return results .filter( @@ -57,7 +59,10 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi const stableAssetIds = useStableArray(assetIds) const load = useCallback(async () => { - if (!stableAssetIds.length || !enabled) { + const validIds = stableAssetIds.filter( + (id): id is string => typeof id === "string" && id.length > 0, + ) + if (!validIds.length || !enabled) { setAssets([]) setReady(false) return @@ -68,7 +73,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi setReady(false) try { - const result = await fetchAssetsByIds(stableAssetIds) + const result = await fetchAssetsByIds(validIds) // 防止竞态:只保留最新请求的结果 if (requestIdRef.current === thisRequestId) { setAssets(result)