diff --git a/apps/web/src/components/layout/PageHead.tsx b/apps/web/src/components/layout/PageHead.tsx index e0c1ff521..119089556 100644 --- a/apps/web/src/components/layout/PageHead.tsx +++ b/apps/web/src/components/layout/PageHead.tsx @@ -62,7 +62,7 @@ const ROUTE_TITLE_MAP: Record = { "/app/editing-planner": "剪辑规划", "/app/my-templates": "我的模板", "/app/voice-clone": "我的音色", - "/app/voice-materials": "配音素材", + "/app/voice-materials": "配音素材库", "/app/accounts": "账号管理", "/app/duplication": "查重", "/app/duplication/results": "查重结果", diff --git a/apps/web/src/config/navigation.tsx b/apps/web/src/config/navigation.tsx index cebd7c255..76a689c32 100644 --- a/apps/web/src/config/navigation.tsx +++ b/apps/web/src/config/navigation.tsx @@ -69,7 +69,7 @@ export const NAV_ITEMS: NavItem[] = [ }, { key: "voice-materials", - label: "配音素材", + label: "配音素材库", path: "/app/voice-materials", icon: , }, @@ -161,7 +161,7 @@ export const NAV_GROUPS: NavGroup[] = [ }, { key: "voice-materials", - label: "配音素材", + label: "配音素材库", path: "/app/voice-materials", icon: , }, diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx index 655700195..be16cab7b 100644 --- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx +++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx @@ -1,16 +1,15 @@ /** * 配音素材管理页面 — V21 设计系统 * - * 功能:配音素材的增删改查 + * 功能:配音素材的增删改查(对接真实 API) * - 卡片 / 列表双布局 * - 上传配音(音频文件 + 元信息) - * - 试听播放 + * - 试听播放(使用 file_url 真实播放) * - 编辑元信息(名称、描述、性别、风格标签) * - 删除素材 - * - * 当前阶段:静态页面 + Mock 数据,等后端 API 就绪后对接 */ -import React, { useState, useRef, useCallback, useEffect } from "react"; +import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { AudioOutlined, PlayCircleOutlined, @@ -30,6 +29,16 @@ import { } from "@ant-design/icons"; import { Button, Input, Select, Modal, Tag } from "@/components/ui"; import PageHead from "@/components/layout/PageHead"; +import { + getAssetsByKind, + createAsset, + updateAsset, + deleteAsset, + uploadAssetDirect, + getAssetLibraries, + createAssetLibrary, + type AssetItem, +} from "@/api/assets"; import "./voice-materials.css"; /* ============================================================ @@ -61,7 +70,7 @@ const GENDER_OPTIONS: { value: VoiceGender; label: string; icon: React.ReactNode { value: "neutral", label: "中性", icon: }, ]; -/** 配音素材数据模型 */ +/** 前端配音素材数据模型(从 AssetItem 映射) */ interface VoiceMaterial { id: string; name: string; @@ -69,114 +78,53 @@ interface VoiceMaterial { gender: VoiceGender; tags: string[]; fileName: string; - fileSize: number; // bytes - duration: number; // seconds + fileSize: number; + duration: number; mimeType: string; createdAt: string; + fileUrl?: string; } /* ============================================================ - * Mock 数据 + * 数据映射:AssetItem ↔ VoiceMaterial * ============================================================ */ -const MOCK_MATERIALS: VoiceMaterial[] = [ - { - id: "vm-001", - name: "品牌宣传片配音", - description: "适合企业品牌宣传片的男声配音,沉稳大气", - gender: "male", - tags: ["沉稳", "专业"], - fileName: "brand_promo.mp3", - fileSize: 4_500_000, - duration: 45, - mimeType: "audio/mpeg", - createdAt: "2026-06-28T10:00:00Z", - }, - { - id: "vm-002", - name: "产品解说配音-活力版", - description: "适合短视频产品介绍的活力女声", - gender: "female", - tags: ["活力", "甜美"], - fileName: "product_intro_v1.mp3", - fileSize: 2_800_000, - duration: 28, - mimeType: "audio/mpeg", - createdAt: "2026-06-29T14:30:00Z", - }, - { - id: "vm-003", - name: "儿童故事旁白", - description: "温柔亲切的童声风格,适合儿童内容", - gender: "child", - tags: ["温柔", "清新"], - fileName: "kids_story.wav", - fileSize: 8_200_000, - duration: 82, - mimeType: "audio/wav", - createdAt: "2026-07-01T09:15:00Z", - }, - { - id: "vm-004", - name: "新闻播报配音", - description: "专业新闻播报风格,中性偏男声", - gender: "neutral", - tags: ["专业", "庄重"], - fileName: "news_report.mp3", - fileSize: 3_600_000, - duration: 36, - mimeType: "audio/mpeg", - createdAt: "2026-07-02T16:45:00Z", - }, - { - id: "vm-005", - name: "广告旁白-磁性男声", - description: "磁性男声,适合高端产品广告", - gender: "male", - tags: ["磁性", "专业"], - fileName: "ad_voice_male.mp3", - fileSize: 1_900_000, - duration: 19, - mimeType: "audio/mpeg", - createdAt: "2026-07-03T11:20:00Z", - }, - { - id: "vm-006", - name: "教程解说配音", - description: "清晰亲切的女声解说,适合教学视频", - gender: "female", - tags: ["清新", "专业"], - fileName: "tutorial_voice.mp3", - fileSize: 5_100_000, - duration: 51, - mimeType: "audio/mpeg", - createdAt: "2026-07-04T08:00:00Z", - }, - { - id: "vm-007", - name: "搞笑视频配音", - description: "幽默风格的男声配音,适合搞笑短视频", - gender: "male", - tags: ["幽默", "活力"], - fileName: "funny_voice.mp3", - fileSize: 1_200_000, - duration: 12, - mimeType: "audio/mpeg", - createdAt: "2026-07-05T13:30:00Z", - }, - { - id: "vm-008", - name: "有声书旁白-温柔女声", - description: "温柔细腻的女声,适合有声读物和情感类内容", - gender: "female", - tags: ["温柔", "甜美"], - fileName: "audiobook_voice.mp3", - fileSize: 12_000_000, - duration: 120, - mimeType: "audio/mpeg", - createdAt: "2026-07-06T17:00:00Z", - }, -]; +/** 后端 AssetItem → 前端 VoiceMaterial */ +const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => { + const meta = asset.metadata || {}; + const tags = Array.isArray(meta.style) + ? (meta.style as string[]) + : typeof meta.style === "string" && meta.style + ? (meta.style as string).split(",").map((s) => s.trim()) + : []; + + return { + id: asset.id, + name: asset.name, + description: (meta.description as string) || "", + gender: ((meta.gender as VoiceGender) || "neutral"), + tags, + fileName: asset.storage_key?.split("/").pop() || asset.name, + fileSize: asset.file_size || 0, + duration: (meta.duration as number) || 0, + mimeType: asset.mime_type || "audio/mpeg", + createdAt: asset.created_at || new Date().toISOString(), + fileUrl: asset.file_url, + }; +}; + +/** 前端表单数据 → 后端 metadata */ +const buildMetadata = (data: { + gender: VoiceGender; + description: string; + tags: string[]; + duration?: number; +}): Record => ({ + gender: data.gender, + description: data.description, + style: data.tags, + duration: data.duration || 0, +}); /* ============================================================ * 工具函数 @@ -209,6 +157,23 @@ const formatDate = (iso: string): string => day: "2-digit", }); +/** 获取音频文件时长(秒) */ +const getAudioDuration = (file: File): Promise => { + return 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; + }); +}; + /* ============================================================ * 上传 / 编辑 表单 * ============================================================ */ @@ -498,6 +463,7 @@ const VoiceMaterialCard: React.FC = ({ e.stopPropagation(); isPlaying ? onPause() : onPlay(); }} + disabled={!material.fileUrl} > {isPlaying ? : } @@ -566,6 +532,7 @@ const VoiceMaterialRow: React.FC = ({ e.stopPropagation(); isPlaying ? onPause() : onPlay(); }} + disabled={!material.fileUrl} > {isPlaying ? : } @@ -647,59 +614,189 @@ const VoiceMaterialRow: React.FC = ({ * ============================================================ */ const VoiceMaterialLibrary: React.FC = () => { - // 数据(Mock) - const [materials, setMaterials] = useState(MOCK_MATERIALS); + const queryClient = useQueryClient(); - // 视图 + // ── 获取 voice 类型素材库(用于上传) ────────────────────── + const { data: libraries = [] } = useQuery({ + queryKey: ["asset-libraries"], + queryFn: getAssetLibraries, + staleTime: 60_000, + }); + + const voiceLibrary = useMemo( + () => libraries.find((lib) => lib.kind === "voice"), + [libraries], + ); + + // 自动创建 voice 素材库(如果不存在) + const createLibMutation = useMutation({ + mutationFn: () => createAssetLibrary({ name: "配音素材库", kind: "voice" }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); + }, + }); + + // 页面加载时,如果没有 voice 库则创建 + useEffect(() => { + if (libraries.length > 0 && !voiceLibrary && !createLibMutation.isPending) { + createLibMutation.mutate(); + } + }, [libraries, voiceLibrary, createLibMutation]); + + // ── 获取配音素材列表 ────────────────────────────────────── + const { + data: assets = [], + isLoading, + } = useQuery({ + queryKey: ["assets", "voice"], + queryFn: () => getAssetsByKind("voice"), + staleTime: 30_000, + }); + + const materials: VoiceMaterial[] = useMemo( + () => assets.map(mapAssetToMaterial), + [assets], + ); + + // ── 上传 mutation ───────────────────────────────────────── + const uploadMutation = useMutation({ + mutationFn: async (data: { + file: File; + name: string; + gender: VoiceGender; + description: string; + tags: string[]; + }) => { + // 1. 获取或等待 voice library + let lib = voiceLibrary; + if (!lib) { + if (createLibMutation.isPending) { + await createLibMutation.mutateAsync(); + } + const libs = await queryClient.fetchQuery({ + queryKey: ["asset-libraries"], + queryFn: getAssetLibraries, + }); + lib = libs.find((l) => l.kind === "voice"); + if (!lib) throw new Error("无法创建配音素材库"); + } + + // 2. 上传文件 + const { storage_key } = await uploadAssetDirect({ + file: data.file, + library_id: lib.id, + }); + + // 3. 获取音频时长 + const duration = await getAudioDuration(data.file); + + // 4. 创建素材记录 + await createAsset({ + library_id: lib.id, + name: data.name, + storage_key, + mime_type: data.file.type || "audio/mpeg", + metadata: buildMetadata({ + gender: data.gender, + description: data.description, + tags: data.tags, + duration, + }), + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); + }, + }); + + // ── 编辑 mutation ───────────────────────────────────────── + const editMutation = useMutation({ + mutationFn: (data: { + id: string; + name: string; + gender: VoiceGender; + description: string; + tags: string[]; + }) => + updateAsset(data.id, { + name: data.name, + metadata: buildMetadata({ + gender: data.gender, + description: data.description, + tags: data.tags, + }), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); + }, + }); + + // ── 删除 mutation ───────────────────────────────────────── + const deleteMutation = useMutation({ + mutationFn: (assetId: string) => deleteAsset(assetId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); + }, + }); + + // ── 视图状态 ────────────────────────────────────────────── const [viewMode, setViewMode] = useState("card"); const [searchText, setSearchText] = useState(""); const [filterGender, setFilterGender] = useState("all"); const [filterTag, setFilterTag] = useState("all"); - // 播放 + // ── 播放状态 ────────────────────────────────────────────── const [playingId, setPlayingId] = useState(null); const [currentTime, setCurrentTime] = useState(0); - const intervalRef = useRef(null); + const audioRef = useRef(null); - // 弹窗 + // ── 弹窗状态 ────────────────────────────────────────────── const [uploadOpen, setUploadOpen] = useState(false); const [editingMaterial, setEditingMaterial] = useState(null); - /* ── 播放控制 ─────────────────────────────────────────── */ - + // ── 播放控制 ────────────────────────────────────────────── const stopPlayback = useCallback(() => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; } setPlayingId(null); setCurrentTime(0); }, []); const startPlayback = useCallback( - (id: string, duration: number) => { + (material: VoiceMaterial) => { + if (!material.fileUrl) return; stopPlayback(); - setPlayingId(id); + + const audio = new Audio(material.fileUrl); + audioRef.current = audio; + + audio.addEventListener("timeupdate", () => { + setCurrentTime(audio.currentTime); + }); + + audio.addEventListener("ended", () => { + setPlayingId(null); + setCurrentTime(0); + audioRef.current = null; + }); + + audio.play().catch(() => { + audioRef.current = null; + setPlayingId(null); + }); + + setPlayingId(material.id); setCurrentTime(0); - intervalRef.current = window.setInterval(() => { - setCurrentTime((prev) => { - if (prev >= duration) { - if (intervalRef.current) clearInterval(intervalRef.current); - intervalRef.current = null; - setPlayingId(null); - return 0; - } - return prev + 0.1; - }); - }, 100); }, [stopPlayback], ); const handlePlay = useCallback( - (id: string, duration: number) => { - if (playingId === id) return; - startPlayback(id, duration); + (material: VoiceMaterial) => { + if (playingId === material.id) return; + startPlayback(material); }, [playingId, startPlayback], ); @@ -709,44 +806,65 @@ const VoiceMaterialLibrary: React.FC = () => { }, [stopPlayback]); const handleSeek = useCallback( - (id: string, time: number, duration: number) => { - setCurrentTime(time); - if (playingId !== id) { - startPlayback(id, duration); + (material: VoiceMaterial, time: number) => { + if (audioRef.current) { + audioRef.current.currentTime = time; + setCurrentTime(time); + } else { + startPlayback(material); + setTimeout(() => { + if (audioRef.current) { + audioRef.current.currentTime = time; + } + }, 100); } }, - [playingId, startPlayback], + [startPlayback], ); + // 组件卸载时清理 audio useEffect(() => { return () => { - if (intervalRef.current) clearInterval(intervalRef.current); + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } }; }, []); - /* ── 数据操作(Mock) ──────────────────────────────────── */ + /* ── 数据操作 ──────────────────────────────────────────── */ - const handleUpload = (data: Omit & { file?: File }) => { - const newMaterial: VoiceMaterial = { - ...data, - id: `vm-${Date.now()}`, - createdAt: new Date().toISOString(), - // 模拟音频时长(实际应从 Audio 元素获取) - duration: data.duration || Math.floor(Math.random() * 60) + 10, - }; - setMaterials((prev) => [newMaterial, ...prev]); - setUploadOpen(false); + const handleUpload = ( + data: Omit & { file?: File }, + ) => { + if (!data.file) return; + uploadMutation.mutate( + { + file: data.file, + name: data.name, + gender: data.gender, + description: data.description, + tags: data.tags, + }, + { + onSuccess: () => { + setUploadOpen(false); + }, + }, + ); }; - const handleEdit = (data: Omit & { file?: File }) => { + const handleEdit = ( + data: Omit & { file?: File }, + ) => { if (!editingMaterial) return; - setMaterials((prev) => - prev.map((m) => - m.id === editingMaterial.id - ? { ...m, name: data.name, description: data.description, gender: data.gender, tags: data.tags } - : m, - ), - ); + editMutation.mutate({ + id: editingMaterial.id, + name: data.name, + gender: data.gender, + description: data.description, + tags: data.tags, + }); setEditingMaterial(null); }; @@ -761,7 +879,7 @@ const VoiceMaterialLibrary: React.FC = () => { cancelText: "取消", onOk: () => { if (playingId === id) stopPlayback(); - setMaterials((prev) => prev.filter((m) => m.id !== id)); + deleteMutation.mutate(id); }, }); }; @@ -798,6 +916,9 @@ const VoiceMaterialLibrary: React.FC = () => { /* ── 渲染 ─────────────────────────────────────────────── */ + const isUploading = uploadMutation.isPending; + const isEditing = editMutation.isPending; + const pageActions = (
+ {/* 加载中 */} + {isLoading && ( +
+
+ +
+

加载中...

+
+ )} + {/* 内容区 */} - {filtered.length > 0 && viewMode === "card" && ( + {!isLoading && filtered.length > 0 && viewMode === "card" && (
{filtered.map((m) => ( { material={m} isPlaying={playingId === m.id} currentTime={playingId === m.id ? currentTime : 0} - onPlay={() => handlePlay(m.id, m.duration)} + onPlay={() => handlePlay(m)} onPause={handlePause} - onSeek={(t) => handleSeek(m.id, t, m.duration)} + onSeek={(t) => handleSeek(m, t)} onEdit={() => setEditingMaterial(m)} onDelete={() => handleDelete(m.id)} /> @@ -896,7 +1027,7 @@ const VoiceMaterialLibrary: React.FC = () => {
)} - {filtered.length > 0 && viewMode === "list" && ( + {!isLoading && filtered.length > 0 && viewMode === "list" && (
{/* 列表头 */}
@@ -915,9 +1046,9 @@ const VoiceMaterialLibrary: React.FC = () => { material={m} isPlaying={playingId === m.id} currentTime={playingId === m.id ? currentTime : 0} - onPlay={() => handlePlay(m.id, m.duration)} + onPlay={() => handlePlay(m)} onPause={handlePause} - onSeek={(t) => handleSeek(m.id, t, m.duration)} + onSeek={(t) => handleSeek(m, t)} onEdit={() => setEditingMaterial(m)} onDelete={() => handleDelete(m.id)} /> @@ -925,7 +1056,7 @@ const VoiceMaterialLibrary: React.FC = () => {
)} - {filtered.length === 0 && ( + {!isLoading && filtered.length === 0 && (
@@ -961,6 +1092,7 @@ const VoiceMaterialLibrary: React.FC = () => { setUploadOpen(false)} + loading={isUploading} /> @@ -978,6 +1110,7 @@ const VoiceMaterialLibrary: React.FC = () => { initial={editingMaterial} onSubmit={handleEdit} onCancel={() => setEditingMaterial(null)} + loading={isEditing} /> )}