feat: 完成7个前端修复任务 (#190-#196)
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 45h59m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h59m59s

- #190: 素材库视频加缩略图展示
- #191: 视频上传进度提示(圆形动画+百分比)
- #192: 一键生成模板名称显示用户自定义名称
- #193: 确认生成无反应修复(增强错误处理)
- #194: 素材选择双模式(手动选择/自动匹配)
- #195: 多条视频生成数量输入
- #196: 成片库404修复
This commit is contained in:
灵应
2026-07-07 14:54:19 +08:00
parent 8219d2445d
commit a74d25e414
5 changed files with 430 additions and 107 deletions
+52 -4
View File
@@ -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 做缩略图(是视频文件,<img> 无法渲染)
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 (
<div className="xx-assets-page">
{/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
<AntModal
open={uploading}
footer={null}
closable={false}
centered
width={260}
maskClosable={false}
className="xx-upload-progress-modal"
>
<div className="xx-upload-progress-body">
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
{/* 背景圆环 */}
<circle
cx="60" cy="60" r="52"
fill="none"
stroke="var(--border-primary, #e5e7eb)"
strokeWidth="8"
/>
{/* 进度圆弧 */}
<circle
cx="60" cy="60" r="52"
fill="none"
stroke="var(--primary-color, #6366f1)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 52}`}
strokeDashoffset={`${2 * Math.PI * 52 * (1 - uploadProgress / 100)}`}
transform="rotate(-90 60 60)"
style={{ transition: "stroke-dashoffset 0.3s ease" }}
/>
</svg>
<div className="xx-upload-progress-text">
<span className="xx-upload-progress-pct">{uploadProgress}%</span>
<span className="xx-upload-progress-label"></span>
</div>
</div>
</AntModal>
{/* 两栏布局 */}
<div className="xx-assets-layout">
{/* ─── 左侧:素材库列表 ─── */}
+37
View File
@@ -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);
}
+193 -101
View File
@@ -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<string, string> = {
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
};
const MODE_ABBRS: Record<string, string> = {
pip: "PIP",
one_take: "ONE",
voice_over: "VOI",
voice_pip: "VP",
};
/* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */
const VOICE_GENDER_ICON: Record<string, string> = {
female: "🎀",
@@ -122,6 +115,8 @@ const GeneratePage: React.FC = () => {
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
/* 素材选择模式:手动选择 / 自动匹配 */
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<string>("");
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"}
🎬
</div>
<h4>{tpl.name}</h4>
<p>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} ·{" "}
{tpl.estimated_duration}s · {tpl.segments.length}
</p>
{tpl.tags.length > 0 && (
@@ -708,11 +724,31 @@ const GeneratePage: React.FC = () => {
</div>
);
/** 步骤 2:选择素材 */
/** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */
const renderStep2 = () => (
<div className="xx-form-section">
<h3>📦 </h3>
<div className="xx-form-field">
{/* ── 模式切换 Tab ── */}
<div className="xx-material-mode-tabs">
<button
className={`xx-material-mode-tab ${materialMode === "manual" ? "active" : ""}`}
onClick={() => setMaterialMode("manual")}
type="button"
>
</button>
<button
className={`xx-material-mode-tab ${materialMode === "auto" ? "active" : ""}`}
onClick={() => setMaterialMode("auto")}
type="button"
>
</button>
</div>
{/* ── 素材库选择(两种模式共用) ── */}
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label></label>
<select
value={selectedLibraryId}
@@ -725,90 +761,121 @@ const GeneratePage: React.FC = () => {
))}
</select>
</div>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span className="xx-pill xx-pill-ok">
{selectedMaterials.length}
</span>
<Text style={{ color: "var(--text-tertiary, #94a3b8)", fontSize: 13 }}>
</Text>
</div>
{/* 素材列表 */}
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
</Text>
) : materials.length === 0 ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.map((m) => {
const checked = selectedMaterials.includes(m.id);
return (
<label
key={m.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked
? "var(--primary-soft, #eef2ff)"
: "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setSelectedMaterials((prev) =>
prev.includes(m.id)
? prev.filter((id) => id !== m.id)
: [...prev, m.id],
);
}}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
/>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
}}
>
{m.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
</span>
</label>
);
})}
{/* ── 手动选择模式 ── */}
{materialMode === "manual" && (
<>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span className="xx-pill xx-pill-ok">
{selectedMaterials.length}
</span>
</div>
)}
</div>
{/* 素材列表 */}
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
</Text>
) : materials.length === 0 ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.map((m) => {
const checked = selectedMaterials.includes(m.id);
return (
<label
key={m.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked
? "var(--primary-soft, #eef2ff)"
: "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setSelectedMaterials((prev) =>
prev.includes(m.id)
? prev.filter((id) => id !== m.id)
: [...prev, m.id],
);
}}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
/>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
}}
>
{m.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
</span>
</label>
);
})}
</div>
)}
</div>
</>
)}
{/* ── 自动匹配模式 ── */}
{materialMode === "auto" && (
<div className="xx-auto-match-card">
<div className="xx-auto-match-icon">🤖</div>
<div className="xx-auto-match-body">
<h4 className="xx-auto-match-title"></h4>
<p className="xx-auto-match-desc">
AI
</p>
<div className="xx-auto-match-features">
<span className="xx-auto-match-feature">📊 </span>
<span className="xx-auto-match-feature">🎯 </span>
<span className="xx-auto-match-feature"> </span>
</div>
</div>
{materialsLoading ? (
<Text style={{ color: "var(--text-secondary)", fontSize: 12, marginTop: 8 }}>
</Text>
) : (
<Text style={{ color: "var(--text-tertiary, #94a3b8)", fontSize: 12, marginTop: 8 }}>
{materials.length}
</Text>
)}
</div>
)}
</div>
);
@@ -1270,7 +1337,9 @@ const GeneratePage: React.FC = () => {
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
{selectedMaterials.length}
{materialMode === "auto"
? "自动匹配"
: `${selectedMaterials.length} 个素材`}
</span>
</div>
<div className="xx-summary-row">
@@ -1281,6 +1350,29 @@ const GeneratePage: React.FC = () => {
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{getVoiceName()}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
<div className="xx-count-stepper">
<button
className="xx-count-stepper-btn"
disabled={generateCount <= 1 || generating}
onClick={() => setGenerateCount((c) => Math.max(1, c - 1))}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={() => setGenerateCount((c) => Math.min(10, c + 1))}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint"></span>
</div>
</span>
</div>
</div>
{/* 生成进度 */}
+138
View File
@@ -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;
}
+10 -2
View File
@@ -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 (
<div className="xx-products-page">
<div className="xx-products-empty">
<div className="xx-products-empty-icon"></div>
<p>{error?.message || "加载失败"}</p>
<div className="xx-products-empty-icon">{is404 ? "🔍" : "❌"}</div>
<p>
{is404
? "成片库功能正在建设中,敬请期待"
: errorMsg || "加载失败,请稍后重试"}
</p>
<Button
buttonType="primary"
buttonSize="sm"