2026-01-19 11:03:08 +00:00
|
|
|
|
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 {
|
|
|
|
|
|
ClockCircleOutlined, UserOutlined,
|
|
|
|
|
|
EditOutlined, DeleteOutlined,
|
|
|
|
|
|
SettingOutlined, FireOutlined, SyncOutlined,
|
|
|
|
|
|
UploadOutlined, QrcodeOutlined,
|
|
|
|
|
|
EyeOutlined, FileTextOutlined, PartitionOutlined,
|
|
|
|
|
|
SaveOutlined, CloseOutlined,
|
|
|
|
|
|
StarFilled, RobotOutlined, DownloadOutlined,
|
|
|
|
|
|
DownOutlined,
|
|
|
|
|
|
MoreOutlined, AudioOutlined
|
|
|
|
|
|
} from '@ant-design/icons';
|
|
|
|
|
|
import MarkdownRenderer from '../components/MarkdownRenderer';
|
|
|
|
|
|
import MarkdownEditor from '../components/MarkdownEditor';
|
|
|
|
|
|
import MindMap from '../components/MindMap';
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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';
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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-01-19 11:03:08 +00:00
|
|
|
|
|
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-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
const MeetingDetails = ({ user }) => {
|
|
|
|
|
|
const { meeting_id } = useParams();
|
|
|
|
|
|
const navigate = useNavigate();
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const { message, modal } = App.useApp();
|
|
|
|
|
|
|
2026-01-19 11:03:08 +00:00
|
|
|
|
const [meeting, setMeeting] = useState(null);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [transcript, setTranscript] = useState([]);
|
|
|
|
|
|
const [audioUrl, setAudioUrl] = useState(null);
|
2026-03-26 06:55:12 +00:00
|
|
|
|
|
|
|
|
|
|
// 发言人
|
2026-01-19 11:03:08 +00:00
|
|
|
|
const [editingSpeakers, setEditingSpeakers] = useState({});
|
|
|
|
|
|
const [speakerList, setSpeakerList] = useState([]);
|
2026-03-26 06:55:12 +00:00
|
|
|
|
|
|
|
|
|
|
// 转录状态
|
2026-01-19 11:03:08 +00:00
|
|
|
|
const [transcriptionStatus, setTranscriptionStatus] = useState(null);
|
|
|
|
|
|
const [transcriptionProgress, setTranscriptionProgress] = useState(0);
|
|
|
|
|
|
const [statusCheckInterval, setStatusCheckInterval] = useState(null);
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1);
|
|
|
|
|
|
|
|
|
|
|
|
// AI 总结
|
|
|
|
|
|
const [showSummaryDrawer, setShowSummaryDrawer] = useState(false);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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);
|
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);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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-01-19 11:03:08 +00:00
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
// 转录编辑 Drawer
|
|
|
|
|
|
const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false);
|
|
|
|
|
|
const [editingSegmentIndex, setEditingSegmentIndex] = useState(-1);
|
|
|
|
|
|
const [editingSegments, setEditingSegments] = useState({});
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
// 总结内容编辑(同窗口)
|
|
|
|
|
|
const [isEditingSummary, setIsEditingSummary] = useState(false);
|
|
|
|
|
|
const [editingSummaryContent, setEditingSummaryContent] = useState('');
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
const audioRef = useRef(null);
|
|
|
|
|
|
const transcriptRefs = useRef([]);
|
2026-03-27 08:01:52 +00:00
|
|
|
|
const isMeetingOwner = user?.user_id === meeting?.creator_id;
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
/* ══════════════════ 数据获取 ══════════════════ */
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchMeetingDetails();
|
|
|
|
|
|
fetchPromptList();
|
2026-03-26 06:55:12 +00:00
|
|
|
|
fetchLlmModels();
|
2026-01-19 11:03:08 +00:00
|
|
|
|
return () => {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
if (statusCheckInterval) clearInterval(statusCheckInterval);
|
|
|
|
|
|
if (summaryPollInterval) clearInterval(summaryPollInterval);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
}, [meeting_id]);
|
|
|
|
|
|
|
|
|
|
|
|
const fetchMeetingDetails = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
|
2026-01-19 11:03:08 +00:00
|
|
|
|
setMeeting(response.data);
|
2026-03-27 08:01:52 +00:00
|
|
|
|
setAccessPasswordEnabled(Boolean(response.data.access_password));
|
|
|
|
|
|
setAccessPasswordDraft(response.data.access_password || '');
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)));
|
|
|
|
|
|
setAudioUrl(buildApiUrl(`/api/meetings/${meeting_id}/audio/stream`));
|
|
|
|
|
|
} catch { setAudioUrl(null); }
|
|
|
|
|
|
|
|
|
|
|
|
fetchTranscript();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
message.error('加载会议详情失败');
|
2026-01-19 11:03:08 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const fetchTranscript = async () => {
|
2026-01-19 11:03:08 +00:00
|
|
|
|
try {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
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([]); }
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch { clearInterval(interval); }
|
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
setStatusCheckInterval(interval);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const fetchPromptList = async () => {
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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);
|
|
|
|
|
|
} catch {}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const fetchLlmModels = async () => {
|
2026-01-19 11:03:08 +00:00
|
|
|
|
try {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LLM_MODELS));
|
|
|
|
|
|
const models = res.data.models || [];
|
|
|
|
|
|
setLlmModels(models);
|
|
|
|
|
|
const def = models.find(m => m.is_default);
|
|
|
|
|
|
if (def) setSelectedModelCode(def.model_code);
|
|
|
|
|
|
} catch {}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const fetchSummaryHistory = async () => {
|
2026-01-19 11:03:08 +00:00
|
|
|
|
try {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const res = await apiClient.get(buildApiUrl(`/api/meetings/${meeting_id}/llm-tasks`));
|
|
|
|
|
|
setSummaryHistory(res.data.tasks?.filter(t => t.status === 'completed') || []);
|
|
|
|
|
|
} catch {}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
/* ══════════════════ 操作 ══════════════════ */
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
const handleTimeUpdate = () => {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
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' });
|
2026-01-19 11:03:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const jumpToTime = (ms) => {
|
2026-01-19 11:03:08 +00:00
|
|
|
|
if (audioRef.current) {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
audioRef.current.currentTime = ms / 1000;
|
|
|
|
|
|
audioRef.current.play();
|
2026-01-19 11:03:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
setIsUploading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData);
|
|
|
|
|
|
message.success('音频上传成功');
|
|
|
|
|
|
fetchMeetingDetails();
|
|
|
|
|
|
} catch { message.error('上传失败'); }
|
|
|
|
|
|
finally { setIsUploading(false); }
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const changePlaybackRate = (nextRate) => {
|
|
|
|
|
|
setPlaybackRate(nextRate);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
if (audioRef.current) {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
audioRef.current.playbackRate = nextRate;
|
2026-01-19 11:03:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const handleStartTranscription = async () => {
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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-01-19 11:03:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const handleDeleteMeeting = () => {
|
|
|
|
|
|
modal.confirm({
|
|
|
|
|
|
title: '删除会议',
|
|
|
|
|
|
content: '确定要删除此会议吗?此操作无法撤销。',
|
|
|
|
|
|
okText: '删除',
|
|
|
|
|
|
okType: 'danger',
|
|
|
|
|
|
onOk: async () => {
|
|
|
|
|
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting_id)));
|
|
|
|
|
|
navigate('/dashboard');
|
|
|
|
|
|
}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const generateSummary = async () => {
|
|
|
|
|
|
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`), {
|
2026-01-19 11:03:08 +00:00
|
|
|
|
user_prompt: userPrompt,
|
2026-03-26 06:55:12 +00:00
|
|
|
|
prompt_id: selectedPromptId,
|
|
|
|
|
|
model_code: selectedModelCode
|
2026-01-19 11:03:08 +00:00
|
|
|
|
});
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const taskId = res.data.task_id;
|
2026-01-19 11:03:08 +00:00
|
|
|
|
const interval = setInterval(async () => {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId)));
|
|
|
|
|
|
const s = statusRes.data;
|
|
|
|
|
|
setSummaryTaskProgress(s.progress || 0);
|
|
|
|
|
|
setSummaryTaskMessage(s.message);
|
|
|
|
|
|
if (s.status === 'completed') {
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
setSummaryLoading(false);
|
|
|
|
|
|
setShowSummaryDrawer(false);
|
|
|
|
|
|
fetchSummaryHistory();
|
|
|
|
|
|
fetchMeetingDetails();
|
|
|
|
|
|
} else if (s.status === 'failed') {
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
setSummaryLoading(false);
|
|
|
|
|
|
message.error('生成总结失败');
|
2026-01-19 11:03:08 +00:00
|
|
|
|
}
|
2026-03-26 06:55:12 +00:00
|
|
|
|
}, 3000);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
setSummaryPollInterval(interval);
|
2026-03-26 06:55:12 +00:00
|
|
|
|
} catch { setSummaryLoading(false); }
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const openSummaryDrawer = () => {
|
|
|
|
|
|
setShowSummaryDrawer(true);
|
|
|
|
|
|
fetchSummaryHistory();
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
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-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
/* ── 转录编辑 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] };
|
2026-01-19 11:03:08 +00:00
|
|
|
|
}
|
2026-03-26 06:55:12 +00:00
|
|
|
|
setEditingSegments(segments);
|
|
|
|
|
|
setEditingSegmentIndex(index);
|
|
|
|
|
|
setShowTranscriptEditDrawer(true);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const saveTranscriptEdits = async () => {
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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();
|
|
|
|
|
|
} catch { message.error('更新失败'); }
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
/* ── 总结内容编辑 ── */
|
|
|
|
|
|
const openSummaryEditDrawer = () => {
|
|
|
|
|
|
setEditingSummaryContent(meeting?.summary || '');
|
|
|
|
|
|
setIsEditingSummary(true);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const saveSummaryContent = async () => {
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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('总结已保存');
|
|
|
|
|
|
setIsEditingSummary(false);
|
|
|
|
|
|
fetchMeetingDetails();
|
|
|
|
|
|
} catch { message.error('保存失败'); }
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
/* ── 更多操作菜单 ── */
|
|
|
|
|
|
const panelMoreMenuItems = [
|
|
|
|
|
|
{ key: 'delete', icon: <DeleteOutlined />, label: '删除会议', danger: true, onClick: handleDeleteMeeting },
|
|
|
|
|
|
];
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
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-01-19 11:03:08 +00:00
|
|
|
|
|
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-01-19 11:03:08 +00:00
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
/* ══════════════════ 渲染 ══════════════════ */
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
if (loading) return <div style={{ textAlign: 'center', padding: '100px' }}><Spin size="large" tip="正在加载..." /></div>;
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-26 06:55:12 +00:00
|
|
|
|
<div>
|
|
|
|
|
|
{/* ── 标题 Header ── */}
|
|
|
|
|
|
<Card
|
2026-03-26 11:51:00 +00:00
|
|
|
|
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>
|
|
|
|
|
|
<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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
<Space style={{ flexShrink: 0 }}>
|
|
|
|
|
|
<Button icon={<SyncOutlined />} onClick={openSummaryDrawer}>重新总结</Button>
|
|
|
|
|
|
<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) && (
|
2026-03-26 11:51:00 +00:00
|
|
|
|
<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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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
|
2026-03-26 11:51:00 +00:00
|
|
|
|
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>
|
|
|
|
|
|
<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 }}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2026-03-26 06:55:12 +00:00
|
|
|
|
<div className="meeting-audio-toolbar-empty">
|
|
|
|
|
|
<Text type="secondary">暂无音频,可通过右侧更多操作上传音频</Text>
|
|
|
|
|
|
</div>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</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']}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-26 06:55:12 +00:00
|
|
|
|
</div>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
{/* 转录时间轴 */}
|
|
|
|
|
|
<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 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
strong
|
|
|
|
|
|
style={{ color: '#1677ff', cursor: 'pointer', fontSize: 13 }}
|
|
|
|
|
|
onClick={e => { e.stopPropagation(); openTranscriptEditDrawer(index); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.speaker_tag || `发言人 ${item.speaker_id}`}
|
|
|
|
|
|
<EditOutlined style={{ fontSize: 11, marginLeft: 3 }} />
|
|
|
|
|
|
</Text>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
2026-03-26 06:55:12 +00:00
|
|
|
|
<Text style={{ fontSize: 14, lineHeight: 1.7, color: '#333' }}>{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
|
2026-03-26 11:51:00 +00:00
|
|
|
|
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 }}>
|
|
|
|
|
|
{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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
)}
|
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}
|
|
|
|
|
|
/>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
2026-03-26 06:55:12 +00:00
|
|
|
|
) : meeting.summary ? (
|
|
|
|
|
|
<MarkdownRenderer content={meeting.summary} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Empty
|
|
|
|
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
|
|
|
|
description='暂无 AI 总结,点击"重新总结"按钮生成'
|
|
|
|
|
|
style={{ marginTop: 80 }}
|
|
|
|
|
|
/>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-26 06:55:12 +00:00
|
|
|
|
{/* 总结生成中进度条 */}
|
|
|
|
|
|
{summaryLoading && (
|
|
|
|
|
|
<div style={{ padding: '0 16px 16px', flexShrink: 0 }}>
|
2026-03-26 11:51:00 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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-01-19 11:03:08 +00:00
|
|
|
|
) : (
|
2026-03-26 06:55:12 +00:00
|
|
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无总结内容" style={{ marginTop: 80 }} />
|
2026-01-19 11:03:08 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</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={
|
|
|
|
|
|
<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;
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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-01-19 11:03:08 +00:00
|
|
|
|
>
|
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}
|
|
|
|
|
|
<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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
);
|
2026-03-26 06:55:12 +00:00
|
|
|
|
}) : <Empty description="暂无可用模板" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
|
|
|
|
|
</Space>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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
|
|
|
|
|
|
>
|
|
|
|
|
|
{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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
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="例如:请重点分析会议中的决策事项..."
|
|
|
|
|
|
/>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
2026-03-26 06:55:12 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 生成进度 */}
|
|
|
|
|
|
{summaryLoading && (
|
2026-03-26 11:51:00 +00:00
|
|
|
|
<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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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={
|
|
|
|
|
|
<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',
|
|
|
|
|
|
}}
|
2026-01-19 11:03:08 +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-01-19 11:03:08 +00:00
|
|
|
|
/>
|
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 || ''} />
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</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="已关闭"
|
|
|
|
|
|
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>
|
2026-03-26 06:55:12 +00:00
|
|
|
|
<MeetingFormDrawer
|
|
|
|
|
|
open={editDrawerOpen}
|
|
|
|
|
|
onClose={() => setEditDrawerOpen(false)}
|
|
|
|
|
|
meetingId={meeting_id}
|
|
|
|
|
|
user={user}
|
|
|
|
|
|
onSuccess={() => fetchMeetingDetails()}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default MeetingDetails;
|