Files
xiaoxia-saas/apps/web/src/pages/voices/VoiceLibrary.tsx
T
xiaoxia 560856cf22
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 210h26m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 210h27m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 210h27m7s
fix: resolve all flake8 errors and apply Prettier formatting (#131)
2026-06-30 18:26:53 +08:00

377 lines
9.6 KiB
TypeScript

/**
* 配音库页面
* 管理用户配音,支持手动创建和 AI 生成
*/
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Button,
Typography,
Space,
Table,
Empty,
Spin,
Modal,
Input,
Select,
Tag,
Popconfirm,
message,
Slider,
} from "antd";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
RobotOutlined,
} from "@ant-design/icons";
import {
getVoices,
createVoice,
updateVoice,
deleteVoice,
generateAIVoice,
type VoiceItem,
} from "@/api/voices";
import type { ColumnsType } from "antd/es/table";
const { Title, Text } = Typography;
/** 配音状态标签 */
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>;
};
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 { data: voices = [], isLoading } = useQuery({
queryKey: ["voices"],
queryFn: getVoices,
});
// 创建配音
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("创建失败");
},
});
// 更新配音
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("更新失败");
},
});
// 删除配音
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 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>
),
},
];
return (
<div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
flexWrap: "wrap",
gap: 12,
}}
>
<Title level={3} style={{ margin: 0 }}>
配音库
</Title>
<Space wrap>
<Button icon={<RobotOutlined />} onClick={() => setAiModalOpen(true)}>
AI 生成
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建配音
</Button>
</Space>
</div>
{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}
>
<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>
{/* AI 生成弹窗 */}
<Modal
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}
>
<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>
<Text>语速:{aiSpeed.toFixed(1)}x</Text>
<Slider
min={0.5}
max={2.0}
step={0.1}
value={aiSpeed}
onChange={setAiSpeed}
/>
</div>
</Space>
</Modal>
</div>
);
};
export default VoiceLibrary;