import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Alert, Avatar, Breadcrumb, Button, Card, Checkbox, Col, Divider, Drawer, Empty, Form, Input, List, message, Modal, Popover, Progress, Row, Select, Skeleton, Space, Tag, Typography, } from 'antd'; import { AudioOutlined, CaretRightFilled, ClockCircleOutlined, DownloadOutlined, EditOutlined, FastForwardOutlined, LeftOutlined, LoadingOutlined, PauseOutlined, RobotOutlined, SyncOutlined, UserOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import ReactMarkdown from 'react-markdown'; import { downloadMeetingSummary, getMeetingDetail, getMeetingProgress, getTranscripts, MeetingProgress, MeetingTranscriptVO, MeetingVO, reSummary, updateMeetingBasic, updateMeetingTranscript, updateMeetingSummary, updateSpeakerInfo, } from '../../api/business/meeting'; import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel'; import { getHotWordPage, saveHotWord } from '../../api/business/hotword'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { listUsers } from '../../api'; import { useDict } from '../../hooks/useDict'; import { SysUser } from '../../types'; const { Title, Text } = Typography; const { Option } = Select; type AnalysisChapter = { time?: string; title: string; summary: string; }; type AnalysisSpeakerSummary = { speaker: string; summary: string; }; type AnalysisKeyPoint = { title: string; summary: string; speaker?: string; time?: string; }; type MeetingAnalysis = { overview: string; keywords: string[]; chapters: AnalysisChapter[]; speakerSummaries: AnalysisSpeakerSummary[]; keyPoints: AnalysisKeyPoint[]; todos: string[]; }; const ANALYSIS_EMPTY: MeetingAnalysis = { overview: '', keywords: [], chapters: [], speakerSummaries: [], keyPoints: [], todos: [], }; const splitLines = (value?: string | null) => (value || '') .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); const parseLooseJson = (raw?: string | null) => { const input = (raw || '').trim(); if (!input) return null; const tryParse = (text: string) => { try { return JSON.parse(text); } catch { return null; } }; const direct = tryParse(input); if (direct && typeof direct === 'object') return direct; const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim(); if (fenced) { const fencedParsed = tryParse(fenced); if (fencedParsed && typeof fencedParsed === 'object') return fencedParsed; } const start = input.indexOf('{'); const end = input.lastIndexOf('}'); if (start >= 0 && end > start) { const wrapped = tryParse(input.slice(start, end + 1)); if (wrapped && typeof wrapped === 'object') return wrapped; } return null; }; const extractSection = (markdown: string, aliases: string[]) => { const lines = markdown.split(/\r?\n/); const lowerAliases = aliases.map((item) => item.toLowerCase()); const cleanHeading = (line: string) => line.replace(/^#{1,6}\s*/, '').trim().toLowerCase(); let start = -1; for (let index = 0; index < lines.length; index += 1) { const line = lines[index].trim(); if (!line.startsWith('#')) continue; const heading = cleanHeading(line); if (lowerAliases.some((alias) => heading.includes(alias))) { start = index + 1; break; } } if (start < 0) return ''; const buffer: string[] = []; for (let index = start; index < lines.length; index += 1) { const line = lines[index]; if (line.trim().startsWith('#')) break; buffer.push(line); } return buffer.join('\n').trim(); }; const parseBulletList = (content?: string | null) => splitLines(content) .map((line) => line.replace(/^[-*•\s]+/, '').replace(/^\d+[.)]\s*/, '').trim()) .filter(Boolean); const parseOverviewSection = (markdown: string) => extractSection(markdown, ['全文概要', '概要', '摘要', '概览']) || markdown.replace(/^---[\s\S]*?---/, '').trim(); const parseKeywordsSection = (markdown: string, tags: string) => { const section = extractSection(markdown, ['关键词', '关键字', '标签']); const fromSection = parseBulletList(section) .flatMap((line) => line.split(/[,、]/)) .map((item) => item.trim()) .filter(Boolean); if (fromSection.length) { return Array.from(new Set(fromSection)).slice(0, 12); } return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(Boolean))).slice(0, 12); }; const buildMeetingAnalysis = ( sourceAnalysis: MeetingVO['analysis'] | undefined, summaryContent: string | undefined, tags: string, ): MeetingAnalysis => { const parseStructured = (parsed: Record): MeetingAnalysis => { const chapters = Array.isArray(parsed.chapters) ? parsed.chapters : []; const speakerSummaries = Array.isArray(parsed.speakerSummaries) ? parsed.speakerSummaries : []; const keyPoints = Array.isArray(parsed.keyPoints) ? parsed.keyPoints : []; const todos = Array.isArray(parsed.todos) ? parsed.todos : Array.isArray(parsed.actionItems) ? parsed.actionItems : []; return { overview: String(parsed.overview || '').trim(), keywords: Array.from( new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(Boolean)), ).slice(0, 12), chapters: chapters .map((item: any) => ({ time: item?.time ? String(item.time).trim() : undefined, title: String(item?.title || '').trim(), summary: String(item?.summary || '').trim(), })) .filter((item: AnalysisChapter) => item.title || item.summary), speakerSummaries: speakerSummaries .map((item: any) => ({ speaker: String(item?.speaker || '').trim(), summary: String(item?.summary || '').trim(), })) .filter((item: AnalysisSpeakerSummary) => item.speaker || item.summary), keyPoints: keyPoints .map((item: any) => ({ title: String(item?.title || '').trim(), summary: String(item?.summary || '').trim(), speaker: item?.speaker ? String(item.speaker).trim() : undefined, time: item?.time ? String(item.time).trim() : undefined, })) .filter((item: AnalysisKeyPoint) => item.title || item.summary), todos: todos.map((item: any) => String(item).trim()).filter(Boolean).slice(0, 10), }; }; if (sourceAnalysis) { return parseStructured(sourceAnalysis as Record); } const raw = (summaryContent || '').trim(); if (!raw && !tags) return ANALYSIS_EMPTY; const loose = parseLooseJson(raw); if (loose) { return parseStructured(loose); } return { overview: parseOverviewSection(raw), keywords: parseKeywordsSection(raw, tags), chapters: [], speakerSummaries: [], keyPoints: [], todos: [], }; }; function formatTime(ms: number) { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const remainSeconds = seconds % 60; return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`; } function formatPlayerTime(seconds: number) { const safeSeconds = Math.max(0, Math.floor(seconds || 0)); const hours = Math.floor(safeSeconds / 3600); const minutes = Math.floor((safeSeconds % 3600) / 60); const remainSeconds = safeSeconds % 60; if (hours > 0) { return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`; } return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`; } const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void }> = ({ meetingId, onComplete }) => { const [progress, setProgress] = useState(null); useEffect(() => { const fetchProgress = async () => { try { const res = await getMeetingProgress(meetingId); if (res.data?.data) { setProgress(res.data.data); if (res.data.data.percent === 100) { onComplete(); } } } catch { // ignore } }; fetchProgress(); const timer = setInterval(fetchProgress, 3000); return () => clearInterval(timer); }, [meetingId, onComplete]); const percent = progress?.percent || 0; const isError = percent < 0; const formatEta = (seconds?: number) => { if (!seconds || seconds <= 0) return '计算中'; if (seconds < 60) return `${seconds} 秒`; const minutes = Math.floor(seconds / 60); const remainSeconds = seconds % 60; return remainSeconds > 0 ? `${minutes} 分 ${remainSeconds} 秒` : `${minutes} 分钟`; }; return (
AI 智能分析中
{progress?.message || '正在准备计算资源...'} 分析进行中,请稍候,你可以先处理其他工作。
当前进度 {isError ? 'ERROR' : `${percent}%`} 预计剩余 {isError ? '--' : formatEta(progress?.eta)} 任务状态 {isError ? '已中断' : '正常'}
); }; const SpeakerEditor: React.FC<{ meetingId: number; speakerId: string; initialName: string; initialLabel: string; onSuccess: () => void; }> = ({ meetingId, speakerId, initialName, initialLabel, onSuccess }) => { const [name, setName] = useState(initialName || speakerId); const [label, setLabel] = useState(initialLabel); const [loading, setLoading] = useState(false); const { items: speakerLabels } = useDict('biz_speaker_label'); const handleSave = async (event: React.MouseEvent) => { event.stopPropagation(); setLoading(true); try { await updateSpeakerInfo({ meetingId, speakerId, newName: name, label }); message.success('发言人信息已更新'); onSuccess(); } catch (error) { console.error(error); } finally { setLoading(false); } }; return (
event.stopPropagation()}>
发言人姓名 setName(event.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} />
角色标签
); }; type ActiveTranscriptRowProps = { item: MeetingTranscriptVO; meetingId: number; isOwner: boolean; isEditing: boolean; isSaving: boolean; speakerLabelMap: Map; onSeek: (timeMs: number) => void; onStartEdit: (item: MeetingTranscriptVO, event: React.MouseEvent) => void; onDraftBlur: (item: MeetingTranscriptVO, value: string) => void; onDraftKeyDown: (item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent) => void; onSpeakerUpdated: () => void; }; const ActiveTranscriptRow = React.memo(({ item, meetingId, isOwner, isEditing, isSaving, speakerLabelMap, onSeek, onStartEdit, onDraftBlur, onDraftKeyDown, onSpeakerUpdated, }) => { const [draftValue, setDraftValue] = useState(item.content || ''); const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : ''; useEffect(() => { if (isEditing) { setDraftValue(item.content || ''); } }, [isEditing, item.content]); return ( onSeek(item.startTime)}>
{formatTime(item.startTime)}
} className="transcript-avatar" /> {isOwner ? ( )} title="编辑发言人" trigger="click" > event.stopPropagation()}> {item.speakerName || item.speakerId || '发言人'} ) : ( {item.speakerName || item.speakerId || '发言人'} )} {formatTime(item.startTime)} {speakerTagLabel && {speakerTagLabel}}
{isEditing ? (
event.stopPropagation()} > setDraftValue(event.target.value)} onKeyDown={(event) => onDraftKeyDown(item, draftValue, event)} onBlur={(event) => { event.stopPropagation(); onDraftBlur(item, draftValue); }} autoSize={{ minRows: 1, maxRows: 8 }} className="transcript-bubble-input" bordered={false} />
) : (
onStartEdit(item, event) : undefined} > {item.content}
)}
); }, (prevProps, nextProps) => ( prevProps.item === nextProps.item && prevProps.meetingId === nextProps.meetingId && prevProps.isOwner === nextProps.isOwner && prevProps.isEditing === nextProps.isEditing && prevProps.isSaving === nextProps.isSaving && prevProps.speakerLabelMap === nextProps.speakerLabelMap && prevProps.onSeek === nextProps.onSeek && prevProps.onStartEdit === nextProps.onStartEdit && prevProps.onDraftBlur === nextProps.onDraftBlur && prevProps.onDraftKeyDown === nextProps.onDraftKeyDown && prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated )); const MeetingDetail: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [form] = Form.useForm(); const [summaryForm] = Form.useForm(); const [meeting, setMeeting] = useState(null); const [transcripts, setTranscripts] = useState([]); const [loading, setLoading] = useState(true); const [editVisible, setEditVisible] = useState(false); const [summaryVisible, setSummaryVisible] = useState(false); const [actionLoading, setActionLoading] = useState(false); const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | null>(null); const [isEditingSummary, setIsEditingSummary] = useState(false); const [summaryDraft, setSummaryDraft] = useState(''); const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters'); const [expandKeywords, setExpandKeywords] = useState(false); const [expandSummary, setExpandSummary] = useState(false); const [selectedKeywords, setSelectedKeywords] = useState([]); const [addingHotwords, setAddingHotwords] = useState(false); const [editingTranscriptId, setEditingTranscriptId] = useState(null); const [savingTranscriptId, setSavingTranscriptId] = useState(null); const [audioCurrentTime, setAudioCurrentTime] = useState(0); const [audioDuration, setAudioDuration] = useState(0); const [audioPlaying, setAudioPlaying] = useState(false); const [audioPlaybackRate, setAudioPlaybackRate] = useState(1); const [llmModels, setLlmModels] = useState([]); const [prompts, setPrompts] = useState([]); const [, setUserList] = useState([]); const { items: speakerLabels } = useDict('biz_speaker_label'); const audioRef = useRef(null); const summaryPdfRef = useRef(null); const analysis = useMemo( () => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ''), [meeting?.analysis, meeting?.summaryContent, meeting?.tags], ); const hasAnalysis = !!( analysis.overview || analysis.keywords.length || analysis.chapters.length || analysis.speakerSummaries.length || analysis.keyPoints.length || analysis.todos.length ); const visibleKeywords = expandKeywords ? analysis.keywords : analysis.keywords.slice(0, 9); const speakerLabelMap = useMemo( () => new Map(speakerLabels.map((item) => [item.itemValue, item.itemLabel])), [speakerLabels], ); const isOwner = useMemo(() => { if (!meeting) return false; const profileStr = sessionStorage.getItem('userProfile'); if (profileStr) { const profile = JSON.parse(profileStr); return profile.isPlatformAdmin === true || profile.userId === meeting.creatorId; } return false; }, [meeting]); const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2; useEffect(() => { if (!id) return; fetchData(Number(id)); loadAiConfigs(); loadUsers(); }, [id]); useEffect(() => { setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item))); }, [analysis.keywords]); useEffect(() => { if (meeting?.audioSaveStatus === 'FAILED') { message.warning(meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'); } }, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]); useEffect(() => { const audio = audioRef.current; if (!audio) return undefined; const handleLoadedMetadata = () => { setAudioDuration(Number.isFinite(audio.duration) ? audio.duration : 0); setAudioCurrentTime(audio.currentTime || 0); audio.playbackRate = audioPlaybackRate; }; const handleTimeUpdate = () => setAudioCurrentTime(audio.currentTime || 0); const handlePlay = () => setAudioPlaying(true); const handlePause = () => setAudioPlaying(false); const handleEnded = () => setAudioPlaying(false); audio.addEventListener('loadedmetadata', handleLoadedMetadata); audio.addEventListener('timeupdate', handleTimeUpdate); audio.addEventListener('play', handlePlay); audio.addEventListener('pause', handlePause); audio.addEventListener('ended', handleEnded); handleLoadedMetadata(); return () => { audio.removeEventListener('loadedmetadata', handleLoadedMetadata); audio.removeEventListener('timeupdate', handleTimeUpdate); audio.removeEventListener('play', handlePlay); audio.removeEventListener('pause', handlePause); audio.removeEventListener('ended', handleEnded); }; }, [meeting?.audioUrl, audioPlaybackRate]); const fetchData = useCallback(async (meetingId: number) => { try { const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]); setMeeting(detailRes.data.data); setTranscripts(transcriptRes.data.data || []); } catch (error) { console.error(error); } finally { setLoading(false); } }, []); const loadAiConfigs = async () => { try { const [modelRes, promptRes, defaultRes] = await Promise.all([ getAiModelPage({ current: 1, size: 100, type: 'LLM' }), getPromptPage({ current: 1, size: 100 }), getAiModelDefault('LLM'), ]); setLlmModels(modelRes.data.data.records.filter((item) => item.status === 1)); setPrompts(promptRes.data.data.records.filter((item) => item.status === 1)); summaryForm.setFieldsValue({ summaryModelId: defaultRes.data.data?.id }); } catch { // ignore } }; const loadUsers = async () => { try { const users = await listUsers(); setUserList(users || []); } catch { // ignore } }; const handleEditMeeting = () => { if (!meeting || !isOwner) return; form.setFieldsValue({ ...meeting, tags: meeting.tags?.split(',').filter(Boolean), }); setEditVisible(true); }; const handleUpdateBasic = async () => { const values = await form.validateFields(); setActionLoading(true); try { await updateMeetingBasic({ ...values, meetingId: meeting?.id, tags: values.tags?.join(','), }); message.success('会议信息已更新'); setEditVisible(false); fetchData(Number(id)); } catch (error) { console.error(error); } finally { setActionLoading(false); } }; const handleSaveSummary = async () => { setActionLoading(true); try { await updateMeetingSummary({ meetingId: meeting?.id, summaryContent: summaryDraft, }); message.success('总结内容已更新'); setIsEditingSummary(false); fetchData(Number(id)); } catch (error) { console.error(error); } finally { setActionLoading(false); } }; const handleReSummary = async () => { const values = await summaryForm.validateFields(); setActionLoading(true); try { await reSummary({ meetingId: Number(id), summaryModelId: values.summaryModelId, promptId: values.promptId, }); message.success('已重新发起总结任务'); setSummaryVisible(false); fetchData(Number(id)); } catch (error) { console.error(error); } finally { setActionLoading(false); } }; const handleKeywordToggle = (keyword: string, checked: boolean) => { setSelectedKeywords((current) => { if (checked) { return current.includes(keyword) ? current : [...current, keyword]; } return current.filter((item) => item !== keyword); }); }; const handleAddSelectedHotwords = async () => { const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean); if (!keywords.length) { message.warning('请先选择关键词'); return; } setAddingHotwords(true); try { const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' }); const existingWords = new Set( (existingRes.data?.data?.records || []) .map((item) => item.word?.trim()) .filter(Boolean), ); const toCreate = keywords.filter((item) => !existingWords.has(item)); if (!toCreate.length) { message.info('所选关键词已存在于热词库'); return; } await Promise.all( toCreate.map((word) => saveHotWord({ word, pinyinList: [], matchStrategy: 1, category: '', weight: 2, status: 1, isPublic: 0, remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词', }), ), ); const skippedCount = keywords.length - toCreate.length; message.success( skippedCount > 0 ? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项` : `已新增 ${toCreate.length} 个热词`, ); setSelectedKeywords([]); } catch (error) { console.error(error); } finally { setAddingHotwords(false); } }; const handleStartEditTranscript = useCallback((item: MeetingTranscriptVO, event: React.MouseEvent) => { event.stopPropagation(); setEditingTranscriptId(item.id); }, []); const handleCancelEditTranscript = useCallback((event?: React.SyntheticEvent) => { event?.stopPropagation(); setEditingTranscriptId(null); }, []); const handleSaveTranscript = useCallback(async (item: MeetingTranscriptVO, nextContent?: string) => { const content = (nextContent ?? item.content ?? '').trim(); if (!content) { message.warning('转录内容不能为空'); return; } if (!meeting) return; if (content === (item.content || '').trim()) { handleCancelEditTranscript(); return; } setSavingTranscriptId(item.id); try { await updateMeetingTranscript({ meetingId: meeting.id, transcriptId: item.id, content, }); message.success('原文已更新,如需同步摘要请重新总结'); handleCancelEditTranscript(); await fetchData(meeting.id); } catch (error) { console.error(error); } finally { setSavingTranscriptId(null); } }, [fetchData, handleCancelEditTranscript, meeting]); const handleTranscriptDraftKeyDown = useCallback((item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent) => { if (event.key === 'Escape') { handleCancelEditTranscript(); return; } if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); void handleSaveTranscript(item, value); } }, [handleCancelEditTranscript, handleSaveTranscript]); const handleTranscriptDraftBlur = useCallback((item: MeetingTranscriptVO, value: string) => { void handleSaveTranscript(item, value); }, [handleSaveTranscript]); const handleTranscriptSpeakerUpdated = useCallback(() => { if (!meeting) return; void fetchData(meeting.id); }, [fetchData, meeting]); const seekTo = useCallback((timeMs: number) => { if (!audioRef.current) return; audioRef.current.currentTime = timeMs / 1000; audioRef.current.play(); }, []); const toggleAudioPlayback = () => { if (!audioRef.current) return; if (audioRef.current.paused) { audioRef.current.play(); } else { audioRef.current.pause(); } }; const handleAudioProgressChange = (event: React.ChangeEvent) => { const nextTime = Number(event.target.value || 0); setAudioCurrentTime(nextTime); if (audioRef.current) { audioRef.current.currentTime = nextTime; } }; const cyclePlaybackRate = () => { if (!audioRef.current) return; const rates = [1, 1.25, 1.5, 2]; const currentIndex = rates.findIndex((item) => item === audioPlaybackRate); const nextRate = rates[(currentIndex + 1) % rates.length]; audioRef.current.playbackRate = nextRate; setAudioPlaybackRate(nextRate); }; const getFileNameFromDisposition = (disposition?: string, fallback?: string) => { if (!disposition) return fallback || 'summary'; const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]); const normalMatch = disposition.match(/filename="?([^";]+)"?/i); return normalMatch?.[1] || fallback || 'summary'; }; const handleDownloadSummary = async (format: 'pdf' | 'word') => { if (!meeting) return; if (!meeting.summaryContent) { message.warning('当前暂无可下载的 AI 总结'); return; } try { setDownloadLoading(format); const res = await downloadMeetingSummary(meeting.id, format); const contentType = res.headers['content-type'] || (format === 'pdf' ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); if (contentType.includes('application/json')) { const text = await (res.data as Blob).text(); try { const json = JSON.parse(text); message.error(json?.msg || '下载失败'); } catch { message.error('下载失败'); } return; } const blob = new Blob([res.data], { type: contentType }); const url = window.URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = getFileNameFromDisposition( res.headers['content-disposition'], `${(meeting.title || 'meeting').replace(/[\\/:*?"<>|\r\n]/g, '_')}-AI纪要.${format === 'pdf' ? 'pdf' : 'docx'}`, ); document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.URL.revokeObjectURL(url); } catch (error) { console.error(error); message.error(`${format.toUpperCase()} 下载失败`); } finally { setDownloadLoading(null); } }; if (loading) { return (
); } if (!meeting) { return (
); } return (
navigate('/meetings')}>会议中心 会议详情 {meeting.title} {isOwner && ( <EditOutlined style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff', marginLeft: 8 }} onClick={handleEditMeeting} /> )} }> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')} {meeting.tags?.split(',').filter(Boolean).map((tag) => ( {tag} ))} {meeting.participants || '未指定'} {canRetrySummary && ( )} {isOwner && meeting.status === 2 && ( )} {meeting.status === 3 && !!meeting.summaryContent && ( <> )}
{meeting.status === 1 || meeting.status === 2 ? ( fetchData(meeting.id)} /> ) : (
智能速览 {isOwner && analysis.keywords.length > 0 && ( )}
{hasAnalysis ? '已生成' : '待生成'}
关键词
{analysis.keywords.length ? ( <>
{visibleKeywords.map((tag) => ( ))}
{analysis.keywords.length > 9 && ( )} ) : ( 暂无关键词 )}
全文概要
{analysis.overview ? ( <>
220 ? 'summary-copy summary-fade' : 'summary-copy'}> {analysis.overview}
{analysis.overview.length > 220 && ( )} ) : ( 暂无概要 )}
{summaryTab === 'chapters' && (
{analysis.chapters.length ? ( analysis.chapters.map((item, index) => (
{item.time || '--:--'}
{item.title || `章节 ${index + 1}`} {item.summary || '暂无章节描述'}
)) ) : ( )}
)} {summaryTab === 'speakers' && (
{analysis.speakerSummaries.length ? ( analysis.speakerSummaries.map((item, index) => (
{(item.speaker || '发').slice(0, 1)}
{item.speaker || `发言人${index + 1}`}
发言概述
{item.summary || '暂无发言总结'}
)) ) : ( )}
)} {summaryTab === 'actions' && (
{analysis.keyPoints.length ? ( analysis.keyPoints.map((item, index) => (
{String(index + 1).padStart(2, '0')}
{item.title || `要点 ${index + 1}`} {item.summary || '暂无要点说明'} {(item.speaker || item.time) && (
{item.speaker ? {item.speaker} : null} {item.time ? {item.time} : null}
)}
)) ) : ( )}
)} {summaryTab === 'todos' && (
{analysis.todos.length ? ( analysis.todos.map((item, index) => (
{item}
)) ) : ( )}
)}
智能内容由 AI 模型生成,我们不对内容准确性和完整性作任何保证,也不代表我们的观点或态度
原文}> {meeting.audioUrl &&
AI 总结} extra={ meeting.summaryContent && isOwner && ( {isEditingSummary ? ( <> ) : ( )} ) } style={{ height: '100%' }} bodyStyle={{ padding: 24, height: '100%', overflowY: 'auto', overflowX: 'hidden', minWidth: 0 }} >
{meeting.summaryContent ? ( isEditingSummary ? ( setSummaryDraft(event.target.value)} style={{ height: '100%', resize: 'none' }} /> ) : (
{meeting.summaryContent}
) ) : (
{meeting.status === 2 ? ( 正在重新总结... ) : ( )}
)}
)}
{isOwner && ( setEditVisible(false)} confirmLoading={actionLoading} width={600}>
{llmModels.map((model) => ( ))} 重新总结会基于当前语音转录全文重新生成纪要,原有总结内容将被覆盖。 )}
); }; export default MeetingDetail;