diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index 867bc2686..8e69d8f83 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -219,7 +219,8 @@ const AssetCard: React.FC<{ onToggle: () => void; onDiagnose: () => void; onPlay: () => void; -}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay }) => ( + onDelete: () => void; +}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay, onDelete }) => (
)} + {/* 删除按钮 */} + { + e?.stopPropagation(); + onDelete(); + }} + onCancel={(e) => e?.stopPropagation()} + okText="删除" + cancelText="取消" + okButtonProps={{ danger: true }} + > + e.stopPropagation()} + > + + + + {/* 选中态勾选 */} {selected && ( @@ -504,6 +526,24 @@ const AssetLibrary: React.FC = () => { }; /* 批量删除 */ + /* 单个素材删除 */ + const handleSingleDelete = async (assetId: string) => { + try { + await deleteAsset(assetId); + queryClient.invalidateQueries({ queryKey: ["assets"] }); + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); + // 从选中集合中移除 + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(assetId); + return next; + }); + message.success("素材已删除"); + } catch { + message.error("删除失败,请重试"); + } + }; + const handleBatchDelete = async () => { const ids = Array.from(selectedIds); let successCount = 0; @@ -763,6 +803,7 @@ const AssetLibrary: React.FC = () => { onToggle={() => toggleSelect(asset.id)} onDiagnose={() => handleDiagnose(asset)} onPlay={() => setPlayingAsset(asset)} + onDelete={() => handleSingleDelete(asset.id)} /> ))}
diff --git a/apps/web/src/pages/assets/assets.css b/apps/web/src/pages/assets/assets.css index 63e46e653..202de4a89 100644 --- a/apps/web/src/pages/assets/assets.css +++ b/apps/web/src/pages/assets/assets.css @@ -282,6 +282,35 @@ transform: scale(1.1); } +/* 删除按钮 */ +.xx-asset-delete { + position: absolute; + bottom: var(--space-sm, 8px); + right: var(--space-sm, 8px); + width: 28px; + height: 28px; + border-radius: var(--radius-full, 999px); + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); + display: grid; + place-items: center; + color: #fff; + font-size: 13px; + cursor: pointer; + opacity: 0; + transition: var(--transition-all, all 0.2s ease); + z-index: 2; +} + +.xx-asset-card:hover .xx-asset-delete { + opacity: 1; +} + +.xx-asset-delete:hover { + background: rgba(255, 77, 79, 0.85); + transform: scale(1.1); +} + /* 选中态 */ .xx-asset-card-selected { border-color: var(--primary-color) !important; diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 7e582007b..4e09b86dc 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -25,7 +25,7 @@ import { } from "@ant-design/icons"; import type { AssetItem } from "@/api/assets"; import { getAssets, getAssetLibraries } from "@/api/assets"; -import { createEditPlan, generateEditPlan } from "@/api/editPlans"; +import { createEditPlan, generateEditPlan, updateEditPlan } from "@/api/editPlans"; import { getEditingTemplates } from "@/api/editingPlanner"; import { getTitles } from "@/api/titles"; import apiClient from "@/api/client"; @@ -514,6 +514,9 @@ const GeneratePage: React.FC = () => { source_edit_plan_id: editPlanId || undefined, }); + // 后端要求计划处于 editing 状态才能触发渲染,自动转换状态 + await updateEditPlan(plan.id, { status: "editing" }); + await generateEditPlan(plan.id); const poll = async () => { @@ -647,8 +650,34 @@ const GeneratePage: React.FC = () => { } return String(val ?? ""); }; - const finalMsg = - safeExtractErr(backendMsg) || "生成失败,请检查网络后重试或联系管理员"; + const rawError = safeExtractErr(backendMsg); + // 将技术错误翻译为用户友好提示(不暴露状态机、字段名等内部概念) + const translateError = (msg: string): string => { + if (!msg) return "生成失败,请检查网络后重试或联系管理员"; + // 状态机相关错误 + if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) { + return "正在准备生成,请稍候再试"; + } + // 参数校验错误 + if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) { + return "所选模板或素材不可用,请重新选择"; + } + if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) { + return "素材数据异常,请返回素材库重新检查"; + } + // 网络/超时 + if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) { + return "网络连接超时,请检查网络后重试"; + } + // 配额/限制 + if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) { + return "已达到生成次数上限,请稍后再试或联系客服"; + } + // 兜底:返回原始消息(如果已经是中文人话)或默认提示 + if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg; + return "生成失败,请稍后重试或联系管理员"; + }; + const finalMsg = translateError(rawError); setGenerateError(finalMsg); message.error(finalMsg); } diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index 40c66cf1a..63edd3f83 100644 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -30,6 +30,7 @@ import { ReloadOutlined, CloseCircleOutlined, } from "@ant-design/icons"; +import { Modal, Upload, message } from "antd"; import { Button, Input, Select, Tooltip } from "@/components/ui"; import PageHead from "@/components/layout/PageHead"; import { @@ -44,6 +45,16 @@ import { toVoiceClone, type VoiceClone, } from "@/api/voiceClone"; +import { + synthesizeSpeech, + getTTSJobStatus, + saveTtsToLibrary, +} from "@/api/tts"; +import { + uploadAssetDirect, + getAssetLibraries, + createAsset, +} from "@/api/assets"; import CloneModal from "@/components/voice/CloneModal"; import "./voices.css"; @@ -593,6 +604,41 @@ const CloneCardSkeleton: React.FC = () => ( ); +/* ── 辅助函数 ─────────────────────────────────────────── */ + +const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +const getAudioDuration = (file: File): Promise => + new Promise((resolve) => { + const audio = new Audio(); + const url = URL.createObjectURL(file); + audio.addEventListener("loadedmetadata", () => { + resolve(audio.duration); + URL.revokeObjectURL(url); + }); + audio.addEventListener("error", () => { + resolve(0); + URL.revokeObjectURL(url); + }); + audio.src = url; + }); + +const buildVoiceMetadata = (data: { + gender?: string; + description?: string; + duration?: number; +}): Record => { + const metadata: Record = {}; + if (data.gender) metadata.gender = data.gender; + if (data.description) metadata.description = data.description; + if (data.duration) metadata.duration = Math.round(data.duration); + return metadata; +}; + /* ============================================================ * 主组件 * ============================================================ */ @@ -613,6 +659,25 @@ const VoiceLibrary: React.FC = () => { const [toasts, setToasts] = useState([]); const [cloneModalOpen, setCloneModalOpen] = useState(false); + /* ── 上传音频弹窗状态 ── */ + const [uploadOpen, setUploadOpen] = useState(false); + const [uploadFile, setUploadFile] = useState(null); + const [uploadName, setUploadName] = useState(""); + const [uploadGender, setUploadGender] = useState("female"); + const [uploadDesc, setUploadDesc] = useState(""); + const [uploadProgress, setUploadProgress] = useState(null); + + /* ── AI 配音弹窗状态 ── */ + const [ttsOpen, setTtsOpen] = useState(false); + const [ttsText, setTtsText] = useState(""); + const [ttsVoiceId, setTtsVoiceId] = useState(""); + const [ttsSpeed, setTtsSpeed] = useState(1.0); + const [ttsJobId, setTtsJobId] = useState(null); + const [ttsStatus, setTtsStatus] = useState<"idle" | "synthesizing" | "done" | "error">("idle"); + const [ttsAudioUrl, setTtsAudioUrl] = useState(null); + const [ttsError, setTtsError] = useState(null); + const ttsTimerRef = useRef | null>(null); + const showToast = useCallback((message: string, type: Toast["type"]) => { const id = ++toastIdSeq; setToasts((prev) => [...prev, { id, message, type }]); @@ -645,6 +710,130 @@ const VoiceLibrary: React.FC = () => { }, }); + /* ── 上传音频 mutation ── */ + const uploadMutation = useMutation({ + mutationFn: async (data: { + file: File; + name: string; + gender: VoiceGender; + description: string; + }) => { + setUploadProgress(0); + try { + /* 获取或创建默认配音素材库 */ + const libs = await queryClient.fetchQuery({ + queryKey: ["asset-libraries"], + queryFn: getAssetLibraries, + }); + const lib = libs.find((l) => l.kind === "voice"); + if (!lib) throw new Error("配音素材库不存在,请先在配音素材库页面创建"); + + /* 直传文件 */ + const { storage_key } = await uploadAssetDirect({ + file: data.file, + library_id: lib.id, + onProgress: (p) => setUploadProgress(p), + }); + + /* 获取音频时长 */ + const duration = await getAudioDuration(data.file); + + /* 创建素材记录 */ + await createAsset({ + library_id: lib.id, + name: data.name, + storage_key, + mime_type: data.file.type || "audio/mpeg", + metadata: buildVoiceMetadata({ + gender: data.gender, + description: data.description, + duration, + }), + }); + } finally { + setUploadProgress(null); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); + setUploadOpen(false); + setUploadFile(null); + setUploadName(""); + setUploadDesc(""); + showToast("上传成功", "success"); + }, + onError: (err: Error) => { + showToast(err.message || "上传失败,请重试", "error"); + }, + }); + + /* ── TTS 合成 ── */ + 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, 50) }); + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); + showToast("已保存到配音素材库", "success"); + setTtsOpen(false); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "保存失败"; + showToast(msg, "error"); + } + }, [ttsJobId, ttsText, queryClient, showToast]); + + /* ── TTS 定时器清理 ── */ + useEffect(() => { + return () => { + if (ttsTimerRef.current) clearInterval(ttsTimerRef.current); + }; + }, []); + /* ── 数据查询(任务 3.11:替换 Mock) ──────────────── */ /** 预置音色列表 */ @@ -788,7 +977,12 @@ const VoiceLibrary: React.FC = () => { const pageActions = (
- -
@@ -1008,6 +1207,417 @@ const VoiceLibrary: React.FC = () => { /> )} + {/* ── 上传音频弹窗 ── */} + { + if (uploadProgress !== null) return; // 上传中不可关闭 + setUploadOpen(false); + setUploadFile(null); + setUploadName(""); + setUploadDesc(""); + }} + footer={null} + width={520} + maskClosable={uploadProgress === null} + > +
+ {/* 拖拽上传区 */} + { + setUploadFile(file); + if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, "")); + return false; + }} + onRemove={() => { + setUploadFile(null); + setUploadProgress(null); + }} + showUploadList={false} + disabled={uploadProgress !== null} + > +

+ +

+

+ 点击或拖拽音频文件到此处 +

+

+ 支持 MP3、WAV、AAC、FLAC 等格式,最大 200MB +

+
+ + {/* 已选文件信息 */} + {uploadFile && ( +
+ +
+
+ {uploadFile.name} +
+
+ {formatFileSize(uploadFile.size)} +
+
+
+ )} + + {/* 上传进度 */} + {uploadProgress !== null && ( +
+
+ {uploadProgress}% +
+
+ {uploadProgress < 100 ? "上传中..." : "处理中..."} +
+
+
+
+
+ )} + + {/* 名称 */} +
+
+ 素材名称 +
+ setUploadName(e.target.value)} + placeholder="输入素材名称" + maxLength={100} + disabled={uploadProgress !== null} + style={{ + width: "100%", + padding: "8px 12px", + border: "1px solid var(--border-color)", + borderRadius: 8, + fontSize: 13, + background: "var(--bg-primary)", + color: "var(--text-primary)", + outline: "none", + }} + /> +
+ + {/* 性别选择 */} +
+
+ 音色性别 +
+
+ {(["female", "male", "child"] as VoiceGender[]).map((g) => ( + + ))} +
+
+ + {/* 描述 */} +
+
+ 音色描述(可选) +
+