import React, { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Card, Row, Col, Typography, Tag, Space, Divider, Button, Skeleton, Empty, List, Avatar, Breadcrumb, Popover, Input, Select, message, Drawer, Form, Modal, Progress, } from 'antd'; import { LeftOutlined, UserOutlined, ClockCircleOutlined, AudioOutlined, RobotOutlined, LoadingOutlined, EditOutlined, SyncOutlined, DownloadOutlined, } from '@ant-design/icons'; import ReactMarkdown from 'react-markdown'; import dayjs from 'dayjs'; import { getMeetingDetail, getTranscripts, updateSpeakerInfo, reSummary, updateMeeting, MeetingVO, MeetingTranscriptVO, getMeetingProgress, MeetingProgress, downloadMeetingSummary, } from '../../api/business/meeting'; import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { useDict } from '../../hooks/useDict'; import { listUsers } from '../../api'; import { SysUser } from '../../types'; const { Title, Text } = Typography; const { Option } = Select; const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void }> = ({ meetingId, onComplete }) => { const [progress, setProgress] = useState(null); useEffect(() => { const fetchProgress = async () => { try { const res = await getMeetingProgress(meetingId); if (res.data?.data) { setProgress(res.data.data); if (res.data.data.percent === 100) { onComplete(); } } } catch (err) { // ignore polling errors } }; fetchProgress(); const timer = setInterval(fetchProgress, 3000); return () => clearInterval(timer); }, [meetingId, onComplete]); const percent = progress?.percent || 0; const isError = percent < 0; const formatETA = (seconds?: number) => { if (!seconds || seconds <= 0) return '正在分析中'; if (seconds < 60) return `${seconds}秒`; const m = Math.floor(seconds / 60); const s = seconds % 60; return s > 0 ? `${m}分${s}秒` : `${m}分钟`; }; return (
AI 智能分析中
{progress?.message || '正在准备计算资源...'} 分析过程中,请耐心等待,你可以先去处理其他工作
当前进度 {isError ? 'ERROR' : `${percent}%`} 预计剩余 {isError ? '--' : formatETA(progress?.eta)} 任务状态 {isError ? '已中断' : '正常'}
); }; const SpeakerEditor: React.FC<{ meetingId: number; speakerId: string; initialName: string; initialLabel: string; onSuccess: () => void; }> = ({ meetingId, speakerId, initialName, initialLabel, onSuccess }) => { const [name, setName] = useState(initialName || speakerId); const [label, setLabel] = useState(initialLabel); const [loading, setLoading] = useState(false); const { items: speakerLabels } = useDict('biz_speaker_label'); const handleSave = async (e: React.MouseEvent) => { e.stopPropagation(); setLoading(true); try { await updateSpeakerInfo({ meetingId, speakerId, newName: name, label }); message.success('发言人信息已全局更新'); onSuccess(); } catch (err) { console.error(err); } finally { setLoading(false); } }; return (
e.stopPropagation()}>
发言人姓名 setName(e.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} />
角色标签
); }; const MeetingDetail: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [form] = Form.useForm(); const [summaryForm] = Form.useForm(); const [meeting, setMeeting] = useState(null); const [transcripts, setTranscripts] = useState([]); const [loading, setLoading] = useState(true); const [editVisible, setEditVisible] = useState(false); const [summaryVisible, setSummaryVisible] = useState(false); const [actionLoading, setActionLoading] = useState(false); const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | null>(null); const [llmModels, setLlmModels] = useState([]); const [prompts, setPrompts] = useState([]); const [, setUserList] = useState([]); const { items: speakerLabels } = useDict('biz_speaker_label'); const audioRef = useRef(null); const summaryPdfRef = useRef(null); const isOwner = React.useMemo(() => { if (!meeting) return false; const profileStr = sessionStorage.getItem('userProfile'); if (profileStr) { const profile = JSON.parse(profileStr); return profile.isPlatformAdmin === true || profile.userId === meeting.creatorId; } return false; }, [meeting]); useEffect(() => { if (id) { fetchData(Number(id)); loadAiConfigs(); loadUsers(); } }, [id]); const fetchData = async (meetingId: number) => { try { const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]); setMeeting(detailRes.data.data); setTranscripts(transcriptRes.data.data || []); } catch (err) { console.error(err); } finally { setLoading(false); } }; const loadAiConfigs = async () => { try { const [mRes, pRes, dRes] = await Promise.all([ getAiModelPage({ current: 1, size: 100, type: 'LLM' }), getPromptPage({ current: 1, size: 100 }), getAiModelDefault('LLM'), ]); setLlmModels(mRes.data.data.records.filter((m) => m.status === 1)); setPrompts(pRes.data.data.records.filter((p) => p.status === 1)); summaryForm.setFieldsValue({ summaryModelId: dRes.data.data?.id }); } catch (e) { // ignore } }; const loadUsers = async () => { try { const users = await listUsers(); setUserList(users || []); } catch (err) { // ignore } }; const handleEditMeeting = () => { if (!meeting || !isOwner) return; form.setFieldsValue({ ...meeting, tags: meeting.tags?.split(',').filter(Boolean), }); setEditVisible(true); }; const handleUpdateBasic = async () => { const vals = await form.validateFields(); setActionLoading(true); try { await updateMeeting({ ...vals, id: meeting?.id, tags: vals.tags?.join(','), }); message.success('会议信息已更新'); setEditVisible(false); fetchData(Number(id)); } catch (err) { console.error(err); } finally { setActionLoading(false); } }; const handleReSummary = async () => { const vals = await summaryForm.validateFields(); setActionLoading(true); try { await reSummary({ meetingId: Number(id), summaryModelId: vals.summaryModelId, promptId: vals.promptId, }); message.success('已重新发起总结任务'); setSummaryVisible(false); fetchData(Number(id)); } catch (err) { console.error(err); } finally { setActionLoading(false); } }; const formatTime = (ms: number) => { const seconds = Math.floor(ms / 1000); const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; }; const seekTo = (timeMs: number) => { if (audioRef.current) { audioRef.current.currentTime = timeMs / 1000; audioRef.current.play(); } }; const getFileNameFromDisposition = (disposition?: string, fallback?: string) => { if (!disposition) return fallback || 'summary'; const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]); const normalMatch = disposition.match(/filename=\"?([^\";]+)\"?/i); return normalMatch?.[1] || fallback || 'summary'; }; const handleDownloadSummary = async (format: 'pdf' | 'word') => { if (!meeting) return; if (!meeting.summaryContent) { message.warning('当前暂无可下载的AI总结'); return; } try { setDownloadLoading(format); const res = await downloadMeetingSummary(meeting.id, format); const contentType: string = res.headers['content-type'] || (format === 'pdf' ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); // 后端若返回业务错误,可能是 JSON Blob,不能当文件保存 if (contentType.includes('application/json')) { const text = await (res.data as Blob).text(); try { const json = JSON.parse(text); message.error(json?.msg || '下载失败'); } catch { message.error('下载失败'); } return; } const blob = new Blob([res.data], { type: contentType }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = getFileNameFromDisposition( res.headers['content-disposition'], `${(meeting.title || 'meeting').replace(/[\\\\/:*?\"<>|\\r\\n]/g, '_')}-AI纪要.${format === 'pdf' ? 'pdf' : 'docx'}`, ); document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url); } catch (err) { console.error(err); message.error(`${format.toUpperCase()}下载失败`); } finally { setDownloadLoading(null); } }; if (loading) return
; if (!meeting) return
; return (
navigate('/meetings')}>会议中心 会议详情 {meeting.title} {isOwner && ( <EditOutlined style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff', marginLeft: 8 }} onClick={handleEditMeeting} /> )} }> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')} {meeting.tags?.split(',').filter(Boolean).map((t) => {t})} {meeting.participants || '未指定'} {isOwner && meeting.status === 3 && ( )} {isOwner && meeting.status === 2 && ( )} {meeting.status === 3 && !!meeting.summaryContent && ( <> )}
{meeting.status === 1 || meeting.status === 2 ? ( fetchData(meeting.id)} /> ) : ( 语音转录} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }} extra={meeting.audioUrl && AI 总结} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }} >
{meeting.summaryContent ? (
{meeting.summaryContent}
) : (
{meeting.status === 2 ? ( 正在重新总结... ) : ( )}
)}
)}
{isOwner && ( setEditVisible(false)} confirmLoading={actionLoading} width={600} >
{llmModels.map((m) => ( ))} 提示:重新总结将基于当前语音转录全文重新生成纪要,原有总结内容会被覆盖。 )}
); }; export default MeetingDetail;