feat(ui): 改造配音库页面
Deploy / Deploy Staging (push) Failing after 205h11m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 205h14m25s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 205h14m34s
Deploy / Production Browser E2E (push) Failing after 1863h44m5s
Deploy / Deploy Production (push) Failing after 1863h44m7s
Deploy / Build Production Runtime Images (push) Failing after 1863h44m10s

- 两栏布局:左侧配音库分类列表(260px)+ 右侧配音卡片列表
- VoiceCard 组件:头像渐变、名称/类型/语言/时长信息、标签、波形可视化
- 播放控制:播放/暂停按钮 + 进度条(可点击跳转)+ 时间显示
- 音频互斥播放:同一时间只有一个配音在播放
- 筛选功能:按音色类型、语言筛选 + 名称/标签搜索
- 新建配音库弹窗 + AI 生成配音弹窗
- 删除配音库(Popconfirm 确认)
- 响应式适配:1200px/768px/480px 断点
- Mock 数据:4 个配音库 + 10 个配音项
- 全部使用 CSS 变量,支持深色/浅色主题
This commit is contained in:
灵应
2026-06-30 23:39:21 +08:00
parent b6c6dedfec
commit 202037f4e8
2 changed files with 932 additions and 332 deletions
+537 -332
View File
@@ -1,366 +1,571 @@
/**
* 配音库页面
* 管理用户配音,支持手动创建和 AI 生成
* 配音库页面 — V21 设计系统
* 两栏布局:左侧配音库列表(260px)+ 右侧配音卡片列表
* 使用 mock 数据,后端 API 对接暂不要求
*/
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import React, { useMemo, useState, useRef, useEffect } from "react";
import {
Button,
Typography,
Space,
Table,
Empty,
Spin,
Modal,
Input,
Select,
Tag,
Popconfirm,
Modal as AntModal,
message,
Slider,
Popconfirm,
} from "antd";
import {
PlusOutlined,
EditOutlined,
SearchOutlined,
SoundOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
RobotOutlined,
UploadOutlined,
} from "@ant-design/icons";
import {
getVoices,
createVoice,
updateVoice,
deleteVoice,
generateAIVoice,
type VoiceItem,
} from "@/api/voices";
import type { ColumnsType } from "antd/es/table";
import PageHead from "@/components/layout/PageHead";
import { Button, Input, Select } from "@/components/ui";
import "./voices.css";
const { Text } = Typography;
/* ============================================================
* 类型
* ============================================================ */
type VoiceType = "male" | "female" | "child" | "elderly";
type Language = "zh" | "en" | "ja" | "ko";
/** 配音状态标签 */
const StatusTag: React.FC<{ status?: string }> = ({ status }) => {
if (!status) return null;
const colorMap: Record<string, string> = {
completed: "success",
processing: "processing",
failed: "error",
pending: "default",
};
return <Tag color={colorMap[status] || "default"}>{status}</Tag>;
interface LibraryItem {
id: string;
name: string;
voiceType: VoiceType;
count: number;
}
interface VoiceItem {
id: string;
name: string;
voiceType: VoiceType;
language: Language;
duration: number; // 秒
tags: string[];
createdAt: string;
}
/* ============================================================
* Mock 数据
* ============================================================ */
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_VOICES: VoiceItem[] = [
{ id: "v-1", name: "磁性男声_深沉", voiceType: "male", language: "zh", duration: 45, tags: ["磁性", "深沉"], createdAt: "2026-06-28" },
{ 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" },
];
/* ============================================================
* 工具函数
* ============================================================ */
const voiceTypeLabel = (type: VoiceType) => {
switch (type) {
case "male": return "男声";
case "female": return "女声";
case "child": return "童声";
case "elderly": return "老年";
}
};
const VoiceLibrary: React.FC = () => {
const queryClient = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [aiModalOpen, setAiModalOpen] = useState(false);
const [editingVoice, setEditingVoice] = useState<VoiceItem | null>(null);
const [formName, setFormName] = useState("");
const [formText, setFormText] = useState("");
const [formVoiceType, setFormVoiceType] = useState("");
const [aiText, setAiText] = useState("");
const [aiVoiceType, setAiVoiceType] = useState("");
const [aiSpeed, setAiSpeed] = useState(1.0);
const languageLabel = (lang: Language) => {
switch (lang) {
case "zh": return "中文";
case "en": return "英文";
case "ja": return "日文";
case "ko": return "韩文";
}
};
// 获取配音列表
const { data: voices = [], isLoading } = useQuery({
queryKey: ["voices"],
queryFn: getVoices,
});
/** 根据音色类型返回渐变背景 */
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%)";
}
};
// 创建配音
const createMutation = useMutation({
mutationFn: createVoice,
onSuccess: () => {
message.success("配音创建成功");
setModalOpen(false);
resetForm();
queryClient.invalidateQueries({ queryKey: ["voices"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("创建失败");
},
});
/** 格式化时间 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 updateMutation = useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: Partial<{ name: string; text: string; voice_type: string }>;
}) => updateVoice(id, data),
onSuccess: () => {
message.success("配音更新成功");
setModalOpen(false);
resetForm();
queryClient.invalidateQueries({ queryKey: ["voices"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("更新失败");
},
});
/* ============================================================
* VoiceCard 组件
* ============================================================ */
const VoiceCard: React.FC<{
voice: VoiceItem;
isPlaying: boolean;
currentTime: number;
onPlay: () => void;
onPause: () => void;
onSeek: (time: number) => void;
}> = ({ voice, isPlaying, currentTime, onPlay, onPause, onSeek }) => {
const progressRef = useRef<HTMLDivElement>(null);
// 删除配音
const deleteMutation = useMutation({
mutationFn: deleteVoice,
onSuccess: () => {
message.success("已删除");
queryClient.invalidateQueries({ queryKey: ["voices"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("删除失败");
},
});
// AI 生成配音
const aiMutation = useMutation({
mutationFn: generateAIVoice,
onSuccess: () => {
message.success("AI 配音生成成功");
setAiModalOpen(false);
setAiText("");
queryClient.invalidateQueries({ queryKey: ["voices"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("AI 生成失败");
},
});
const resetForm = () => {
setEditingVoice(null);
setFormName("");
setFormText("");
setFormVoiceType("");
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return;
const rect = progressRef.current.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
onSeek(percent * voice.duration);
};
const openCreate = () => {
resetForm();
setModalOpen(true);
};
const openEdit = (voice: VoiceItem) => {
setEditingVoice(voice);
setFormName(voice.name);
setFormText(voice.text);
setFormVoiceType(voice.voice_type || "");
setModalOpen(true);
};
const handleSave = () => {
if (!formName.trim() || !formText.trim()) {
message.warning("请填写名称和文本");
return;
}
if (editingVoice) {
updateMutation.mutate({
id: editingVoice.id,
data: { name: formName, text: formText, voice_type: formVoiceType },
});
} else {
createMutation.mutate({
name: formName,
text: formText,
voice_type: formVoiceType,
});
}
};
const columns: ColumnsType<VoiceItem> = [
{
title: "名称",
dataIndex: "name",
key: "name",
width: 150,
ellipsis: true,
},
{
title: "文本",
dataIndex: "text",
key: "text",
ellipsis: true,
},
{
title: "类型",
dataIndex: "voice_type",
key: "voice_type",
width: 100,
render: (type: string) => type || "-",
},
{
title: "时长",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 80,
render: (d: number) => (d ? `${d.toFixed(1)}s` : "-"),
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 80,
render: (status: string) => <StatusTag status={status} />,
},
{
title: "操作",
key: "actions",
width: 120,
render: (_, record) => (
<Space>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
/>
<Popconfirm
title="确定删除此配音?"
onConfirm={() => deleteMutation.mutate(record.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const progress = (currentTime / voice.duration) * 100;
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<PageHead
title="配音库"
actions={
<Space wrap>
<Button icon={<RobotOutlined />} onClick={() => setAiModalOpen(true)}>
AI 生成
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建配音
</Button>
</Space>
}
/>
{isLoading ? (
<div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" />
</div>
) : voices.length === 0 ? (
<Empty description="暂无配音">
<Space>
<Button type="primary" onClick={openCreate}>
手动创建
</Button>
<Button
icon={<RobotOutlined />}
onClick={() => setAiModalOpen(true)}
>
AI 生成
</Button>
</Space>
</Empty>
) : (
<Table
columns={columns}
dataSource={voices}
rowKey="id"
pagination={{ pageSize: 20, showSizeChanger: true }}
size="small"
scroll={{ x: 700 }}
/>
)}
{/* 新建/编辑弹窗 */}
<Modal
title={editingVoice ? "编辑配音" : "新建配音"}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
resetForm();
}}
onOk={handleSave}
confirmLoading={createMutation.isPending || updateMutation.isPending}
<div className="xx-voice-card">
{/* 头像 */}
<div
className="xx-voice-avatar"
style={{ background: voiceGradient(voice.voiceType) }}
>
<Space direction="vertical" style={{ width: "100%" }}>
<Input
placeholder="配音名称"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
<Input.TextArea
placeholder="配音文本内容"
value={formText}
onChange={(e) => setFormText(e.target.value)}
rows={4}
/>
<Select
placeholder="配音类型(可选)"
value={formVoiceType || undefined}
onChange={setFormVoiceType}
allowClear
style={{ width: "100%" }}
options={[
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
]}
/>
</Space>
</Modal>
<SoundOutlined />
</div>
{/* AI 生成弹窗 */}
<Modal
{/* 信息区 */}
<div className="xx-voice-info">
<h4 className="xx-voice-name" title={voice.name}>
{voice.name}
</h4>
<div className="xx-voice-meta">
<span>{voiceTypeLabel(voice.voiceType)}</span>
<span>·</span>
<span>{languageLabel(voice.language)}</span>
<span>·</span>
<span>{formatTime(voice.duration)}</span>
</div>
<div className="xx-voice-tags" style={{ marginTop: 6 }}>
{voice.tags.map((tag) => (
<span key={tag} className="xx-voice-tag">
{tag}
</span>
))}
</div>
</div>
{/* 波形可视化 */}
<div className="xx-voice-wave" />
{/* 播放控制区 */}
<div className="xx-voice-controls">
<button
className="xx-voice-play-btn"
onClick={isPlaying ? onPause : onPlay}
title={isPlaying ? "暂停" : "播放"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div
ref={progressRef}
className="xx-voice-progress"
onClick={handleProgressClick}
>
<div
className="xx-voice-progress-bar"
style={{ width: `${progress}%` }}
/>
</div>
<span className="xx-voice-time">
{formatTime(currentTime)}
</span>
</div>
</div>
);
};
/* ============================================================
* 主组件
* ============================================================ */
const VoiceLibrary: React.FC = () => {
/* 状态 */
const [libraries, setLibraries] = useState<LibraryItem[]>(MOCK_LIBRARIES);
const [activeLibId, setActiveLibId] = useState<string>(MOCK_LIBRARIES[0].id);
const [voices] = useState<VoiceItem[]>(MOCK_VOICES);
/* 筛选 */
const [searchText, setSearchText] = useState("");
const [filterType, setFilterType] = useState<string>("all");
const [filterLang, setFilterLang] = useState<string>("all");
/* 播放控制 */
const [playingId, setPlayingId] = useState<string | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const intervalRef = useRef<number | null>(null);
/* 新建配音库 */
const [createModalOpen, setCreateModalOpen] = useState(false);
const [newLibName, setNewLibName] = useState("");
const [newLibType, setNewLibType] = useState<VoiceType>("male");
/* AI 生成配音 */
const [aiModalOpen, setAiModalOpen] = useState(false);
const [aiText, setAiText] = useState("");
const [aiVoiceType, setAiVoiceType] = useState<VoiceType>("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);
}
/* 按类型筛选 */
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.tags.some((tag) => tag.toLowerCase().includes(q))
);
}
return list;
}, [voices, activeLibrary, filterType, filterLang, searchText]);
/* 播放控制 */
const handlePlay = (voiceId: string) => {
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 (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setPlayingId(null);
return 0;
}
return prev + 0.1;
});
}, 100);
};
const handlePause = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setPlayingId(null);
};
const handleSeek = (voiceId: string, time: number) => {
setCurrentTime(time);
/* 如果正在播放其他音频,切换到新的 */
if (playingId !== voiceId) {
handlePlay(voiceId);
}
};
/* 清理 */
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
/* 新建配音库 */
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 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");
};
return (
<div className="xx-voices-page">
{/* 两栏布局 */}
<div className="xx-voices-layout">
{/* ─── 左侧:配音库列表 ─── */}
<div className="xx-voice-library-list">
{libraries.map((lib) => (
<div
key={lib.id}
className={`xx-voice-library-item${lib.id === activeLibId ? " active" : ""}`}
onClick={() => setActiveLibId(lib.id)}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h4 style={{ flex: 1, margin: 0 }}>
<SoundOutlined /> {lib.name}
</h4>
<Popconfirm
title={`确定删除配音库 "${lib.name}"?`}
onConfirm={(e) => {
e?.stopPropagation();
handleDeleteLibrary(lib.id);
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<button
className="xx-voice-library-delete"
onClick={(e) => e.stopPropagation()}
title="删除配音库"
>
<DeleteOutlined />
</button>
</Popconfirm>
</div>
<span>
{voiceTypeLabel(lib.voiceType)} · {lib.count} 个配音
</span>
</div>
))}
{/* 新建配音库 */}
<div
className="xx-voice-library-add"
onClick={() => setCreateModalOpen(true)}
>
<PlusOutlined />
新建配音库
</div>
</div>
{/* ─── 右侧:内容区 ─── */}
<div className="xx-voices-content">
{/* 筛选栏 */}
<div className="xx-voices-filters">
<div className="xx-voices-filters-left">
<Input
placeholder="搜索配音名称或标签..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 240 }}
/>
<Select
value={filterType}
onChange={setFilterType}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部类型" },
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
{ value: "elderly", label: "老年" },
]}
/>
<Select
value={filterLang}
onChange={setFilterLang}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部语言" },
{ value: "zh", label: "中文" },
{ value: "en", label: "英文" },
{ value: "ja", label: "日文" },
{ value: "ko", label: "韩文" },
]}
/>
</div>
<div className="xx-voices-filters-right">
<Button buttonType="ghost" buttonSize="sm" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
新建配音库
</Button>
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />} onClick={() => setAiModalOpen(true)}>
AI 生成配音
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<UploadOutlined />}>
上传配音
</Button>
</div>
</div>
{/* 配音卡片列表 */}
{filteredVoices.length > 0 ? (
<div className="xx-voice-list">
{filteredVoices.map((voice) => (
<VoiceCard
key={voice.id}
voice={voice}
isPlaying={playingId === voice.id}
currentTime={playingId === voice.id ? currentTime : 0}
onPlay={() => handlePlay(voice.id)}
onPause={handlePause}
onSeek={(time) => handleSeek(voice.id, time)}
/>
))}
</div>
) : (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<SoundOutlined />
</div>
<p>暂无配音,请切换配音库或添加新配音</p>
</div>
)}
</div>
</div>
{/* ─── 新建配音库弹窗 ─── */}
<AntModal
title="新建配音库"
open={createModalOpen}
onCancel={() => setCreateModalOpen(false)}
onOk={handleCreateLibrary}
okText="创建"
cancelText="取消"
destroyOnClose
>
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: "8px 0" }}>
<div>
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
名称
</div>
<Input
placeholder="请输入配音库名称"
value={newLibName}
onChange={(e) => setNewLibName(e.target.value)}
maxLength={50}
/>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
音色类型
</div>
<Select
value={newLibType}
onChange={(v) => setNewLibType(v)}
style={{ width: "100%" }}
options={[
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
{ value: "elderly", label: "老年" },
]}
/>
</div>
</div>
</AntModal>
{/* ─── AI 生成配音弹窗 ─── */}
<AntModal
title="AI 生成配音"
open={aiModalOpen}
onCancel={() => setAiModalOpen(false)}
onOk={() => {
if (!aiText.trim()) {
message.warning("请输入配音文本");
return;
}
aiMutation.mutate({
text: aiText,
voice_type: aiVoiceType || undefined,
speed: aiSpeed,
});
}}
confirmLoading={aiMutation.isPending}
onOk={handleAIGenerate}
okText="生成"
cancelText="取消"
destroyOnClose
width={600}
>
<Space direction="vertical" style={{ width: "100%" }}>
<Input.TextArea
placeholder="输入需要配音的文本"
value={aiText}
onChange={(e) => setAiText(e.target.value)}
rows={5}
/>
<Select
placeholder="选择声音类型"
value={aiVoiceType || undefined}
onChange={setAiVoiceType}
allowClear
style={{ width: "100%" }}
options={[
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
]}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: "8px 0" }}>
<div>
<Text>语速:{aiSpeed.toFixed(1)}x</Text>
<Slider
min={0.5}
max={2.0}
step={0.1}
value={aiSpeed}
onChange={setAiSpeed}
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
配音文本
</div>
<Input.TextArea
placeholder="请输入需要配音的文本内容..."
value={aiText}
onChange={(e) => setAiText(e.target.value)}
rows={6}
maxLength={2000}
showCount
/>
</div>
</Space>
</Modal>
<div>
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
音色类型
</div>
<Select
value={aiVoiceType}
onChange={(v) => setAiVoiceType(v)}
style={{ width: "100%" }}
options={[
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
{ value: "elderly", label: "老年" },
]}
/>
</div>
</div>
</AntModal>
</div>
);
};
+395
View File
@@ -0,0 +1,395 @@
/**
* 配音库页面 - V21 设计系统样式
* 两栏布局:左侧配音库分类列表(260px)+ 右侧配音卡片列表
* 统一使用 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-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
}
.xx-voices-filters-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.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;
transition: var(--transition-all);
}
.xx-voice-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
/* 头像 */
.xx-voice-avatar {
width: 50px;
height: 50px;
border-radius: var(--radius-full);
display: grid;
place-items: center;
color: #fff;
font-size: 22px;
flex-shrink: 0;
}
/* 信息区 */
.xx-voice-info {
min-width: 0;
}
.xx-voice-name {
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-meta {
font-size: var(--font-size-sm);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* 波形可视化 */
.xx-voice-wave {
height: 24px;
width: 120px;
border-radius: var(--radius-full);
background: repeating-linear-gradient(
90deg,
var(--primary-color) 0 5px,
transparent 5px 10px
);
opacity: 0.3;
transition: var(--transition-all);
}
.xx-voice-card:hover .xx-voice-wave {
opacity: 0.5;
}
/* 播放控制区 */
.xx-voice-controls {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 180px;
}
.xx-voice-play-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
background: var(--gradient-primary);
color: var(--text-inverse);
display: grid;
place-items: center;
font-size: var(--font-size-md);
cursor: pointer;
transition: var(--transition-all);
border: none;
flex-shrink: 0;
}
.xx-voice-play-btn:hover {
transform: scale(1.05);
box-shadow: var(--shadow-primary);
}
.xx-voice-play-btn:active {
transform: scale(0.98);
}
/* 进度条 */
.xx-voice-progress {
flex: 1;
height: 4px;
background: var(--bg-tertiary);
border-radius: var(--radius-full);
overflow: hidden;
cursor: pointer;
position: relative;
}
.xx-voice-progress-bar {
height: 100%;
background: var(--gradient-primary);
border-radius: var(--radius-full);
transition: width 0.1s linear;
}
.xx-voice-time {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
white-space: nowrap;
min-width: 40px;
}
/* 标签 */
.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);
color: var(--text-secondary);
}
.xx-voices-empty-icon {
font-size: 48px;
margin-bottom: var(--space-md);
opacity: 0.5;
}
/* ============================================================
响应式
============================================================ */
@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;
}
}
@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;
}
}
@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;
}
}