feat(viral-video/ui): 内置音色弹窗 + 智能剪辑/AI数字人顶部精简 #2118
@@ -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
|
||||
Reference in New Issue
Block a user