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
377 lines
9.6 KiB
TypeScript
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;
|