import React, { useState, useEffect } from 'react'; import { Card, Button, Input, Space, Tag, message, Popconfirm, Typography, Row, Col, List, Badge, Empty, Skeleton, Tooltip, Radio, Pagination, Progress, Drawer, Form, DatePicker, Upload, Avatar, Divider, Switch, Select, Modal } from 'antd'; import { PlusOutlined, DeleteOutlined, SearchOutlined, CheckCircleOutlined, LoadingOutlined, UserOutlined, CalendarOutlined, PlayCircleOutlined, TeamOutlined, ClockCircleOutlined, EditOutlined, RightOutlined, SyncOutlined, InfoCircleOutlined, CloudUploadOutlined, SettingOutlined, QuestionCircleOutlined, FileTextOutlined, CheckOutlined, RocketOutlined, AudioOutlined, PauseCircleOutlined } from '@ant-design/icons'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { usePermission } from '../../hooks/usePermission'; import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants, getRealtimeMeetingSessionStatus } from '../../api/business/meeting'; import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { getHotWordPage, HotWordVO } from '../../api/business/hotword'; import { listUsers } from '../../api'; import { SysUser } from '../../types'; import dayjs from 'dayjs'; import { useTranslation } from 'react-i18next'; const { Text, Title } = Typography; const { Dragger } = Upload; const { Option } = Select; const PAUSED_DISPLAY_STATUS = 5; // --- 进度感知 Hook --- const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => { const [progress, setProgress] = useState(null); useEffect(() => { const effectiveStatus = meeting.displayStatus ?? meeting.status; if (effectiveStatus !== 1 && effectiveStatus !== 2) return; const fetchProgress = async () => { try { const res = await getMeetingProgress(meeting.id); if (res.data && res.data.data) { setProgress(res.data.data); // 当达到 100% 时触发完成回调 if (res.data.data.percent === 100 && onComplete) { onComplete(); } } } catch (err) {} }; fetchProgress(); const timer = setInterval(fetchProgress, 3000); return () => clearInterval(timer); }, [meeting.id, meeting.status, meeting.displayStatus]); return progress; }; // --- 状态标签组件 --- const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgress | null }> = ({ meeting, progress }) => { const effectiveStatus = meeting.displayStatus ?? meeting.status; const statusConfig: Record = { 0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' }, 1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' }, 2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' }, 3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' }, 4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' }, 5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' } }; const config = statusConfig[effectiveStatus] || statusConfig[0]; const percent = progress?.percent || 0; const isProcessing = effectiveStatus === 1 || effectiveStatus === 2; return (
{/* 进度填充背景 */} {isProcessing && percent > 0 && (
)} {isProcessing ? : null} {config.text} {isProcessing && {percent}%}
); }; // --- 发起会议表单组件 (左侧高度占满版) --- const MeetingCreateForm: React.FC<{ form: any, audioUrl: string, setAudioUrl: (url: string) => void, uploadProgress: number, setUploadProgress: (p: number) => void, fileList: any[], setFileList: (list: any[]) => void }> = ({ form, audioUrl, setAudioUrl, uploadProgress, setUploadProgress, fileList, setFileList }) => { const [asrModels, setAsrModels] = useState([]); const [llmModels, setLlmModels] = useState([]); const [prompts, setPrompts] = useState([]); const [hotwordList, setHotwordList] = useState([]); const [userList, setUserList] = useState([]); const watchedPromptId = Form.useWatch('promptId', form); useEffect(() => { loadInitialData(); }, []); const loadInitialData = async () => { try { const [asrRes, llmRes, promptRes, hotwordRes, users] = 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() ]); setAsrModels(asrRes.data.data.records.filter(m => m.status === 1)); setLlmModels(llmRes.data.data.records.filter(m => m.status === 1)); const activePrompts = promptRes.data.data.records.filter(p => p.status === 1); setPrompts(activePrompts); setHotwordList(hotwordRes.data.data.records.filter(h => h.status === 1)); setUserList(users || []); const defaultAsr = await getAiModelDefault('ASR'); const defaultLlm = await getAiModelDefault('LLM'); form.setFieldsValue({ asrModelId: defaultAsr.data.data?.id, summaryModelId: defaultLlm.data.data?.id, promptId: activePrompts.length > 0 ? activePrompts[0].id : undefined, meetingTime: dayjs(), useSpkId: 1, enableTextRefine: false }); } catch (err) {} }; const customUpload = async (options: any) => { const { file, onSuccess: uploadSuccess, onError } = options; setUploadProgress(0); try { const interval = setInterval(() => setUploadProgress(prev => (prev < 95 ? prev + 5 : prev)), 300); const res = await uploadAudio(file); clearInterval(interval); setUploadProgress(100); setAudioUrl(res.data.data); uploadSuccess(res.data.data); message.success('录音上传成功'); } catch (err) { onError(err); message.error('文件上传失败'); } }; return (
{/* 基础信息卡片 - 固定高度 */} 基础信息} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)', marginBottom: 20 }}> {userList.map(u => ())} {/* 录音上传卡片 - 占满剩余高度 */} 录音上传} bordered={false} style={{ borderRadius: 12, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-surface-soft)', border: '1px solid var(--app-border-color)', flex: 1, display: 'flex', flexDirection: 'column', backdropFilter: 'blur(16px)' }} bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px' }} > setFileList(info.fileList.slice(-1))} maxCount={1} style={{ borderRadius: 8, flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }} >

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

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

{uploadProgress > 0 && uploadProgress < 100 && (
文件传输中,请稍候...
)} {audioUrl && ( 就绪: {audioUrl.split('/').pop()} )}
AI 分析配置} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)', marginBottom: 20 }}> {prompts.length > 15 ? ( ) : (
{prompts.map(p => { const isSelected = watchedPromptId === p.id; return (
form.setFieldsValue({ promptId: p.id })} style={{ padding: '6px', borderRadius: 6, border: `1.5px solid ${isSelected ? 'var(--app-primary-color)' : 'var(--app-border-color)'}`, background: isSelected ? 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))' : 'var(--app-bg-surface-strong)', cursor: 'pointer', textAlign: 'center', position: 'relative' }}>
{p.templateName}
{isSelected &&
}
); })}
)}
声纹识别 } valuePropName="checked" getValueProps={(v) => ({ checked: v === 1 })} normalize={(v) => (v ? 1 : 0)} style={{ marginBottom: 20 }}> 文本修正 } valuePropName="checked" style={{ marginBottom: 20 }}>
智能分析链路:转录固化 + AI 总结 + 说话人分离。
); }; // --- 卡片项组件 --- const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void, onOpenMeeting: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants, onOpenMeeting }) => { // 注入自动刷新回调 const progress = useMeetingProgress(item, () => fetchData()); const effectiveStatus = item.displayStatus ?? item.status; const isProcessing = effectiveStatus === 1 || effectiveStatus === 2; const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS; return ( onOpenMeeting(item)} className="meeting-card" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', backdropFilter: 'blur(16px)', height: '220px', position: 'relative', boxShadow: 'var(--app-shadow)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} bodyStyle={{ padding: 0, display: 'flex', height: '100%' }}>
e.stopPropagation()}>
onEditParticipants(item)}>
deleteMeeting(item.id).then(fetchData)} okText={t('common.confirm')} cancelText={t('common.cancel')} >
{item.title}
{dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')}
{isProcessing ? (
{progress?.message || '等待引擎调度...'}
) : isPaused ? (
会议已暂停,可继续识别
) : (
{item.participants || '无参与人员'}
)}
{item.tags?.split(',').slice(0, 2).map(t => ( {t} ))}
); }; // --- 主组件 --- const Meetings: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { can } = usePermission(); const [searchParams, setSearchParams] = useSearchParams(); const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [submitLoading, setSubmitLoading] = useState(false); const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [current, setCurrent] = useState(1); const [size, setSize] = useState(8); const [searchTitle, setSearchTitle] = useState(''); const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all'); const [createDrawerVisible, setCreateDrawerVisible] = useState(false); useEffect(() => { if (searchParams.get('create') === 'true') { setCreateDrawerVisible(true); const newParams = new URLSearchParams(searchParams); newParams.delete('create'); setSearchParams(newParams, { replace: true }); } }, [searchParams]); const [audioUrl, setAudioUrl] = useState(''); const [uploadProgress, setUploadProgress] = useState(0); const [fileList, setFileList] = useState([]); const [userList, setUserList] = useState([]); const [participantsEditVisible, setParticipantsEditVisible] = useState(false); const [editingMeeting, setEditingMeeting] = useState(null); const [participantsEditLoading, setParticipantsEditLoading] = useState(false); const [participantsEditForm] = Form.useForm(); const hasRunningTasks = data.some(item => { const effectiveStatus = item.displayStatus ?? item.status; return effectiveStatus === 0 || effectiveStatus === 1 || effectiveStatus === 2; }); useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]); useEffect(() => { if (!hasRunningTasks) return; const timer = setInterval(() => fetchData(true), 5000); return () => clearInterval(timer); }, [hasRunningTasks, current, size, searchTitle, viewType]); useEffect(() => { listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([])); }, []); const fetchData = async (silent = false) => { if (!silent) setLoading(true); try { const res = await getMeetingPage({ current, size, title: searchTitle, viewType }); if (res.data && res.data.data) { const records = res.data.data.records || []; const withDisplayStatus = await Promise.all(records.map(async (item) => { try { const sessionRes = await getRealtimeMeetingSessionStatus(item.id); const sessionStatus = sessionRes.data?.data; if (sessionStatus?.status === 'PAUSED_EMPTY' || sessionStatus?.status === 'PAUSED_RESUMABLE') { return { ...item, displayStatus: PAUSED_DISPLAY_STATUS, realtimeSessionStatus: sessionStatus.status }; } if (sessionStatus?.status === 'ACTIVE') { return { ...item, displayStatus: 1, realtimeSessionStatus: sessionStatus.status }; } return { ...item, realtimeSessionStatus: sessionStatus?.status }; } catch { return item; } })); setData(withDisplayStatus); setTotal(res.data.data.total); } } catch (err) {} finally { if (!silent) setLoading(false); } }; const handleOpenMeeting = async (meeting: MeetingVO) => { try { const res = await getRealtimeMeetingSessionStatus(meeting.id); const sessionStatus = res.data?.data; if (sessionStatus && (sessionStatus.status === 'PAUSED_EMPTY' || sessionStatus.status === 'PAUSED_RESUMABLE' || sessionStatus.status === 'ACTIVE')) { navigate(`/meeting-live-session/${meeting.id}`); return; } } catch (error) {} navigate(`/meetings/${meeting.id}`); }; const handleCreateSubmit = async () => { if (!audioUrl) { message.error('请先上传录音文件'); return; } const values = await form.validateFields(); setSubmitLoading(true); try { await createMeeting({ ...values, meetingTime: values.meetingTime.format('YYYY-MM-DD HH:mm:ss'), audioUrl, participants: values.participants?.join(','), tags: values.tags?.join(',') }); message.success('会议发起成功'); setCreateDrawerVisible(false); fetchData(); } catch (err) {} finally { setSubmitLoading(false); } }; const openEditParticipants = (meeting: MeetingVO) => { setEditingMeeting(meeting); participantsEditForm.setFieldsValue({ participantIds: meeting.participantIds || [] }); setParticipantsEditVisible(true); }; const handleUpdateParticipants = async () => { if (!editingMeeting) { return; } const values = await participantsEditForm.validateFields(); const participantIds: number[] = values.participantIds || []; setParticipantsEditLoading(true); try { await updateMeetingParticipants({ meetingId: editingMeeting.id, participants: participantIds.join(",") }); message.success("参会人已更新"); setParticipantsEditVisible(false); fetchData(); } finally { setParticipantsEditLoading(false); } }; const statusConfig: Record = { 0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' }, 1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' }, 2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' }, 3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' }, 4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' }, 5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' } }; return (
会议中心
{ setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid"> 全部我发起我参与 } allowClear onPressEnter={(e) => { setSearchTitle((e.target as any).value); setCurrent(1); }} style={{ width: 220, borderRadius: 8 }} /> {can("meeting:create:realtime") && }
{ const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0]; return ; }} locale={{ emptyText: }} />
{total > 0 && (
{ setCurrent(p); setSize(s); }} showTotal={(total) => 为您找到 {total} 场会议} size="small" />
)}
发起新会议分析
} width="clamp(800px, 85vw, 1100px)" onClose={() => setCreateDrawerVisible(false)} open={createDrawerVisible} destroyOnClose styles={{ body: { background: 'var(--app-bg-page)', padding: '24px 32px' } }} footer={
} >
setParticipantsEditVisible(false)} onOk={handleUpdateParticipants} confirmLoading={participantsEditLoading} destroyOnClose >
); }; export default Meetings;