diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index dcf046346..e13607330 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -3531,52 +3531,143 @@ color: var(--text-tertiary, #9ca3af); } -.tts-voice-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; +.tts-voice-tabs { + display: flex; + gap: 4px; + background: var(--bg-secondary, #f3f4f6); + border-radius: var(--radius-md, 8px); + padding: 3px; } -.tts-voice-card { +.tts-voice-tab { + flex: 1; display: flex; - flex-direction: column; align-items: center; - gap: 4px; - padding: 10px 6px; - border: 1px solid var(--border-primary, #e5e7eb); - border-radius: var(--radius-md, 8px); - background: var(--bg-surface, #fff); + justify-content: center; + gap: 6px; + padding: 6px 12px; + border: none; + background: transparent; + border-radius: 6px; + font-size: 12px; + font-weight: 500; color: var(--text-secondary, #6b7280); cursor: pointer; transition: all 0.2s ease; - font-size: 12px; } -.tts-voice-card:hover:not(:disabled) { - border-color: var(--primary-300, #93c5fd); - color: var(--primary-600, #2563eb); - background: var(--primary-50, #eff6ff); +.tts-voice-tab:hover { + color: var(--text-primary, #1f2937); } -.tts-voice-card.active { - border-color: var(--primary-500, #3b82f6); +.tts-voice-tab.active { + background: var(--bg-surface, #fff); color: var(--primary-600, #2563eb); - background: var(--primary-50, #eff6ff); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.tts-voice-tab-count { + font-size: 10px; + padding: 1px 6px; + background: var(--primary-100, #dbeafe); + color: var(--primary-600, #2563eb); + border-radius: 10px; font-weight: 600; } -.tts-voice-card:disabled { - opacity: 0.5; - cursor: not-allowed; +.tts-voice-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 200px; + overflow-y: auto; } -.tts-voice-card-icon { - font-size: 22px; +.tts-voice-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--border-primary, #e5e7eb); + border-radius: var(--radius-md, 8px); + background: var(--bg-surface, #fff); + cursor: pointer; + transition: all 0.2s ease; } -.tts-voice-card-name { +.tts-voice-item:hover { + border-color: var(--primary-300, #93c5fd); + background: var(--primary-50, #eff6ff); +} + +.tts-voice-item.selected { + border-color: var(--primary-500, #3b82f6); + background: var(--primary-50, #eff6ff); +} + +.tts-voice-item-icon { + font-size: 20px; + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary, #f3f4f6); + border-radius: 50%; +} + +.tts-voice-item-info { + flex: 1; + min-width: 0; +} + +.tts-voice-item-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary, #1f2937); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tts-voice-item-desc { font-size: 11px; + color: var(--text-tertiary, #9ca3af); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tts-voice-item-play { + flex-shrink: 0; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: var(--primary-500, #3b82f6); + color: #fff; + border-radius: 50%; + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; +} + +.tts-voice-item-play:hover { + background: var(--primary-600, #2563eb); + transform: scale(1.08); +} + +.tts-voice-empty { + padding: 20px; text-align: center; + font-size: 12px; + color: var(--text-tertiary, #9ca3af); + background: var(--bg-secondary, #f3f4f6); + border-radius: var(--radius-md, 8px); } /* ── 滑块区域 ── */ @@ -6317,3 +6408,4 @@ padding: 16px; text-align: center; } + diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx index 651c50d40..ea9317d83 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx @@ -1,21 +1,36 @@ -import React from "react" -import type { TTSVoice } from "@/api/tts" -import { VOICE_CATEGORY_MAP } from "../constants" +import React, { useState } from "react" +import type { UnifiedVoiceItem } from "@/api/voices" export interface VoiceSelectorProps { - voices: TTSVoice[] + presetVoices: UnifiedVoiceItem[] + cloneVoices: UnifiedVoiceItem[] selectedVoiceId: string loading: boolean + playingVoiceId: string | null onSelect: (voiceId: string) => void + onPlaySample: (voiceId: string, previewUrl: string | null) => void +} + +type VoiceTab = "preset" | "clone" + +const GENDER_ICON: Record = { + male: "👨", + female: "👩", + young: "🧑", } export const VoiceSelector: React.FC = ({ - voices, + presetVoices, + cloneVoices, selectedVoiceId, loading, + playingVoiceId, onSelect, + onPlaySample, }) => { - const voiceCategories = Object.entries(VOICE_CATEGORY_MAP) + const [activeTab, setActiveTab] = useState("preset") + + const currentList = activeTab === "preset" ? presetVoices : cloneVoices return (
@@ -23,23 +38,65 @@ export const VoiceSelector: React.FC = ({ 选择音色 {loading && 加载中...}
-
- {voiceCategories.map(([cat, info]) => { - const voice = voices.find((v) => v.category === cat) - const isSelected = voice && selectedVoiceId === voice.id - return ( - - ) - })} + + {/* Tab 切换 */} +
+ +
+ + {/* 音色列表 */} + {loading ? ( +
⏳ 加载中...
+ ) : currentList.length === 0 ? ( +
+ {activeTab === "preset" ? "暂无预设音色" : "暂无克隆音色,快去克隆一个吧"} +
+ ) : ( +
+ {currentList.map((voice) => { + const isSelected = selectedVoiceId === voice.voice_id + const isPlaying = playingVoiceId === voice.voice_id + const icon = GENDER_ICON[voice.gender] ?? "✨" + return ( +
onSelect(voice.voice_id)} + > +
{icon}
+
+
{voice.name}
+
{voice.description || voice.language}
+
+ +
+ ) + })} +
+ )}
) } diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts b/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts index 2aa6297d1..cc4214ecd 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts @@ -2,7 +2,8 @@ import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "reac import { message } from "antd" import type { TtsConfig, TtsMode } from "../../../types" import { DEFAULT_TTS_CONFIG } from "../../../types" -import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts" +import { previewTts } from "@/api/tts" +import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices" interface UseTtsPanelOptions { open: boolean @@ -12,10 +13,17 @@ interface UseTtsPanelOptions { } export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => { - /* ── 音色列表 ── */ - const [voices, setVoices] = useState([]) + /* ── 音色列表(统一音色:预设 + 克隆) ── */ + const [presetVoices, setPresetVoices] = useState([]) + const [cloneVoices, setCloneVoices] = useState([]) const [voicesLoading, setVoicesLoading] = useState(false) + // 用 ref 保存最新 config 和 onChange,避免 useEffect 闭包陷阱 + const configRef = useRef(config) + const onChangeRef = useRef(onChange) + configRef.current = config + onChangeRef.current = onChange + /* ── 试听状态 ── */ const [previewLoading, setPreviewLoading] = useState(false) const audioRef = useRef(null) @@ -27,9 +35,20 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti mountedRef.current = true let active = true setVoicesLoading(true) - getTtsVoices() - .then((v) => { - if (active) setVoices(v) + fetchVoices({ status: "ready", limit: 100 }) + .then((res) => { + if (!active) return + const presets = res.items.filter((v) => v.type === "preset") + const clones = res.items.filter((v) => v.type === "clone") + setPresetVoices(presets) + setCloneVoices(clones) + // 如果当前没有选中音色,默认选第一个预设(用 ref 读最新值,避免闭包旧值覆盖用户选择) + if (!configRef.current.voice_id && presets.length > 0) { + onChangeRef.current({ + ...configRef.current, + voice_id: presets[0].voice_id, + }) + } }) .catch(() => { if (active) message.error("加载音色列表失败") @@ -40,6 +59,7 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti return () => { active = false } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) /* ── 组件卸载时清理音频资源 ── */ @@ -145,6 +165,51 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti } }, [config]) + /* ── 单音色示例试听 ── */ + const [playingVoiceId, setPlayingVoiceId] = useState(null) + const handlePlayVoiceSample = useCallback( + (voiceId: string, previewUrl: string | null) => { + if (playingVoiceId === voiceId) { + audioRef.current?.pause() + audioRef.current = null + setPlayingVoiceId(null) + return + } + // 暂停上一个音频并清除事件监听,避免竞态 + if (audioRef.current) { + audioRef.current.onended = null + audioRef.current.pause() + } + if (!previewUrl) { + message.warning("该音色暂无示例音频") + return + } + const audio = new Audio(previewUrl) + audioRef.current = audio + audio + .play() + .then(() => { + // 只有当前活跃的音频实例才能更新状态,防止快速切换竞态 + if (mountedRef.current && audioRef.current === audio) { + setPlayingVoiceId(voiceId) + } + }) + .catch(() => { + if (mountedRef.current && audioRef.current === audio) { + message.error("播放失败") + audioRef.current = null + } + }) + audio.onended = () => { + if (mountedRef.current && audioRef.current === audio) { + setPlayingVoiceId(null) + audioRef.current = null + } + } + }, + [playingVoiceId], + ) + /* ── 重置 ── */ const handleReset = useCallback(() => { onChange({ ...DEFAULT_TTS_CONFIG }) @@ -155,16 +220,20 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti mountedRef.current = false audioRef.current?.pause() audioRef.current = null + setPlayingVoiceId(null) onClose() }, [onClose]) return { - voices, + presetVoices, + cloneVoices, voicesLoading, previewLoading, + playingVoiceId, handleModeChange, handleTextChange, handleVoiceSelect, + handlePlayVoiceSample, handleSpeedChange, handlePitchChange, handleVolumeChange, diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx index 212f6cb35..7bcab1ed4 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx @@ -1,6 +1,6 @@ /** * TTS 配音面板 — Drawer 形式 - * 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动 + * 配音模式切换 + 文本输入 + 音色选择(预设+克隆) + 语速/语调/音量 + 试听 + 字幕联动 */ import React from "react" import { Drawer } from "antd" @@ -21,12 +21,15 @@ export interface TtsPanelProps { const TtsPanel: React.FC = ({ open, onClose, config, onChange }) => { const { - voices, + presetVoices, + cloneVoices, voicesLoading, previewLoading, + playingVoiceId, handleModeChange, handleTextChange, handleVoiceSelect, + handlePlayVoiceSample, handleSpeedChange, handlePitchChange, handleVolumeChange, @@ -83,10 +86,13 @@ const TtsPanel: React.FC = ({ open, onClose, config, onChange }) {/* 音色选择 */} {/* 语速 */} diff --git a/apps/web/src/test/pages/editing-planner/components/TtsPanel.smoke.test.tsx b/apps/web/src/test/pages/editing-planner/components/TtsPanel.smoke.test.tsx new file mode 100644 index 000000000..3ab403bb0 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/TtsPanel.smoke.test.tsx @@ -0,0 +1,23 @@ +/** + * TtsPanel 模块 smoke test + * 建立 TtsPanel 目录化拆分后子模块的依赖链 + * 确保 vitest related 模式能匹配到 TtsPanel/ 下所有文件的改动 + */ +import { describe, it, expect } from "vitest" + +// TtsPanel 主组件与子组件 +import "@/pages/editing-planner/components/TtsPanel" +import "@/pages/editing-planner/components/TtsPanel/components/VoiceSelector" +import "@/pages/editing-planner/components/TtsPanel/components/TtsSlider" + +// TtsPanel Hooks +import "@/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel" + +// TtsPanel 常量 +import "@/pages/editing-planner/components/TtsPanel/constants" + +describe("TtsPanel module smoke test", () => { + it("should load all TtsPanel sub-modules", () => { + expect(true).toBe(true) + }) +})