imetting/frontend/src/pages/MeetingDetails.jsx

1398 lines
57 KiB
React
Raw Normal View History

import React, { useState, useEffect, useRef } from 'react';
2026-03-26 06:55:12 +00:00
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Row, Col, Button, Space, Typography, Tag, Avatar,
Tooltip, Progress, Spin, App, Dropdown,
2026-03-27 08:01:52 +00:00
Divider, List, Timeline, Tabs, Input, Upload, Empty, Drawer, Select, Switch
2026-03-26 06:55:12 +00:00
} from 'antd';
import {
2026-04-03 16:25:53 +00:00
ClockCircleOutlined, UserOutlined, TeamOutlined,
2026-03-26 06:55:12 +00:00
EditOutlined, DeleteOutlined,
SettingOutlined, FireOutlined, SyncOutlined,
UploadOutlined, QrcodeOutlined,
EyeOutlined, FileTextOutlined, PartitionOutlined,
SaveOutlined, CloseOutlined,
StarFilled, RobotOutlined, DownloadOutlined,
DownOutlined, CheckOutlined,
2026-04-03 16:25:53 +00:00
MoreOutlined, AudioOutlined, CopyOutlined
2026-03-26 06:55:12 +00:00
} from '@ant-design/icons';
import MarkdownRenderer from '../components/MarkdownRenderer';
import MarkdownEditor from '../components/MarkdownEditor';
import MindMap from '../components/MindMap';
2026-04-03 16:25:53 +00:00
import ActionButton from '../components/ActionButton';
import apiClient from '../utils/apiClient';
import tools from '../utils/tools';
2026-03-26 06:55:12 +00:00
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import QRCodeModal from '../components/QRCodeModal';
2026-03-26 06:55:12 +00:00
import MeetingFormDrawer from '../components/MeetingFormDrawer';
const { Title, Text } = Typography;
const { TextArea } = Input;
2026-03-26 06:55:12 +00:00
/* ── 发言人头像颜色池 ── */
const AVATAR_COLORS = [
'#1677ff', '#52c41a', '#fa8c16', '#eb2f96',
'#722ed1', '#13c2c2', '#2f54eb', '#faad14',
];
const getSpeakerColor = (speakerId) => AVATAR_COLORS[(speakerId ?? 0) % AVATAR_COLORS.length];
2026-04-03 16:25:53 +00:00
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();
2026-03-26 06:55:12 +00:00
const { message, modal } = App.useApp();
const [meeting, setMeeting] = useState(null);
const [loading, setLoading] = useState(true);
const [transcript, setTranscript] = useState([]);
2026-04-03 16:25:53 +00:00
const [transcriptLoading, setTranscriptLoading] = useState(false);
const [audioUrl, setAudioUrl] = useState(null);
2026-03-26 06:55:12 +00:00
// 发言人
const [editingSpeakers, setEditingSpeakers] = useState({});
const [speakerList, setSpeakerList] = useState([]);
2026-03-26 06:55:12 +00:00
// 转录状态
const [transcriptionStatus, setTranscriptionStatus] = useState(null);
const [transcriptionProgress, setTranscriptionProgress] = useState(0);
2026-03-26 06:55:12 +00:00
const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1);
// AI 总结
const [showSummaryDrawer, setShowSummaryDrawer] = useState(false);
const [summaryLoading, setSummaryLoading] = useState(false);
2026-04-03 16:25:53 +00:00
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('');
2026-03-26 06:55:12 +00:00
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);
2026-03-26 06:55:12 +00:00
const [playbackRate, setPlaybackRate] = useState(1);
2026-03-27 08:01:52 +00:00
const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
const [savingAccessPassword, setSavingAccessPassword] = useState(false);
2026-03-26 06:55:12 +00:00
// 转录编辑 Drawer
const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false);
const [editingSegments, setEditingSegments] = useState({});
2026-03-26 06:55:12 +00:00
// 总结内容编辑(同窗口)
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);
2026-04-03 16:25:53 +00:00
const [transcriptVisibleCount, setTranscriptVisibleCount] = useState(TRANSCRIPT_INITIAL_RENDER_COUNT);
const audioRef = useRef(null);
const transcriptRefs = useRef([]);
2026-04-03 16:25:53 +00:00
const statusCheckIntervalRef = useRef(null);
const summaryPollIntervalRef = useRef(null);
const activeSummaryTaskIdRef = useRef(null);
2026-03-27 08:01:52 +00:00
const isMeetingOwner = user?.user_id === meeting?.creator_id;
2026-04-03 16:25:53 +00:00
const creatorName = meeting?.creator_username || '未知创建人';
const hasUploadedAudio = Boolean(audioUrl);
const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status);
const summaryDisabledReason = isUploading
? '音频上传中,暂不允许重新总结'
: !hasUploadedAudio
? '请先上传音频后再总结'
: isTranscriptionRunning
? '转录进行中,完成后会自动总结'
: '';
const isSummaryActionDisabled = isUploading || !hasUploadedAudio || isTranscriptionRunning || summaryLoading;
2026-03-26 06:55:12 +00:00
/* ══════════════════ 数据获取 ══════════════════ */
useEffect(() => {
fetchMeetingDetails();
2026-04-03 16:25:53 +00:00
fetchTranscript();
return () => {
2026-04-03 16:25:53 +00:00
if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current);
if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current);
};
}, [meeting_id]);
2026-04-03 16:25:53 +00:00
useEffect(() => {
if (!showSummaryDrawer) {
return;
}
if (promptList.length > 0 && llmModels.length > 0) {
return;
}
fetchSummaryResources();
}, [showSummaryDrawer]);
useEffect(() => {
transcriptRefs.current = [];
setTranscriptVisibleCount(Math.min(transcript.length, TRANSCRIPT_INITIAL_RENDER_COUNT));
}, [transcript]);
useEffect(() => {
if (currentHighlightIndex < 0 || currentHighlightIndex < transcriptVisibleCount || transcriptVisibleCount >= transcript.length) {
return;
}
setTranscriptVisibleCount((prev) => Math.min(
transcript.length,
Math.max(prev + TRANSCRIPT_RENDER_STEP, currentHighlightIndex + 20)
));
}, [currentHighlightIndex, transcript.length, transcriptVisibleCount]);
useEffect(() => {
if (currentHighlightIndex < 0) {
return;
}
transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [currentHighlightIndex, transcriptVisibleCount]);
const fetchMeetingDetails = async (options = {}) => {
const { showPageLoading = true } = options;
try {
2026-04-03 16:25:53 +00:00
if (showPageLoading) {
setLoading(true);
}
2026-03-26 06:55:12 +00:00
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
setMeeting(response.data);
if (response.data.prompt_id) {
setSelectedPromptId(response.data.prompt_id);
}
2026-03-27 08:01:52 +00:00
setAccessPasswordEnabled(Boolean(response.data.access_password));
setAccessPasswordDraft(response.data.access_password || '');
if (response.data.transcription_status) {
2026-03-26 06:55:12 +00:00
const ts = response.data.transcription_status;
setTranscriptionStatus(ts);
setTranscriptionProgress(ts.progress || 0);
if (['pending', 'processing'].includes(ts.status)) {
startStatusPolling(ts.task_id);
}
} else {
setTranscriptionStatus(null);
setTranscriptionProgress(0);
}
if (response.data.llm_status) {
setSummaryTaskProgress(response.data.llm_status.progress || 0);
setSummaryTaskMessage(response.data.llm_status.message || '');
if (['pending', 'processing'].includes(response.data.llm_status.status)) {
startSummaryPolling(response.data.llm_status.task_id);
2026-04-03 16:25:53 +00:00
} else {
setSummaryLoading(false);
}
2026-04-03 16:25:53 +00:00
} else {
setSummaryLoading(false);
setSummaryTaskProgress(0);
setSummaryTaskMessage('');
}
2026-04-03 16:25:53 +00:00
const hasAudio = Boolean(response.data.audio_file_path && String(response.data.audio_file_path).length > 5);
setAudioUrl(hasAudio ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`) : null);
2026-03-26 06:55:12 +00:00
} catch {
message.error('加载会议详情失败');
} finally {
2026-04-03 16:25:53 +00:00
if (showPageLoading) {
setLoading(false);
}
}
};
2026-03-26 06:55:12 +00:00
const fetchTranscript = async () => {
2026-04-03 16:25:53 +00:00
setTranscriptLoading(true);
try {
2026-03-26 06:55:12 +00:00
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id)));
2026-04-03 16:25:53 +00:00
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);
2026-03-26 06:55:12 +00:00
setSpeakerList(list);
const init = {};
list.forEach(s => init[s.speaker_id] = s.speaker_tag);
setEditingSpeakers(init);
2026-04-03 16:25:53 +00:00
} catch {
setTranscript([]);
setSpeakerList([]);
setEditingSpeakers({});
} finally {
setTranscriptLoading(false);
}
};
2026-03-26 06:55:12 +00:00
const startStatusPolling = (taskId) => {
2026-04-03 16:25:53 +00:00
if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current);
2026-03-26 06:55:12 +00:00
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);
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
clearInterval(interval);
2026-04-03 16:25:53 +00:00
statusCheckIntervalRef.current = null;
if (status.status === 'completed') {
fetchTranscript();
2026-04-03 16:25:53 +00:00
fetchMeetingDetails({ showPageLoading: false });
}
2026-03-26 06:55:12 +00:00
}
2026-04-03 16:25:53 +00:00
} catch {
clearInterval(interval);
statusCheckIntervalRef.current = null;
}
2026-03-26 06:55:12 +00:00
}, 3000);
2026-04-03 16:25:53 +00:00
statusCheckIntervalRef.current = interval;
};
2026-03-26 06:55:12 +00:00
const fetchPromptList = async () => {
try {
2026-03-26 06:55:12 +00:00
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);
2026-04-03 16:25:53 +00:00
} catch (error) {
console.debug('加载提示词列表失败:', error);
}
};
2026-03-26 06:55:12 +00:00
const fetchLlmModels = async () => {
try {
2026-03-26 06:55:12 +00:00
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LLM_MODELS));
2026-03-30 08:22:09 +00:00
const models = Array.isArray(res.data) ? res.data : (res.data?.models || []);
2026-03-26 06:55:12 +00:00
setLlmModels(models);
const def = models.find(m => m.is_default);
if (def) setSelectedModelCode(def.model_code);
2026-04-03 16:25:53 +00:00
} 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;
2026-04-03 16:25:53 +00:00
if (summaryPollIntervalRef.current && activeSummaryTaskIdRef.current === taskId) return;
if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current);
2026-04-03 16:25:53 +00:00
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 || '');
if (status.status === 'completed') {
clearInterval(interval);
2026-04-03 16:25:53 +00:00
summaryPollIntervalRef.current = null;
activeSummaryTaskIdRef.current = null;
setSummaryLoading(false);
if (closeDrawerOnComplete) {
setShowSummaryDrawer(false);
}
2026-04-03 16:25:53 +00:00
fetchMeetingDetails({ showPageLoading: false });
} else if (status.status === 'failed') {
clearInterval(interval);
2026-04-03 16:25:53 +00:00
summaryPollIntervalRef.current = null;
activeSummaryTaskIdRef.current = null;
setSummaryLoading(false);
message.error(status.error_message || '生成总结失败');
}
} catch (error) {
clearInterval(interval);
2026-04-03 16:25:53 +00:00
summaryPollIntervalRef.current = null;
activeSummaryTaskIdRef.current = null;
setSummaryLoading(false);
message.error(error?.response?.data?.message || '获取总结状态失败');
}
};
const interval = setInterval(poll, 3000);
2026-04-03 16:25:53 +00:00
summaryPollIntervalRef.current = interval;
poll();
};
2026-03-26 06:55:12 +00:00
/* ══════════════════ 操作 ══════════════════ */
const handleTimeUpdate = () => {
2026-03-26 06:55:12 +00:00
if (!audioRef.current) return;
const timeMs = audioRef.current.currentTime * 1000;
2026-04-03 16:25:53 +00:00
const idx = findTranscriptIndexByTime(transcript, timeMs);
2026-03-26 06:55:12 +00:00
if (idx !== -1 && idx !== currentHighlightIndex) {
setCurrentHighlightIndex(idx);
transcriptRefs.current[idx]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
2026-04-03 16:25:53 +00:00
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));
};
2026-03-26 06:55:12 +00:00
const jumpToTime = (ms) => {
if (audioRef.current) {
2026-03-26 06:55:12 +00:00
audioRef.current.currentTime = ms / 1000;
audioRef.current.play();
}
};
2026-03-26 06:55:12 +00:00
const handleUploadAudio = async (file) => {
const formData = new FormData();
formData.append('audio_file', file);
formData.append('meeting_id', meeting_id);
formData.append('force_replace', 'true');
if (meeting?.prompt_id) {
formData.append('prompt_id', String(meeting.prompt_id));
}
if (selectedModelCode) {
formData.append('model_code', selectedModelCode);
}
2026-03-26 06:55:12 +00:00
setIsUploading(true);
try {
await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData);
message.success('音频上传成功');
2026-04-03 16:25:53 +00:00
setTranscript([]);
setSpeakerList([]);
setEditingSpeakers({});
fetchMeetingDetails({ showPageLoading: false });
fetchTranscript();
2026-03-26 06:55:12 +00:00
} catch { message.error('上传失败'); }
finally { setIsUploading(false); }
};
2026-03-27 08:01:52 +00:00
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);
}
};
2026-04-03 16:25:53 +00:00
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);
}
}
};
2026-03-27 08:01:52 +00:00
const copyAccessPassword = async () => {
if (!accessPasswordDraft) {
message.warning('当前没有可复制的访问密码');
return;
}
await navigator.clipboard.writeText(accessPasswordDraft);
message.success('访问密码已复制');
};
2026-03-26 06:55:12 +00:00
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);
}
};
2026-03-26 06:55:12 +00:00
const changePlaybackRate = (nextRate) => {
setPlaybackRate(nextRate);
if (audioRef.current) {
2026-03-26 06:55:12 +00:00
audioRef.current.playbackRate = nextRate;
}
};
2026-03-26 06:55:12 +00:00
const handleStartTranscription = async () => {
try {
2026-03-26 06:55:12 +00:00
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 || '启动转录失败');
}
};
2026-03-26 06:55:12 +00:00
const handleDeleteMeeting = () => {
2026-04-03 16:25:53 +00:00
if (!isMeetingOwner) {
message.warning('仅会议创建人可删除会议');
return;
}
2026-03-26 06:55:12 +00:00
modal.confirm({
title: '删除会议',
content: '确定要删除此会议吗?此操作无法撤销。',
okText: '删除',
okType: 'danger',
onOk: async () => {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting_id)));
navigate('/dashboard');
}
});
};
const generateSummary = async () => {
2026-04-03 16:25:53 +00:00
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 {
2026-03-26 06:55:12 +00:00
const res = await apiClient.post(buildApiUrl(`/api/meetings/${meeting_id}/generate-summary-async`), {
user_prompt: userPrompt,
2026-03-26 06:55:12 +00:00
prompt_id: selectedPromptId,
model_code: selectedModelCode
});
startSummaryPolling(res.data.task_id, { closeDrawerOnComplete: true });
} catch (error) {
message.error(error?.response?.data?.message || '生成总结失败');
setSummaryLoading(false);
}
};
2026-03-26 06:55:12 +00:00
const openSummaryDrawer = () => {
2026-04-03 16:25:53 +00:00
if (!isMeetingOwner) {
message.warning('仅会议创建人可重新总结');
return;
}
if (isUploading) {
message.warning('音频上传中,暂不允许重新总结');
return;
}
if (!hasUploadedAudio) {
message.warning('请先上传音频后再总结');
return;
}
if (isTranscriptionRunning) {
message.warning('转录进行中,完成后会自动总结');
return;
}
2026-03-26 06:55:12 +00:00
setShowSummaryDrawer(true);
};
2026-03-26 06:55:12 +00:00
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);
};
2026-03-26 06:55:12 +00:00
const saveTranscriptEdits = async () => {
try {
2026-03-26 06:55:12 +00:00
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();
2026-04-03 16:25:53 +00:00
} catch (error) {
console.debug('批量更新转录失败:', error);
message.error('更新失败');
}
};
2026-03-26 06:55:12 +00:00
/* ── 总结内容编辑 ── */
const openSummaryEditDrawer = () => {
2026-04-03 16:25:53 +00:00
if (!isMeetingOwner) {
message.warning('仅会议创建人可编辑总结');
return;
}
2026-03-26 06:55:12 +00:00
setEditingSummaryContent(meeting?.summary || '');
setIsEditingSummary(true);
};
2026-03-26 06:55:12 +00:00
const saveSummaryContent = async () => {
2026-04-03 16:25:53 +00:00
if (!isMeetingOwner) {
message.warning('仅会议创建人可编辑总结');
return;
}
try {
2026-03-26 06:55:12 +00:00
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('总结已保存');
2026-04-03 16:25:53 +00:00
setMeeting(prev => (prev ? { ...prev, summary: editingSummaryContent } : prev));
2026-03-26 06:55:12 +00:00
setIsEditingSummary(false);
} catch { message.error('保存失败'); }
};
2026-03-26 06:55:12 +00:00
/* ── 更多操作菜单 ── */
const panelMoreMenuItems = [
{ key: 'delete', icon: <DeleteOutlined />, label: '删除会议', danger: true, onClick: handleDeleteMeeting },
];
2026-03-26 06:55:12 +00:00
const audioMoreMenuItems = [
{ key: 'transcribe', icon: <AudioOutlined />, label: '智能转录', disabled: !audioUrl || transcriptionStatus?.status === 'processing', onClick: handleStartTranscription },
{ key: 'upload', icon: <UploadOutlined />, label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker },
];
2026-03-26 06:55:12 +00:00
const playbackRateMenuItems = [
{ key: '0.75', label: '0.75x', onClick: () => changePlaybackRate(0.75) },
{ key: '1', label: '1.0x', onClick: () => changePlaybackRate(1) },
{ key: '1.25', label: '1.25x', onClick: () => changePlaybackRate(1.25) },
{ key: '1.5', label: '1.5x', onClick: () => changePlaybackRate(1.5) },
{ key: '2', label: '2.0x', onClick: () => changePlaybackRate(2) },
];
2026-03-26 06:55:12 +00:00
/* ══════════════════ 渲染 ══════════════════ */
2026-03-26 06:55:12 +00:00
if (loading) return <div style={{ textAlign: 'center', padding: '100px' }}><Spin size="large" tip="正在加载..." /></div>;
return (
2026-03-26 06:55:12 +00:00
<div>
{/* ── 标题 Header ── */}
<Card
variant="borderless"
2026-03-26 06:55:12 +00:00
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>
2026-04-03 16:25:53 +00:00
{isMeetingOwner ? (
<ActionButton tone="edit" variant="iconSm" tooltip="编辑会议" icon={<EditOutlined />} style={{ flexShrink: 0 }} onClick={() => setEditDrawerOpen(true)} />
) : null}
2026-03-26 06:55:12 +00:00
</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 }} />
2026-04-03 16:25:53 +00:00
创建人{creatorName}
</Text>
<Text type="secondary">
<TeamOutlined style={{ marginRight: 4 }} />
参会人
2026-03-26 06:55:12 +00:00
{meeting.attendees?.length
? meeting.attendees.map(a => typeof a === 'string' ? a : a.caption).join('、')
: '未指定'}
</Text>
</Space>
</div>
2026-03-26 06:55:12 +00:00
<Space style={{ flexShrink: 0 }}>
2026-04-03 16:25:53 +00:00
{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>
2026-03-26 06:55:12 +00:00
<Tooltip title="分享二维码">
2026-04-03 16:25:53 +00:00
<Button className="btn-pill-secondary" icon={<QrcodeOutlined />} onClick={() => setShowQRModal(true)} />
2026-03-26 06:55:12 +00:00
</Tooltip>
2026-04-03 16:25:53 +00:00
{isMeetingOwner ? (
<Dropdown menu={{ items: panelMoreMenuItems }} trigger={['click']}>
<Button className="btn-pill-secondary" icon={<MoreOutlined />} />
</Dropdown>
) : null}
2026-03-26 06:55:12 +00:00
</Space>
</div>
</Card>
{/* ── 转录进度条 ── */}
{transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status) && (
<Card variant="borderless" className="console-surface" style={{ marginBottom: 16 }} styles={{ body: { padding: '12px 24px' } }}>
2026-03-26 06:55:12 +00:00
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text><SyncOutlined spin style={{ marginRight: 8 }} />正在转录中...</Text>
<Text>{transcriptionProgress}%</Text>
</div>
2026-03-26 06:55:12 +00:00
<Progress percent={transcriptionProgress} status="active" size="small" />
</Card>
)}
{/* ── 隐藏的上传 input ── */}
<Upload
id="audio-upload-input"
showUploadList={false}
customRequest={({ file }) => handleUploadAudio(file)}
style={{ display: 'none' }}
>
<span />
</Upload>
{/* ── 主内容:左转录 右总结 ── */}
<Row gutter={16}>
{/* 左列: 语音转录 */}
<Col xs={24} lg={10}>
<Card
variant="borderless"
2026-03-26 06:55:12 +00:00
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>
2026-04-03 16:25:53 +00:00
{isMeetingOwner ? (
<ActionButton tone="edit" variant="textSm" icon={<SettingOutlined />} onClick={() => setShowSpeakerDrawer(true)}>标签</ActionButton>
) : null}
2026-03-26 06:55:12 +00:00
</div>
{/* 音频播放器 */}
<div style={{ padding: '8px 20px 0' }}>
<div className="meeting-audio-toolbar">
<div className="meeting-audio-toolbar-player">
{audioUrl ? (
<audio
2026-04-03 16:25:53 +00:00
className="meeting-audio-toolbar-native"
2026-03-26 06:55:12 +00:00
ref={audioRef}
src={audioUrl}
controls
controlsList="nodownload noplaybackrate"
onLoadedMetadata={() => {
if (audioRef.current) {
audioRef.current.playbackRate = playbackRate;
}
}}
onTimeUpdate={handleTimeUpdate}
style={{ width: '100%', height: 36 }}
/>
) : (
2026-03-26 06:55:12 +00:00
<div className="meeting-audio-toolbar-empty">
<Text type="secondary">暂无音频可通过右侧更多操作上传音频</Text>
</div>
)}
</div>
2026-03-26 06:55:12 +00:00
<div className="meeting-audio-toolbar-actions">
<Dropdown
menu={{ items: playbackRateMenuItems, selectable: true, selectedKeys: [String(playbackRate)] }}
trigger={['click']}
>
2026-04-03 16:25:53 +00:00
<Button
className="meeting-audio-toolbar-button meeting-audio-toolbar-rate btn-pill-secondary"
disabled={!audioUrl}
>
2026-03-26 06:55:12 +00:00
<span>{playbackRate.toFixed(playbackRate % 1 === 0 ? 1 : 2)}x</span>
<DownOutlined />
</Button>
</Dropdown>
<Dropdown menu={{ items: audioMoreMenuItems }} trigger={['click']}>
2026-04-03 16:25:53 +00:00
<Button
className="meeting-audio-toolbar-button meeting-audio-toolbar-more btn-pill-secondary"
icon={<MoreOutlined />}
/>
2026-03-26 06:55:12 +00:00
</Dropdown>
</div>
</div>
2026-03-26 06:55:12 +00:00
</div>
2026-03-26 06:55:12 +00:00
{/* 转录时间轴 */}
2026-04-03 16:25:53 +00:00
<div
style={{ flex: 1, overflowY: 'auto', padding: '12px 12px 12px 4px' }}
onScroll={handleTranscriptScroll}
>
{transcriptLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 120 }}>
<Spin size="large" tip="正在加载转录内容..." />
</div>
) : transcript.length > 0 ? (
<>
<Timeline
mode="left"
className="transcript-timeline"
items={transcript.slice(0, transcriptVisibleCount).map((item, index) => {
const isActive = currentHighlightIndex === index;
return {
label: (
<Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
{tools.formatDuration(item.start_time_ms / 1000)}
</Text>
),
dot: (
<div style={{
width: 10, height: 10, borderRadius: '50%',
background: getSpeakerColor(item.speaker_id),
boxShadow: isActive ? `0 0 0 3px ${getSpeakerColor(item.speaker_id)}33` : 'none',
}} />
),
children: (
<div
ref={el => transcriptRefs.current[index] = el}
style={{
padding: '6px 10px',
borderRadius: 8,
background: isActive ? '#e6f4ff' : 'transparent',
cursor: 'pointer',
transition: 'background 0.2s',
marginLeft: -4,
}}
onClick={() => jumpToTime(item.start_time_ms)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 2 }}>
<Avatar
size={24}
icon={<UserOutlined />}
style={{ backgroundColor: getSpeakerColor(item.speaker_id), flexShrink: 0 }}
/>
{inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === `speaker-${item.speaker_id}-${item.segment_id}` ? (
<Space.Compact onClick={(e) => e.stopPropagation()}>
<Input
size="small"
autoFocus
value={inlineSpeakerValue}
onChange={(e) => setInlineSpeakerValue(e.target.value)}
onPressEnter={saveInlineSpeakerEdit}
style={{ width: 180 }}
/>
<Button
size="small"
type="text"
icon={<CheckOutlined />}
loading={savingInlineEdit}
onClick={saveInlineSpeakerEdit}
/>
<Button
size="small"
type="text"
icon={<CloseOutlined />}
disabled={savingInlineEdit}
onClick={cancelInlineSpeakerEdit}
/>
</Space.Compact>
) : (
<Text
strong
style={{ color: '#1677ff', cursor: isMeetingOwner ? 'pointer' : 'default', fontSize: 13 }}
onClick={isMeetingOwner ? (e) => {
e.stopPropagation();
startInlineSpeakerEdit(item.speaker_id, item.speaker_tag, item.segment_id);
} : undefined}
>
{item.speaker_tag || `发言人 ${item.speaker_id}`}
{isMeetingOwner ? <EditOutlined style={{ fontSize: 11, marginLeft: 3 }} /> : null}
</Text>
)}
</div>
{inlineSegmentEditId === item.segment_id ? (
<div onClick={(e) => e.stopPropagation()}>
<Input.TextArea
autoFocus
2026-04-03 16:25:53 +00:00
autoSize={{ minRows: 2, maxRows: 6 }}
value={inlineSegmentValue}
onChange={(e) => setInlineSegmentValue(e.target.value)}
onPressEnter={(e) => {
if (e.ctrlKey || e.metaKey) {
saveInlineSegmentEdit();
}
}}
/>
2026-04-03 16:25:53 +00:00
<Space style={{ marginTop: 8 }}>
<Button size="small" type="primary" icon={<CheckOutlined />} loading={savingInlineEdit} onClick={saveInlineSegmentEdit}>
保存
</Button>
<Button size="small" icon={<CloseOutlined />} disabled={savingInlineEdit} onClick={cancelInlineSegmentEdit}>
取消
</Button>
</Space>
</div>
) : (
<Text
2026-04-03 16:25:53 +00:00
style={{ fontSize: 14, lineHeight: 1.7, color: '#333', cursor: isMeetingOwner ? 'text' : 'default' }}
onDoubleClick={isMeetingOwner ? (e) => {
e.stopPropagation();
2026-04-03 16:25:53 +00:00
startInlineSegmentEdit(item);
} : undefined}
>
2026-04-03 16:25:53 +00:00
{item.text_content}
</Text>
)}
</div>
2026-04-03 16:25:53 +00:00
),
};
})}
/>
{transcriptVisibleCount < transcript.length ? (
<div style={{ textAlign: 'center', padding: '8px 0 20px' }}>
<Text type="secondary">
已渲染 {transcriptVisibleCount} / {transcript.length} 条转录继续向下滚动将自动加载更多
</Text>
</div>
) : null}
</>
2026-03-26 06:55:12 +00:00
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无对话数据" style={{ marginTop: 80 }} />
)}
</div>
</Card>
</Col>
{/* 右列: AI 总结 / 思维导图 */}
<Col xs={24} lg={14}>
<Card
variant="borderless"
2026-03-26 06:55:12 +00:00
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 }}>
2026-04-03 16:25:53 +00:00
{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}
2026-03-26 06:55:12 +00:00
</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>
2026-03-26 06:55:12 +00:00
) : meeting.summary ? (
2026-04-03 16:25:53 +00:00
<MarkdownRenderer content={getSummaryDisplayContent(meeting.summary)} />
2026-03-26 06:55:12 +00:00
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description='暂无 AI 总结,点击"重新总结"按钮生成'
style={{ marginTop: 80 }}
/>
)}
</div>
2026-03-26 06:55:12 +00:00
{/* 总结生成中进度条 */}
{summaryLoading && (
<div style={{ padding: '0 16px 16px', flexShrink: 0 }}>
<Card variant="borderless" style={{ borderRadius: 10, background: '#f6f8fa' }} styles={{ body: { padding: '12px 16px' } }}>
2026-03-26 06:55:12 +00:00
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text><SyncOutlined spin style={{ marginRight: 6 }} />{summaryTaskMessage || 'AI 正在思考中...'}</Text>
<Text>{summaryTaskProgress}%</Text>
</div>
<Progress percent={summaryTaskProgress} status="active" size="small" />
</Card>
</div>
)}
</div>
2026-03-26 06:55:12 +00:00
),
},
{
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} />
) : (
2026-03-26 06:55:12 +00:00
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无总结内容" style={{ marginTop: 80 }} />
)}
</div>
2026-03-26 06:55:12 +00:00
),
},
]}
/>
</Card>
</Col>
</Row>
{/* ═══════════ Drawers ═══════════ */}
{/* 总结生成 Drawer */}
<Drawer
title="AI 智能总结"
placement="right"
width={720}
open={showSummaryDrawer}
onClose={() => setShowSummaryDrawer(false)}
destroyOnClose
extra={
2026-04-03 16:25:53 +00:00
isMeetingOwner ? (
<Space>
<Button type="primary" className="btn-pill-primary" icon={<FireOutlined />} loading={summaryLoading} onClick={generateSummary}>生成总结</Button>
</Space>
) : null
2026-03-26 06:55:12 +00:00
}
>
{/* 模板选择 */}
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: 'block', marginBottom: 12 }}>选择总结模板</Text>
<Space direction="vertical" style={{ width: '100%' }}>
2026-04-03 16:25:53 +00:00
{summaryResourcesLoading ? <Spin tip="正在加载可用模板..." /> : null}
2026-03-26 06:55:12 +00:00
{promptList.length ? promptList.map(p => {
const isSelected = selectedPromptId === p.id;
const isSystem = Number(p.is_system) === 1;
return (
2026-03-26 06:55:12 +00:00
<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',
}}
>
2026-03-26 06:55:12 +00:00
<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}
2026-04-03 16:25:53 +00:00
<ActionButton tone="view" variant="iconSm" tooltip="查看模板" icon={<EyeOutlined />} onClick={e => { e.stopPropagation(); setViewingPrompt(p); }} />
2026-03-26 06:55:12 +00:00
</Space>
</Space>
{p.desc && <Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>{p.desc}</Typography.Paragraph>}
</Space>
</Card>
);
2026-03-26 06:55:12 +00:00
}) : <Empty description="暂无可用模板" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</Space>
</div>
2026-03-26 06:55:12 +00:00
{/* 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
2026-04-03 16:25:53 +00:00
loading={summaryResourcesLoading}
2026-03-26 06:55:12 +00:00
>
{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>
2026-04-03 16:25:53 +00:00
{m.is_default ? <Tag color="gold" className="console-tag-compact">默认</Tag> : null}
2026-03-26 06:55:12 +00:00
</Space>
</Select.Option>
))}
</Select>
</div>
2026-03-26 06:55:12 +00:00
{/* 额外要求 */}
<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>
2026-03-26 06:55:12 +00:00
{/* 生成进度 */}
{summaryLoading && (
<Card variant="borderless" style={{ borderRadius: 12, background: '#f6f8fa' }}>
2026-03-26 06:55:12 +00:00
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text><SyncOutlined spin style={{ marginRight: 8 }} />{summaryTaskMessage || 'AI 正在思考中...'}</Text>
<Text>{summaryTaskProgress}%</Text>
</div>
2026-03-26 06:55:12 +00:00
<Progress percent={summaryTaskProgress} status="active" />
</Card>
)}
</Drawer>
{/* 发言人标签 Drawer */}
<Drawer
title="编辑发言人标签"
placement="right"
width={480}
open={showSpeakerDrawer}
onClose={() => setShowSpeakerDrawer(false)}
destroyOnClose
extra={
2026-04-03 16:25:53 +00:00
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
2026-03-26 06:55:12 +00:00
}
>
<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>
2026-04-03 16:25:53 +00:00
<Button type="primary" className="btn-pill-primary" icon={<SaveOutlined />} onClick={saveTranscriptEdits}>保存</Button>
2026-03-26 06:55:12 +00:00
</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,
2026-04-03 16:25:53 +00:00
borderLeft: '4px solid transparent',
background: '#fff',
2026-03-26 06:55:12 +00:00
}}
>
2026-03-26 06:55:12 +00:00
<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 }
})}
/>
2026-03-26 06:55:12 +00:00
</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>
2026-03-26 06:55:12 +00:00
)}
</Drawer>
2026-03-27 08:01:52 +00:00
<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="已关闭"
2026-04-03 16:25:53 +00:00
loading={savingAccessPassword}
onChange={handleAccessPasswordSwitchChange}
2026-03-27 08:01:52 +00:00
/>
</div>
{accessPasswordEnabled ? (
2026-04-03 16:25:53 +00:00
<>
<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>
</>
2026-03-27 08:01:52 +00:00
) : (
2026-04-03 16:25:53 +00:00
<Text type="secondary">关闭后任何拿到链接的人都可以直接查看预览页</Text>
2026-03-27 08:01:52 +00:00
)}
</>
) : meeting?.access_password ? (
<>
<Divider style={{ margin: '0 0 16px' }} />
<Text type="secondary">该分享链接已启用访问密码密码由会议创建人管理</Text>
</>
) : null}
</QRCodeModal>
2026-03-26 06:55:12 +00:00
<MeetingFormDrawer
2026-04-03 16:25:53 +00:00
open={editDrawerOpen && isMeetingOwner}
2026-03-26 06:55:12 +00:00
onClose={() => setEditDrawerOpen(false)}
meetingId={meeting_id}
user={user}
2026-04-03 16:25:53 +00:00
onSuccess={() => fetchMeetingDetails({ showPageLoading: false })}
/>
</div>
);
};
export default MeetingDetails;