import React, { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Card, Row, Col, Button, Space, Typography, Tag, Avatar, Tooltip, Progress, Spin, App, Dropdown, Divider, List, Timeline, Tabs, Input, Upload, Empty, Drawer, Select, Switch } from 'antd'; import { ClockCircleOutlined, UserOutlined, EditOutlined, DeleteOutlined, SettingOutlined, FireOutlined, SyncOutlined, UploadOutlined, QrcodeOutlined, EyeOutlined, FileTextOutlined, PartitionOutlined, SaveOutlined, CloseOutlined, StarFilled, RobotOutlined, DownloadOutlined, DownOutlined, CheckOutlined, MoreOutlined, AudioOutlined } from '@ant-design/icons'; import MarkdownRenderer from '../components/MarkdownRenderer'; import MarkdownEditor from '../components/MarkdownEditor'; import MindMap from '../components/MindMap'; import apiClient from '../utils/apiClient'; import tools from '../utils/tools'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import QRCodeModal from '../components/QRCodeModal'; import MeetingFormDrawer from '../components/MeetingFormDrawer'; const { Title, Text } = Typography; const { TextArea } = Input; /* ── 发言人头像颜色池 ── */ const AVATAR_COLORS = [ '#1677ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1', '#13c2c2', '#2f54eb', '#faad14', ]; const getSpeakerColor = (speakerId) => AVATAR_COLORS[(speakerId ?? 0) % AVATAR_COLORS.length]; const MeetingDetails = ({ user }) => { const { meeting_id } = useParams(); const navigate = useNavigate(); const { message, modal } = App.useApp(); const [meeting, setMeeting] = useState(null); const [loading, setLoading] = useState(true); const [transcript, setTranscript] = useState([]); const [audioUrl, setAudioUrl] = useState(null); // 发言人 const [editingSpeakers, setEditingSpeakers] = useState({}); const [speakerList, setSpeakerList] = useState([]); // 转录状态 const [transcriptionStatus, setTranscriptionStatus] = useState(null); const [transcriptionProgress, setTranscriptionProgress] = useState(0); const [statusCheckInterval, setStatusCheckInterval] = useState(null); const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1); // AI 总结 const [showSummaryDrawer, setShowSummaryDrawer] = useState(false); const [summaryLoading, setSummaryLoading] = useState(false); const [userPrompt, setUserPrompt] = useState(''); const [summaryHistory, setSummaryHistory] = useState([]); const [promptList, setPromptList] = useState([]); const [selectedPromptId, setSelectedPromptId] = useState(null); const [summaryTaskProgress, setSummaryTaskProgress] = useState(0); const [summaryTaskMessage, setSummaryTaskMessage] = useState(''); const [summaryPollInterval, setSummaryPollInterval] = useState(null); const [activeSummaryTaskId, setActiveSummaryTaskId] = useState(null); const [llmModels, setLlmModels] = useState([]); const [selectedModelCode, setSelectedModelCode] = useState(null); // Drawer 状态 const [showSpeakerDrawer, setShowSpeakerDrawer] = useState(false); const [viewingPrompt, setViewingPrompt] = useState(null); const [editDrawerOpen, setEditDrawerOpen] = useState(false); const [showQRModal, setShowQRModal] = useState(false); const [isUploading, setIsUploading] = useState(false); const [playbackRate, setPlaybackRate] = useState(1); const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false); const [accessPasswordDraft, setAccessPasswordDraft] = useState(''); const [savingAccessPassword, setSavingAccessPassword] = useState(false); // 转录编辑 Drawer const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false); const [editingSegmentIndex, setEditingSegmentIndex] = useState(-1); const [editingSegments, setEditingSegments] = useState({}); // 总结内容编辑(同窗口) const [isEditingSummary, setIsEditingSummary] = useState(false); const [editingSummaryContent, setEditingSummaryContent] = useState(''); const [inlineSpeakerEdit, setInlineSpeakerEdit] = useState(null); const [inlineSpeakerEditSegmentId, setInlineSpeakerEditSegmentId] = useState(null); const [inlineSpeakerValue, setInlineSpeakerValue] = useState(''); const [inlineSegmentEditId, setInlineSegmentEditId] = useState(null); const [inlineSegmentValue, setInlineSegmentValue] = useState(''); const [savingInlineEdit, setSavingInlineEdit] = useState(false); const audioRef = useRef(null); const transcriptRefs = useRef([]); const isMeetingOwner = user?.user_id === meeting?.creator_id; const hasUploadedAudio = Boolean(audioUrl); const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status); const summaryDisabledReason = isUploading ? '音频上传中,暂不允许重新总结' : !hasUploadedAudio ? '请先上传音频后再总结' : isTranscriptionRunning ? '转录进行中,完成后会自动总结' : ''; const isSummaryActionDisabled = isUploading || !hasUploadedAudio || isTranscriptionRunning || summaryLoading; /* ══════════════════ 数据获取 ══════════════════ */ useEffect(() => { fetchMeetingDetails(); fetchPromptList(); fetchLlmModels(); return () => { if (statusCheckInterval) clearInterval(statusCheckInterval); if (summaryPollInterval) clearInterval(summaryPollInterval); }; }, [meeting_id]); const fetchMeetingDetails = async () => { try { setLoading(true); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id))); setMeeting(response.data); if (response.data.prompt_id) { setSelectedPromptId(response.data.prompt_id); } setAccessPasswordEnabled(Boolean(response.data.access_password)); setAccessPasswordDraft(response.data.access_password || ''); if (response.data.transcription_status) { const ts = response.data.transcription_status; setTranscriptionStatus(ts); setTranscriptionProgress(ts.progress || 0); if (['pending', 'processing'].includes(ts.status)) { startStatusPolling(ts.task_id); } } else { setTranscriptionStatus(null); setTranscriptionProgress(0); } if (response.data.llm_status) { setSummaryTaskProgress(response.data.llm_status.progress || 0); setSummaryTaskMessage(response.data.llm_status.message || ''); if (['pending', 'processing'].includes(response.data.llm_status.status)) { startSummaryPolling(response.data.llm_status.task_id); } } try { await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id))); setAudioUrl(buildApiUrl(`/api/meetings/${meeting_id}/audio/stream`)); } catch { setAudioUrl(null); } fetchTranscript(); fetchSummaryHistory(); } catch { message.error('加载会议详情失败'); } finally { setLoading(false); } }; const fetchTranscript = async () => { try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))); setTranscript(res.data); const ids = [...new Set(res.data.map(i => i.speaker_id))].filter(id => id != null); const list = ids.map(id => { const item = res.data.find(s => s.speaker_id === id); return { speaker_id: id, speaker_tag: item.speaker_tag || `发言人 ${id}` }; }).sort((a, b) => a.speaker_id - b.speaker_id); setSpeakerList(list); const init = {}; list.forEach(s => init[s.speaker_id] = s.speaker_tag); setEditingSpeakers(init); } catch { setTranscript([]); } }; const startStatusPolling = (taskId) => { if (statusCheckInterval) clearInterval(statusCheckInterval); const interval = setInterval(async () => { try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.TRANSCRIPTION_STATUS(taskId))); const status = res.data; setTranscriptionStatus(status); setTranscriptionProgress(status.progress || 0); if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) { clearInterval(interval); if (status.status === 'completed') { fetchTranscript(); fetchMeetingDetails(); setTimeout(() => { fetchSummaryHistory(); }, 1000); } } } catch { clearInterval(interval); } }, 3000); setStatusCheckInterval(interval); }; const fetchPromptList = async () => { try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))); setPromptList(res.data.prompts || []); const def = res.data.prompts?.find(p => p.is_default) || res.data.prompts?.[0]; if (def) setSelectedPromptId(def.id); } catch {} }; const fetchLlmModels = async () => { try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LLM_MODELS)); const models = Array.isArray(res.data) ? res.data : (res.data?.models || []); setLlmModels(models); const def = models.find(m => m.is_default); if (def) setSelectedModelCode(def.model_code); } catch {} }; const startSummaryPolling = (taskId, options = {}) => { const { closeDrawerOnComplete = false } = options; if (!taskId) return; if (summaryPollInterval && activeSummaryTaskId === taskId) return; if (summaryPollInterval) clearInterval(summaryPollInterval); setActiveSummaryTaskId(taskId); setSummaryLoading(true); const poll = async () => { try { const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId))); const status = statusRes.data; setSummaryTaskProgress(status.progress || 0); setSummaryTaskMessage(status.message || ''); if (status.status === 'completed') { clearInterval(interval); setSummaryPollInterval(null); setActiveSummaryTaskId(null); setSummaryLoading(false); if (closeDrawerOnComplete) { setShowSummaryDrawer(false); } fetchSummaryHistory(); fetchMeetingDetails(); } else if (status.status === 'failed') { clearInterval(interval); setSummaryPollInterval(null); setActiveSummaryTaskId(null); setSummaryLoading(false); message.error(status.error_message || '生成总结失败'); } } catch (error) { clearInterval(interval); setSummaryPollInterval(null); setActiveSummaryTaskId(null); setSummaryLoading(false); message.error(error?.response?.data?.message || '获取总结状态失败'); } }; const interval = setInterval(poll, 3000); setSummaryPollInterval(interval); poll(); }; const fetchSummaryHistory = async () => { try { const res = await apiClient.get(buildApiUrl(`/api/meetings/${meeting_id}/llm-tasks`)); const tasks = res.data.tasks || []; setSummaryHistory(tasks.filter(t => t.status === 'completed')); const latestRunningTask = tasks.find(t => ['pending', 'processing'].includes(t.status)); if (latestRunningTask) { startSummaryPolling(latestRunningTask.task_id); } else if (!activeSummaryTaskId) { setSummaryLoading(false); setSummaryTaskProgress(0); setSummaryTaskMessage(''); } } catch {} }; /* ══════════════════ 操作 ══════════════════ */ const handleTimeUpdate = () => { if (!audioRef.current) return; const timeMs = audioRef.current.currentTime * 1000; const idx = transcript.findIndex(i => timeMs >= i.start_time_ms && timeMs <= i.end_time_ms); if (idx !== -1 && idx !== currentHighlightIndex) { setCurrentHighlightIndex(idx); transcriptRefs.current[idx]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }; const jumpToTime = (ms) => { if (audioRef.current) { audioRef.current.currentTime = ms / 1000; audioRef.current.play(); } }; const handleUploadAudio = async (file) => { const formData = new FormData(); formData.append('audio_file', file); formData.append('meeting_id', meeting_id); formData.append('force_replace', 'true'); if (meeting?.prompt_id) { formData.append('prompt_id', String(meeting.prompt_id)); } if (selectedModelCode) { formData.append('model_code', selectedModelCode); } setIsUploading(true); try { await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData); message.success('音频上传成功'); fetchMeetingDetails(); } catch { message.error('上传失败'); } finally { setIsUploading(false); } }; const saveAccessPassword = async () => { const nextPassword = accessPasswordEnabled ? accessPasswordDraft.trim() : null; if (accessPasswordEnabled && !nextPassword) { message.warning('开启访问密码后,请先输入密码'); return; } setSavingAccessPassword(true); try { const res = await apiClient.put( buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meeting_id)), { password: nextPassword } ); const savedPassword = res.data?.password || null; setMeeting((prev) => (prev ? { ...prev, access_password: savedPassword } : prev)); setAccessPasswordEnabled(Boolean(savedPassword)); setAccessPasswordDraft(savedPassword || ''); message.success(res.message || '访问密码已更新'); } catch (error) { message.error(error?.response?.data?.message || '访问密码更新失败'); } finally { setSavingAccessPassword(false); } }; const copyAccessPassword = async () => { if (!accessPasswordDraft) { message.warning('当前没有可复制的访问密码'); return; } await navigator.clipboard.writeText(accessPasswordDraft); message.success('访问密码已复制'); }; const openAudioUploadPicker = () => { document.getElementById('audio-upload-input')?.click(); }; const startInlineSpeakerEdit = (speakerId, currentTag, segmentId) => { setInlineSpeakerEdit(speakerId); setInlineSpeakerEditSegmentId(`speaker-${speakerId}-${segmentId}`); setInlineSpeakerValue(currentTag || `发言人 ${speakerId}`); }; const cancelInlineSpeakerEdit = () => { setInlineSpeakerEdit(null); setInlineSpeakerEditSegmentId(null); setInlineSpeakerValue(''); }; const saveInlineSpeakerEdit = async () => { if (inlineSpeakerEdit == null) return; const nextTag = inlineSpeakerValue.trim(); if (!nextTag) { message.warning('发言人名称不能为空'); return; } setSavingInlineEdit(true); try { await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/speaker-tags/batch`), { updates: [{ speaker_id: inlineSpeakerEdit, new_tag: nextTag }] }); setTranscript(prev => prev.map(item => ( item.speaker_id === inlineSpeakerEdit ? { ...item, speaker_tag: nextTag } : item ))); setSpeakerList(prev => prev.map(item => ( item.speaker_id === inlineSpeakerEdit ? { ...item, speaker_tag: nextTag } : item ))); setEditingSpeakers(prev => ({ ...prev, [inlineSpeakerEdit]: nextTag })); message.success('发言人名称已更新'); cancelInlineSpeakerEdit(); } catch (error) { message.error(error?.response?.data?.message || '更新发言人名称失败'); } finally { setSavingInlineEdit(false); } }; const startInlineSegmentEdit = (segment) => { setInlineSegmentEditId(segment.segment_id); setInlineSegmentValue(segment.text_content || ''); }; const cancelInlineSegmentEdit = () => { setInlineSegmentEditId(null); setInlineSegmentValue(''); }; const saveInlineSegmentEdit = async () => { if (inlineSegmentEditId == null) return; setSavingInlineEdit(true); try { await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/transcript/batch`), { updates: [{ segment_id: inlineSegmentEditId, text_content: inlineSegmentValue }] }); setTranscript(prev => prev.map(item => ( item.segment_id === inlineSegmentEditId ? { ...item, text_content: inlineSegmentValue } : item ))); message.success('转录内容已更新'); cancelInlineSegmentEdit(); } catch (error) { message.error(error?.response?.data?.message || '更新转录内容失败'); } finally { setSavingInlineEdit(false); } }; const changePlaybackRate = (nextRate) => { setPlaybackRate(nextRate); if (audioRef.current) { audioRef.current.playbackRate = nextRate; } }; const handleStartTranscription = async () => { try { const res = await apiClient.post(buildApiUrl(`/api/meetings/${meeting_id}/transcription/start`)); if (res.data?.task_id) { message.success('转录任务已启动'); setTranscriptionStatus({ status: 'processing' }); startStatusPolling(res.data.task_id); } } catch (e) { message.error(e?.response?.data?.detail || '启动转录失败'); } }; const handleDeleteMeeting = () => { modal.confirm({ title: '删除会议', content: '确定要删除此会议吗?此操作无法撤销。', okText: '删除', okType: 'danger', onOk: async () => { await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting_id))); navigate('/dashboard'); } }); }; const generateSummary = async () => { if (isUploading) { message.warning('音频上传中,暂不允许重新总结'); return; } if (!hasUploadedAudio) { message.warning('请先上传音频后再总结'); return; } if (isTranscriptionRunning) { message.warning('转录进行中,暂不允许重新总结'); return; } setSummaryLoading(true); setSummaryTaskProgress(0); try { const res = await apiClient.post(buildApiUrl(`/api/meetings/${meeting_id}/generate-summary-async`), { user_prompt: userPrompt, prompt_id: selectedPromptId, model_code: selectedModelCode }); startSummaryPolling(res.data.task_id, { closeDrawerOnComplete: true }); } catch (error) { message.error(error?.response?.data?.message || '生成总结失败'); setSummaryLoading(false); } }; const openSummaryDrawer = () => { if (isUploading) { message.warning('音频上传中,暂不允许重新总结'); return; } if (!hasUploadedAudio) { message.warning('请先上传音频后再总结'); return; } if (isTranscriptionRunning) { message.warning('转录进行中,完成后会自动总结'); return; } setShowSummaryDrawer(true); fetchSummaryHistory(); }; const downloadSummaryMd = () => { if (!meeting?.summary) { message.warning('暂无总结内容'); return; } const blob = new Blob([meeting.summary], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${meeting.title || 'summary'}_总结.md`; a.click(); URL.revokeObjectURL(url); }; /* ── 转录编辑 Drawer ── */ const openTranscriptEditDrawer = (index) => { const segments = {}; for (let i = Math.max(0, index - 1); i <= Math.min(transcript.length - 1, index + 1); i++) { segments[transcript[i].segment_id] = { ...transcript[i] }; } setEditingSegments(segments); setEditingSegmentIndex(index); setShowTranscriptEditDrawer(true); }; const saveTranscriptEdits = async () => { try { const updates = Object.values(editingSegments).map(s => ({ segment_id: s.segment_id, text_content: s.text_content, })); await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/transcript/batch`), { updates }); message.success('转录内容已更新'); setShowTranscriptEditDrawer(false); fetchTranscript(); } catch { message.error('更新失败'); } }; /* ── 总结内容编辑 ── */ const openSummaryEditDrawer = () => { setEditingSummaryContent(meeting?.summary || ''); setIsEditingSummary(true); }; const saveSummaryContent = async () => { try { await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), { title: meeting.title, meeting_time: meeting.meeting_time, summary: editingSummaryContent, tags: meeting.tags?.map(t => t.name).join(',') || '', }); message.success('总结已保存'); setIsEditingSummary(false); fetchMeetingDetails(); } catch { message.error('保存失败'); } }; /* ── 更多操作菜单 ── */ const panelMoreMenuItems = [ { key: 'delete', icon: , label: '删除会议', danger: true, onClick: handleDeleteMeeting }, ]; const audioMoreMenuItems = [ { key: 'transcribe', icon: , label: '智能转录', disabled: !audioUrl || transcriptionStatus?.status === 'processing', onClick: handleStartTranscription }, { key: 'upload', icon: , label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker }, ]; const playbackRateMenuItems = [ { key: '0.75', label: '0.75x', onClick: () => changePlaybackRate(0.75) }, { key: '1', label: '1.0x', onClick: () => changePlaybackRate(1) }, { key: '1.25', label: '1.25x', onClick: () => changePlaybackRate(1.25) }, { key: '1.5', label: '1.5x', onClick: () => changePlaybackRate(1.5) }, { key: '2', label: '2.0x', onClick: () => changePlaybackRate(2) }, ]; /* ══════════════════ 渲染 ══════════════════ */ if (loading) return
; return (
{/* ── 标题 Header ── */}
{meeting.title} setEditDrawerOpen(true)} />
{tools.formatDateTime(meeting.meeting_time)} {meeting.attendees?.length ? meeting.attendees.map(a => typeof a === 'string' ? a : a.caption).join('、') : '未指定'}
{/* ── 转录进度条 ── */} {transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status) && (
正在转录中... {transcriptionProgress}%
)} {/* ── 隐藏的上传 input ── */} handleUploadAudio(file)} style={{ display: 'none' }} > {/* ── 主内容:左转录 右总结 ── */} {/* 左列: 语音转录 */} {/* 转录标题栏 */}
<AudioOutlined style={{ marginRight: 6 }} />语音转录
{/* 音频播放器 */}
{audioUrl ? (
{/* 转录时间轴 */}
{transcript.length > 0 ? ( { const isActive = currentHighlightIndex === index; return { label: ( {tools.formatDuration(item.start_time_ms / 1000)} ), dot: (
), children: (
transcriptRefs.current[index] = el} style={{ padding: '6px 10px', borderRadius: 8, background: isActive ? '#e6f4ff' : 'transparent', cursor: 'pointer', transition: 'background 0.2s', marginLeft: -4, }} onClick={() => jumpToTime(item.start_time_ms)} >
} style={{ backgroundColor: getSpeakerColor(item.speaker_id), flexShrink: 0 }} /> {inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === `speaker-${item.speaker_id}-${item.segment_id}` ? ( e.stopPropagation()}> setInlineSpeakerValue(e.target.value)} onPressEnter={saveInlineSpeakerEdit} style={{ width: 180 }} />
{inlineSegmentEditId === item.segment_id ? (
e.stopPropagation()}> setInlineSegmentValue(e.target.value)} onPressEnter={(e) => { if (e.ctrlKey || e.metaKey) { saveInlineSegmentEdit(); } }} />
) : ( { e.stopPropagation(); startInlineSegmentEdit(item); }} > {item.text_content} )}
), }; })} /> ) : ( )}
{/* 右列: AI 总结 / 思维导图 */} , children: (
{/* 操作栏 */}
{isEditingSummary ? ( ) : ( )}
{/* 内容区 */}
{isEditingSummary ? (
) : meeting.summary ? ( ) : ( )}
{/* 总结生成中进度条 */} {summaryLoading && (
{summaryTaskMessage || 'AI 正在思考中...'} {summaryTaskProgress}%
)}
), }, { key: 'mindmap', label: , children: (
{meeting.summary ? ( ) : ( )}
), }, ]} />
{/* ═══════════ Drawers ═══════════ */} {/* 总结生成 Drawer */} setShowSummaryDrawer(false)} destroyOnClose extra={ } > {/* 模板选择 */}
选择总结模板 {promptList.length ? promptList.map(p => { const isSelected = selectedPromptId === p.id; const isSystem = Number(p.is_system) === 1; return ( setSelectedPromptId(p.id)} style={{ borderRadius: 10, cursor: 'pointer', borderLeft: isSelected ? '4px solid #1677ff' : '4px solid transparent', borderColor: isSelected ? '#1677ff' : isSystem ? '#93c5fd' : undefined, background: isSelected ? '#e6f4ff' : isSystem ? '#eff6ff' : '#fff', }} > {isSelected && 已选} {p.name} {isSystem ? 系统 : 个人} {p.is_default ? }>默认 : null}
{/* LLM 模型选择 */}
选择 AI 模型
{/* 额外要求 */}
额外要求 (可选)