imetting/frontend/src/hooks/useMeetingDetailsPage.js

989 lines
32 KiB
JavaScript

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,
};
}