diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index ed2db0e0f..04e0cc07f 100644 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -1,366 +1,571 @@ /** - * 配音库页面 - * 管理用户配音,支持手动创建和 AI 生成 + * 配音库页面 — V21 设计系统 + * 两栏布局:左侧配音库列表(260px)+ 右侧配音卡片列表 + * 使用 mock 数据,后端 API 对接暂不要求 */ -import React, { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import React, { useMemo, useState, useRef, useEffect } from "react"; import { - Button, - Typography, - Space, - Table, - Empty, - Spin, - Modal, - Input, - Select, - Tag, - Popconfirm, + Modal as AntModal, message, - Slider, + Popconfirm, } from "antd"; import { PlusOutlined, - EditOutlined, + SearchOutlined, + SoundOutlined, + PlayCircleOutlined, + PauseCircleOutlined, DeleteOutlined, RobotOutlined, + UploadOutlined, } from "@ant-design/icons"; -import { - getVoices, - createVoice, - updateVoice, - deleteVoice, - generateAIVoice, - type VoiceItem, -} from "@/api/voices"; -import type { ColumnsType } from "antd/es/table"; -import PageHead from "@/components/layout/PageHead"; +import { Button, Input, Select } from "@/components/ui"; +import "./voices.css"; -const { Text } = Typography; +/* ============================================================ + * 类型 + * ============================================================ */ +type VoiceType = "male" | "female" | "child" | "elderly"; +type Language = "zh" | "en" | "ja" | "ko"; -/** 配音状态标签 */ -const StatusTag: React.FC<{ status?: string }> = ({ status }) => { - if (!status) return null; - const colorMap: Record = { - completed: "success", - processing: "processing", - failed: "error", - pending: "default", - }; - return {status}; +interface LibraryItem { + id: string; + name: string; + voiceType: VoiceType; + count: number; +} + +interface VoiceItem { + id: string; + name: string; + voiceType: VoiceType; + language: Language; + duration: number; // 秒 + tags: string[]; + createdAt: string; +} + +/* ============================================================ + * Mock 数据 + * ============================================================ */ +const MOCK_LIBRARIES: LibraryItem[] = [ + { id: "lib-1", name: "男声配音库", voiceType: "male", count: 8 }, + { id: "lib-2", name: "女声配音库", voiceType: "female", count: 12 }, + { id: "lib-3", name: "童声配音库", voiceType: "child", count: 5 }, + { id: "lib-4", name: "老年配音库", voiceType: "elderly", count: 3 }, +]; + +const MOCK_VOICES: VoiceItem[] = [ + { id: "v-1", name: "磁性男声_深沉", voiceType: "male", language: "zh", duration: 45, tags: ["磁性", "深沉"], createdAt: "2026-06-28" }, + { id: "v-2", name: "温暖男声_治愈", voiceType: "male", language: "zh", duration: 38, tags: ["温暖", "治愈"], createdAt: "2026-06-27" }, + { id: "v-3", name: "活力女声_青春", voiceType: "female", language: "zh", duration: 52, tags: ["活力", "青春"], createdAt: "2026-06-26" }, + { id: "v-4", name: "温柔女声_优雅", voiceType: "female", language: "zh", duration: 41, tags: ["温柔", "优雅"], createdAt: "2026-06-25" }, + { id: "v-5", name: "可爱童声_活泼", voiceType: "child", language: "zh", duration: 35, tags: ["可爱", "活泼"], createdAt: "2026-06-24" }, + { id: "v-6", name: "沉稳男声_专业", voiceType: "male", language: "en", duration: 48, tags: ["沉稳", "专业"], createdAt: "2026-06-23" }, + { id: "v-7", name: "甜美女声_清新", voiceType: "female", language: "ja", duration: 42, tags: ["甜美", "清新"], createdAt: "2026-06-22" }, + { id: "v-8", name: "沧桑男声_故事感", voiceType: "elderly", language: "zh", duration: 56, tags: ["沧桑", "故事感"], createdAt: "2026-06-21" }, + { id: "v-9", name: "知性女声_商务", voiceType: "female", language: "ko", duration: 39, tags: ["知性", "商务"], createdAt: "2026-06-20" }, + { id: "v-10", name: "阳光男声_运动", voiceType: "male", language: "zh", duration: 44, tags: ["阳光", "运动"], createdAt: "2026-06-19" }, +]; + +/* ============================================================ + * 工具函数 + * ============================================================ */ +const voiceTypeLabel = (type: VoiceType) => { + switch (type) { + case "male": return "男声"; + case "female": return "女声"; + case "child": return "童声"; + case "elderly": return "老年"; + } }; -const VoiceLibrary: React.FC = () => { - const queryClient = useQueryClient(); - const [modalOpen, setModalOpen] = useState(false); - const [aiModalOpen, setAiModalOpen] = useState(false); - const [editingVoice, setEditingVoice] = useState(null); - const [formName, setFormName] = useState(""); - const [formText, setFormText] = useState(""); - const [formVoiceType, setFormVoiceType] = useState(""); - const [aiText, setAiText] = useState(""); - const [aiVoiceType, setAiVoiceType] = useState(""); - const [aiSpeed, setAiSpeed] = useState(1.0); +const languageLabel = (lang: Language) => { + switch (lang) { + case "zh": return "中文"; + case "en": return "英文"; + case "ja": return "日文"; + case "ko": return "韩文"; + } +}; - // 获取配音列表 - const { data: voices = [], isLoading } = useQuery({ - queryKey: ["voices"], - queryFn: getVoices, - }); +/** 根据音色类型返回渐变背景 */ +const voiceGradient = (type: VoiceType): string => { + switch (type) { + case "male": + return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"; + case "female": + return "linear-gradient(135deg, #831843 0%, #db2777 50%, #ec4899 100%)"; + case "child": + return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"; + case "elderly": + return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"; + } +}; - // 创建配音 - const createMutation = useMutation({ - mutationFn: createVoice, - onSuccess: () => { - message.success("配音创建成功"); - setModalOpen(false); - resetForm(); - queryClient.invalidateQueries({ queryKey: ["voices"] }); - }, - onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("创建失败"); - }, - }); +/** 格式化时间 mm:ss */ +const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; +}; - // 更新配音 - const updateMutation = useMutation({ - mutationFn: ({ - id, - data, - }: { - id: string; - data: Partial<{ name: string; text: string; voice_type: string }>; - }) => updateVoice(id, data), - onSuccess: () => { - message.success("配音更新成功"); - setModalOpen(false); - resetForm(); - queryClient.invalidateQueries({ queryKey: ["voices"] }); - }, - onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("更新失败"); - }, - }); +/* ============================================================ + * VoiceCard 组件 + * ============================================================ */ +const VoiceCard: React.FC<{ + voice: VoiceItem; + isPlaying: boolean; + currentTime: number; + onPlay: () => void; + onPause: () => void; + onSeek: (time: number) => void; +}> = ({ voice, isPlaying, currentTime, onPlay, onPause, onSeek }) => { + const progressRef = useRef(null); - // 删除配音 - const deleteMutation = useMutation({ - mutationFn: deleteVoice, - onSuccess: () => { - message.success("已删除"); - queryClient.invalidateQueries({ queryKey: ["voices"] }); - }, - onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("删除失败"); - }, - }); - - // AI 生成配音 - const aiMutation = useMutation({ - mutationFn: generateAIVoice, - onSuccess: () => { - message.success("AI 配音生成成功"); - setAiModalOpen(false); - setAiText(""); - queryClient.invalidateQueries({ queryKey: ["voices"] }); - }, - onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("AI 生成失败"); - }, - }); - - const resetForm = () => { - setEditingVoice(null); - setFormName(""); - setFormText(""); - setFormVoiceType(""); + const handleProgressClick = (e: React.MouseEvent) => { + if (!progressRef.current) return; + const rect = progressRef.current.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + onSeek(percent * voice.duration); }; - const openCreate = () => { - resetForm(); - setModalOpen(true); - }; - - const openEdit = (voice: VoiceItem) => { - setEditingVoice(voice); - setFormName(voice.name); - setFormText(voice.text); - setFormVoiceType(voice.voice_type || ""); - setModalOpen(true); - }; - - const handleSave = () => { - if (!formName.trim() || !formText.trim()) { - message.warning("请填写名称和文本"); - return; - } - if (editingVoice) { - updateMutation.mutate({ - id: editingVoice.id, - data: { name: formName, text: formText, voice_type: formVoiceType }, - }); - } else { - createMutation.mutate({ - name: formName, - text: formText, - voice_type: formVoiceType, - }); - } - }; - - const columns: ColumnsType = [ - { - title: "名称", - dataIndex: "name", - key: "name", - width: 150, - ellipsis: true, - }, - { - title: "文本", - dataIndex: "text", - key: "text", - ellipsis: true, - }, - { - title: "类型", - dataIndex: "voice_type", - key: "voice_type", - width: 100, - render: (type: string) => type || "-", - }, - { - title: "时长", - dataIndex: "duration_seconds", - key: "duration_seconds", - width: 80, - render: (d: number) => (d ? `${d.toFixed(1)}s` : "-"), - }, - { - title: "状态", - dataIndex: "status", - key: "status", - width: 80, - render: (status: string) => , - }, - { - title: "操作", - key: "actions", - width: 120, - render: (_, record) => ( - - - - - } - /> - - {isLoading ? ( -
- -
- ) : voices.length === 0 ? ( - - - - - - - ) : ( - - )} - - {/* 新建/编辑弹窗 */} - { - setModalOpen(false); - resetForm(); - }} - onOk={handleSave} - confirmLoading={createMutation.isPending || updateMutation.isPending} +
+ {/* 头像 */} +
- - setFormName(e.target.value)} - /> - setFormText(e.target.value)} - rows={4} - /> - } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 240 }} + /> + +
+
+ + + +
+
+ + {/* 配音卡片列表 */} + {filteredVoices.length > 0 ? ( +
+ {filteredVoices.map((voice) => ( + handlePlay(voice.id)} + onPause={handlePause} + onSeek={(time) => handleSeek(voice.id, time)} + /> + ))} +
+ ) : ( +
+
+ +
+

暂无配音,请切换配音库或添加新配音

+
+ )} + + + + {/* ─── 新建配音库弹窗 ─── */} + setCreateModalOpen(false)} + onOk={handleCreateLibrary} + okText="创建" + cancelText="取消" + destroyOnClose + > +
+
+
+ 名称 +
+ setNewLibName(e.target.value)} + maxLength={50} + /> +
+
+
+ 音色类型 +
+ +
- 语速:{aiSpeed.toFixed(1)}x - + 配音文本 +
+ setAiText(e.target.value)} + rows={6} + maxLength={2000} + showCount />
- - +
+
+ 音色类型 +
+