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, Tabs, Input, Upload, Empty, Drawer, Select, Switch
} from 'antd';
import {
ClockCircleOutlined, UserOutlined, TeamOutlined,
EditOutlined, DeleteOutlined,
SettingOutlined, FireOutlined, SyncOutlined,
UploadOutlined, QrcodeOutlined,
EyeOutlined, FileTextOutlined, PartitionOutlined,
SaveOutlined, CloseOutlined,
StarFilled, RobotOutlined, DownloadOutlined,
CheckOutlined,
MoreOutlined, AudioOutlined, CopyOutlined
} from '@ant-design/icons';
import MarkdownRenderer from '../components/MarkdownRenderer';
import MarkdownEditor from '../components/MarkdownEditor';
import MindMap from '../components/MindMap';
import ActionButton from '../components/ActionButton';
import AudioPlayerBar from '../components/AudioPlayerBar';
import TranscriptTimeline from '../components/TranscriptTimeline';
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 getSummaryDisplayContent = (content) => {
if (!content) return content;
return content.replace(/^\s*#\s*(会议总结|AI总结|AI 总结)\s*\n+/i, '');
};
const generateRandomPassword = (length = 4) => {
const charset = '0123456789';
return Array.from({ length }, () => charset[Math.floor(Math.random() * charset.length)]).join('');
};
const TRANSCRIPT_INITIAL_RENDER_COUNT = 80;
const TRANSCRIPT_RENDER_STEP = 120;
const findTranscriptIndexByTime = (segments, timeMs) => {
let left = 0;
let right = segments.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const segment = segments[mid];
if (timeMs < segment.start_time_ms) {
right = mid - 1;
} else if (timeMs > segment.end_time_ms) {
left = mid + 1;
} else {
return mid;
}
}
return -1;
};
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 [transcriptLoading, setTranscriptLoading] = useState(false);
const [audioUrl, setAudioUrl] = useState(null);
// 发言人
const [editingSpeakers, setEditingSpeakers] = useState({});
const [speakerList, setSpeakerList] = useState([]);
// 转录状态
const [transcriptionStatus, setTranscriptionStatus] = useState(null);
const [transcriptionProgress, setTranscriptionProgress] = useState(0);
const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1);
// AI 总结
const [showSummaryDrawer, setShowSummaryDrawer] = useState(false);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryResourcesLoading, setSummaryResourcesLoading] = useState(false);
const [userPrompt, setUserPrompt] = useState('');
const [promptList, setPromptList] = useState([]);
const [selectedPromptId, setSelectedPromptId] = useState(null);
const [summaryTaskProgress, setSummaryTaskProgress] = useState(0);
const [summaryTaskMessage, setSummaryTaskMessage] = useState('');
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 [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatusMessage, setUploadStatusMessage] = useState('');
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 [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 [transcriptVisibleCount, setTranscriptVisibleCount] = useState(TRANSCRIPT_INITIAL_RENDER_COUNT);
const audioRef = useRef(null);
const transcriptRefs = useRef([]);
const statusCheckIntervalRef = useRef(null);
const summaryPollIntervalRef = useRef(null);
const summaryBootstrapTimeoutRef = useRef(null);
const activeSummaryTaskIdRef = useRef(null);
const isMeetingOwner = user?.user_id === meeting?.creator_id;
const creatorName = meeting?.creator_username || '未知创建人';
const hasUploadedAudio = Boolean(audioUrl);
const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status);
const isSummaryRunning = summaryLoading;
const displayUploadProgress = Math.max(0, Math.min(uploadProgress, 100));
const displayTranscriptionProgress = Math.max(0, Math.min(transcriptionProgress, 100));
const displaySummaryProgress = Math.max(0, Math.min(summaryTaskProgress, 100));
const summaryDisabledReason = isUploading
? '音频上传中,暂不允许重新总结'
: !hasUploadedAudio
? '请先上传音频后再总结'
: isTranscriptionRunning
? '转录进行中,完成后会自动总结'
: '';
const isSummaryActionDisabled = isUploading || !hasUploadedAudio || isTranscriptionRunning || summaryLoading;
/* ══════════════════ 数据获取 ══════════════════ */
useEffect(() => {
fetchMeetingDetails();
fetchTranscript();
return () => {
if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current);
if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current);
if (summaryBootstrapTimeoutRef.current) clearTimeout(summaryBootstrapTimeoutRef.current);
};
}, [meeting_id]);
useEffect(() => {
if (!showSummaryDrawer) {
return;
}
if (promptList.length > 0 && llmModels.length > 0) {
return;
}
fetchSummaryResources();
}, [showSummaryDrawer]);
useEffect(() => {
transcriptRefs.current = [];
setTranscriptVisibleCount(Math.min(transcript.length, TRANSCRIPT_INITIAL_RENDER_COUNT));
}, [transcript]);
useEffect(() => {
if (currentHighlightIndex < 0 || currentHighlightIndex < transcriptVisibleCount || transcriptVisibleCount >= transcript.length) {
return;
}
setTranscriptVisibleCount((prev) => Math.min(
transcript.length,
Math.max(prev + TRANSCRIPT_RENDER_STEP, currentHighlightIndex + 20)
));
}, [currentHighlightIndex, transcript.length, transcriptVisibleCount]);
useEffect(() => {
if (currentHighlightIndex < 0) {
return;
}
transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [currentHighlightIndex, transcriptVisibleCount]);
const clearSummaryBootstrapPolling = () => {
if (summaryBootstrapTimeoutRef.current) {
clearTimeout(summaryBootstrapTimeoutRef.current);
summaryBootstrapTimeoutRef.current = null;
}
};
const applyMeetingDetailsState = (meetingData, options = {}) => {
const { allowSummaryBootstrap = true } = options;
setMeeting(meetingData);
if (meetingData.prompt_id) {
setSelectedPromptId(meetingData.prompt_id);
}
setAccessPasswordEnabled(Boolean(meetingData.access_password));
setAccessPasswordDraft(meetingData.access_password || '');
if (meetingData.transcription_status) {
const ts = meetingData.transcription_status;
setTranscriptionStatus(ts);
setTranscriptionProgress(ts.progress || 0);
if (['pending', 'processing'].includes(ts.status) && ts.task_id) {
startStatusPolling(ts.task_id);
}
} else {
setTranscriptionStatus(null);
setTranscriptionProgress(0);
}
if (meetingData.llm_status) {
const llmStatus = meetingData.llm_status;
clearSummaryBootstrapPolling();
setSummaryTaskProgress(llmStatus.progress || 0);
setSummaryTaskMessage(
llmStatus.message
|| (llmStatus.status === 'processing'
? 'AI 正在分析会议内容...'
: llmStatus.status === 'pending'
? 'AI 总结任务排队中...'
: '')
);
if (['pending', 'processing'].includes(llmStatus.status) && llmStatus.task_id) {
startSummaryPolling(llmStatus.task_id);
} else {
setSummaryLoading(false);
}
} else if (meetingData.transcription_status?.status === 'completed' && !meetingData.summary) {
if (!activeSummaryTaskIdRef.current) {
setSummaryLoading(true);
setSummaryTaskProgress(0);
setSummaryTaskMessage('转录完成,正在启动 AI 分析...');
}
if (allowSummaryBootstrap) {
scheduleSummaryBootstrapPolling();
}
} else {
clearSummaryBootstrapPolling();
if (!activeSummaryTaskIdRef.current) {
setSummaryLoading(false);
setSummaryTaskProgress(0);
setSummaryTaskMessage('');
}
}
const hasAudioFile = Boolean(meetingData.audio_file_path && String(meetingData.audio_file_path).length > 5);
setAudioUrl(hasAudioFile ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`) : null);
};
const scheduleSummaryBootstrapPolling = (attempt = 0) => {
if (summaryPollIntervalRef.current || activeSummaryTaskIdRef.current) {
return;
}
clearSummaryBootstrapPolling();
if (attempt >= 10) {
setSummaryLoading(false);
setSummaryTaskMessage('');
return;
}
summaryBootstrapTimeoutRef.current = setTimeout(async () => {
summaryBootstrapTimeoutRef.current = null;
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
const meetingData = response.data;
applyMeetingDetailsState(meetingData, { allowSummaryBootstrap: false });
if (meetingData.llm_status || meetingData.summary) {
return;
}
} catch {
if (attempt >= 9) {
setSummaryLoading(false);
setSummaryTaskMessage('');
return;
}
}
scheduleSummaryBootstrapPolling(attempt + 1);
}, attempt === 0 ? 1200 : 2000);
};
const fetchMeetingDetails = async (options = {}) => {
const { showPageLoading = true } = options;
try {
if (showPageLoading) {
setLoading(true);
}
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
applyMeetingDetailsState(response.data);
return response.data;
} catch {
message.error('加载会议详情失败');
return null;
} finally {
if (showPageLoading) {
setLoading(false);
}
}
};
const fetchTranscript = async () => {
setTranscriptLoading(true);
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id)));
const segments = Array.isArray(res.data) ? res.data : [];
setTranscript(segments);
const speakerMap = new Map();
segments.forEach((segment) => {
if (segment?.speaker_id == null || speakerMap.has(segment.speaker_id)) {
return;
}
speakerMap.set(segment.speaker_id, {
speaker_id: segment.speaker_id,
speaker_tag: segment.speaker_tag || `发言人 ${segment.speaker_id}`,
});
});
const list = Array.from(speakerMap.values()).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([]);
setSpeakerList([]);
setEditingSpeakers({});
} finally {
setTranscriptLoading(false);
}
};
const startStatusPolling = (taskId) => {
if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current);
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);
setMeeting(prev => (prev ? { ...prev, transcription_status: status } : prev));
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
clearInterval(interval);
statusCheckIntervalRef.current = null;
if (status.status === 'completed') {
fetchTranscript();
fetchMeetingDetails({ showPageLoading: false });
}
}
} catch {
clearInterval(interval);
statusCheckIntervalRef.current = null;
}
}, 3000);
statusCheckIntervalRef.current = 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 (error) {
console.debug('加载提示词列表失败:', error);
}
};
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 (error) {
console.debug('加载模型列表失败:', error);
}
};
const fetchSummaryResources = async () => {
setSummaryResourcesLoading(true);
try {
await Promise.allSettled([
promptList.length > 0 ? Promise.resolve() : fetchPromptList(),
llmModels.length > 0 ? Promise.resolve() : fetchLlmModels(),
]);
} finally {
setSummaryResourcesLoading(false);
}
};
const startSummaryPolling = (taskId, options = {}) => {
const { closeDrawerOnComplete = false } = options;
if (!taskId) return;
if (summaryPollIntervalRef.current && activeSummaryTaskIdRef.current === taskId) return;
if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current);
activeSummaryTaskIdRef.current = 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 || 'AI 正在分析会议内容...');
setMeeting(prev => (prev ? { ...prev, llm_status: status } : prev));
if (status.status === 'completed') {
clearInterval(interval);
summaryPollIntervalRef.current = null;
activeSummaryTaskIdRef.current = null;
setSummaryLoading(false);
if (closeDrawerOnComplete) {
setShowSummaryDrawer(false);
}
fetchMeetingDetails({ showPageLoading: false });
} else if (status.status === 'failed') {
clearInterval(interval);
summaryPollIntervalRef.current = null;
activeSummaryTaskIdRef.current = null;
setSummaryLoading(false);
message.error(status.error_message || '生成总结失败');
}
} catch (error) {
clearInterval(interval);
summaryPollIntervalRef.current = null;
activeSummaryTaskIdRef.current = null;
setSummaryLoading(false);
message.error(error?.response?.data?.message || '获取总结状态失败');
}
};
const interval = setInterval(poll, 3000);
summaryPollIntervalRef.current = interval;
poll();
};
/* ══════════════════ 操作 ══════════════════ */
const handleTimeUpdate = () => {
if (!audioRef.current) return;
const timeMs = audioRef.current.currentTime * 1000;
const idx = findTranscriptIndexByTime(transcript, timeMs);
if (idx !== -1 && idx !== currentHighlightIndex) {
setCurrentHighlightIndex(idx);
transcriptRefs.current[idx]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
const handleTranscriptScroll = (event) => {
if (transcriptVisibleCount >= transcript.length) {
return;
}
const { scrollTop, clientHeight, scrollHeight } = event.currentTarget;
if (scrollHeight - scrollTop - clientHeight > 240) {
return;
}
setTranscriptVisibleCount((prev) => Math.min(transcript.length, prev + TRANSCRIPT_RENDER_STEP));
};
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);
setUploadProgress(0);
setUploadStatusMessage('正在上传音频文件...');
try {
await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
setUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total)));
}
setUploadStatusMessage('正在上传音频文件...');
}
});
setUploadProgress(100);
setUploadStatusMessage('上传完成,正在启动转录任务...');
message.success('音频上传成功');
setTranscript([]);
setSpeakerList([]);
setEditingSpeakers({});
await fetchMeetingDetails({ showPageLoading: false });
await fetchTranscript();
} catch (error) {
message.error(error?.response?.data?.message || error?.response?.data?.detail || '上传失败');
throw error;
} finally {
setIsUploading(false);
setUploadProgress(0);
setUploadStatusMessage('');
}
};
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 handleAccessPasswordSwitchChange = async (checked) => {
setAccessPasswordEnabled(checked);
if (checked) {
const existingPassword = (meeting?.access_password || accessPasswordDraft || '').trim();
setAccessPasswordDraft(existingPassword || generateRandomPassword());
return;
}
if (!checked) {
setAccessPasswordDraft('');
setSavingAccessPassword(true);
try {
const res = await apiClient.put(
buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meeting_id)),
{ password: null }
);
setMeeting((prev) => (prev ? { ...prev, access_password: null } : prev));
message.success(res.message || '访问密码已关闭');
} catch (error) {
setAccessPasswordEnabled(true);
setAccessPasswordDraft(meeting?.access_password || '');
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 = () => {
if (!isMeetingOwner) {
message.warning('仅会议创建人可删除会议');
return;
}
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 (!isMeetingOwner) {
message.warning('仅会议创建人可重新总结');
return;
}
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 (!isMeetingOwner) {
message.warning('仅会议创建人可重新总结');
return;
}
if (isUploading) {
message.warning('音频上传中,暂不允许重新总结');
return;
}
if (!hasUploadedAudio) {
message.warning('请先上传音频后再总结');
return;
}
if (isTranscriptionRunning) {
message.warning('转录进行中,完成后会自动总结');
return;
}
setShowSummaryDrawer(true);
};
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);
};
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 (error) {
console.debug('批量更新转录失败:', error);
message.error('更新失败');
}
};
/* ── 总结内容编辑 ── */
const openSummaryEditDrawer = () => {
if (!isMeetingOwner) {
message.warning('仅会议创建人可编辑总结');
return;
}
setEditingSummaryContent(meeting?.summary || '');
setIsEditingSummary(true);
};
const saveSummaryContent = async () => {
if (!isMeetingOwner) {
message.warning('仅会议创建人可编辑总结');
return;
}
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('总结已保存');
setMeeting(prev => (prev ? { ...prev, summary: editingSummaryContent } : prev));
setIsEditingSummary(false);
} catch { message.error('保存失败'); }
};
/* ── 更多操作菜单 ── */
const panelMoreMenuItems = [
{ key: 'delete', icon: