imeeting/frontend/src/components/business/MeetingCreateDrawer.tsx

654 lines
28 KiB
TypeScript
Raw Normal View History

import React, { useEffect, useMemo, useState } from "react";
import {
App,
Avatar,
Button,
Col,
Collapse,
DatePicker,
Drawer,
Form,
Input,
Progress,
Radio,
Row,
Select,
Skeleton,
Space,
Switch,
Tag,
Tooltip,
Typography,
Upload,
} from "antd";
import {
AudioOutlined,
CheckOutlined,
CloudUploadOutlined,
QuestionCircleOutlined,
SettingOutlined,
UserOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { useNavigate } from "react-router-dom";
import { listUsers } from "../../api";
import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel";
import { getHotWordPage, type HotWordVO } from "../../api/business/hotword";
import { getHotWordGroupOptions, type HotWordGroupVO } from "../../api/business/hotwordGroup";
import {
createMeeting,
createRealtimeMeeting,
getMeetingCreateConfig,
type CreateRealtimeMeetingCommand,
type MeetingCreateConfig,
type SummaryDetailLevel,
uploadAudio,
} from "../../api/business/meeting";
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
import type { SysUser } from "../../types";
const { Dragger } = Upload;
const { Option } = Select;
const { Text, Title } = Typography;
export type MeetingCreateType = "upload" | "realtime";
const DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB = 1024;
const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
offlineEnabled: true,
realtimeEnabled: true,
offlineAudioMaxSizeMb: DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB,
};
interface MeetingCreateDrawerProps {
open: boolean;
initialType?: MeetingCreateType;
onCancel: () => void;
onSuccess: () => void;
}
type RealtimeMeetingSessionDraft = {
meetingId: number;
meetingTitle: string;
asrModelName: string;
summaryModelName: string;
asrModelId: number;
mode: string;
language: string;
useSpkId: number;
enablePunctuation: boolean;
enableItn: boolean;
enableTextRefine: boolean;
saveAudio: boolean;
hotwords: Array<{ hotword: string; weight: number }>;
};
function resolveAvailableCreateTypes(config: MeetingCreateConfig): MeetingCreateType[] {
const types: MeetingCreateType[] = [];
if (config.offlineEnabled) {
types.push("upload");
}
if (config.realtimeEnabled) {
types.push("realtime");
}
return types;
}
function resolveAvailableCreateType(initialType: MeetingCreateType, config: MeetingCreateConfig): MeetingCreateType {
const availableTypes = resolveAvailableCreateTypes(config);
if (availableTypes.includes(initialType)) {
return initialType;
}
return availableTypes[0] || initialType;
}
function resolveWsUrl(model?: AiModelVO | null) {
if (model?.wsUrl) return model.wsUrl;
if (model?.baseUrl) return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
return "";
}
function getSessionKey(meetingId: number) {
return `realtimeMeetingSession:${meetingId}`;
}
export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
open,
initialType = "upload",
onCancel,
onSuccess,
}) => {
const { message } = App.useApp();
const navigate = useNavigate();
const [form] = Form.useForm();
const [type, setType] = useState<MeetingCreateType>(initialType);
const [loading, setLoading] = useState(false);
const [configLoaded, setConfigLoaded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>({
offlineEnabled: false,
realtimeEnabled: false,
offlineAudioMaxSizeMb: DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB,
});
const [asrModels, setAsrModels] = useState<AiModelVO[]>([]);
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
const [hotWordGroups, setHotWordGroups] = useState<HotWordGroupVO[]>([]);
const [userList, setUserList] = useState<SysUser[]>([]);
const [audioUrl, setAudioUrl] = useState("");
const [uploadProgress, setUploadProgress] = useState(0);
const [fileList, setFileList] = useState<any[]>([]);
const watchedAsrModelId = Form.useWatch("asrModelId", form);
const watchedSummaryModelId = Form.useWatch("summaryModelId", form);
const watchedPromptId = Form.useWatch("promptId", form);
const watchedHotWordGroupId = Form.useWatch("hotWordGroupId", form);
const selectedAsrModel = useMemo(
() => asrModels.find((item) => item.id === watchedAsrModelId) || null,
[asrModels, watchedAsrModelId]
);
const selectedSummaryModel = useMemo(
() => llmModels.find((item) => item.id === watchedSummaryModelId) || null,
[llmModels, watchedSummaryModelId]
);
const selectedPrompt = useMemo(
() => prompts.find((item) => item.id === watchedPromptId) || null,
[prompts, watchedPromptId]
);
const availableTypes = useMemo(() => resolveAvailableCreateTypes(createConfig), [createConfig]);
const offlineAudioMaxSizeBytes = useMemo(
() => (createConfig.offlineAudioMaxSizeMb || DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB) * 1024 * 1024,
[createConfig]
);
useEffect(() => {
if (!open) {
return;
}
setAudioUrl("");
setUploadProgress(0);
setFileList([]);
void loadInitialData();
}, [open, initialType]);
const loadInitialData = async () => {
setLoading(true);
try {
const [asrRes, llmRes, promptRes, hotwordRes, hotWordGroupRes, users, defaultAsr, defaultLlm, createConfigRes] = await Promise.all([
getAiModelPage({ current: 1, size: 100, type: "ASR" }),
getAiModelPage({ current: 1, size: 100, type: "LLM" }),
getPromptPage({ current: 1, size: 100 }),
getHotWordPage({ current: 1, size: 1000 }),
getHotWordGroupOptions(),
listUsers(),
getAiModelDefault("ASR"),
getAiModelDefault("LLM"),
getMeetingCreateConfig(),
]);
const nextConfig = createConfigRes.data.data || DEFAULT_CREATE_CONFIG;
const nextType = resolveAvailableCreateType(initialType, nextConfig);
const activePrompts = promptRes.data.data.records.filter((item: PromptTemplateVO) => item.status === 1);
setCreateConfig(nextConfig);
setConfigLoaded(true);
setType(nextType);
setAsrModels(asrRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
setLlmModels(llmRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
setPrompts(activePrompts);
setHotwordList(hotwordRes.data.data.records.filter((item: HotWordVO) => item.status === 1));
setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1));
setUserList(users || []);
const defaultPrompt = activePrompts[0];
form.setFieldsValue({
title: nextType === "upload" ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
meetingTime: dayjs(),
asrModelId: defaultAsr.data.data?.id,
summaryModelId: defaultLlm.data.data?.id,
promptId: defaultPrompt?.id,
hotWordGroupId: defaultPrompt?.hotWordGroupId ?? 0,
summaryDetailLevel: "STANDARD",
useSpkId: 1,
enableTextRefine: false,
mode: "2pass",
language: "auto",
enablePunctuation: true,
enableItn: true,
saveAudio: false,
});
} catch {
message.error("加载配置失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!open) {
return;
}
const currentTitle = form.getFieldValue("title");
if (typeof currentTitle !== "string") {
return;
}
if (currentTitle.startsWith("文件会议") || currentTitle.startsWith("实时会议")) {
form.setFieldsValue({
title: type === "upload" ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
});
}
}, [form, open, type]);
const beforeAudioUpload = (file: File) => {
if (file.size > offlineAudioMaxSizeBytes) {
message.error(`录音文件大小不能超过 ${createConfig.offlineAudioMaxSizeMb}MB`);
setUploadProgress(0);
return Upload.LIST_IGNORE;
}
return true;
};
const customUpload = async (options: any) => {
const { file, onSuccess: uploadSuccess, onError, onProgress } = options;
setUploadProgress(0);
try {
const res = await uploadAudio(file, (progressEvent) => {
if (progressEvent.total) {
const percent = Math.min(99, Math.round((progressEvent.loaded * 100) / progressEvent.total));
setUploadProgress(percent);
onProgress({ percent });
}
});
setUploadProgress(100);
onProgress({ percent: 100 });
setAudioUrl(res.data.data);
uploadSuccess(res.data.data);
message.success("录音上传成功");
} catch (err) {
onError(err);
message.error("文件上传失败");
}
};
const handleOk = async () => {
if (availableTypes.length === 0) {
message.error("当前系统参数已关闭全部会议创建入口");
return;
}
if (!availableTypes.includes(type)) {
const fallbackType = availableTypes[0];
if (fallbackType) {
setType(fallbackType);
}
message.warning("当前入口已关闭,已切换到可用创建方式");
return;
}
if (type === "upload" && !audioUrl) {
message.error("请先上传录音文件");
return;
}
const values = await form.validateFields();
if (type === "realtime") {
const wsUrl = resolveWsUrl(selectedAsrModel);
if (!wsUrl) {
message.error("当前 ASR 模型未配置 WebSocket 地址");
return;
}
}
setSubmitting(true);
try {
const { hostUserId, ...meetingValues } = values;
const selectedHotWords = meetingValues.hotWordGroupId == null || meetingValues.hotWordGroupId === 0
? undefined
: hotwordList
.filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId)
.map((item) => item.word)
.filter((word) => !!word?.trim());
if (type === "upload") {
await createMeeting({
...meetingValues,
...(hostUserId != null ? { hostUserId } : {}),
meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
audioUrl,
participants: meetingValues.participants?.join(","),
tags: meetingValues.tags?.join(","),
summaryDetailLevel: meetingValues.summaryDetailLevel as SummaryDetailLevel,
hotWords: selectedHotWords,
});
message.success("会议发起成功");
onSuccess();
onCancel();
return;
}
const selectedHotwords = hotwordList
.filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId && meetingValues.hotWordGroupId !== 0)
.map((item) => ({
hotword: item.word,
weight: Number(item.weight || 2) / 10,
}));
const payload: CreateRealtimeMeetingCommand = {
...meetingValues,
...(hostUserId != null ? { hostUserId } : {}),
meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
participants: meetingValues.participants?.join(",") || "",
tags: meetingValues.tags?.join(",") || "",
summaryDetailLevel: meetingValues.summaryDetailLevel as SummaryDetailLevel,
mode: meetingValues.mode || "2pass",
language: meetingValues.language || "auto",
useSpkId: meetingValues.useSpkId == null ? 1 : (meetingValues.useSpkId ? 1 : 0),
enablePunctuation: meetingValues.enablePunctuation !== false,
enableItn: meetingValues.enableItn !== false,
enableTextRefine: !!meetingValues.enableTextRefine,
saveAudio: !!meetingValues.saveAudio,
hotWords: selectedHotWords,
};
const res = await createRealtimeMeeting(payload);
const createdMeeting = res.data.data;
const sessionDraft: RealtimeMeetingSessionDraft = {
meetingId: createdMeeting.id,
meetingTitle: createdMeeting.title,
asrModelName: selectedAsrModel?.modelName || "ASR",
summaryModelName: selectedSummaryModel?.modelName || "LLM",
asrModelId: selectedAsrModel?.id || values.asrModelId,
mode: values.mode || "2pass",
language: values.language || "auto",
useSpkId: values.useSpkId == null ? 1 : (values.useSpkId ? 1 : 0),
enablePunctuation: values.enablePunctuation !== false,
enableItn: values.enableItn !== false,
enableTextRefine: !!values.enableTextRefine,
saveAudio: !!values.saveAudio,
hotwords: selectedHotwords,
};
sessionStorage.setItem(getSessionKey(createdMeeting.id), JSON.stringify(sessionDraft));
message.success("会议已创建,即将进入实时识别");
onSuccess();
onCancel();
navigate(`/meeting-live-session/${createdMeeting.id}`);
} catch {
message.error(type === "upload" ? "创建会议失败" : "创建实时会议失败");
} finally {
setSubmitting(false);
}
};
return (
<Drawer
title={null}
open={open}
onClose={onCancel}
width={960}
forceRender
destroyOnClose={false}
placement="right"
closable={false}
footer={
configLoaded ? (
<div style={{ textAlign: "right", padding: "16px 32px" }}>
<Space size={16}>
<Button onClick={onCancel} size="large" style={{ borderRadius: 8, minWidth: 120 }}></Button>
<Button type="primary" onClick={handleOk} loading={submitting} size="large" style={{ borderRadius: 8, minWidth: 140, fontWeight: 500 }}>
{type === "upload" ? (audioUrl ? "完成并分析" : "上传并分析") : "创建并进入识别"}
</Button>
</Space>
</div>
) : null
}
styles={{
header: { display: "none" },
body: { padding: 0, display: "flex", flexDirection: "column", background: "var(--app-bg-layout)" },
footer: { padding: 0, borderTop: "1px solid var(--app-border-color)", background: "var(--app-bg-surface)", minHeight: configLoaded ? 72 : 0 }
}}
>
{!configLoaded ? (
<div style={{ padding: "40px" }}>
<Skeleton active paragraph={{ rows: 12 }} />
</div>
) : (
<>
<div style={{ background: "var(--app-bg-surface)", padding: "24px 32px", borderBottom: "1px solid var(--app-border-color)" }}>
<Row justify="space-between" align="middle">
<Col>
<Space size={16}>
<div style={{ width: 48, height: 48, borderRadius: 12, background: "var(--app-bg-surface-strong)", color: "var(--app-text-main)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24, border: "1px solid var(--app-border-color)" }}>
{type === "upload" ? <CloudUploadOutlined /> : <AudioOutlined />}
</div>
<div>
<Title level={4} style={{ margin: 0, fontWeight: 600 }}>{type === "upload" ? "上传录音发起分析" : "创建实时会议"}</Title>
<Text type="secondary" style={{ fontSize: 13 }}>{type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"}</Text>
</div>
</Space>
</Col>
<Col>
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" size="large">
{createConfig.offlineEnabled && (
<Radio.Button value="upload" style={{ padding: "0 24px" }}><CloudUploadOutlined style={{ marginRight: 6 }} /> </Radio.Button>
)}
{createConfig.realtimeEnabled && (
<Radio.Button value="realtime" style={{ padding: "0 24px" }}><AudioOutlined style={{ marginRight: 6 }} /> </Radio.Button>
)}
</Radio.Group>
</Col>
</Row>
</div>
<div style={{ padding: "32px 40px", flex: 1, overflowY: "auto", background: "var(--app-bg-layout)" }}>
<Form form={form} layout="vertical" disabled={loading}>
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
<Title level={5} style={{ margin: 0 }}></Title>
</div>
<Row gutter={32}>
<Col span={12}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
<Input placeholder="输入会议标题" size="large" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]}>
<DatePicker showTime style={{ width: "100%" }} size="large" />
</Form.Item>
</Col>
</Row>
<Row gutter={32}>
<Col span={12}>
<Form.Item name="participants" label="参会人员">
<Select mode="multiple" placeholder="选择参会人员" showSearch optionFilterProp="children" size="large">
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="hostUserId" label="会议主持人">
<Select allowClear placeholder="默认为创建人" showSearch optionFilterProp="children" size="large">
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={32}>
<Col span={12}>
<Form.Item name="tags" label="会议标签">
<Select mode="tags" placeholder="输入标签" size="large" />
</Form.Item>
</Col>
</Row>
<div style={{ margin: "32px 0", borderTop: "1px solid var(--app-border-color)" }} />
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
<Title level={5} style={{ margin: 0 }}></Title>
</div>
<Row gutter={32}>
<Col span={12}>
<Form.Item name="asrModelId" label="语音识别模型 (ASR)" rules={[{ required: true }]}>
<Select placeholder="选择 ASR 模型" size="large">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
<Select placeholder="选择总结模型" size="large">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]}>
{prompts.length > 15 ? (
<Select placeholder="请选择总结模板" showSearch optionFilterProp="children" size="large">
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
</Select>
) : (
<div style={{ padding: "2px" }}>
<Row gutter={[12, 12]}>
{prompts.map(p => {
const isSelected = watchedPromptId === p.id;
return (
<Col span={8} key={p.id}>
<div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: "12px 16px", borderRadius: 8, border: `1px solid ${isSelected ? "#1890ff" : "var(--app-border-color)"}`, background: isSelected ? "#e6f7ff" : "var(--app-bg-surface)", cursor: "pointer", position: "relative", transition: "all 0.2s", display: "flex", alignItems: "center", height: "100%" }}>
<div style={{ fontSize: "14px", color: isSelected ? "#1890ff" : "var(--app-text-main)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isSelected ? 500 : 400 }}>{p.templateName}</div>
{isSelected && <div style={{ position: "absolute", top: -1, right: -1, width: 20, height: 20, background: "#1890ff", borderRadius: "0 8px 0 8px", display: "flex", alignItems: "center", justifyContent: "center" }}><CheckOutlined style={{ color: "#fff", fontSize: 12 }} /></div>}
</div>
</Col>
);
})}
</Row>
</div>
)}
</Form.Item>
<Row gutter={32}>
<Col span={12}>
<Form.Item name="hotWordGroupId" label="热词组" tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : "模板未绑定热词组时可手动选择"} extra={watchedHotWordGroupId != null ? "创建会议时会优先使用这里选中的热词组" : undefined}>
<Select placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"} size="large" options={[{ label: "不使用热词组", value: 0 }, ...hotWordGroups.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="summaryDetailLevel" label="总结详细程度" rules={[{ required: true }]}>
<Radio.Group size="large">
<Radio value="DETAILED"></Radio>
<Radio value="STANDARD"></Radio>
<Radio value="BRIEF"></Radio>
</Radio.Group>
</Form.Item>
</Col>
</Row>
<Collapse
ghost
expandIconPosition="end"
style={{ marginBottom: 24, background: "var(--app-bg-surface)", border: "1px solid var(--app-border-color)", borderRadius: 8, overflow: "hidden" }}
items={[
{
key: "advanced",
forceRender: true,
label: (
<div style={{ display: "flex", alignItems: "center", width: "100%", height: "32px" }}>
<div style={{ display: "flex", alignItems: "center", fontWeight: 600, color: "var(--app-text-main)", fontSize: 15 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: "#f0f5ff", color: "#1677ff", display: "flex", alignItems: "center", justifyContent: "center", marginRight: 12 }}>
<SettingOutlined style={{ fontSize: 16 }} />
</div>
</div>
</div>
),
children: (
<div style={{ paddingTop: 8, borderTop: "1px dashed var(--app-border-color)" }}>
<Row gutter={32}>
<Col span={8}>
<Form.Item name="useSpkId" label={<span> <Tooltip title="自动识别录音中的不同发言者并进行角色分离"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}>
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="enableTextRefine" label={<span> <Tooltip title="自动识别并修正语音转写中的口语词、语气助词等"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked">
<Switch />
</Form.Item>
</Col>
{type === "realtime" && (
<>
<Col span={8}>
<Form.Item name="saveAudio" label="保存录音" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="mode" label="识别模式">
<Radio.Group>
<Radio value="2pass">2pass + 线</Radio>
<Radio value="online">online</Radio>
</Radio.Group>
</Form.Item>
</Col>
</>
)}
</Row>
<Form.Item name="userPrompt" label="用户提示词" extra="提供补充信息以指导 AI 在生成会议总结时更侧重于特定方面或遵循特定风格">
<Input.TextArea placeholder="请输入额外说明,例如:请重点关注会议中的待办事项、以摘要形式输出、或者采用严谨的公文风格等" autoSize={{ minRows: 3, maxRows: 6 }} showCount maxLength={1000} />
</Form.Item>
</div>
),
}
]}
/>
{type === "upload" && (
<>
<div style={{ margin: "32px 0", borderTop: "1px solid var(--app-border-color)" }} />
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
<Title level={5} style={{ margin: 0 }}></Title>
</div>
<Dragger
accept=".mp3,.wav,.m4a"
beforeUpload={beforeAudioUpload}
fileList={fileList}
customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))}
maxCount={1}
style={{ borderRadius: 12, padding: "32px 0", background: "var(--app-bg-surface)", border: "1px dashed var(--app-border-color)" }}
>
<div>
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: "#1890ff" }} /></p>
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: "var(--app-text-main)" }}></p>
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: "var(--app-text-secondary)" }}> mp3wavm4a {createConfig.offlineAudioMaxSizeMb}MB</p>
{uploadProgress > 0 && uploadProgress < 100 && (
<div style={{ width: "60%", margin: "32px auto 0" }}>
<Progress percent={uploadProgress} size="small" />
<div style={{ fontSize: 13, color: "#1890ff", marginTop: 8 }}>...</div>
</div>
)}
{audioUrl && (
<Tag color="processing" style={{ marginTop: 24, padding: "6px 16px", fontSize: 14, borderRadius: 6, maxWidth: "90%", display: "inline-flex", alignItems: "center" }}>
<span></span>
<span style={{ marginLeft: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{audioUrl.split("/").pop()}</span>
</Tag>
)}
</div>
</Dragger>
</>
)}
</Form>
</div>
</>
)}
</Drawer>
);
};