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: