989 lines
32 KiB
JavaScript
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,
|
|
};
|
|
}
|