fix: 配音库AI配音按钮 + 上传按钮修复 + 多项UI修复
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 41h9m45s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 41h9m45s

- Task #197: 配音库新增AI配音按钮 + TTS合成面板
- Task #198: 上传音频按钮事件修复
- Task #199: 一键生成增加标题库选择
- Task #200: 模板库报错修复
- Task #201: 素材库视频上传失败修复
- Task #202: 素材库去掉配音类型
- Task #203: 模板卡片名称位置调整
- Task #204: 一键生成状态反馈完善
- Task #205: 成片库页面接入真实数据

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
灵应
2026-07-07 19:44:42 +08:00
parent f7a945d417
commit 9e37c7b73d
6 changed files with 401 additions and 51 deletions
+8 -17
View File
@@ -10,7 +10,6 @@ import {
SearchOutlined,
InboxOutlined,
VideoCameraOutlined,
SoundOutlined,
PictureOutlined,
PlayCircleOutlined,
CheckOutlined,
@@ -37,7 +36,7 @@ import "./assets.css";
/* ============================================================
* 类型
* ============================================================ */
type AssetKind = "video" | "voice" | "image";
type AssetKind = "video" | "image";
type StatusType = "ok" | "warn" | "bad" | "info";
interface LibraryItem {
@@ -67,7 +66,6 @@ interface AssetItem {
/** 根据 mime_type 推断前端 AssetKind */
const inferKind = (mimeType: string): AssetKind => {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "voice";
return "image";
};
@@ -99,7 +97,7 @@ const formatDuration = (seconds: number): string => {
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
id: item.id,
name: item.name,
kind: item.kind || inferKind("video"),
kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"),
count: item.asset_count ?? 0,
});
@@ -149,8 +147,6 @@ const kindIcon = (kind: AssetKind) => {
switch (kind) {
case "video":
return <VideoCameraOutlined />;
case "voice":
return <SoundOutlined />;
case "image":
return <PictureOutlined />;
}
@@ -160,8 +156,6 @@ const kindLabel = (kind: AssetKind) => {
switch (kind) {
case "video":
return "视频";
case "voice":
return "配音";
case "image":
return "图片";
}
@@ -172,8 +166,6 @@ const thumbGradient = (kind: AssetKind): string => {
switch (kind) {
case "video":
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)";
case "voice":
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)";
case "image":
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)";
}
@@ -246,7 +238,7 @@ const AssetCard: React.FC<{
)}
{/* 视频/配音类显示播放按钮 */}
{(asset.kind === "video" || asset.kind === "voice") && (
{asset.kind === "video" && (
<span
className="xx-asset-play"
onClick={(e) => {
@@ -450,8 +442,9 @@ const AssetLibrary: React.FC = () => {
message.success(`"${file.name}" 上传成功`);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
} catch {
message.error(`"${file.name}" 上传失败`);
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : "";
message.error(`"${file.name}" 上传失败${detail ? `${detail}` : ""}`);
} finally {
setUploading(false);
setUploadProgress(0);
@@ -645,7 +638,7 @@ const AssetLibrary: React.FC = () => {
beforeUpload={handleUpload}
showUploadList={false}
multiple
accept="video/*,audio/*,image/*"
accept="video/*,image/*"
>
<div className="xx-asset-upload-zone">
<p className="xx-asset-upload-icon">
@@ -655,7 +648,7 @@ const AssetLibrary: React.FC = () => {
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
</p>
<p className="xx-asset-upload-hint">
2GB
2GB
</p>
</div>
</Upload.Dragger>
@@ -678,7 +671,6 @@ const AssetLibrary: React.FC = () => {
options={[
{ value: "all", label: "全部类型" },
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
@@ -809,7 +801,6 @@ const AssetLibrary: React.FC = () => {
style={{ width: "100%" }}
options={[
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
+122 -22
View File
@@ -6,11 +6,14 @@
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Typography, message } from "antd";
import { Typography, message, Select } from "antd";
import {
AudioOutlined,
ThunderboltOutlined,
CheckCircleFilled,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DownloadOutlined,
@@ -160,6 +163,7 @@ const GeneratePage: React.FC = () => {
const [generating, setGenerating] = useState(false);
const [progress, setProgress] = useState(0);
const [generated, setGenerated] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -477,6 +481,7 @@ const GeneratePage: React.FC = () => {
setGenerating(true);
setProgress(0);
setGenerated(false);
setGenerateError(null);
try {
const voiceConfig: Record<string, unknown> = {};
@@ -537,6 +542,7 @@ const GeneratePage: React.FC = () => {
)?.error_message ||
"视频生成失败,请联系管理员或重试";
console.error("[生成失败] planId:", plan.id, "响应:", data);
setGenerateError(errorMsg);
message.error(errorMsg);
return;
}
@@ -593,7 +599,9 @@ const GeneratePage: React.FC = () => {
"完整错误:",
axiosErr,
);
message.error(backendMsg || "生成失败,请检查网络后重试或联系管理员");
const errorMsg = backendMsg || "生成失败,请检查网络后重试或联系管理员";
setGenerateError(errorMsg);
message.error(errorMsg);
}
}, [
title,
@@ -917,14 +925,30 @@ const GeneratePage: React.FC = () => {
<h3>📝 </h3>
<div className="xx-form-field">
<label></label>
<select value={title} onChange={(e) => setTitle(e.target.value)}>
<option value=""></option>
{userTitles.map((t) => (
<option key={t.id} value={t.content}>
{t.content}
</option>
))}
</select>
<Select
placeholder="请选择标题…"
allowClear
showSearch
style={{ width: "100%" }}
value={title || undefined}
onChange={(val) => setTitle(val || "")}
options={userTitles.map((t) => ({
label: t.content,
value: t.content,
}))}
filterOption={(input, option) =>
(option?.label as string || "")
.toLowerCase()
.includes(input.toLowerCase())
}
notFoundContent={
userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
</span>
) : null
}
/>
</div>
<div className="xx-form-field" style={{ marginTop: 14 }}>
<label></label>
@@ -1407,18 +1431,88 @@ const GeneratePage: React.FC = () => {
</div>
</div>
{/* 生成进度 */}
{generating && (
{/* 生成进度 / 结果反馈 */}
{(generating || generated || generateError) && (
<div style={{ marginTop: 16 }}>
<div className="xx-progress-bar">
{generating && (
<>
<div className="xx-progress-bar">
<div
className="xx-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
<LoadingOutlined style={{ marginRight: 6 }} />
{Math.round(progress)}%
</Text>
</>
)}
{generated && !generating && (
<div
className="xx-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
{Math.round(progress)}%
</Text>
style={{
padding: "12px 16px",
borderRadius: 8,
background: "rgba(82, 196, 26, 0.08)",
border: "1px solid rgba(82, 196, 26, 0.3)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<CheckCircleOutlined
style={{ color: "#52c41a", fontSize: 18 }}
/>
<div>
<Text
strong
style={{ color: "#52c41a", display: "block", fontSize: 14 }}
>
</Text>
<Text
style={{ color: "var(--text-secondary)", fontSize: 12 }}
>
</Text>
</div>
</div>
)}
{generateError && !generating && (
<div
style={{
padding: "12px 16px",
borderRadius: 8,
background: "rgba(255, 77, 79, 0.08)",
border: "1px solid rgba(255, 77, 79, 0.3)",
display: "flex",
alignItems: "flex-start",
gap: 8,
}}
>
<CloseCircleOutlined
style={{
color: "#ff4d4f",
fontSize: 18,
marginTop: 2,
flexShrink: 0,
}}
/>
<div>
<Text
strong
style={{ color: "#ff4d4f", display: "block", fontSize: 14 }}
>
</Text>
<Text
style={{ color: "var(--text-secondary)", fontSize: 12 }}
>
{generateError}
</Text>
</div>
</div>
)}
</div>
)}
</div>
@@ -1529,10 +1623,16 @@ const GeneratePage: React.FC = () => {
<button
className="xx-btn xx-btn-primary"
onClick={handleGenerate}
disabled={generating || generated}
disabled={generating || (generated && !generateError)}
>
<ThunderboltOutlined />
{generating ? "生成中…" : generated ? "已生成" : "✨ 确认生成"}
{generating
? "生成中…"
: generated && !generateError
? "已生成"
: generateError
? "🔄 重新生成"
: "✨ 确认生成"}
</button>
)}
</div>
+21 -7
View File
@@ -739,17 +739,31 @@ const ProductLibrary: React.FC = () => {
if (isError) {
console.error("[ProductLibrary] 加载失败:", error);
const errorMsg = error?.message || "加载失败";
// 区分 404 和其他错误
// 404 视为空数据(API 尚未就绪或无数据)
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found");
if (is404) {
return (
<div className="xx-products-page">
<div className="xx-products-header">
<h2>
<VideoCameraOutlined />
</h2>
</div>
<div className="xx-products-empty">
<div className="xx-products-empty-icon">🎬</div>
<p></p>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 4 }}>
</p>
</div>
</div>
);
}
return (
<div className="xx-products-page">
<div className="xx-products-empty">
<div className="xx-products-empty-icon">{is404 ? "🔍" : "❌"}</div>
<p>
{is404
? "成片库功能正在建设中,敬请期待"
: errorMsg || "加载失败,请稍后重试"}
</p>
<div className="xx-products-empty-icon"></div>
<p>{errorMsg || "加载失败,请稍后重试"}</p>
<Button
buttonType="primary"
buttonSize="sm"
@@ -91,7 +91,7 @@ const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
id: item.id,
name: item.name,
type: inferTemplateType(item.category),
description: item.description,
description: item.description ?? "",
usageCount: 0,
isFavorite: item.is_favorite ?? false,
thumbnailGradient: gradientForCategory(item.category),
@@ -370,9 +370,10 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
className="xx-template-thumb-bg"
style={{ background: template.thumbnailGradient }}
>
{template.description.slice(0, 80)}...
{(template.description ?? "").slice(0, 80)}...
</div>
<div className="xx-template-thumb-overlay" />
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-preview-hint"></div>
<button
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
@@ -386,7 +387,6 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
{/* 信息区 */}
<div className="xx-template-info">
<div className="xx-template-info-top">
<h4 className="xx-template-name">{template.name}</h4>
<span
className="xx-template-category-pill"
style={{
@@ -397,7 +397,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
{template.type}
</span>
</div>
<p className="xx-template-desc">{template.description}</p>
<p className="xx-template-desc">{template.description ?? ""}</p>
<div className="xx-template-meta">
<span className="xx-template-usage">
使 {template.usageCount}
@@ -483,7 +483,7 @@ const TemplateLibrary: React.FC = () => {
const matchSearch =
!searchText ||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
t.description.toLowerCase().includes(searchText.toLowerCase()) ||
(t.description ?? "").toLowerCase().includes(searchText.toLowerCase()) ||
t.tags.some((tag) =>
tag.toLowerCase().includes(searchText.toLowerCase()),
);
@@ -233,6 +233,24 @@
pointer-events: none;
}
/* 缩略图底部名称 */
.xx-template-thumb-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 24px 14px 10px;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.55) 0%, transparent 100%);
color: #fff;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
z-index: 1;
pointer-events: none;
}
/* 预览提示(hover 显示) */
.xx-template-preview-hint {
position: absolute;
@@ -35,6 +35,8 @@ import {
CheckOutlined,
TagsOutlined,
MutedOutlined,
RobotOutlined,
LoadingOutlined,
} from "@ant-design/icons";
import { Button, Input, Select, Modal, Tag } from "@/components/ui";
import { message, Popover, Popconfirm, Tooltip } from "antd";
@@ -56,6 +58,12 @@ import {
tagAsset,
untagAsset,
} from "@/api/tags";
import {
synthesizeSpeech,
getTTSJobStatus,
saveTtsToLibrary,
} from "@/api/tts";
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices";
import "./voice-materials.css";
/* ============================================================
@@ -1086,6 +1094,14 @@ const VoiceMaterialLibrary: React.FC = () => {
[assets],
);
// ── 获取预设音色列表(AI 配音用) ─────────────────────────
const { data: presetVoicesData } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
staleTime: 60_000,
});
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? [];
// ── 上传 mutation ─────────────────────────────────────────
const uploadMutation = useMutation({
mutationFn: async (data: {
@@ -1218,6 +1234,17 @@ const VoiceMaterialLibrary: React.FC = () => {
);
const [batchCustomTag, setBatchCustomTag] = useState("");
// ── AI 配音(TTS 合成)状态 ────────────────────────────────
const [ttsOpen, setTtsOpen] = useState(false);
const [ttsText, setTtsText] = useState("");
const [ttsVoiceId, setTtsVoiceId] = useState<string>("");
const [ttsSpeed, setTtsSpeed] = useState(1.0);
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
const [ttsStatus, setTtsStatus] = useState<"idle" | "synthesizing" | "done" | "error">("idle");
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null);
const [ttsError, setTtsError] = useState<string | null>(null);
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ── 播放控制 ──────────────────────────────────────────────
const stopPlayback = useCallback(() => {
if (audioRef.current) {
@@ -1505,6 +1532,75 @@ const VoiceMaterialLibrary: React.FC = () => {
[tags, createTagMutation, handleBatchTag],
);
// ── TTS 合成处理 ─────────────────────────────────────────
/** 开始 AI 配音合成 */
const handleTtsSynthesize = useCallback(async () => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本");
return;
}
setTtsError(null);
setTtsStatus("synthesizing");
setTtsAudioUrl(null);
setTtsJobId(null);
try {
const resp = await synthesizeSpeech({
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
});
setTtsJobId(resp.job_id);
// 轮询任务状态
ttsTimerRef.current = setInterval(async () => {
try {
const job = await getTTSJobStatus(resp.job_id);
if (job.status === "completed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("done");
setTtsAudioUrl(job.output_audio_url);
} else if (job.status === "failed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError(job.error_message || "合成失败");
}
} catch {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError("查询合成状态失败");
}
}, 2000);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "合成请求失败";
setTtsStatus("error");
setTtsError(msg);
}
}, [ttsText, ttsVoiceId, ttsSpeed]);
/** 保存 TTS 结果到素材库 */
const handleTtsSave = useCallback(async () => {
if (!ttsJobId) return;
try {
await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 20) || "AI配音" });
message.success("已保存到配音素材库");
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
setTtsOpen(false);
} catch {
message.error("保存失败");
}
}, [ttsJobId, ttsText, queryClient]);
// TTS 定时器清理
useEffect(() => {
return () => {
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current);
};
}, []);
/* ── 渲染 ─────────────────────────────────────────────── */
const isUploading = uploadMutation.isPending;
@@ -1512,6 +1608,13 @@ const VoiceMaterialLibrary: React.FC = () => {
const pageActions = (
<div className="vmat-page-actions">
<Button
buttonSize="sm"
icon={<RobotOutlined />}
onClick={() => setTtsOpen(true)}
>
AI配音
</Button>
<Button
buttonType="primary"
buttonSize="sm"
@@ -1818,6 +1921,130 @@ const VoiceMaterialLibrary: React.FC = () => {
/>
)}
</Modal>
{/* AI 配音(TTS 合成)弹窗 */}
<Modal
title="AI 配音"
open={ttsOpen}
onCancel={() => {
setTtsOpen(false);
if (ttsTimerRef.current) {
clearInterval(ttsTimerRef.current);
ttsTimerRef.current = null;
}
setTtsStatus("idle");
setTtsAudioUrl(null);
setTtsError(null);
setTtsJobId(null);
}}
footer={null}
width={560}
destroyOnClose
>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* 文本输入 */}
<div>
<label style={{ fontSize: 13, fontWeight: 500, marginBottom: 6, display: "block" }}>
</label>
<textarea
rows={4}
placeholder="请输入需要转换为语音的文本内容…"
value={ttsText}
onChange={(e) => setTtsText(e.target.value)}
maxLength={2000}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
resize: "vertical",
fontFamily: "inherit",
}}
/>
<div style={{ fontSize: 11, color: "var(--text-tertiary, #999)", marginTop: 4, textAlign: "right" }}>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<label style={{ fontSize: 13, fontWeight: 500, marginBottom: 6, display: "block" }}>
</label>
<select
value={ttsVoiceId}
onChange={(e) => setTtsVoiceId(e.target.value)}
style={{
width: "100%",
height: 36,
padding: "0 10px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
background: "var(--bg-primary, #fff)",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.voice_id} value={v.voice_id}>
{v.name}
</option>
))}
</select>
</div>
{/* 语速调节 */}
<div>
<label style={{ fontSize: 13, fontWeight: 500, marginBottom: 6, display: "block" }}>
{ttsSpeed.toFixed(1)}x
</label>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
style={{ width: "100%" }}
/>
</div>
{/* 合成按钮 */}
<Button
buttonType="primary"
buttonSize="md"
icon={ttsStatus === "synthesizing" ? <LoadingOutlined /> : <RobotOutlined />}
onClick={handleTtsSynthesize}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
>
{ttsStatus === "synthesizing" ? "合成中…" : "开始合成"}
</Button>
{/* 错误提示 */}
{ttsStatus === "error" && ttsError && (
<div style={{ padding: "8px 12px", background: "#fff2f0", borderRadius: 6, color: "#ff4d4f", fontSize: 13 }}>
{ttsError}
</div>
)}
{/* 合成结果 */}
{ttsStatus === "done" && ttsAudioUrl && (
<div style={{ padding: 12, background: "var(--bg-surface, #f5f5f5)", borderRadius: 8 }}>
<audio controls src={ttsAudioUrl} style={{ width: "100%", marginBottom: 12 }} />
<Button
buttonType="primary"
buttonSize="sm"
icon={<PlusOutlined />}
onClick={handleTtsSave}
>
</Button>
</div>
)}
</div>
</Modal>
</div>
);
};