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
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:
@@ -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: "图片" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user