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
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user