feat(viral-video/ui): 内置音色弹窗 + 智能剪辑/AI数字人顶部精简 #2118

Merged
xiaoxia merged 3 commits from feat/2041-voice-modal-and-top-banner-removal into develop 2026-10-01 11:26:24 +08:00
5 changed files with 559 additions and 57 deletions
@@ -580,20 +580,6 @@ const AiAvatarPage: React.FC = () => {
return (
<div className="aa-page">
<div className="aa-page-header">
<h1>AI数字人</h1>
</div>
{/* 步骤切换导航条 */}
<div className="aa-step-nav">
<span className={`aa-step-nav__item${currentStep === 1 ? " active" : ""}`}>
1. 视频 / 配音 / 文案
</span>
<span className={`aa-step-nav__item${currentStep === 2 ? " active" : ""}`}>
2. 对口型 / 标题 / 封面 / 生成
</span>
</div>
<div className="aa-page-body">
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ════ */}
{currentStep === 1 && (
@@ -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 (
<div className="xx-generate-page">
<GenerateHeader fromEditPlan={!!editPlanId} />
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
<div className={layoutClassName}>
{/* ════ 步骤1~2 表单 / 步骤3 标题设置 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
<div className="xx-generate-form">
@@ -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);
}
}
@@ -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<PresetVoice[]>([])
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 = () => {
</div>
<div className="vv-audio-grid">
<button
className={`vv-audio-cell ${task.voicePanel === "preset" ? "active" : ""} ${task.refAudio?.source === "preset" ? "selected" : ""}`}
onClick={() =>
setTask({ voicePanel: task.voicePanel === "preset" ? null : "preset" })
}
className={`vv-audio-cell ${task.refAudio?.source === "preset" ? "selected" : ""}`}
onClick={() => setVoicePickerOpen(true)}
disabled={!canEditAssets}
>
<FileTextOutlined className="vv-audio-ic" />
@@ -1129,31 +1123,6 @@ const ViralVideoPage: React.FC = () => {
</button>
</div>
)}
{task.voicePanel === "preset" && (
<div className="vv-voice-panel">
{presetVoices.length === 0 ? (
<div className="vv-muted" style={{ padding: 12, textAlign: "center" }}>
加载中…
</div>
) : (
<div className="vv-voice-list">
{presetVoices.map((v) => (
<div
key={v.id}
className={`vv-voice-item ${task.refAudio?.id === v.id && task.refAudio.source === "preset" ? "selected" : ""}`}
onClick={() => pickPresetVoice(v)}
>
<div className="vv-voice-radio" />
<div className="vv-voice-info">
<div className="vv-voice-name">{v.name}</div>
<div className="vv-voice-desc">{v.desc || ""}</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{task.voicePanel === "library" && (
<div className="vv-voice-panel">
{voiceAssets.length === 0 ? (
@@ -1557,6 +1526,16 @@ const ViralVideoPage: React.FC = () => {
setAssetPicker((p) => ({ ...p, open: false }))
}}
/>
<PresetVoicePickerModal
open={voicePickerOpen}
voices={presetVoices}
selectedId={task.refAudio?.source === "preset" ? task.refAudio.id : undefined}
onClose={() => setVoicePickerOpen(false)}
onConfirm={(v) => {
setTask({ refAudio: { source: "preset", id: v.id, name: v.name }, voicePanel: null })
setVoicePickerOpen(false)
}}
/>
</div>
)
}
@@ -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<string, string> = {
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<Props> = ({
open,
voices,
loading,
selectedId,
onClose,
onConfirm,
}) => {
const [keyword, setKeyword] = useState("")
const [category, setCategory] = useState<string>("all")
const [pickedId, setPickedId] = useState<string | undefined>(selectedId)
const [playingId, setPlayingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(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<string>()
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 (
<div className="vv-modal-mask" onClick={onClose}>
<div className="vv-modal vv-modal-lg" onClick={(e) => e.stopPropagation()}>
<div className="vv-modal-head">
<div className="vv-modal-title">选择音色</div>
<button className="vv-modal-close" onClick={onClose}>
<CloseOutlined />
</button>
</div>
<div className="vv-modal-body">
{/* 搜索 */}
<Input
className="vv-voice-search"
placeholder="搜索音色名称或风格"
prefix={<SearchOutlined style={{ color: "#9ca3af" }} />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
size="large"
/>
{/* 分类筛选 */}
<div className="vv-voice-cat-row">
<span className="vv-voice-cat-label">音色分类</span>
<Select
value={category}
onChange={setCategory}
style={{ width: 180 }}
options={[
{ value: "all", label: "全部分类" },
...[
"女声",
"男声",
"童声",
"方言",
"情绪",
...categories.filter(
(c) => !["女声", "男声", "童声", "方言", "情绪"].includes(c),
),
].map((c) => ({ value: c, label: c })),
]}
/>
</div>
{/* 卡片网格 */}
<div className="vv-voice-grid">
{loading && filtered.length === 0 ? (
<div className="vv-modal-empty">加载中…</div>
) : filtered.length === 0 ? (
<div className="vv-modal-empty">没有匹配的音色</div>
) : (
filtered.map((v) => {
const isPicked = pickedId === v.id
const isPlaying = playingId === v.id
return (
<div
key={v.id}
className={`vv-voice-card ${isPicked ? "selected" : ""}`}
onClick={() => setPickedId(v.id)}
>
<div
className="vv-voice-card-avatar"
style={{ background: AVATAR_BG(v.gender), color: AVATAR_COLOR(v.gender) }}
>
{v.avatar_url ? (
<img src={v.avatar_url} alt={v.name} />
) : (
<UserOutlined style={{ fontSize: 22 }} />
)}
</div>
<div className="vv-voice-card-name" title={v.name}>
{v.name}
</div>
<div className="vv-voice-card-gender">{GENDER_LABEL(v)}</div>
{v.desc && <div className="vv-voice-card-desc">{v.desc}</div>}
<div className="vv-voice-card-actions">
<button
className={`vv-voice-card-btn ${isPicked ? "picked" : ""}`}
onClick={(e) => {
e.stopPropagation()
setPickedId(v.id)
}}
>
{isPicked ? "✓ 已选择" : "选择"}
</button>
<button
className={`vv-voice-card-btn vv-voice-card-btn-preview ${isPlaying ? "playing" : ""} ${!v.sample_audio_url ? "disabled" : ""}`}
onClick={(e) => {
e.stopPropagation()
handlePreview(v)
}}
disabled={!v.sample_audio_url}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
{isPlaying ? "停止" : "试听"}
</button>
</div>
</div>
)
})
)}
</div>
</div>
<div className="vv-modal-foot">
<button className="vv-btn vv-btn-ghost" onClick={onClose}>
取消
</button>
<button className="vv-btn vv-btn-primary" onClick={handleConfirm} disabled={!pickedId}>
完成选择
</button>
</div>
</div>
</div>
)
}
export default PresetVoicePickerModal