diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index 84c7017cd..68b56f5f0 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -580,20 +580,6 @@ const AiAvatarPage: React.FC = () => { return (
-
-

AI数字人

-
- - {/* 步骤切换导航条 */} -
- - 1. 视频 / 配音 / 文案 - - - 2. 对口型 / 标题 / 封面 / 生成 - -
-
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ════ */} {currentStep === 1 && ( diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index bb57eba68..a5d37c409 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -13,8 +13,6 @@ import CloneModal from "@/components/voice/CloneModal" import VoiceSelectModal from "./components/VoiceSelectModal" import ScriptSelectModal from "./components/ScriptSelectModal" import TtsVoiceModal from "./components/TtsVoiceModal" -import GenerateHeader from "./components/GenerateHeader" -import GenerateStepsBar from "./components/GenerateStepsBar" import GenerateStepContent from "./components/GenerateStepContent" import GenerateStepActions from "./components/GenerateStepActions" import { useGenerateFormState } from "./hooks/useGenerateFormState" @@ -88,7 +86,6 @@ const GeneratePage: React.FC = () => { style, autoSubtitles, bgm, - editPlanId, sourceEditPlanId, previewTaskId, setPreviewTaskId, @@ -523,10 +520,6 @@ const GeneratePage: React.FC = () => { return (
- - - -
{/* ════ 步骤1~2 表单 / 步骤3 标题设置 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.css b/apps/web/src/pages/viral-video/ViralVideoPage.css index d989b38d0..ab3966a36 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.css +++ b/apps/web/src/pages/viral-video/ViralVideoPage.css @@ -1366,3 +1366,171 @@ color: #374151; cursor: default; } + +/* ─────────── PresetVoicePickerModal 音色选择弹窗 ─────────── */ +.vv-modal-lg { + max-width: 820px; + width: 90vw; +} +.vv-voice-search { + margin-bottom: 12px; +} +.vv-voice-search .ant-input-affix-wrapper { + border-radius: 10px; + border-color: #e5e7eb; + padding: 8px 12px; +} +.vv-voice-search .ant-input-affix-wrapper:focus, +.vv-voice-search .ant-input-affix-wrapper-focused { + border-color: #7c3aed; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.12); +} +.vv-voice-cat-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} +.vv-voice-cat-label { + font-size: 13px; + color: #374151; + font-weight: 500; +} +.vv-voice-cat-row .ant-select { + border-radius: 8px; +} +.vv-voice-cat-row .ant-select-selector { + border-radius: 8px !important; + border-color: #e5e7eb !important; +} +.vv-voice-cat-row .ant-select-focused .ant-select-selector { + border-color: #7c3aed !important; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.12) !important; +} + +.vv-voice-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + max-height: 480px; + overflow-y: auto; + padding: 4px; +} +.vv-voice-card { + border: 1.5px solid #e5e7eb; + border-radius: 12px; + padding: 16px 14px; + background: #f9fafb; + cursor: pointer; + transition: all 0.15s; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} +.vv-voice-card:hover { + border-color: #7c3aed; + background: #f3f0ff; + transform: translateY(-1px); +} +.vv-voice-card.selected { + border-color: #7c3aed; + background: #f3f0ff; + box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.12); +} +.vv-voice-card-avatar { + width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; + overflow: hidden; +} +.vv-voice-card-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} +.vv-voice-card-name { + font-size: 14px; + font-weight: 600; + color: #111827; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.vv-voice-card-gender { + font-size: 11px; + color: #9ca3af; + margin-top: 2px; +} +.vv-voice-card-desc { + font-size: 11px; + color: #6b7280; + margin-top: 4px; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.vv-voice-card-actions { + display: flex; + gap: 6px; + margin-top: 10px; + width: 100%; +} +.vv-voice-card-btn { + flex: 1; + border: 1px solid #d1d5db; + background: #fff; + border-radius: 6px; + padding: 5px 8px; + font-size: 12px; + cursor: pointer; + color: #4b5563; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + transition: all 0.15s; +} +.vv-voice-card-btn:hover { + border-color: #7c3aed; + color: #7c3aed; +} +.vv-voice-card-btn.picked { + background: #7c3aed; + border-color: #7c3aed; + color: #fff; +} +.vv-voice-card-btn-preview { + flex: 0 0 auto; + min-width: 56px; +} +.vv-voice-card-btn-preview.playing { + background: #f3f0ff; + color: #7c3aed; + border-color: #7c3aed; +} +.vv-voice-card-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} +.vv-modal-foot { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 18px; + border-top: 1px solid #f3f4f6; +} + +/* 让 voice-grid 在窄屏下 2 列 */ +@media (max-width: 720px) { + .vv-voice-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index f2382ee6b..b059a7306 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -44,6 +44,7 @@ import { import { useViralVideoPolling } from "./hooks/useViralVideoPolling" import CloneModal from "@/components/voice/CloneModal" import AssetPickerModal from "./components/AssetPickerModal" +import PresetVoicePickerModal, { type PresetVoice } from "./components/PresetVoicePickerModal" import "./ViralVideoPage.css" /* ─────────── UI 阶段(三步骤独立交互) ─────────── */ @@ -207,9 +208,8 @@ const ViralVideoPage: React.FC = () => { const [voiceAssets, setVoiceAssets] = useState< { id: string; name: string; url: string; desc?: string }[] >([]) - const [presetVoices, setPresetVoices] = useState<{ id: string; name: string; desc?: string }[]>( - [], - ) + const [presetVoices, setPresetVoices] = useState([]) + const [voicePickerOpen, setVoicePickerOpen] = useState(false) const [assetPicker, setAssetPicker] = useState<{ open: boolean kind: "image" | "video" | "voice" @@ -546,10 +546,6 @@ const ViralVideoPage: React.FC = () => { }) } - const pickPresetVoice = (v: { id: string; name: string }) => { - setTask({ refAudio: { source: "preset", id: v.id, name: v.name }, voicePanel: null }) - } - /* ── Tab 管理 ── */ const newTab = () => { const id = `t${Date.now()}` @@ -1072,10 +1068,8 @@ const ViralVideoPage: React.FC = () => {
)} - {task.voicePanel === "preset" && ( -
- {presetVoices.length === 0 ? ( -
- 加载中… -
- ) : ( -
- {presetVoices.map((v) => ( -
pickPresetVoice(v)} - > -
-
-
{v.name}
-
{v.desc || ""}
-
-
- ))} -
- )} -
- )} {task.voicePanel === "library" && (
{voiceAssets.length === 0 ? ( @@ -1557,6 +1526,16 @@ const ViralVideoPage: React.FC = () => { setAssetPicker((p) => ({ ...p, open: false })) }} /> + setVoicePickerOpen(false)} + onConfirm={(v) => { + setTask({ refAudio: { source: "preset", id: v.id, name: v.name }, voicePanel: null }) + setVoicePickerOpen(false) + }} + />
) } diff --git a/apps/web/src/pages/viral-video/components/PresetVoicePickerModal.tsx b/apps/web/src/pages/viral-video/components/PresetVoicePickerModal.tsx new file mode 100644 index 000000000..6a0d704b5 --- /dev/null +++ b/apps/web/src/pages/viral-video/components/PresetVoicePickerModal.tsx @@ -0,0 +1,376 @@ +/** + * 内置音色选择弹窗(浅色紫调版) + * - 标题「选择音色」+ 搜索框 + 分类筛选 + 3列卡片网格 + 试听 + 选中 + 完成选择 + */ +import React, { useEffect, useMemo, useRef, useState } from "react" +import { + CloseOutlined, + SearchOutlined, + PlayCircleOutlined, + PauseCircleOutlined, + UserOutlined, +} from "@ant-design/icons" +import { Select, Input } from "antd" + +export interface PresetVoice { + id: string + name: string + gender?: "female" | "male" | "child" | "other" + gender_label?: string + category?: string + avatar_url?: string + sample_audio_url?: string + desc?: string +} + +interface Props { + open: boolean + voices?: PresetVoice[] + loading?: boolean + selectedId?: string + onClose: () => void + onConfirm: (voice: PresetVoice) => void +} + +/** 兜底 mock 音色(后端 /api/v1/tts/presets 返回字段不够时使用) */ +const MOCK_VOICES: PresetVoice[] = [ + { + id: "long-xiaochun", + name: "龙小淳", + gender: "female", + category: "女声", + desc: "知性积极女声,适合语音助手", + }, + { + id: "long-xiaoxia", + name: "龙小夏", + gender: "female", + category: "女声", + desc: "沉稳权威女声,适合新闻播报", + }, + { + id: "long-xiaoyan", + name: "龙小颜", + gender: "female", + category: "女声", + desc: "温柔甜美女声,适合情感口播", + }, + { + id: "long-xiaotong", + name: "龙小彤", + gender: "female", + category: "女声", + desc: "活力少女音,适合短视频带货", + }, + { + id: "long-sanshu", + name: "龙三叔", + gender: "male", + category: "男声", + desc: "沉稳质感男声,适合有声书", + }, + { + id: "long-xiaogang", + name: "龙小刚", + gender: "male", + category: "男声", + desc: "阳光活力男声,适合解说", + }, + { + id: "long-xiaocheng", + name: "龙小诚", + gender: "male", + category: "男声", + desc: "磁性商务男声,适合品牌宣传", + }, + { + id: "long-xiaozhi", + name: "龙小智", + gender: "child", + category: "童声", + desc: "可爱童声,适合亲子内容", + }, + { + id: "long-yue", + name: "龙悦", + gender: "female", + category: "情绪", + desc: "温柔治愈女声,适合睡前/助眠", + }, + { + id: "long-xiaodong", + name: "龙晓东", + gender: "male", + category: "方言", + desc: "东北方言男声,接地气", + }, + { id: "long-xiaoling", name: "龙小玲", gender: "female", category: "方言", desc: "粤语女声" }, + { + id: "long-xiaoxiao-neural", + name: "晓晓", + gender: "female", + category: "女声", + desc: "温柔女声", + }, +] + +const CATEGORY_LABELS: Record = { + all: "全部分类", + female: "女声", + male: "男声", + child: "童声", + dialect: "方言", + emotion: "情绪", +} + +const GENDER_LABEL = (v: PresetVoice) => { + if (v.gender_label) return v.gender_label + const g = v.gender + if (g === "female") return "女声·女声" + if (g === "male") return "男声·男声" + if (g === "child") return "童声·童声" + return "性别未标注·其他" +} + +const AVATAR_BG = (gender?: string) => { + if (gender === "female") return "#fce7f3" + if (gender === "male") return "#dbeafe" + if (gender === "child") return "#fef3c7" + return "#f3f0ff" +} +const AVATAR_COLOR = (gender?: string) => { + if (gender === "female") return "#be185d" + if (gender === "male") return "#1d4ed8" + if (gender === "child") return "#b45309" + return "#7c3aed" +} + +const PresetVoicePickerModal: React.FC = ({ + open, + voices, + loading, + selectedId, + onClose, + onConfirm, +}) => { + const [keyword, setKeyword] = useState("") + const [category, setCategory] = useState("all") + const [pickedId, setPickedId] = useState(selectedId) + const [playingId, setPlayingId] = useState(null) + const audioRef = useRef(null) + + useEffect(() => { + if (open) { + setKeyword("") + setCategory("all") + setPickedId(selectedId) + setPlayingId(null) + } + }, [open, selectedId]) + + // 停止播放 + useEffect(() => { + return () => { + audioRef.current?.pause() + audioRef.current = null + } + }, []) + + // 合并真实数据和 mock:如果真实数据 gender/category 缺失,用 mock 兜底 + const allVoices: PresetVoice[] = useMemo(() => { + const realList: PresetVoice[] = (voices || []).map((v) => { + // 按 name 模糊匹配 mock 获取补充信息 + const mockMatch = MOCK_VOICES.find( + (m) => v.name?.includes(m.name.slice(1)) || m.name.includes(v.name?.slice(0, 2) || "___"), + ) + return { + ...v, + gender: v.gender || mockMatch?.gender, + category: + v.category || + mockMatch?.category || + (v.gender === "female" ? "女声" : v.gender === "male" ? "男声" : undefined), + desc: v.desc || mockMatch?.desc, + sample_audio_url: v.sample_audio_url, + } + }) + // 如果没有真实数据,使用 mock + return realList.length > 0 ? realList : MOCK_VOICES + }, [voices]) + + const categories = useMemo(() => { + const set = new Set() + allVoices.forEach((v) => { + if (v.category) set.add(v.category) + }) + return Array.from(set) + }, [allVoices]) + + const filtered = useMemo(() => { + const kw = keyword.trim().toLowerCase() + return allVoices.filter((v) => { + if (category !== "all") { + if (v.category !== category && category !== CATEGORY_LABELS[v.gender || ""]) { + // gender 兜底匹配 + if ( + !(category === "女声" && v.gender === "female") && + !(category === "男声" && v.gender === "male") && + !(category === "童声" && v.gender === "child") && + !(category === "方言" && v.category === "方言") && + !(category === "情绪" && v.category === "情绪") + ) { + return false + } + } + } + if (!kw) return true + return ( + v.name?.toLowerCase().includes(kw) || + v.desc?.toLowerCase().includes(kw) || + v.category?.toLowerCase().includes(kw) + ) + }) + }, [allVoices, keyword, category]) + + const handlePreview = (v: PresetVoice) => { + if (!v.sample_audio_url) { + // 无示例音频 + return + } + if (playingId === v.id) { + audioRef.current?.pause() + setPlayingId(null) + return + } + audioRef.current?.pause() + const a = new Audio(v.sample_audio_url) + a.onended = () => setPlayingId(null) + a.onerror = () => setPlayingId(null) + a.play().catch(() => {}) + audioRef.current = a + setPlayingId(v.id) + } + + const handleConfirm = () => { + const picked = allVoices.find((v) => v.id === pickedId) + if (!picked) return + onConfirm(picked) + } + + if (!open) return null + + return ( +
+
e.stopPropagation()}> +
+
选择音色
+ +
+
+ {/* 搜索 */} + } + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + allowClear + size="large" + /> + {/* 分类筛选 */} +
+ 音色分类 +