import { useCallback, useEffect, useRef, useState } from 'react'; import { App } from 'antd'; import { useNavigate, useParams } from 'react-router-dom'; import apiClient from '../utils/apiClient'; import configService from '../utils/configService'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import { uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService'; 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 generateRandomPassword = (length = 4) => { const charset = '0123456789'; return Array.from({ length }, () => charset[Math.floor(Math.random() * charset.length)]).join(''); }; const buildSpeakerState = (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 speakerList = Array.from(speakerMap.values()).sort((a, b) => a.speaker_id - b.speaker_id); const editingSpeakers = {}; speakerList.forEach((speaker) => { editingSpeakers[speaker.speaker_id] = speaker.speaker_tag; }); return { speakerList, editingSpeakers }; }; export default function useMeetingDetailsPage({ user }) { const { meeting_id: meetingId } = 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); 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); 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); const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024); 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; const clearSummaryBootstrapPolling = () => { if (summaryBootstrapTimeoutRef.current) { clearTimeout(summaryBootstrapTimeoutRef.current); summaryBootstrapTimeoutRef.current = null; } }; const loadAudioUploadConfig = useCallback(async () => { try { const nextMaxAudioSize = await configService.getMaxAudioSize(); setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024); } catch { setMaxAudioSize(100 * 1024 * 1024); } }, []); const fetchPromptList = useCallback(async () => { try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))); setPromptList(res.data.prompts || []); const defaultPrompt = res.data.prompts?.find((prompt) => prompt.is_default) || res.data.prompts?.[0]; if (defaultPrompt) { setSelectedPromptId(defaultPrompt.id); } } catch (error) { console.debug('加载提示词列表失败:', error); } }, []); const fetchLlmModels = useCallback(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 defaultModel = models.find((model) => model.is_default); if (defaultModel) { setSelectedModelCode(defaultModel.model_code); } } catch (error) { console.debug('加载模型列表失败:', error); } }, []); const fetchSummaryResources = useCallback(async () => { setSummaryResourcesLoading(true); try { await Promise.allSettled([ promptList.length > 0 ? Promise.resolve() : fetchPromptList(), llmModels.length > 0 ? Promise.resolve() : fetchLlmModels(), ]); } finally { setSummaryResourcesLoading(false); } }, [fetchLlmModels, fetchPromptList, llmModels.length, promptList.length]); const fetchTranscript = useCallback(async () => { setTranscriptLoading(true); try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meetingId))); const segments = Array.isArray(res.data) ? res.data : []; const speakerState = buildSpeakerState(segments); setTranscript(segments); setSpeakerList(speakerState.speakerList); setEditingSpeakers(speakerState.editingSpeakers); } catch { setTranscript([]); setSpeakerList([]); setEditingSpeakers({}); } finally { setTranscriptLoading(false); } }, [meetingId]); const fetchMeetingDetails = useCallback(async (options = {}) => { const { showPageLoading = true } = options; try { if (showPageLoading) { setLoading(true); } const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId))); const meetingData = response.data; setMeeting(meetingData); if (meetingData.prompt_id) { setSelectedPromptId(meetingData.prompt_id); } setAccessPasswordEnabled(Boolean(meetingData.access_password)); setAccessPasswordDraft(meetingData.access_password || ''); if (meetingData.transcription_status) { const nextStatus = meetingData.transcription_status; setTranscriptionStatus(nextStatus); setTranscriptionProgress(nextStatus.progress || 0); } 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)) { setSummaryLoading(false); } } else if (meetingData.transcription_status?.status === 'completed' && !meetingData.summary) { if (!activeSummaryTaskIdRef.current) { setSummaryLoading(true); setSummaryTaskProgress(0); setSummaryTaskMessage('转录完成,正在启动 AI 分析...'); } } 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(meetingId)}/stream`) : null); return meetingData; } catch { message.error('加载会议详情失败'); return null; } finally { if (showPageLoading) { setLoading(false); } } }, [meetingId, message]); const startStatusPolling = useCallback((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') { await fetchTranscript(); await fetchMeetingDetails({ showPageLoading: false }); } } } catch { clearInterval(interval); statusCheckIntervalRef.current = null; } }, 3000); statusCheckIntervalRef.current = interval; }, [fetchMeetingDetails, fetchTranscript]); const startSummaryPolling = useCallback((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); } await 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(); }, [fetchMeetingDetails, message]); const scheduleSummaryBootstrapPolling = useCallback((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(meetingId))); const meetingData = response.data; if (meetingData.llm_status?.task_id) { startSummaryPolling(meetingData.llm_status.task_id); return; } if (meetingData.llm_status || meetingData.summary) { await fetchMeetingDetails({ showPageLoading: false }); return; } } catch { if (attempt >= 9) { setSummaryLoading(false); setSummaryTaskMessage(''); return; } } scheduleSummaryBootstrapPolling(attempt + 1); }, attempt === 0 ? 1200 : 2000); }, [fetchMeetingDetails, meetingId, startSummaryPolling]); useEffect(() => { const bootstrapMeetingPage = async () => { const meetingData = await fetchMeetingDetails(); await fetchTranscript(); await loadAudioUploadConfig(); if (meetingData?.transcription_status?.task_id && ['pending', 'processing'].includes(meetingData.transcription_status.status)) { startStatusPolling(meetingData.transcription_status.task_id); } if (meetingData?.llm_status?.task_id && ['pending', 'processing'].includes(meetingData.llm_status.status)) { startSummaryPolling(meetingData.llm_status.task_id); } else if (meetingData?.transcription_status?.status === 'completed' && !meetingData.summary) { scheduleSummaryBootstrapPolling(); } }; bootstrapMeetingPage(); return () => { if (statusCheckIntervalRef.current) { clearInterval(statusCheckIntervalRef.current); } if (summaryPollIntervalRef.current) { clearInterval(summaryPollIntervalRef.current); } if (summaryBootstrapTimeoutRef.current) { clearTimeout(summaryBootstrapTimeoutRef.current); } }; }, [ fetchMeetingDetails, fetchTranscript, loadAudioUploadConfig, meetingId, scheduleSummaryBootstrapPolling, startStatusPolling, startSummaryPolling, ]); useEffect(() => { if (!showSummaryDrawer) { return; } if (promptList.length > 0 && llmModels.length > 0) { return; } fetchSummaryResources(); }, [fetchSummaryResources, llmModels.length, promptList.length, 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 validateAudioBeforeUpload = (file) => { const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService)); if (validationMessage) { message.warning(validationMessage); return validationMessage; } return null; }; const handleUploadAudio = async (file) => { const validationMessage = validateAudioBeforeUpload(file); if (validationMessage) { throw new Error(validationMessage); } setIsUploading(true); setUploadProgress(0); setUploadStatusMessage('正在上传音频文件...'); try { const response = await uploadMeetingAudio({ meetingId, file, promptId: meeting?.prompt_id, modelCode: selectedModelCode, onUploadProgress: (progressEvent) => { if (progressEvent.total) { setUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total))); } setUploadStatusMessage('正在上传音频文件...'); }, }); setUploadProgress(100); setUploadStatusMessage('上传完成,后台正在处理音频...'); message.success(response?.message || '音频上传成功,后台正在处理音频'); setTranscript([]); setSpeakerList([]); setEditingSpeakers({}); if (response?.data?.task_id) { const nextStatus = { task_id: response.data.task_id, status: 'processing', progress: 5 }; setTranscriptionStatus(nextStatus); setTranscriptionProgress(5); setMeeting((prev) => (prev ? { ...prev, transcription_status: nextStatus, llm_status: null, summary: null } : prev)); startStatusPolling(response.data.task_id); } } catch (error) { message.error(error?.response?.data?.message || error?.response?.data?.detail || '上传失败'); throw error; } finally { setIsUploading(false); setUploadProgress(0); setUploadStatusMessage(''); } }; const handleUploadAudioRequest = async ({ file, onSuccess, onError }) => { try { await handleUploadAudio(file); onSuccess?.({}, file); } catch (error) { onError?.(error); } }; const handleTimeUpdate = () => { if (!audioRef.current) { return; } const timeMs = audioRef.current.currentTime * 1000; const nextIndex = findTranscriptIndexByTime(transcript, timeMs); if (nextIndex !== -1 && nextIndex !== currentHighlightIndex) { setCurrentHighlightIndex(nextIndex); transcriptRefs.current[nextIndex]?.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 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(meetingId)), { 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; } setAccessPasswordDraft(''); setSavingAccessPassword(true); try { const res = await apiClient.put( buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meetingId)), { 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/${meetingId}/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/${meetingId}/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/${meetingId}/transcription/start`)); if (res.data?.task_id) { message.success('转录任务已启动'); setTranscriptionStatus({ status: 'processing' }); startStatusPolling(res.data.task_id); } } catch (error) { message.error(error?.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(meetingId))); 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/${meetingId}/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 anchor = document.createElement('a'); anchor.href = url; anchor.download = `${meeting.title || 'summary'}_总结.md`; anchor.click(); URL.revokeObjectURL(url); }; const saveTranscriptEdits = async () => { try { const updates = Object.values(editingSegments).map((segment) => ({ segment_id: segment.segment_id, text_content: segment.text_content, })); await apiClient.put(buildApiUrl(`/api/meetings/${meetingId}/transcript/batch`), { updates }); message.success('转录内容已更新'); setShowTranscriptEditDrawer(false); await 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(meetingId)), { title: meeting.title, meeting_time: meeting.meeting_time, summary: editingSummaryContent, tags: meeting.tags?.map((tag) => tag.name).join(',') || '', }); message.success('总结已保存'); setMeeting((prev) => (prev ? { ...prev, summary: editingSummaryContent } : prev)); setIsEditingSummary(false); } catch { message.error('保存失败'); } }; const saveSpeakerTags = async () => { const updates = Object.entries(editingSpeakers).map(([id, tag]) => ({ speaker_id: parseInt(id, 10), new_tag: tag, })); await apiClient.put(buildApiUrl(`/api/meetings/${meetingId}/speaker-tags/batch`), { updates }); setShowSpeakerDrawer(false); await fetchTranscript(); message.success('更新成功'); }; return { meetingId, meeting, loading, transcript, transcriptLoading, audioUrl, editingSpeakers, setEditingSpeakers, speakerList, transcriptionStatus, currentHighlightIndex, showSummaryDrawer, setShowSummaryDrawer, summaryLoading, summaryResourcesLoading, userPrompt, setUserPrompt, promptList, selectedPromptId, setSelectedPromptId, summaryTaskProgress, summaryTaskMessage, llmModels, selectedModelCode, setSelectedModelCode, showSpeakerDrawer, setShowSpeakerDrawer, viewingPrompt, setViewingPrompt, editDrawerOpen, setEditDrawerOpen, showQRModal, setShowQRModal, isUploading, displayUploadProgress, uploadStatusMessage, playbackRate, accessPasswordEnabled, accessPasswordDraft, setAccessPasswordDraft, savingAccessPassword, showTranscriptEditDrawer, setShowTranscriptEditDrawer, editingSegments, setEditingSegments, isEditingSummary, setIsEditingSummary, editingSummaryContent, setEditingSummaryContent, inlineSpeakerEdit, inlineSpeakerEditSegmentId, inlineSpeakerValue, setInlineSpeakerValue, inlineSegmentEditId, inlineSegmentValue, setInlineSegmentValue, savingInlineEdit, transcriptVisibleCount, audioRef, transcriptRefs, isMeetingOwner, creatorName, isTranscriptionRunning, isSummaryRunning, displayTranscriptionProgress, displaySummaryProgress, summaryDisabledReason, isSummaryActionDisabled, validateAudioBeforeUpload, handleUploadAudioRequest, fetchMeetingDetails, handleTimeUpdate, handleTranscriptScroll, jumpToTime, saveAccessPassword, handleAccessPasswordSwitchChange, copyAccessPassword, openAudioUploadPicker, startInlineSpeakerEdit, saveInlineSpeakerEdit, cancelInlineSpeakerEdit, startInlineSegmentEdit, saveInlineSegmentEdit, cancelInlineSegmentEdit, changePlaybackRate, handleStartTranscription, handleDeleteMeeting, generateSummary, openSummaryDrawer, downloadSummaryMd, saveTranscriptEdits, openSummaryEditDrawer, saveSummaryContent, saveSpeakerTags, }; }