imetting/frontend/src/pages/MeetingDetails.jsx

1213 lines
50 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, Timeline, Tabs, Input, Upload, Empty, Drawer, Select, Switch
} from 'antd';
import {
ClockCircleOutlined, UserOutlined,
EditOutlined, DeleteOutlined,
SettingOutlined, FireOutlined, SyncOutlined,
UploadOutlined, QrcodeOutlined,
EyeOutlined, FileTextOutlined, PartitionOutlined,
SaveOutlined, CloseOutlined,
StarFilled, RobotOutlined, DownloadOutlined,
DownOutlined, CheckOutlined,
MoreOutlined, AudioOutlined
} from '@ant-design/icons';
import MarkdownRenderer from '../components/MarkdownRenderer';
import MarkdownEditor from '../components/MarkdownEditor';
import MindMap from '../components/MindMap';
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';
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 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 [audioUrl, setAudioUrl] = useState(null);
// 发言人
const [editingSpeakers, setEditingSpeakers] = useState({});
const [speakerList, setSpeakerList] = useState([]);
// 转录状态
const [transcriptionStatus, setTranscriptionStatus] = useState(null);
const [transcriptionProgress, setTranscriptionProgress] = useState(0);
const [statusCheckInterval, setStatusCheckInterval] = useState(null);
const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1);
// AI 总结
const [showSummaryDrawer, setShowSummaryDrawer] = useState(false);
const [summaryLoading, setSummaryLoading] = useState(false);
const [userPrompt, setUserPrompt] = useState('');
const [summaryHistory, setSummaryHistory] = useState([]);
const [promptList, setPromptList] = useState([]);
const [selectedPromptId, setSelectedPromptId] = useState(null);
const [summaryTaskProgress, setSummaryTaskProgress] = useState(0);
const [summaryTaskMessage, setSummaryTaskMessage] = useState('');
const [summaryPollInterval, setSummaryPollInterval] = useState(null);
const [activeSummaryTaskId, setActiveSummaryTaskId] = useState(null);
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 [playbackRate, setPlaybackRate] = useState(1);
const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
const [savingAccessPassword, setSavingAccessPassword] = useState(false);
// 转录编辑 Drawer
const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false);
const [editingSegmentIndex, setEditingSegmentIndex] = useState(-1);
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 audioRef = useRef(null);
const transcriptRefs = useRef([]);
const isMeetingOwner = user?.user_id === meeting?.creator_id;
const hasUploadedAudio = Boolean(audioUrl);
const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status);
const summaryDisabledReason = isUploading
? '音频上传中,暂不允许重新总结'
: !hasUploadedAudio
? '请先上传音频后再总结'
: isTranscriptionRunning
? '转录进行中,完成后会自动总结'
: '';
const isSummaryActionDisabled = isUploading || !hasUploadedAudio || isTranscriptionRunning || summaryLoading;
/* ══════════════════ 数据获取 ══════════════════ */
useEffect(() => {
fetchMeetingDetails();
fetchPromptList();
fetchLlmModels();
return () => {
if (statusCheckInterval) clearInterval(statusCheckInterval);
if (summaryPollInterval) clearInterval(summaryPollInterval);
};
}, [meeting_id]);
const fetchMeetingDetails = async () => {
try {
setLoading(true);
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);
}
setAccessPasswordEnabled(Boolean(response.data.access_password));
setAccessPasswordDraft(response.data.access_password || '');
if (response.data.transcription_status) {
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);
}
}
try {
await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)));
setAudioUrl(buildApiUrl(`/api/meetings/${meeting_id}/audio/stream`));
} catch { setAudioUrl(null); }
fetchTranscript();
fetchSummaryHistory();
} catch {
message.error('加载会议详情失败');
} finally {
setLoading(false);
}
};
const fetchTranscript = async () => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id)));
setTranscript(res.data);
const ids = [...new Set(res.data.map(i => i.speaker_id))].filter(id => id != null);
const list = ids.map(id => {
const item = res.data.find(s => s.speaker_id === id);
return { speaker_id: id, speaker_tag: item.speaker_tag || `发言人 ${id}` };
}).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([]); }
};
const startStatusPolling = (taskId) => {
if (statusCheckInterval) clearInterval(statusCheckInterval);
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);
if (status.status === 'completed') {
fetchTranscript();
fetchMeetingDetails();
setTimeout(() => {
fetchSummaryHistory();
}, 1000);
}
}
} catch { clearInterval(interval); }
}, 3000);
setStatusCheckInterval(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 {}
};
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 {}
};
const startSummaryPolling = (taskId, options = {}) => {
const { closeDrawerOnComplete = false } = options;
if (!taskId) return;
if (summaryPollInterval && activeSummaryTaskId === taskId) return;
if (summaryPollInterval) clearInterval(summaryPollInterval);
setActiveSummaryTaskId(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);
setSummaryPollInterval(null);
setActiveSummaryTaskId(null);
setSummaryLoading(false);
if (closeDrawerOnComplete) {
setShowSummaryDrawer(false);
}
fetchSummaryHistory();
fetchMeetingDetails();
} else if (status.status === 'failed') {
clearInterval(interval);
setSummaryPollInterval(null);
setActiveSummaryTaskId(null);
setSummaryLoading(false);
message.error(status.error_message || '生成总结失败');
}
} catch (error) {
clearInterval(interval);
setSummaryPollInterval(null);
setActiveSummaryTaskId(null);
setSummaryLoading(false);
message.error(error?.response?.data?.message || '获取总结状态失败');
}
};
const interval = setInterval(poll, 3000);
setSummaryPollInterval(interval);
poll();
};
const fetchSummaryHistory = async () => {
try {
const res = await apiClient.get(buildApiUrl(`/api/meetings/${meeting_id}/llm-tasks`));
const tasks = res.data.tasks || [];
setSummaryHistory(tasks.filter(t => t.status === 'completed'));
const latestRunningTask = tasks.find(t => ['pending', 'processing'].includes(t.status));
if (latestRunningTask) {
startSummaryPolling(latestRunningTask.task_id);
} else if (!activeSummaryTaskId) {
setSummaryLoading(false);
setSummaryTaskProgress(0);
setSummaryTaskMessage('');
}
} catch {}
};
/* ══════════════════ 操作 ══════════════════ */
const handleTimeUpdate = () => {
if (!audioRef.current) return;
const timeMs = audioRef.current.currentTime * 1000;
const idx = transcript.findIndex(i => timeMs >= i.start_time_ms && timeMs <= i.end_time_ms);
if (idx !== -1 && idx !== currentHighlightIndex) {
setCurrentHighlightIndex(idx);
transcriptRefs.current[idx]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
const jumpToTime = (ms) => {
if (audioRef.current) {
audioRef.current.currentTime = ms / 1000;
audioRef.current.play();
}
};
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);
}
setIsUploading(true);
try {
await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData);
message.success('音频上传成功');
fetchMeetingDetails();
} catch { message.error('上传失败'); }
finally { setIsUploading(false); }
};
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 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 = () => {
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 (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 (isUploading) {
message.warning('音频上传中,暂不允许重新总结');
return;
}
if (!hasUploadedAudio) {
message.warning('请先上传音频后再总结');
return;
}
if (isTranscriptionRunning) {
message.warning('转录进行中,完成后会自动总结');
return;
}
setShowSummaryDrawer(true);
fetchSummaryHistory();
};
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);
};
/* ── 转录编辑 Drawer ── */
const openTranscriptEditDrawer = (index) => {
const segments = {};
for (let i = Math.max(0, index - 1); i <= Math.min(transcript.length - 1, index + 1); i++) {
segments[transcript[i].segment_id] = { ...transcript[i] };
}
setEditingSegments(segments);
setEditingSegmentIndex(index);
setShowTranscriptEditDrawer(true);
};
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 { message.error('更新失败'); }
};
/* ── 总结内容编辑 ── */
const openSummaryEditDrawer = () => {
setEditingSummaryContent(meeting?.summary || '');
setIsEditingSummary(true);
};
const saveSummaryContent = async () => {
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('总结已保存');
setIsEditingSummary(false);
fetchMeetingDetails();
} catch { message.error('保存失败'); }
};
/* ── 更多操作菜单 ── */
const panelMoreMenuItems = [
{ key: 'delete', icon: <DeleteOutlined />, label: '删除会议', danger: true, onClick: handleDeleteMeeting },
];
const audioMoreMenuItems = [
{ key: 'transcribe', icon: <AudioOutlined />, label: '智能转录', disabled: !audioUrl || transcriptionStatus?.status === 'processing', onClick: handleStartTranscription },
{ key: 'upload', icon: <UploadOutlined />, label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker },
];
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) },
];
/* ══════════════════ 渲染 ══════════════════ */
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>
<Tooltip title="编辑会议">
<EditOutlined
style={{ fontSize: 16, color: '#1677ff', cursor: 'pointer', flexShrink: 0 }}
onClick={() => setEditDrawerOpen(true)}
/>
</Tooltip>
</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 }} />
{meeting.attendees?.length
? meeting.attendees.map(a => typeof a === 'string' ? a : a.caption).join('、')
: '未指定'}
</Text>
</Space>
</div>
<Space style={{ flexShrink: 0 }}>
<Tooltip title={summaryDisabledReason}>
<span>
<Button icon={<SyncOutlined />} onClick={openSummaryDrawer} disabled={isSummaryActionDisabled}>
重新总结
</Button>
</span>
</Tooltip>
<Button icon={<DownloadOutlined />} onClick={downloadSummaryMd}>下载总结</Button>
<Tooltip title="分享二维码">
<Button icon={<QrcodeOutlined />} onClick={() => setShowQRModal(true)} />
</Tooltip>
<Dropdown menu={{ items: panelMoreMenuItems }} trigger={['click']}>
<Button icon={<MoreOutlined />} />
</Dropdown>
</Space>
</div>
</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>{transcriptionProgress}%</Text>
</div>
<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"
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>
<Button size="small" icon={<SettingOutlined />} onClick={() => setShowSpeakerDrawer(true)}>标签</Button>
</div>
{/* 音频播放器 */}
<div style={{ padding: '8px 20px 0' }}>
<div className="meeting-audio-toolbar">
<div className="meeting-audio-toolbar-player">
{audioUrl ? (
<audio
ref={audioRef}
src={audioUrl}
controls
controlsList="nodownload noplaybackrate"
onLoadedMetadata={() => {
if (audioRef.current) {
audioRef.current.playbackRate = playbackRate;
}
}}
onTimeUpdate={handleTimeUpdate}
style={{ width: '100%', height: 36 }}
/>
) : (
<div className="meeting-audio-toolbar-empty">
<Text type="secondary">暂无音频可通过右侧更多操作上传音频</Text>
</div>
)}
</div>
<div className="meeting-audio-toolbar-actions">
<Dropdown
menu={{ items: playbackRateMenuItems, selectable: true, selectedKeys: [String(playbackRate)] }}
trigger={['click']}
>
<Button type="text" className="meeting-audio-toolbar-button meeting-audio-toolbar-rate" disabled={!audioUrl}>
<span>{playbackRate.toFixed(playbackRate % 1 === 0 ? 1 : 2)}x</span>
<DownOutlined />
</Button>
</Dropdown>
<Dropdown menu={{ items: audioMoreMenuItems }} trigger={['click']}>
<Button type="text" className="meeting-audio-toolbar-button meeting-audio-toolbar-more" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
</div>
{/* 转录时间轴 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 12px 12px 4px' }}>
{transcript.length > 0 ? (
<Timeline
mode="left"
className="transcript-timeline"
items={transcript.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: 'pointer', fontSize: 13 }}
onClick={e => {
e.stopPropagation();
startInlineSpeakerEdit(item.speaker_id, item.speaker_tag, item.segment_id);
}}
>
{item.speaker_tag || `发言人 ${item.speaker_id}`}
<EditOutlined style={{ fontSize: 11, marginLeft: 3 }} />
</Text>
)}
</div>
{inlineSegmentEditId === item.segment_id ? (
<div onClick={(e) => e.stopPropagation()}>
<Input.TextArea
autoFocus
autoSize={{ minRows: 2, maxRows: 6 }}
value={inlineSegmentValue}
onChange={(e) => setInlineSegmentValue(e.target.value)}
onPressEnter={(e) => {
if (e.ctrlKey || e.metaKey) {
saveInlineSegmentEdit();
}
}}
/>
<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
style={{ fontSize: 14, lineHeight: 1.7, color: '#333', cursor: 'text' }}
onDoubleClick={(e) => {
e.stopPropagation();
startInlineSegmentEdit(item);
}}
>
{item.text_content}
</Text>
)}
</div>
),
};
})}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无对话数据" style={{ marginTop: 80 }} />
)}
</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 }}>
{isEditingSummary ? (
<Space>
<Button icon={<CloseOutlined />} onClick={() => setIsEditingSummary(false)}>取消</Button>
<Button type="primary" icon={<SaveOutlined />} onClick={saveSummaryContent}>保存</Button>
</Space>
) : (
<Button type="link" icon={<EditOutlined />} onClick={openSummaryEditDrawer}>编辑</Button>
)}
</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={meeting.summary} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description='暂无 AI 总结,点击"重新总结"按钮生成'
style={{ marginTop: 80 }}
/>
)}
</div>
{/* 总结生成中进度条 */}
{summaryLoading && (
<div style={{ padding: '0 16px 16px', flexShrink: 0 }}>
<Card variant="borderless" style={{ borderRadius: 10, background: '#f6f8fa' }} styles={{ body: { padding: '12px 16px' } }}>
<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>
),
},
{
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={
<Space>
<Button type="primary" icon={<FireOutlined />} loading={summaryLoading} onClick={generateSummary}>生成总结</Button>
</Space>
}
>
{/* 模板选择 */}
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: 'block', marginBottom: 12 }}>选择总结模板</Text>
<Space direction="vertical" style={{ width: '100%' }}>
{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}
<Button type="text" size="small" 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
>
{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" style={{ fontSize: 11 }}>默认</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={
<Space>
<Button type="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>
}
>
<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" icon={<SaveOutlined />} onClick={saveTranscriptEdits}>保存</Button>
</Space>
}
>
{Object.values(editingSegments)
.sort((a, b) => a.start_time_ms - b.start_time_ms)
.map((seg) => {
const isCurrent = transcript[editingSegmentIndex]?.segment_id === seg.segment_id;
return (
<Card
key={seg.segment_id}
size="small"
style={{
marginBottom: 12,
borderRadius: 10,
borderLeft: isCurrent ? '4px solid #1677ff' : '4px solid transparent',
background: isCurrent ? '#f0f7ff' : '#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="已关闭"
onChange={setAccessPasswordEnabled}
/>
</div>
{accessPasswordEnabled ? (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input.Password
value={accessPasswordDraft}
onChange={(e) => setAccessPasswordDraft(e.target.value)}
placeholder="请输入访问密码"
/>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Text type="secondary">开启后访客打开分享链接时需要输入这个密码</Text>
<Space>
<Button onClick={copyAccessPassword} disabled={!accessPasswordDraft}>复制密码</Button>
<Button type="primary" loading={savingAccessPassword} onClick={saveAccessPassword}>保存密码</Button>
</Space>
</Space>
</Space>
) : (
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Text type="secondary">关闭后任何拿到链接的人都可以直接查看预览页</Text>
<Button type="primary" loading={savingAccessPassword} onClick={saveAccessPassword}>关闭密码保护</Button>
</Space>
)}
</>
) : meeting?.access_password ? (
<>
<Divider style={{ margin: '0 0 16px' }} />
<Text type="secondary">该分享链接已启用访问密码密码由会议创建人管理</Text>
</>
) : null}
</QRCodeModal>
<MeetingFormDrawer
open={editDrawerOpen}
onClose={() => setEditDrawerOpen(false)}
meetingId={meeting_id}
user={user}
onSuccess={() => fetchMeetingDetails()}
/>
</div>
);
};
export default MeetingDetails;