imetting/frontend/src/pages/MeetingDetails.jsx

1413 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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';
import configService from '../utils/configService';
import { AUDIO_UPLOAD_ACCEPT, uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService';
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);
const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024);
// 转录编辑 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;
/* ══════════════════ 数据获取 ══════════════════ */
// The initial bootstrap should run only when the meeting id changes.
// Polling helpers are intentionally excluded here to avoid restarting intervals on every render.
useEffect(() => {
fetchMeetingDetails();
fetchTranscript();
loadAudioUploadConfig();
return () => {
if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current);
if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current);
if (summaryBootstrapTimeoutRef.current) clearTimeout(summaryBootstrapTimeoutRef.current);
};
}, [meeting_id]); // eslint-disable-line react-hooks/exhaustive-deps
const loadAudioUploadConfig = async () => {
try {
const nextMaxAudioSize = await configService.getMaxAudioSize();
setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024);
} catch {
setMaxAudioSize(100 * 1024 * 1024);
}
};
// Summary resources are loaded lazily when the drawer opens; the existing prompt/model caches gate repeat fetches.
useEffect(() => {
if (!showSummaryDrawer) {
return;
}
if (promptList.length > 0 && llmModels.length > 0) {
return;
}
fetchSummaryResources();
}, [showSummaryDrawer]); // eslint-disable-line react-hooks/exhaustive-deps
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 validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
if (validationMessage) {
message.warning(validationMessage);
throw new Error(validationMessage);
}
setIsUploading(true);
setUploadProgress(0);
setUploadStatusMessage('正在上传音频文件...');
try {
await uploadMeetingAudio({
meetingId: meeting_id,
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('音频上传成功');
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: <DeleteOutlined />, label: '删除会议', danger: true, onClick: handleDeleteMeeting },
];
const audioMoreMenuItems = [
{ key: 'transcribe', icon: <AudioOutlined />, label: '智能转录', disabled: !audioUrl || isUploading || transcriptionStatus?.status === 'processing', onClick: handleStartTranscription },
{ key: 'upload', icon: <UploadOutlined />, label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker },
];
/* ══════════════════ 渲染 ══════════════════ */
if (loading) return <div style={{ textAlign: 'center', padding: '100px' }}><Spin size="large" tip="正在加载..." /></div>;
return (
<div>
{/* ── 标题 Header ── */}
<Card
variant="borderless"
className="console-surface"
style={{ marginBottom: 16 }}
styles={{ body: { padding: '16px 24px' } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Title level={3} style={{ margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{meeting.title}
</Title>
{isMeetingOwner ? (
<ActionButton tone="edit" variant="iconSm" tooltip="编辑会议" icon={<EditOutlined />} style={{ flexShrink: 0 }} onClick={() => setEditDrawerOpen(true)} />
) : null}
</div>
<Space size={16} wrap>
<Text type="secondary"><ClockCircleOutlined style={{ marginRight: 4 }} />{tools.formatDateTime(meeting.meeting_time)}</Text>
<Text type="secondary">
<UserOutlined style={{ marginRight: 4 }} />
创建人{creatorName}
</Text>
<Text type="secondary">
<TeamOutlined style={{ marginRight: 4 }} />
参会人
{meeting.attendees?.length
? meeting.attendees.map(a => typeof a === 'string' ? a : a.caption).join('、')
: '未指定'}
</Text>
</Space>
</div>
<Space style={{ flexShrink: 0 }}>
{isMeetingOwner ? (
<Tooltip title={summaryDisabledReason}>
<span>
<Button className="btn-pill-secondary" icon={<SyncOutlined />} onClick={openSummaryDrawer} disabled={isSummaryActionDisabled}>
重新总结
</Button>
</span>
</Tooltip>
) : null}
<Button className="btn-pill-secondary" icon={<DownloadOutlined />} onClick={downloadSummaryMd}>下载总结</Button>
<Tooltip title="分享二维码">
<Button className="btn-pill-secondary" icon={<QrcodeOutlined />} onClick={() => setShowQRModal(true)} />
</Tooltip>
{isMeetingOwner ? (
<Dropdown menu={{ items: panelMoreMenuItems }} trigger={['click']}>
<Button className="btn-pill-secondary" icon={<MoreOutlined />} />
</Dropdown>
) : null}
</Space>
</div>
</Card>
{isUploading && (
<Card
variant="borderless"
className="console-surface"
style={{ marginBottom: 16, border: '1px solid #91caff', background: 'linear-gradient(135deg, #f0f9ff 0%, #ffffff 100%)' }}
styles={{ body: { padding: '14px 24px' } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Space size={10}>
<UploadOutlined style={{ color: '#1677ff', fontSize: 16 }} />
<div>
<Text strong style={{ display: 'block' }}>音频上传中</Text>
<Text type="secondary">{uploadStatusMessage || '正在上传音频文件...'}</Text>
</div>
</Space>
<Text strong style={{ color: '#1677ff' }}>{displayUploadProgress}%</Text>
</div>
<Progress percent={displayUploadProgress} status="active" strokeColor={{ from: '#69b1ff', to: '#1677ff' }} />
</Card>
)}
{/* ── 转录进度条 ── */}
{transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status) && (
<Card variant="borderless" className="console-surface" style={{ marginBottom: 16 }} styles={{ body: { padding: '12px 24px' } }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text><SyncOutlined spin style={{ marginRight: 8 }} />正在转录中...</Text>
<Text>{displayTranscriptionProgress}%</Text>
</div>
<Progress percent={displayTranscriptionProgress} status="active" size="small" />
</Card>
)}
{isSummaryRunning && (
<Card
variant="borderless"
className="console-surface"
style={{ marginBottom: 16, border: '1px solid #d3adf7', background: 'linear-gradient(135deg, #f9f0ff 0%, #ffffff 100%)' }}
styles={{ body: { padding: '14px 24px' } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Space size={10}>
<RobotOutlined style={{ color: '#722ed1', fontSize: 16 }} />
<div>
<Text strong style={{ display: 'block' }}>AI 正在分析会议内容</Text>
<Text type="secondary">{summaryTaskMessage || '正在生成会议总结,请稍候...'}</Text>
</div>
</Space>
<Text strong style={{ color: '#722ed1' }}>{displaySummaryProgress}%</Text>
</div>
<Progress percent={displaySummaryProgress} status="active" strokeColor={{ from: '#b37feb', to: '#722ed1' }} />
</Card>
)}
{/* ── 隐藏的上传 input ── */}
<Upload
id="audio-upload-input"
showUploadList={false}
accept={AUDIO_UPLOAD_ACCEPT}
beforeUpload={(file) => {
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
if (validationMessage) {
message.warning(validationMessage);
return Upload.LIST_IGNORE;
}
return true;
}}
customRequest={async ({ file, onSuccess, onError }) => {
try {
await handleUploadAudio(file);
onSuccess?.({}, file);
} catch (error) {
onError?.(error);
}
}}
style={{ display: 'none' }}
>
<span />
</Upload>
{/* ── 主内容:左转录 右总结 ── */}
<Row gutter={16}>
{/* 左列: 语音转录 */}
<Col xs={24} lg={10}>
<Card
variant="borderless"
className="console-surface"
style={{ height: 'calc(100vh - 220px)', display: 'flex', flexDirection: 'column' }}
styles={{ body: { display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', padding: 0 } }}
>
{/* 转录标题栏 */}
<div style={{ padding: '14px 20px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={5} style={{ margin: 0 }}><AudioOutlined style={{ marginRight: 6 }} />语音转录</Title>
{isMeetingOwner ? (
<ActionButton tone="edit" variant="textSm" icon={<SettingOutlined />} onClick={() => setShowSpeakerDrawer(true)}>标签</ActionButton>
) : null}
</div>
{/* 音频播放器 */}
<div style={{ padding: '8px 20px 0' }}>
<AudioPlayerBar
audioRef={audioRef}
src={audioUrl}
playbackRate={playbackRate}
onPlaybackRateChange={changePlaybackRate}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => {
if (audioRef.current) {
audioRef.current.playbackRate = playbackRate;
}
}}
moreMenuItems={audioMoreMenuItems}
moreButtonDisabled={!isMeetingOwner}
emptyText="暂无音频,可通过'更多'上传音频"
/>
</div>
{/* 转录时间轴 */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', minHeight: 0 }}>
<TranscriptTimeline
transcript={transcript}
loading={transcriptLoading}
visibleCount={transcriptVisibleCount}
currentHighlightIndex={currentHighlightIndex}
onJumpToTime={jumpToTime}
onScroll={handleTranscriptScroll}
transcriptRefs={transcriptRefs}
getSpeakerColor={getSpeakerColor}
showRenderHint
fillHeight
editable
isMeetingOwner={isMeetingOwner}
editing={{
inlineSpeakerEdit,
inlineSpeakerEditSegmentId,
inlineSpeakerValue,
setInlineSpeakerValue,
startInlineSpeakerEdit,
saveInlineSpeakerEdit,
cancelInlineSpeakerEdit,
inlineSegmentEditId,
inlineSegmentValue,
setInlineSegmentValue,
startInlineSegmentEdit,
saveInlineSegmentEdit,
cancelInlineSegmentEdit,
savingInlineEdit,
}}
/>
</div>
</Card>
</Col>
{/* 右列: AI 总结 / 思维导图 */}
<Col xs={24} lg={14}>
<Card
variant="borderless"
className="console-surface"
style={{ height: 'calc(100vh - 220px)', display: 'flex', flexDirection: 'column' }}
styles={{ body: { display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', padding: '12px 0 0' } }}
>
<Tabs
tabPosition="left"
className="console-tabs console-tabs-left"
style={{ flex: 1, overflow: 'hidden' }}
items={[
{
key: 'summary',
label: <Tooltip title="AI 总结" placement="right"><FileTextOutlined style={{ fontSize: 18, margin: 0 }} /></Tooltip>,
children: (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 268px)' }}>
{/* 操作栏 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0 16px 8px', flexShrink: 0 }}>
{isMeetingOwner ? (
isEditingSummary ? (
<Space>
<Button className="btn-pill-secondary" icon={<CloseOutlined />} onClick={() => setIsEditingSummary(false)}>取消</Button>
<Button type="primary" className="btn-pill-primary" icon={<SaveOutlined />} onClick={saveSummaryContent}>保存</Button>
</Space>
) : (
<ActionButton tone="edit" variant="textSm" icon={<EditOutlined />} onClick={openSummaryEditDrawer}>编辑</ActionButton>
)
) : null}
</div>
{/* 内容区 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px 16px' }}>
{isEditingSummary ? (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<MarkdownEditor
value={editingSummaryContent}
onChange={setEditingSummaryContent}
height={window.innerHeight - 380}
placeholder="在这里编写总结内容(支持 Markdown..."
showImageUpload={false}
/>
</div>
) : meeting.summary ? (
<MarkdownRenderer content={getSummaryDisplayContent(meeting.summary)} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description='暂无 AI 总结,点击"重新总结"按钮生成'
style={{ marginTop: 80 }}
/>
)}
</div>
</div>
),
},
{
key: 'mindmap',
label: <Tooltip title="思维导图" placement="right"><PartitionOutlined style={{ fontSize: 18, margin: 0 }} /></Tooltip>,
children: (
<div style={{ height: 'calc(100vh - 268px)', background: '#f8fafc', borderRadius: 12, overflow: 'hidden' }}>
{meeting.summary ? (
<MindMap content={meeting.summary} title={meeting.title} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无总结内容" style={{ marginTop: 80 }} />
)}
</div>
),
},
]}
/>
</Card>
</Col>
</Row>
{/* ═══════════ Drawers ═══════════ */}
{/* 总结生成 Drawer */}
<Drawer
title="AI 智能总结"
placement="right"
width={720}
open={showSummaryDrawer}
onClose={() => setShowSummaryDrawer(false)}
destroyOnClose
extra={
isMeetingOwner ? (
<Space>
<Button type="primary" className="btn-pill-primary" icon={<FireOutlined />} loading={summaryLoading} onClick={generateSummary}>生成总结</Button>
</Space>
) : null
}
>
{/* 模板选择 */}
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: 'block', marginBottom: 12 }}>选择总结模板</Text>
<Space direction="vertical" style={{ width: '100%' }}>
{summaryResourcesLoading ? <Spin tip="正在加载可用模板..." /> : null}
{promptList.length ? promptList.map(p => {
const isSelected = selectedPromptId === p.id;
const isSystem = Number(p.is_system) === 1;
return (
<Card
key={p.id}
size="small"
hoverable
onClick={() => setSelectedPromptId(p.id)}
style={{
borderRadius: 10,
cursor: 'pointer',
borderLeft: isSelected ? '4px solid #1677ff' : '4px solid transparent',
borderColor: isSelected ? '#1677ff' : isSystem ? '#93c5fd' : undefined,
background: isSelected ? '#e6f4ff' : isSystem ? '#eff6ff' : '#fff',
}}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Space>
{isSelected && <Tag color="blue">已选</Tag>}
<Text strong>{p.name}</Text>
</Space>
<Space>
{isSystem ? <Tag color="blue">系统</Tag> : <Tag></Tag>}
{p.is_default ? <Tag color="gold" icon={<StarFilled />}>默认</Tag> : null}
<ActionButton tone="view" variant="iconSm" tooltip="查看模板" icon={<EyeOutlined />} onClick={e => { e.stopPropagation(); setViewingPrompt(p); }} />
</Space>
</Space>
{p.desc && <Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>{p.desc}</Typography.Paragraph>}
</Space>
</Card>
);
}) : <Empty description="暂无可用模板" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</Space>
</div>
{/* LLM 模型选择 */}
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
<RobotOutlined style={{ marginRight: 4 }} />选择 AI 模型
</Text>
<Select
style={{ width: '100%' }}
value={selectedModelCode}
onChange={setSelectedModelCode}
placeholder="选择模型(默认使用系统配置)"
allowClear
loading={summaryResourcesLoading}
>
{llmModels.map(m => (
<Select.Option key={m.model_code} value={m.model_code}>
<Space>
{m.model_name}
<Text type="secondary" style={{ fontSize: 12 }}>{m.provider}</Text>
{m.is_default ? <Tag color="gold" className="console-tag-compact">默认</Tag> : null}
</Space>
</Select.Option>
))}
</Select>
</div>
{/* 额外要求 */}
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>额外要求 (可选)</Text>
<TextArea
rows={3}
value={userPrompt}
onChange={e => setUserPrompt(e.target.value)}
placeholder="例如:请重点分析会议中的决策事项..."
/>
</div>
{/* 生成进度 */}
{summaryLoading && (
<Card variant="borderless" style={{ borderRadius: 12, background: '#f6f8fa' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text><SyncOutlined spin style={{ marginRight: 8 }} />{summaryTaskMessage || 'AI 正在思考中...'}</Text>
<Text>{summaryTaskProgress}%</Text>
</div>
<Progress percent={summaryTaskProgress} status="active" />
</Card>
)}
</Drawer>
{/* 发言人标签 Drawer */}
<Drawer
title="编辑发言人标签"
placement="right"
width={480}
open={showSpeakerDrawer}
onClose={() => setShowSpeakerDrawer(false)}
destroyOnClose
extra={
isMeetingOwner ? (
<Space>
<Button type="primary" className="btn-pill-primary" icon={<SaveOutlined />} onClick={async () => {
const updates = Object.entries(editingSpeakers).map(([id, tag]) => ({ speaker_id: parseInt(id), new_tag: tag }));
await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/speaker-tags/batch`), { updates });
setShowSpeakerDrawer(false);
fetchTranscript();
message.success('更新成功');
}}>保存</Button>
</Space>
) : null
}
>
<List
dataSource={speakerList}
renderItem={s => (
<List.Item>
<Space style={{ width: '100%' }}>
<Avatar size={28} icon={<UserOutlined />} style={{ backgroundColor: getSpeakerColor(s.speaker_id) }} />
<Text style={{ width: 80 }}>发言人 {s.speaker_id}</Text>
<Input
style={{ flex: 1 }}
value={editingSpeakers[s.speaker_id]}
onChange={e => setEditingSpeakers({ ...editingSpeakers, [s.speaker_id]: e.target.value })}
/>
</Space>
</List.Item>
)}
/>
</Drawer>
{/* 转录编辑 Drawer */}
<Drawer
title="编辑转录内容"
placement="right"
width={560}
open={showTranscriptEditDrawer}
onClose={() => setShowTranscriptEditDrawer(false)}
destroyOnClose
extra={
<Space>
<Button type="primary" className="btn-pill-primary" icon={<SaveOutlined />} onClick={saveTranscriptEdits}>保存</Button>
</Space>
}
>
{Object.values(editingSegments)
.sort((a, b) => a.start_time_ms - b.start_time_ms)
.map((seg) => {
return (
<Card
key={seg.segment_id}
size="small"
style={{
marginBottom: 12,
borderRadius: 10,
borderLeft: '4px solid transparent',
background: '#fff',
}}
>
<Space style={{ marginBottom: 8 }}>
<Avatar size={24} icon={<UserOutlined />} style={{ backgroundColor: getSpeakerColor(seg.speaker_id) }} />
<Text strong style={{ color: '#1677ff' }}>{seg.speaker_tag || `发言人 ${seg.speaker_id}`}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{tools.formatDuration(seg.start_time_ms / 1000)}</Text>
</Space>
<TextArea
autoSize={{ minRows: 2, maxRows: 6 }}
value={editingSegments[seg.segment_id]?.text_content ?? ''}
onChange={e => setEditingSegments({
...editingSegments,
[seg.segment_id]: { ...editingSegments[seg.segment_id], text_content: e.target.value }
})}
/>
</Card>
);
})}
</Drawer>
{/* 提示词预览 Drawer */}
<Drawer
title="查看提示词定义"
placement="right"
width={760}
open={Boolean(viewingPrompt)}
onClose={() => setViewingPrompt(null)}
>
{viewingPrompt && (
<div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>名称</Text>
<div style={{ fontSize: 16, fontWeight: 600 }}>{viewingPrompt.name}</div>
</div>
<Space style={{ marginBottom: 16 }}>
{Number(viewingPrompt.is_system) === 1 ? <Tag color="blue">系统</Tag> : <Tag></Tag>}
{Number(viewingPrompt.is_system) === 1 && Number(viewingPrompt.is_default) === 1 && <Tag color="gold" icon={<StarFilled />}>默认</Tag>}
</Space>
{viewingPrompt.desc && (
<div style={{ marginBottom: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>描述</Text>
<div style={{ color: 'rgba(0,0,0,0.72)' }}>{viewingPrompt.desc}</div>
</div>
)}
<div>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>提示词内容</Text>
<div style={{ padding: 16, border: '1px solid #e5e7eb', borderRadius: 8, background: '#fafbfc', maxHeight: 520, overflowY: 'auto' }}>
<MarkdownRenderer content={viewingPrompt.content || ''} />
</div>
</div>
</div>
)}
</Drawer>
<QRCodeModal open={showQRModal} onClose={() => setShowQRModal(false)} url={`${window.location.origin}/meetings/preview/${meeting_id}`}>
{isMeetingOwner ? (
<>
<Divider style={{ margin: '0 0 16px' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>访问密码保护</Text>
<Switch
checked={accessPasswordEnabled}
checkedChildren="已开启"
unCheckedChildren="已关闭"
loading={savingAccessPassword}
onChange={handleAccessPasswordSwitchChange}
/>
</div>
{accessPasswordEnabled ? (
<>
<Space.Compact style={{ width: '100%' }}>
<Input.Password
value={accessPasswordDraft}
onChange={(e) => setAccessPasswordDraft(e.target.value)}
placeholder="请输入访问密码"
/>
<ActionButton tone="view" variant="iconLg" tooltip="复制密码" icon={<CopyOutlined />} disabled={!accessPasswordDraft} onClick={copyAccessPassword} />
<ActionButton tone="edit" variant="iconLg" tooltip="保存密码" icon={<SaveOutlined />} loading={savingAccessPassword} onClick={saveAccessPassword} />
</Space.Compact>
<Text type="secondary" style={{ display: 'block', marginTop: 10 }}>
开启后访客打开分享链接时需要输入这个密码
</Text>
</>
) : (
<Text type="secondary">关闭后任何拿到链接的人都可以直接查看预览页</Text>
)}
</>
) : meeting?.access_password ? (
<>
<Divider style={{ margin: '0 0 16px' }} />
<Text type="secondary">该分享链接已启用访问密码密码由会议创建人管理</Text>
</>
) : null}
</QRCodeModal>
<MeetingFormDrawer
open={editDrawerOpen && isMeetingOwner}
onClose={() => setEditDrawerOpen(false)}
meetingId={meeting_id}
user={user}
onSuccess={() => fetchMeetingDetails({ showPageLoading: false })}
/>
</div>
);
};
export default MeetingDetails;