import React, { useState, useEffect, useMemo } from 'react'; import { Drawer, Form, Input, Select, DatePicker, Switch, Upload, Progress, Space, Avatar, Row, Col, Radio, Typography, Tooltip, App, Tag, Button, Collapse } from 'antd'; import { UserOutlined, CloudUploadOutlined, AudioOutlined, QuestionCircleOutlined, CheckOutlined, LinkOutlined, SettingOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import { useNavigate } from 'react-router-dom'; import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { getHotWordPage, HotWordVO } from '../../api/business/hotword'; import { listUsers, pageParams } from '../../api'; import { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting'; import { SysUser } from '../../types'; const { Option } = Select; const { Dragger } = Upload; const { Text, Title } = Typography; export type MeetingCreateType = 'upload' | 'realtime'; const DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB = 1024; const OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY = 'meeting.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 resolveWsUrl(model?: AiModelVO | null) { if (model?.wsUrl) return model.wsUrl; if (model?.baseUrl) return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://"); return ""; } function buildRealtimeProxyPreviewUrl() { const protocol = window.location.protocol === "https:" ? "wss" : "ws"; return `${protocol}://${window.location.host}/ws/meeting/realtime`; } function getSessionKey(meetingId: number) { return `realtimeMeetingSession:${meetingId}`; } export const MeetingCreateDrawer: React.FC = ({ open, initialType = 'upload', onCancel, onSuccess }) => { const { message } = App.useApp(); const navigate = useNavigate(); const [form] = Form.useForm(); const [type, setType] = useState(initialType); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [asrModels, setAsrModels] = useState([]); const [llmModels, setLlmModels] = useState([]); const [prompts, setPrompts] = useState([]); const [hotwordList, setHotwordList] = useState([]); const [userList, setUserList] = useState([]); const [audioUrl, setAudioUrl] = useState(''); const [uploadProgress, setUploadProgress] = useState(0); const [fileList, setFileList] = useState([]); const [offlineAudioMaxSizeMb, setOfflineAudioMaxSizeMb] = useState(DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB); const watchedAsrModelId = Form.useWatch("asrModelId", form); const watchedPromptId = Form.useWatch("promptId", form); const watchedSummaryModelId = Form.useWatch("summaryModelId", 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 offlineAudioMaxSizeBytes = useMemo(() => offlineAudioMaxSizeMb * 1024 * 1024, [offlineAudioMaxSizeMb]); useEffect(() => { if (open) { setType(initialType); loadInitialData(); setAudioUrl(''); setUploadProgress(0); setFileList([]); } }, [open, initialType]); const loadInitialData = async () => { setLoading(true); try { const [asrRes, llmRes, promptRes, hotwordRes, users, defaultAsr, defaultLlm] = 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 }), listUsers(), getAiModelDefault("ASR"), getAiModelDefault("LLM"), ]); const activeAsrModels = asrRes.data.data.records.filter((m: AiModelVO) => m.status === 1); const activeLlmModels = llmRes.data.data.records.filter((m: AiModelVO) => m.status === 1); const activePrompts = promptRes.data.data.records.filter((p: PromptTemplateVO) => p.status === 1); const activeHotwords = hotwordRes.data.data.records.filter((h: HotWordVO) => h.status === 1); setAsrModels(activeAsrModels); setLlmModels(activeLlmModels); setPrompts(activePrompts); setHotwordList(activeHotwords); setUserList(users || []); setOfflineAudioMaxSizeMb(await loadOfflineAudioMaxSizeMb()); form.setFieldsValue({ title: type === '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: activePrompts.length > 0 ? activePrompts[0].id : undefined, useSpkId: 1, enableTextRefine: false, mode: "2pass", language: "auto", enablePunctuation: true, enableItn: true, saveAudio: false, }); } catch (err) { message.error("加载配置失败"); } finally { setLoading(false); } }; // Sync title when type changes useEffect(() => { if (!open) return; const currentTitle = form.getFieldValue('title'); if (currentTitle && (currentTitle.startsWith('文件会议') || currentTitle.startsWith('实时会议'))) { form.setFieldsValue({ title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`, useSpkId: 1 }); } }, [type, form, open]); 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 percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); // Only show up to 99% during upload, save 100% for actual completion const displayPercent = percentCompleted > 99 ? 99 : percentCompleted; setUploadProgress(displayPercent); onProgress({ percent: displayPercent }); } }); setUploadProgress(100); onProgress({ percent: 100 }); setAudioUrl(res.data.data); uploadSuccess(res.data.data); message.success('录音上传成功'); } catch (err) { onError(err); if (!(err instanceof Error) || !err.message) { message.error('文件上传失败'); } } }; const beforeAudioUpload = (file: File) => { if (file.size > offlineAudioMaxSizeBytes) { message.error(`录音文件大小不能超过 ${offlineAudioMaxSizeMb}MB`); setUploadProgress(0); return Upload.LIST_IGNORE; } return true; }; const loadOfflineAudioMaxSizeMb = async () => { try { const result = await pageParams({ paramKey: OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY, pageNum: 1, pageSize: 10, }); const matched = (result.records || []).find((item) => item.paramKey === OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY); const parsed = Number(matched?.paramValue); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB; } catch { return DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB; } }; const handleOk = async () => { 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; 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(',') }); message.success('会议发起成功'); onSuccess(); onCancel(); } else { const selectedHotwords = hotwordList.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(",") || "", 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, }; 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 (err) { message.error(type === 'upload' ? '创建会议失败' : '创建实时会议失败'); } finally { setSubmitting(false); } }; return ( } 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)' } }} >
{type === 'upload' ? : }
{type === 'upload' ? '上传录音分析' : '创建实时会议'} {type === 'upload' ? '上传已有录音文件进行转写和总结' : '创建会议实时进行语音转写和内容分析'}
setType(e.target.value)} optionType="button" buttonStyle="solid" size="large"> 上传录音 实时识别
基础信息
{asrModels.map(m => ())} {prompts.length > 15 ? ( ) : (
{prompts.map(p => { const isSelected = watchedPromptId === p.id; return (
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%' }}>
{p.templateName}
{isSelected &&
}
); })}
)}
高级设置
), children: (
声纹区分 } valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}> 文本修正 } valuePropName="checked"> {type === 'realtime' && ( )}
), } ]} /> {type === 'realtime' && ( <> )} {type === 'upload' && ( <>
上传录音文件
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)' }} >

点击或拖拽录音文件到此处

支持高质量 .mp3, .wav, .m4a 格式音频

文件大小不超过 {offlineAudioMaxSizeMb}MB,取值来自系统参数配置

{uploadProgress > 0 && uploadProgress < 100 && (
文件传输中,请稍候...
)} {audioUrl && ( 就绪: {audioUrl.split('/').pop()} )}
)}
); };