diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index 35593316e..c0fc669d7 100644 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -1,276 +1,294 @@ /** - * 配音库页面 — V21 设计系统 - * 两栏布局:左侧配音库列表(260px)+ 右侧配音卡片列表 - * 使用 mock 数据,后端 API 对接暂不要求 + * 配音库页面 — V21 设计系统(任务 3.11 升级) + * Tab 分类:「预置音色」+「我的克隆」 + * 网格卡片展示,支持播放/选中状态 + * Mock 数据 + 预留 CosyVoice API 对接接口 */ -import React, { useMemo, useState, useRef, useEffect } from "react"; -import { Modal as AntModal, message, Popconfirm } from "antd"; +import React, { useMemo, useState, useRef, useEffect, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; import { - PlusOutlined, - SearchOutlined, SoundOutlined, PlayCircleOutlined, PauseCircleOutlined, - DeleteOutlined, + SearchOutlined, + PlusOutlined, RobotOutlined, UploadOutlined, + AudioOutlined, + HeartOutlined, + UserOutlined, } from "@ant-design/icons"; -import { Button, Input, Select } from "@/components/ui"; +import { Button, Input, Select, Modal } from "@/components/ui"; +import { PageHead } from "@/components/layout/PageHead"; import "./voices.css"; /* ============================================================ * 类型 * ============================================================ */ -type VoiceType = "male" | "female" | "child" | "elderly"; -type Language = "zh" | "en" | "ja" | "ko"; +type VoiceGender = "male" | "female" | "child" | "elderly"; +type VoiceLanguage = "zh" | "en" | "ja" | "ko"; +type TabKey = "preset" | "cloned"; -interface LibraryItem { +interface PresetVoice { id: string; name: string; - voiceType: VoiceType; - count: number; + gender: VoiceGender; + language: VoiceLanguage; + duration: number; + tags: string[]; + description: string; + voiceId: string; + previewUrl: string; + starred: boolean; } -interface VoiceItem { +interface ClonedVoice { id: string; name: string; - voiceType: VoiceType; - language: Language; - duration: number; // 秒 - tags: string[]; + sourceName: string; + status: "ready" | "processing" | "failed"; createdAt: string; + duration: number; + tags: string[]; + voiceId: string; } /* ============================================================ - * Mock 数据 + * Mock 数据(后续替换为 API 调用) * ============================================================ */ -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_PRESET_VOICES: PresetVoice[] = [ + { + id: "pv-1", name: "磁性男声 · 深沉", gender: "male", language: "zh", + duration: 45, tags: ["磁性", "深沉", "纪录片"], description: "适合纪录片、品牌宣传片的男声配音", + voiceId: "cosyvoice-male-deep", previewUrl: "/mock/audio/preset-1.mp3", starred: true, + }, + { + id: "pv-2", name: "温暖男声 · 治愈", gender: "male", language: "zh", + duration: 38, tags: ["温暖", "治愈", "故事"], description: "温暖治愈系男声,适合故事讲述", + voiceId: "cosyvoice-male-warm", previewUrl: "/mock/audio/preset-2.mp3", starred: false, + }, + { + id: "pv-3", name: "活力女声 · 青春", gender: "female", language: "zh", + duration: 52, tags: ["活力", "青春", "种草"], description: "活力满满的女声,适合种草类短视频", + voiceId: "cosyvoice-female-youth", previewUrl: "/mock/audio/preset-3.mp3", starred: true, + }, + { + id: "pv-4", name: "温柔女声 · 优雅", gender: "female", language: "zh", + duration: 41, tags: ["温柔", "优雅", "品牌"], description: "知性优雅的女声,适合品牌广告", + voiceId: "cosyvoice-female-elegant", previewUrl: "/mock/audio/preset-4.mp3", starred: false, + }, + { + id: "pv-5", name: "可爱童声 · 活泼", gender: "child", language: "zh", + duration: 35, tags: ["可爱", "活泼", "动画"], description: "活泼可爱的童声,适合动画配音", + voiceId: "cosyvoice-child-cute", previewUrl: "/mock/audio/preset-5.mp3", starred: false, + }, + { + id: "pv-6", name: "沉稳男声 · 专业", gender: "male", language: "en", + duration: 48, tags: ["沉稳", "专业", "商务"], description: "专业沉稳的英文男声", + voiceId: "cosyvoice-male-pro-en", previewUrl: "/mock/audio/preset-6.mp3", starred: false, + }, + { + id: "pv-7", name: "甜美女声 · 清新", gender: "female", language: "ja", + duration: 42, tags: ["甜美", "清新", "日系"], description: "清新甜美的日文女声", + voiceId: "cosyvoice-female-sweet-ja", previewUrl: "/mock/audio/preset-7.mp3", starred: false, + }, + { + id: "pv-8", name: "沧桑男声 · 故事感", gender: "elderly", language: "zh", + duration: 56, tags: ["沧桑", "故事感", "怀旧"], description: "充满阅历感的男声,适合怀旧内容", + voiceId: "cosyvoice-elderly-story", previewUrl: "/mock/audio/preset-8.mp3", starred: true, + }, + { + id: "pv-9", name: "知性女声 · 商务", gender: "female", language: "zh", + duration: 39, tags: ["知性", "商务", "科技"], description: "知性干练的女声,适合科技产品介绍", + voiceId: "cosyvoice-female-biz", previewUrl: "/mock/audio/preset-9.mp3", starred: false, + }, + { + id: "pv-10", name: "阳光男声 · 运动", gender: "male", language: "zh", + duration: 44, tags: ["阳光", "运动", "活力"], description: "阳光活力的男声,适合运动类内容", + voiceId: "cosyvoice-male-sport", previewUrl: "/mock/audio/preset-10.mp3", starred: false, + }, + { + id: "pv-11", name: "甜美女声 · 韩系", gender: "female", language: "ko", + duration: 40, tags: ["甜美", "韩系", "时尚"], description: "甜美时尚的韩文女声", + voiceId: "cosyvoice-female-sweet-ko", previewUrl: "/mock/audio/preset-11.mp3", starred: false, + }, + { + id: "pv-12", name: "浑厚男声 · 解说", gender: "male", language: "zh", + duration: 50, tags: ["浑厚", "解说", "科普"], description: "浑厚有力的男声,适合科普解说", + voiceId: "cosyvoice-male-narrator", previewUrl: "/mock/audio/preset-12.mp3", starred: false, + }, ]; -const MOCK_VOICES: VoiceItem[] = [ +const MOCK_CLONED_VOICES: ClonedVoice[] = [ { - id: "v-1", - name: "磁性男声_深沉", - voiceType: "male", - language: "zh", - duration: 45, - tags: ["磁性", "深沉"], - createdAt: "2026-06-28", + id: "cv-1", name: "我的声音副本", sourceName: "录音_2026-06-28", + status: "ready", createdAt: "2026-06-28", duration: 30, + tags: ["个人", "日常"], voiceId: "clone-voice-001", }, { - 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", + id: "cv-2", name: "主播音色复刻", sourceName: "主播A_训练素材", + status: "processing", createdAt: "2026-06-30", duration: 0, + tags: ["主播", "专业"], voiceId: "clone-voice-002", }, ]; +/* ============================================================ + * API 接口预留(CosyVoice 后端对接) + * ============================================================ */ +export async function fetchPresetVoices(params?: { + gender?: VoiceGender; + language?: VoiceLanguage; + keyword?: string; +}): Promise { + // TODO: GET /api/v1/voices/preset + console.log("[API] fetchPresetVoices", params); + return MOCK_PRESET_VOICES; +} + +export async function fetchClonedVoices(): Promise { + // TODO: GET /api/v1/voices/cloned + console.log("[API] fetchClonedVoices"); + return MOCK_CLONED_VOICES; +} + +export async function getVoicePreviewUrl(voiceId: string): Promise { + // TODO: POST /api/v1/voices/{voiceId}/preview + console.log("[API] getVoicePreviewUrl", voiceId); + const voice = MOCK_PRESET_VOICES.find((v) => v.voiceId === voiceId); + return voice?.previewUrl || ""; +} + +export async function toggleVoiceStar(voiceId: string, starred: boolean): Promise { + // TODO: POST /api/v1/voices/{voiceId}/star + console.log("[API] toggleVoiceStar", voiceId, starred); +} + /* ============================================================ * 工具函数 * ============================================================ */ -const voiceTypeLabel = (type: VoiceType) => { - switch (type) { - case "male": - return "男声"; - case "female": - return "女声"; - case "child": - return "童声"; - case "elderly": - return "老年"; - } +const genderLabel = (g: VoiceGender) => { + const map: Record = { male: "男声", female: "女声", child: "童声", elderly: "老年" }; + return map[g]; }; -const languageLabel = (lang: Language) => { - switch (lang) { - case "zh": - return "中文"; - case "en": - return "英文"; - case "ja": - return "日文"; - case "ko": - return "韩文"; - } +const languageLabel = (l: VoiceLanguage) => { + const map: Record = { zh: "中文", en: "英文", ja: "日文", ko: "韩文" }; + return map[l]; }; -/** 根据音色类型返回渐变背景 */ -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%)"; - } -}; - -/** 格式化时间 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 genderClass = (g: VoiceGender) => `xx-voice-gender--${g}`; + /* ============================================================ - * VoiceCard 组件 + * VoiceCard 组件(预置音色 + 克隆音色统一) * ============================================================ */ -const VoiceCard: React.FC<{ - voice: VoiceItem; +interface VoiceCardProps { + id: string; + name: string; + subtitle: string; + tags: string[]; + duration: number; + gender: VoiceGender; isPlaying: boolean; + isSelected: boolean; currentTime: number; + starred?: boolean; + status?: "ready" | "processing" | "failed"; onPlay: () => void; onPause: () => void; onSeek: (time: number) => void; -}> = ({ voice, isPlaying, currentTime, onPlay, onPause, onSeek }) => { + onSelect?: () => void; + onToggleStar?: () => void; +} + +const VoiceCard: React.FC = ({ + id, name, subtitle, tags, duration, gender, + isPlaying, isSelected, currentTime, starred, status = "ready", + onPlay, onPause, onSeek, onSelect, onToggleStar, +}) => { const progressRef = useRef(null); const handleProgressClick = (e: React.MouseEvent) => { - if (!progressRef.current) return; + if (!progressRef.current || status !== "ready") return; const rect = progressRef.current.getBoundingClientRect(); const percent = (e.clientX - rect.left) / rect.width; - onSeek(percent * voice.duration); + onSeek(percent * duration); }; - const progress = (currentTime / voice.duration) * 100; + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; return ( -
- {/* 头像 */} -
+
+
- {/* 信息区 */}
-

- {voice.name} -

-
- {voiceTypeLabel(voice.voiceType)} - · - {languageLabel(voice.language)} - · - {formatTime(voice.duration)} +
+

{name}

+ {starred !== undefined && ( + + )}
-
- {voice.tags.map((tag) => ( - - {tag} - +
{subtitle}
+
+ {tags.slice(0, 3).map((tag) => ( + {tag} ))}
- {/* 波形可视化 */} -
- - {/* 播放控制区 */} -
- -
-
+ {status === "processing" && ( +
+ + 处理中...
- {formatTime(currentTime)} -
+ )} + {status === "failed" && ( +
+ 克隆失败 +
+ )} + + {status === "ready" && ( +
+ )} + + {status === "ready" && ( +
+ +
+
+
+ + {isPlaying ? formatTime(currentTime) : formatTime(duration)} + +
+ )}
); }; @@ -279,84 +297,49 @@ const VoiceCard: React.FC<{ * 主组件 * ============================================================ */ const VoiceLibrary: React.FC = () => { - /* 状态 */ - const [libraries, setLibraries] = useState(MOCK_LIBRARIES); - const [activeLibId, setActiveLibId] = useState(MOCK_LIBRARIES[0].id); - const [voices] = useState(MOCK_VOICES); + const navigate = useNavigate(); - /* 筛选 */ + const [activeTab, setActiveTab] = useState("preset"); + const [presetVoices, setPresetVoices] = useState(MOCK_PRESET_VOICES); const [searchText, setSearchText] = useState(""); - const [filterType, setFilterType] = useState("all"); + const [filterGender, setFilterGender] = useState("all"); const [filterLang, setFilterLang] = useState("all"); + const [clonedVoices] = useState(MOCK_CLONED_VOICES); - /* 播放控制 */ const [playingId, setPlayingId] = useState(null); const [currentTime, setCurrentTime] = useState(0); - const intervalRef = useRef(null); + const intervalRef = useRef | null>(null); - /* 新建配音库 */ - const [createModalOpen, setCreateModalOpen] = useState(false); - const [newLibName, setNewLibName] = useState(""); - const [newLibType, setNewLibType] = useState("male"); - - /* AI 生成配音 */ - const [aiModalOpen, setAiModalOpen] = useState(false); - const [aiText, setAiText] = useState(""); - const [aiVoiceType, setAiVoiceType] = useState("male"); - - /* 派生数据 */ - const activeLibrary = libraries.find((l) => l.id === activeLibId); - - const filteredVoices = useMemo(() => { - let list = voices; - - /* 按配音库类型过滤 */ - if (activeLibrary) { - list = list.filter((v) => v.voiceType === activeLibrary.voiceType); + const filteredPreset = useMemo(() => { + let list = presetVoices; + if (filterGender !== "all") { + list = list.filter((v) => v.gender === filterGender); } - - /* 按类型筛选 */ - if (filterType !== "all") { - list = list.filter((v) => v.voiceType === filterType); - } - - /* 按语言筛选 */ if (filterLang !== "all") { list = list.filter((v) => v.language === filterLang); } - - /* 搜索 */ if (searchText.trim()) { const q = searchText.trim().toLowerCase(); list = list.filter( (v) => v.name.toLowerCase().includes(q) || + v.description.toLowerCase().includes(q) || v.tags.some((tag) => tag.toLowerCase().includes(q)), ); } - return list; - }, [voices, activeLibrary, filterType, filterLang, searchText]); + }, [presetVoices, filterGender, filterLang, searchText]); - /* 播放控制 */ - const handlePlay = (voiceId: string) => { + const handlePlay = useCallback((voiceId: string, duration: number) => { if (playingId === voiceId) return; - - /* 互斥播放:停止当前播放 */ if (intervalRef.current) { clearInterval(intervalRef.current); } - setPlayingId(voiceId); setCurrentTime(0); - - /* Mock 播放:使用定时器模拟进度 */ - const voice = voices.find((v) => v.id === voiceId); - if (!voice) return; - intervalRef.current = window.setInterval(() => { setCurrentTime((prev) => { - if (prev >= voice.duration) { + if (prev >= duration) { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; @@ -367,25 +350,23 @@ const VoiceLibrary: React.FC = () => { return prev + 0.1; }); }, 100); - }; + }, [playingId]); - const handlePause = () => { + const handlePause = useCallback(() => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } setPlayingId(null); - }; + }, []); - const handleSeek = (voiceId: string, time: number) => { + const handleSeek = useCallback((voiceId: string, time: number, duration: number) => { setCurrentTime(time); - /* 如果正在播放其他音频,切换到新的 */ if (playingId !== voiceId) { - handlePlay(voiceId); + handlePlay(voiceId, duration); } - }; + }, [playingId, handlePlay]); - /* 清理 */ useEffect(() => { return () => { if (intervalRef.current) { @@ -394,318 +375,164 @@ const VoiceLibrary: React.FC = () => { }; }, []); - /* 新建配音库 */ - const handleCreateLibrary = () => { - if (!newLibName.trim()) { - message.warning("请输入配音库名称"); - return; - } - const lib: LibraryItem = { - id: `lib-${Date.now()}`, - name: newLibName.trim(), - voiceType: newLibType, - count: 0, - }; - setLibraries((prev) => [...prev, lib]); - setActiveLibId(lib.id); - setCreateModalOpen(false); - setNewLibName(""); - setNewLibType("male"); - message.success(`配音库 "${lib.name}" 创建成功`); + const handleToggleStar = (voiceId: string) => { + setPresetVoices((prev) => + prev.map((v) => (v.voiceId === voiceId ? { ...v, starred: !v.starred } : v)), + ); + toggleVoiceStar(voiceId, !presetVoices.find((v) => v.voiceId === voiceId)?.starred); }; - /* 删除配音库 */ - const handleDeleteLibrary = (id: string) => { - setLibraries((prev) => prev.filter((l) => l.id !== id)); - if (activeLibId === id) { - const remaining = libraries.filter((l) => l.id !== id); - if (remaining.length > 0) setActiveLibId(remaining[0].id); - } - message.success("配音库已删除"); - }; - - /* AI 生成配音 */ - const handleAIGenerate = () => { - if (!aiText.trim()) { - message.warning("请输入配音文本"); - return; - } - message.success(`AI 配音生成成功(mock):${aiText.slice(0, 20)}...`); - setAiModalOpen(false); - setAiText(""); - setAiVoiceType("male"); - }; + const pageActions = ( +
+ + +
+ ); return (
- {/* 两栏布局 */} -
- {/* ─── 左侧:配音库列表 ─── */} -
- {libraries.map((lib) => ( -
setActiveLibId(lib.id)} - > -
-

- {lib.name} -

- { - e?.stopPropagation(); - handleDeleteLibrary(lib.id); - }} - onCancel={(e) => e?.stopPropagation()} - okText="删除" - cancelText="取消" - > - - -
- - {voiceTypeLabel(lib.voiceType)} · {lib.count} 个配音 - -
- ))} + - {/* 新建配音库 */} -
setCreateModalOpen(true)} - > - - 新建配音库 -
-
+
+ + +
- {/* ─── 右侧:内容区 ─── */} -
- {/* 筛选栏 */} + {activeTab === "preset" && ( +
-
- } - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - style={{ width: 240 }} - /> - -
-
- - - -
+ } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 260 }} + /> +
- {/* 配音卡片列表 */} - {filteredVoices.length > 0 ? ( -
- {filteredVoices.map((voice) => ( + {filteredPreset.length > 0 ? ( +
+ {filteredPreset.map((voice) => ( handlePlay(voice.id)} + starred={voice.starred} + onPlay={() => handlePlay(voice.id, voice.duration)} onPause={handlePause} - onSeek={(time) => handleSeek(voice.id, time)} + onSeek={(time) => handleSeek(voice.id, time, voice.duration)} + onToggleStar={() => handleToggleStar(voice.voiceId)} /> ))}
) : (
-
- -
-

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

+
+

未找到匹配的音色

+
)}
-
+ )} - {/* ─── 新建配音库弹窗 ─── */} - setCreateModalOpen(false)} - onOk={handleCreateLibrary} - okText="创建" - cancelText="取消" - destroyOnClose - > -
-
-
- 名称 + {activeTab === "cloned" && ( +
+ {clonedVoices.length > 0 ? ( +
+ {clonedVoices.map((voice) => ( + handlePlay(voice.id, voice.duration)} + onPause={handlePause} + onSeek={(time) => handleSeek(voice.id, time, voice.duration)} + /> + ))}
- setNewLibName(e.target.value)} - maxLength={50} - /> -
-
-
- 音色类型 + ) : ( +
+
+

暂无克隆音色

+

前往「我的音色」页面,上传音频素材即可克隆专属音色

+
- setAiVoiceType(v)} - style={{ width: "100%" }} - options={[ - { value: "male", label: "男声" }, - { value: "female", label: "女声" }, - { value: "child", label: "童声" }, - { value: "elderly", label: "老年" }, - ]} - /> -
+ )}
- + )}
); }; diff --git a/apps/web/src/pages/voices/voices.css b/apps/web/src/pages/voices/voices.css index c7b2428bc..bb4592ad0 100644 --- a/apps/web/src/pages/voices/voices.css +++ b/apps/web/src/pages/voices/voices.css @@ -1,175 +1,112 @@ /** - * 配音库页面 - V21 设计系统样式 - * 两栏布局:左侧配音库分类列表(260px)+ 右侧配音卡片列表 + * 配音库页面 - V21 设计系统样式(任务 3.11 升级) + * Tab 分类布局 + 网格卡片展示 * 统一使用 CSS 变量,支持深色/浅色主题 */ @import "../../styles/global.css"; -/* ============================================================ - 页面容器 - ============================================================ */ .xx-voices-page { min-height: 100%; padding: var(--space-xl); -} - -/* ============================================================ - 两栏布局 - ============================================================ */ -.xx-voices-layout { - display: grid; - grid-template-columns: 260px 1fr; - gap: 20px; - align-items: start; -} - -/* ============================================================ - 左侧配音库列表 - ============================================================ */ -.xx-voice-library-list { - display: flex; - flex-direction: column; - gap: var(--space-sm); - position: sticky; - top: var(--space-md); -} - -.xx-voice-library-item { - border: 1px solid var(--border-color); - background: var(--bg-primary); - border-radius: var(--radius-sm); - padding: 14px; - cursor: pointer; - transition: var(--transition-all); -} - -.xx-voice-library-item:hover { - border-color: var(--primary-color); - background: var(--primary-soft); -} - -.xx-voice-library-item.active { - border-color: var(--primary-color); - background: var(--primary-soft); - box-shadow: var(--shadow-primary); -} - -.xx-voice-library-item h4 { - margin: 0 0 4px; - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.xx-voice-library-item span { - font-size: var(--font-size-sm); - color: var(--text-secondary); -} - -.xx-voice-library-delete { - background: none; - border: none; - color: var(--text-tertiary); - cursor: pointer; - padding: 4px; - border-radius: var(--radius-xs); - font-size: var(--font-size-sm); - transition: var(--transition-all); - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - flex-shrink: 0; -} - -.xx-voice-library-item:hover .xx-voice-library-delete { - opacity: 1; -} - -.xx-voice-library-delete:hover { - color: var(--error-color); - background: var(--error-soft); -} - -.xx-voice-library-add { - border: 1px dashed var(--border-color); - background: transparent; - border-radius: var(--radius-sm); - padding: 14px; - cursor: pointer; - text-align: center; - color: var(--text-secondary); - font-size: var(--font-size-sm); - transition: var(--transition-all); - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-xs); -} - -.xx-voice-library-add:hover { - border-color: var(--primary-color); - color: var(--primary-color); - background: var(--primary-soft); -} - -/* ============================================================ - 右侧内容区 - ============================================================ */ -.xx-voices-content { display: flex; flex-direction: column; gap: var(--space-lg); } -/* ============================================================ - 筛选栏 - ============================================================ */ +.xx-voices-actions { + display: flex; + gap: var(--space-sm); +} + +/* Tab 切换 */ +.xx-voices-tabs { + display: flex; + gap: var(--space-xs); + border-bottom: 1px solid var(--border-color); + padding-bottom: 0; +} + +.xx-voices-tab { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-lg); + border: none; + background: transparent; + color: var(--text-secondary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: var(--transition-all); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; +} + +.xx-voices-tab:hover { + color: var(--primary-color); + background: var(--primary-soft); +} + +.xx-voices-tab.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); + font-weight: var(--font-weight-semibold); +} + +.xx-voices-tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: var(--radius-full); + background: var(--bg-tertiary); + color: var(--text-tertiary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); +} + +.xx-voices-tab.active .xx-voices-tab-count { + background: var(--primary-soft); + color: var(--primary-color); +} + +.xx-voices-tab-content { + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + .xx-voices-filters { display: flex; align-items: center; - justify-content: space-between; - gap: var(--space-md); + gap: var(--space-sm); flex-wrap: wrap; } -.xx-voices-filters-left { - display: flex; - align-items: center; - gap: var(--space-sm); +/* 网格 */ +.xx-voice-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: var(--space-md); } -.xx-voices-filters-right { - display: flex; - align-items: center; - gap: var(--space-sm); -} - -/* ============================================================ - 配音卡片列表 - ============================================================ */ -.xx-voice-list { - display: flex; - flex-direction: column; - gap: 10px; -} - -/* ============================================================ - 配音卡片 - ============================================================ */ +/* 卡片 */ .xx-voice-card { border: 1px solid var(--border-color); background: var(--bg-primary); border-radius: var(--radius-md); padding: var(--space-md); display: grid; - grid-template-columns: 50px 1fr auto auto; - gap: 14px; - align-items: center; + grid-template-columns: 48px 1fr auto; + grid-template-rows: auto auto auto; + gap: var(--space-sm) var(--space-md); + align-items: start; transition: var(--transition-all); + cursor: pointer; + position: relative; } .xx-voice-card:hover { @@ -177,70 +114,182 @@ box-shadow: var(--shadow-sm); } -/* 头像 */ +.xx-voice-card.selected { + border-color: var(--primary-color); + box-shadow: var(--shadow-primary); +} + +.xx-voice-card.playing { + border-color: var(--secondary-color); +} + .xx-voice-avatar { - width: 50px; - height: 50px; + width: 48px; + height: 48px; border-radius: var(--radius-full); display: grid; place-items: center; - color: #fff; - font-size: 22px; + color: var(--text-inverse); + font-size: 20px; flex-shrink: 0; + grid-row: 1 / 3; + transition: var(--transition-all); +} + +/* 头像背景色(CSS 变量,无硬编码) */ +.xx-voice-card.xx-voice-gender--male .xx-voice-avatar { + background: linear-gradient(135deg, var(--color-primary-700) 0%, var(--primary-color) 50%, var(--color-primary-400) 100%); +} + +.xx-voice-card.xx-voice-gender--female .xx-voice-avatar { + background: linear-gradient(135deg, var(--color-secondary-700, #9d174d) 0%, var(--color-secondary-500, #db2777) 50%, var(--color-secondary-400, #ec4899) 100%); +} + +.xx-voice-card.xx-voice-gender--child .xx-voice-avatar { + background: linear-gradient(135deg, var(--color-accent-700, #065f46) 0%, var(--secondary-color) 50%, var(--color-secondary-400, #34d399) 100%); +} + +.xx-voice-card.xx-voice-gender--elderly .xx-voice-avatar { + background: linear-gradient(135deg, var(--color-accent-700, #92400e) 0%, var(--accent-color) 50%, var(--color-accent-400, #fbbf24) 100%); } -/* 信息区 */ .xx-voice-info { min-width: 0; + grid-column: 2 / -1; +} + +.xx-voice-name-row { + display: flex; + align-items: center; + gap: var(--space-xs); } .xx-voice-name { - margin: 0 0 4px; + margin: 0; font-size: var(--font-size-base); font-weight: var(--font-weight-semibold); color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex: 1; } -.xx-voice-meta { - font-size: var(--font-size-sm); - color: var(--text-secondary); +.xx-voice-star { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 2px; + font-size: var(--font-size-base); + transition: var(--transition-all); display: flex; align-items: center; - gap: var(--space-xs); + opacity: 0; } -/* 波形可视化 */ -.xx-voice-wave { - height: 24px; - width: 120px; +.xx-voice-card:hover .xx-voice-star { + opacity: 1; +} + +.xx-voice-star.active { + opacity: 1; + color: var(--accent-color); +} + +.xx-voice-star:hover { + transform: scale(1.2); +} + +.xx-voice-subtitle { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.xx-voice-tags { + display: flex; + gap: var(--space-xs); + flex-wrap: wrap; + margin-top: var(--space-xs); +} + +.xx-voice-tag { + padding: 2px 8px; border-radius: var(--radius-full); - background: repeating-linear-gradient( - 90deg, - var(--primary-color) 0 5px, - transparent 5px 10px - ); - opacity: 0.3; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + background: var(--primary-soft); + color: var(--primary-color); +} + +/* 状态 */ +.xx-voice-status { + grid-column: 3; + grid-row: 1; + display: flex; + align-items: center; + gap: 4px; + font-size: var(--font-size-xs); + padding: 2px 8px; + border-radius: var(--radius-full); +} + +.xx-voice-status--processing { + background: var(--warning-soft, #fef3c7); + color: var(--warning-color, #92400e); +} + +.xx-voice-status--failed { + background: var(--error-soft); + color: var(--error-color); +} + +.xx-voice-status-dot { + width: 6px; + height: 6px; + border-radius: var(--radius-full); + background: var(--warning-color, #92400e); + animation: xx-pulse 1.5s ease-in-out infinite; +} + +@keyframes xx-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* 波形 */ +.xx-voice-wave { + grid-column: 1 / -1; + height: 20px; + border-radius: var(--radius-full); + background: repeating-linear-gradient(90deg, var(--primary-color) 0 4px, transparent 4px 8px); + opacity: 0.15; transition: var(--transition-all); } -.xx-voice-card:hover .xx-voice-wave { - opacity: 0.5; +.xx-voice-card:hover .xx-voice-wave { opacity: 0.3; } +.xx-voice-card.playing .xx-voice-wave { opacity: 0.5; animation: xx-wave-pulse 0.8s ease-in-out infinite; } + +@keyframes xx-wave-pulse { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 0.3; } } -/* 播放控制区 */ +/* 播放控制 */ .xx-voice-controls { + grid-column: 1 / -1; display: flex; align-items: center; gap: var(--space-sm); - min-width: 180px; } .xx-voice-play-btn { - width: 36px; - height: 36px; + width: 32px; + height: 32px; border-radius: var(--radius-full); background: var(--gradient-primary); color: var(--text-inverse); @@ -253,16 +302,9 @@ flex-shrink: 0; } -.xx-voice-play-btn:hover { - transform: scale(1.05); - box-shadow: var(--shadow-primary); -} +.xx-voice-play-btn:hover { transform: scale(1.1); box-shadow: var(--shadow-primary); } +.xx-voice-play-btn:active { transform: scale(0.95); } -.xx-voice-play-btn:active { - transform: scale(0.98); -} - -/* 进度条 */ .xx-voice-progress { flex: 1; height: 4px; @@ -270,9 +312,10 @@ border-radius: var(--radius-full); overflow: hidden; cursor: pointer; - position: relative; } +.xx-voice-progress:hover { height: 6px; } + .xx-voice-progress-bar { height: 100%; background: var(--gradient-primary); @@ -285,27 +328,10 @@ color: var(--text-tertiary); white-space: nowrap; min-width: 40px; + text-align: right; } -/* 标签 */ -.xx-voice-tags { - display: flex; - gap: var(--space-xs); - flex-wrap: wrap; -} - -.xx-voice-tag { - padding: 2px 8px; - border-radius: var(--radius-full); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-medium); - background: var(--primary-soft); - color: var(--primary-color); -} - -/* ============================================================ - 空状态 - ============================================================ */ +/* 空状态 */ .xx-voices-empty { text-align: center; padding: var(--space-3xl) var(--space-xl); @@ -313,83 +339,57 @@ } .xx-voices-empty-icon { - font-size: 48px; - margin-bottom: var(--space-md); - opacity: 0.5; + font-size: 56px; + margin-bottom: var(--space-lg); + opacity: 0.3; + color: var(--text-tertiary); } -/* ============================================================ - 响应式 - ============================================================ */ +.xx-voices-empty h3 { + margin: 0 0 var(--space-sm); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.xx-voices-empty p { + margin: 0 0 var(--space-lg); + color: var(--text-secondary); +} + +/* 提示条 */ +.xx-voices-notice { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + background: var(--primary-soft); + color: var(--primary-color); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + border: 1px solid color-mix(in srgb, var(--primary-color) 20%, transparent); +} + +/* 响应式 */ @media (max-width: 1200px) { - .xx-voices-layout { - grid-template-columns: 1fr; - } - - .xx-voice-library-list { - flex-direction: row; - overflow-x: auto; - position: static; - gap: var(--space-sm); - padding-bottom: var(--space-sm); - } - - .xx-voice-library-item { - min-width: 180px; - flex-shrink: 0; - } - - .xx-voice-card { - grid-template-columns: 50px 1fr; - grid-template-rows: auto auto; - } - - .xx-voice-wave { - display: none; - } - - .xx-voice-controls { - grid-column: 1 / -1; - } + .xx-voice-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } } @media (max-width: 768px) { - .xx-voices-page { - padding: var(--space-md); - } - - .xx-voice-card { - padding: var(--space-sm); - gap: var(--space-sm); - } - - .xx-voice-controls { - min-width: 140px; - } - - .xx-voices-filters { - flex-direction: column; - align-items: stretch; - } - - .xx-voices-filters-left, - .xx-voices-filters-right { - justify-content: flex-start; - } + .xx-voices-page { padding: var(--space-md); gap: var(--space-md); } + .xx-voice-grid { grid-template-columns: 1fr; } + .xx-voice-card { padding: var(--space-sm); gap: var(--space-xs) var(--space-sm); } + .xx-voices-filters { flex-direction: column; align-items: stretch; } + .xx-voices-filters .xx-input, .xx-voices-filters .xx-select { width: 100% !important; } + .xx-voices-tab { padding: var(--space-xs) var(--space-md); font-size: var(--font-size-sm); } } @media (max-width: 480px) { - .xx-voices-page { - padding: var(--space-sm); - } - - .xx-voice-avatar { - width: 40px; - height: 40px; - font-size: 18px; - } - - .xx-voice-controls { - min-width: 120px; - } + .xx-voices-page { padding: var(--space-sm); } + .xx-voice-avatar { width: 40px; height: 40px; font-size: 16px; } + .xx-voice-card { grid-template-columns: 40px 1fr; } + .xx-voice-wave { height: 14px; } + .xx-voice-play-btn { width: 28px; height: 28px; font-size: var(--font-size-sm); } + .xx-voices-tab { padding: var(--space-xs) var(--space-sm); font-size: var(--font-size-xs); } + .xx-voices-tab-count { min-width: 16px; height: 16px; font-size: 10px; } }