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 || "加载失败,请稍后重试"}
+