diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index e7129fff7..df65a1a62 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -110,14 +110,16 @@ const mapAsset = (item: ApiAssetItem): AssetItem => { item.classification_status ?? undefined, ); const metadata = item.metadata || {}; + const kind = inferKind(item.mime_type || ""); return { id: item.id, name: item.name, - kind: inferKind(item.mime_type || ""), + kind, + // 视频类型不能用 file_url 做缩略图(是视频文件, 无法渲染) thumbUrl: (item.thumbnail_url as string | undefined) || - (item.file_url as string | undefined) || - (metadata.thumbnail_url as string | undefined), + (metadata.thumbnail_url as string | undefined) || + (kind !== "video" ? (item.file_url as string | undefined) : undefined), fileUrl: (item.file_url as string | undefined) || (metadata.file_url as string | undefined), @@ -361,6 +363,7 @@ const AssetLibrary: React.FC = () => { /* 上传 */ const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); /* 新建素材库 */ const [createModalOpen, setCreateModalOpen] = useState(false); @@ -434,11 +437,16 @@ const AssetLibrary: React.FC = () => { } setUploading(true); + setUploadProgress(0); try { if (file.size > LARGE_FILE_THRESHOLD) { message.info(`大文件 "${file.name}" 将使用直传上传`); } - await uploadAssetDirect({ file, library_id: effectiveLibId }); + await uploadAssetDirect({ + file, + library_id: effectiveLibId, + onProgress: (pct) => setUploadProgress(pct), + }); message.success(`"${file.name}" 上传成功`); queryClient.invalidateQueries({ queryKey: ["assets"] }); queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); @@ -446,6 +454,7 @@ const AssetLibrary: React.FC = () => { message.error(`"${file.name}" 上传失败`); } finally { setUploading(false); + setUploadProgress(0); } return false; }; @@ -532,6 +541,45 @@ const AssetLibrary: React.FC = () => { return (
+ {/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */} + +
+ + {/* 背景圆环 */} + + {/* 进度圆弧 */} + + +
+ {uploadProgress}% + 上传中… +
+
+
+ {/* 两栏布局 */}
{/* ─── 左侧:素材库列表 ─── */} diff --git a/apps/web/src/pages/assets/assets.css b/apps/web/src/pages/assets/assets.css index 3333f1306..63e46e653 100644 --- a/apps/web/src/pages/assets/assets.css +++ b/apps/web/src/pages/assets/assets.css @@ -587,3 +587,40 @@ grid-template-columns: 1fr; } } + +/* ─── 上传进度弹窗 ─── */ +.xx-upload-progress-modal .ant-modal-content { + padding: 24px 16px 20px; + border-radius: 16px; +} + +.xx-upload-progress-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 8px 0; +} + +.xx-upload-progress-ring { + display: block; +} + +.xx-upload-progress-text { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.xx-upload-progress-pct { + font-size: 22px; + font-weight: 700; + color: var(--primary-color, #6366f1); + line-height: 1; +} + +.xx-upload-progress-label { + font-size: 13px; + color: var(--text-secondary, #6b7280); +} diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 6e9dc0342..674c7d637 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -17,13 +17,13 @@ import { ShareAltOutlined, SaveOutlined, PlusOutlined, + MinusOutlined, CloseOutlined, } from "@ant-design/icons"; import type { AssetItem } from "@/api/assets"; import { getAssets, getAssetLibraries } from "@/api/assets"; import { createEditPlan, generateEditPlan } from "@/api/editPlans"; import { getEditingTemplates } from "@/api/editingPlanner"; -import { MODE_LABELS, type TemplateMode } from "@/api/editingPlanner"; import { getTitles } from "@/api/titles"; import apiClient from "@/api/client"; import { fetchPresetVoices } from "@/api/voices"; @@ -54,13 +54,6 @@ const MODE_GRADIENTS: Record = { voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)", voice_pip: "linear-gradient(135deg, #10b981, #059669)", }; -const MODE_ABBRS: Record = { - pip: "PIP", - one_take: "ONE", - voice_over: "VOI", - voice_pip: "VP", -}; - /* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */ const VOICE_GENDER_ICON: Record = { female: "🎀", @@ -122,6 +115,8 @@ const GeneratePage: React.FC = () => { /* ── 素材 ── */ const [selectedMaterials, setSelectedMaterials] = useState([]); + /* 素材选择模式:手动选择 / 自动匹配 */ + const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual"); /* ── 标题 ── */ const [title, setTitle] = useState(""); @@ -146,6 +141,9 @@ const GeneratePage: React.FC = () => { ); const [customVoiceText, setCustomVoiceText] = useState(""); + /* ── 生成数量 ── */ + const [generateCount, setGenerateCount] = useState(1); + /* ── 克隆声音 ── */ const [selectedClonedVoice, setSelectedClonedVoice] = useState(""); const [cloneModalOpen, setCloneModalOpen] = useState(false); @@ -456,11 +454,17 @@ const GeneratePage: React.FC = () => { }, [navigate]); const handleGenerate = useCallback(async () => { + console.log("[handleGenerate] 开始生成, 参数:", { + title, + selectedTemplate, + selectedMaterials, + voiceMode, + }); if (!title.trim()) { message.warning("请先选择或输入标题"); return; } - if (selectedMaterials.length === 0) { + if (materialMode === "manual" && selectedMaterials.length === 0) { message.warning("请至少选择一个素材"); return; } @@ -498,6 +502,8 @@ const GeneratePage: React.FC = () => { duration, auto_subtitles: autoSubtitles, bgm, + generate_count: generateCount, + material_mode: materialMode, }, total_duration: duration, source_edit_plan_id: editPlanId || undefined, @@ -560,20 +566,29 @@ const GeneratePage: React.FC = () => { typeof setInterval >; } catch (err: unknown) { - console.error("生成失败:", err); + console.error("[handleGenerate] 生成失败:", err); setGenerating(false); // 提取 axios 响应中的后端错误信息 const axiosErr = err as { response?: { - data?: { message?: string; error?: string; detail?: string }; + data?: { + message?: string; + error?: string; + detail?: string; + msg?: string; + }; }; + message?: string; }; const backendMsg = axiosErr.response?.data?.message || axiosErr.response?.data?.error || axiosErr.response?.data?.detail || + axiosErr.response?.data?.msg || + axiosErr.message || ""; - message.error(backendMsg || "生成失败,请重试"); + console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr); + message.error(backendMsg || "生成失败,请检查网络后重试或联系管理员"); } }, [ title, @@ -590,6 +605,8 @@ const GeneratePage: React.FC = () => { bgm, editPlanId, selectedTemplate, + generateCount, + materialMode, ]); /* ── 步骤导航 ── */ @@ -598,7 +615,7 @@ const GeneratePage: React.FC = () => { message.warning("请先选择一个模板"); return; } - if (currentStep === 2 && selectedMaterials.length === 0) { + if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) { message.warning("请至少选择一个素材"); return; } @@ -609,7 +626,7 @@ const GeneratePage: React.FC = () => { if (currentStep < 5) { setCurrentStep((s) => s + 1); } - }, [currentStep, selectedTemplate, selectedMaterials.length, title]); + }, [currentStep, selectedTemplate, selectedMaterials.length, title, materialMode]); const goPrev = useCallback(() => { if (currentStep > 1) { @@ -669,11 +686,10 @@ const GeneratePage: React.FC = () => { background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip, }} > - {MODE_ABBRS[tpl.mode] || "TPL"} + 🎬

{tpl.name}

- {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} ·{" "} {tpl.estimated_duration}s · {tpl.segments.length}片段

{tpl.tags.length > 0 && ( @@ -708,11 +724,31 @@ const GeneratePage: React.FC = () => {
); - /** 步骤 2:选择素材 */ + /** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */ const renderStep2 = () => (

📦 选择素材

-
+ + {/* ── 模式切换 Tab ── */} +
+ + +
+ + {/* ── 素材库选择(两种模式共用) ── */} +
-
- - 已选 {selectedMaterials.length} 个素材 - - - 系统将自动选择最合适的素材 - -
- {/* 素材列表 */} -
- {materialsLoading ? ( - - 加载素材中… - - ) : materials.length === 0 ? ( - - 暂无素材,请先在素材库中上传 - - ) : ( -
- {materials.map((m) => { - const checked = selectedMaterials.includes(m.id); - return ( - - ); - })} + {/* ── 手动选择模式 ── */} + {materialMode === "manual" && ( + <> +
+ + 已选 {selectedMaterials.length} 个素材 +
- )} -
+ + {/* 素材列表 */} +
+ {materialsLoading ? ( + + 加载素材中… + + ) : materials.length === 0 ? ( + + 暂无素材,请先在素材库中上传 + + ) : ( +
+ {materials.map((m) => { + const checked = selectedMaterials.includes(m.id); + return ( + + ); + })} +
+ )} +
+ + )} + + {/* ── 自动匹配模式 ── */} + {materialMode === "auto" && ( +
+
🤖
+
+

智能素材匹配

+

+ 系统将根据所选模板和标题,从素材库中自动分析并匹配最合适的素材进行视频生成。 + 无需手动挑选,AI 会综合素材质量、时长、内容相关性等维度进行智能筛选。 +

+
+ 📊 质量评分筛选 + 🎯 内容相关性匹配 + ⏱️ 时长智能分配 +
+
+ {materialsLoading ? ( + + 扫描素材库中… + + ) : ( + + 当前素材库共 {materials.length} 个素材可供匹配 + + )} +
+ )}
); @@ -1270,7 +1337,9 @@ const GeneratePage: React.FC = () => {
素材 - {selectedMaterials.length} 个素材 + {materialMode === "auto" + ? "自动匹配" + : `${selectedMaterials.length} 个素材`}
@@ -1281,6 +1350,29 @@ const GeneratePage: React.FC = () => { 配音 {getVoiceName()}
+
+ 生成数量 + +
+ + {generateCount} + + 条视频 +
+
+
{/* 生成进度 */} diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 82052f16b..8f915d49e 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -1074,3 +1074,141 @@ opacity: 1; } } + +/* ── 生成数量步进器 ── */ +.xx-count-stepper { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.xx-count-stepper-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--border-primary, #e2e8f0); + border-radius: 8px; + background: var(--bg-surface, #fff); + color: var(--text-secondary, #64748b); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; +} + +.xx-count-stepper-btn:hover:not(:disabled) { + border-color: var(--primary-400, #818cf8); + color: var(--primary-600, #4f46e5); + background: var(--primary-50, #eef2ff); +} + +.xx-count-stepper-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.xx-count-stepper-value { + min-width: 24px; + text-align: center; + font-size: 16px; + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +.xx-count-stepper-hint { + font-size: 12px; + color: var(--text-tertiary, #94a3b8); + margin-left: 2px; +} + +/* ── 素材选择模式切换 Tab ── */ +.xx-material-mode-tabs { + display: flex; + gap: 0; + border: 1px solid var(--border-primary, #e2e8f0); + border-radius: 10px; + overflow: hidden; + margin-bottom: 4px; +} + +.xx-material-mode-tab { + flex: 1; + padding: 10px 16px; + font-size: 13px; + font-weight: 500; + border: none; + background: var(--bg-surface, #fff); + color: var(--text-secondary, #64748b); + cursor: pointer; + transition: all 0.2s ease; + text-align: center; +} + +.xx-material-mode-tab:first-child { + border-right: 1px solid var(--border-primary, #e2e8f0); +} + +.xx-material-mode-tab:hover { + background: var(--primary-50, #eef2ff); + color: var(--primary-600, #4f46e5); +} + +.xx-material-mode-tab.active { + background: var(--primary-500, #6366f1); + color: #fff; + font-weight: 600; +} + +/* ── 自动匹配卡片 ── */ +.xx-auto-match-card { + margin-top: 14px; + padding: 20px; + background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%); + border: 1px solid var(--border-primary, #e2e8f0); + border-radius: 14px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.xx-auto-match-icon { + font-size: 36px; + margin-bottom: 10px; +} + +.xx-auto-match-body { + width: 100%; +} + +.xx-auto-match-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary, #1e293b); + margin: 0 0 8px; +} + +.xx-auto-match-desc { + font-size: 13px; + color: var(--text-secondary, #64748b); + line-height: 1.6; + margin: 0 0 14px; +} + +.xx-auto-match-features { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; +} + +.xx-auto-match-feature { + display: inline-block; + padding: 4px 12px; + font-size: 12px; + color: var(--primary-600, #4f46e5); + background: rgba(255, 255, 255, 0.8); + border: 1px solid var(--border-light, #f1f5f9); + border-radius: 20px; +} diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx index d0aa34e58..b992c853f 100644 --- a/apps/web/src/pages/products/ProductLibrary.tsx +++ b/apps/web/src/pages/products/ProductLibrary.tsx @@ -737,11 +737,19 @@ const ProductLibrary: React.FC = () => { // ── Error 状态 ── if (isError) { + console.error("[ProductLibrary] 加载失败:", error); + const errorMsg = error?.message || "加载失败"; + // 区分 404 和其他错误 + const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found"); return (
-
-

{error?.message || "加载失败"}

+
{is404 ? "🔍" : "❌"}
+

+ {is404 + ? "成片库功能正在建设中,敬请期待" + : errorMsg || "加载失败,请稍后重试"} +