import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Alert, Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd'; import { AudioOutlined, CaretRightFilled, ClockCircleOutlined, CopyOutlined, DownloadOutlined, EditOutlined, FastForwardOutlined, FileTextOutlined, LeftOutlined, LinkOutlined, LoadingOutlined, PauseOutlined, RobotOutlined, SyncOutlined, UserOutlined, PlusOutlined, CheckCircleFilled, FilePdfOutlined, FileWordOutlined, ShareAltOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import ReactMarkdown from 'react-markdown'; import { downloadMeetingTranscript, downloadMeetingSummary, getMeetingDetail, getMeetingChapters, getMeetingProgress, getTranscripts, MeetingChapterVO, MeetingProgress, MeetingTranscriptVO, MeetingVO, reSummary, resolveAudioMimeType, resolveMeetingPlaybackAudioUrl, retryMeetingTranscription, updateMeetingBasic, updateMeetingTranscript, updateMeetingSummary, updateSpeakerInfo, } from '../../api/business/meeting'; import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel'; import { getHotWordPage, getPinyinSuggestion, 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'; import PageHeader from '../../components/shared/PageHeader'; import PageContainer from "@/components/shared/PageContainer"; 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[]; }; type WorkspaceTab = 'catalog' | 'transcript'; type ChapterTranscriptLink = { key: string; title: string; timeLabel: string; transcriptIds: number[]; firstTranscriptId: number | null; firstTranscriptStartTime: number | null; }; const ANALYSIS_EMPTY: MeetingAnalysis = { overview: '', keywords: [], chapters: [], speakerSummaries: [], keyPoints: [], todos: [], }; const ACCESS_PASSWORD_PATTERN = /^[A-Za-z0-9]{4}$/; const ACCESS_PASSWORD_SOURCE = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const copyMeetingLink = async (text: string) => { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', 'true'); textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); }; const generateAccessPassword = () => Array.from({ length: 4 }, () => ACCESS_PASSWORD_SOURCE[Math.floor(Math.random() * ACCESS_PASSWORD_SOURCE.length)]).join(''); const normalizeAccessPasswordInput = (value?: string | null) => (value || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 4); const sanitizeDownloadFileName = (value?: string | null, fallback = 'meeting-recording') => { const normalized = (value || '').replace(/[\\/:*?"<>|\r\n]/g, '_').trim(); return normalized || fallback; }; const resolveAudioExtension = (audioUrl?: string) => { const normalizedUrl = audioUrl?.split('#')[0]?.split('?')[0] || ''; return normalizedUrl.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() || 'mp3'; }; const getMeetingAudioDownloadName = (meeting?: Pick | null) => { const audioUrl = resolveMeetingPlaybackAudioUrl(meeting); const extension = resolveAudioExtension(audioUrl); return `${sanitizeDownloadFileName(meeting?.title)}-录音.${extension}`; }; const buildMeetingPreviewUrl = (meetingId?: number, accessPassword?: string) => { if (!meetingId || Number.isNaN(meetingId) || typeof window === 'undefined') { return ''; } const url = new URL(`/meetings/${meetingId}/preview`, window.location.origin); const normalizedPassword = (accessPassword || '').trim(); if (normalizedPassword) { url.searchParams.set('accessPassword', normalizedPassword); } return url.toString(); }; 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')}`; } function parseChapterTimeToMs(value?: string) { const raw = String(value || '').trim(); if (!raw) return null; const matched = raw.match(/(\d{1,2}:\d{2}(?::\d{2})?)/)?.[1]; if (!matched) return null; const parts = matched.split(':').map((item) => Number(item)); if (parts.some((item) => Number.isNaN(item))) { return null; } const totalSeconds = parts.length === 3 ? (parts[0] * 3600) + (parts[1] * 60) + parts[2] : (parts[0] * 60) + parts[1]; return totalSeconds * 1000; } /** * 给 Markdown 文本中的关键词添加虚拟超链接 */ const linkifySummary = (content: string, keywords: string[]) => { if (!content || !keywords.length) return content; // 按长度降序排列关键词,防止短词匹配长词的一部分 const sortedKeywords = [...keywords].sort((a, b) => b.length - a.length); const keywordPattern = sortedKeywords.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); // 这种正则替换需要非常小心,不要破坏已有的 Markdown 结构(链接、代码块等) // 这里的策略是:先按代码块/链接分割,只在纯文本部分进行替换 const parts = content.split(/(```[\s\S]*?```|`[^`]*`|\[[^\]]*\]\([^)]*\))/g); return parts.map(part => { // 如果是代码块或已有链接,不处理 if (part.startsWith('```') || part.startsWith('`') || part.startsWith('[')) { return part; } // 在普通文本中查找并替换关键词 return part.replace(new RegExp(`(${keywordPattern})`, 'g'), '[$1](#/keyword/$1)'); }).join(''); }; const MarkdownSummary: React.FC<{ content: string; keywords: string[]; onKeywordClick: (keyword: string) => void; }> = ({ content, keywords, onKeywordClick }) => { const processedContent = useMemo(() => linkifySummary(content, keywords), [content, keywords]); return ( { // 检查是否是关键词链接(支持 URL 编码后的格式) const isKeywordLink = href?.startsWith('#/keyword/') || (href && decodeURIComponent(href).startsWith('#/keyword/')); if (isKeywordLink) { const decodedHref = decodeURIComponent(href!); const keyword = decodedHref.replace('#/keyword/', ''); return ( { e.preventDefault(); e.stopPropagation(); onKeywordClick(keyword); }} > {children} ); } return {children}; } }} > {processedContent} ); }; const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void; onProgressUpdate?: (meeting: MeetingVO) => void; compact?: boolean; }> = ({ meetingId, onComplete, onProgressUpdate, compact }) => { const [progress, setProgress] = useState(null); useEffect(() => { const fetchProgress = async () => { try { const [progressRes, detailRes] = await Promise.all([ getMeetingProgress(meetingId), getMeetingDetail(meetingId), ]); if (detailRes.data?.data) { onProgressUpdate?.(detailRes.data.data); } if (progressRes.data?.data) { setProgress(progressRes.data.data); if (progressRes.data.data.percent === 100) { onComplete(); } } } catch { // ignore } }; fetchProgress(); const timer = setInterval(fetchProgress, 3000); return () => clearInterval(timer); }, [meetingId, onComplete, onProgressUpdate]); 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} 分钟`; }; if (compact) { return (
AI 智能总结中
{progress?.message || '正在分析内容...'} 预计剩余:{isError ? '--' : formatEta(progress?.eta)}
); } 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 { message } = App.useApp(); 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 }} />
角色标签
); }; const renderContentWithHighlight = (content: string, keyword: string) => { if (!keyword || !content.toLowerCase().includes(keyword.toLowerCase())) { return content; } const parts = content.split(new RegExp(`(${keyword})`, 'gi')); return ( <> {parts.map((part, i) => ( part.toLowerCase() === keyword.toLowerCase() ? ( {part} ) : part ))} ); }; type ActiveTranscriptRowProps = { item: MeetingTranscriptVO; meetingId: number; isOwner: boolean; isEditing: boolean; isSaving: boolean; speakerLabelMap: Map; onPlay: (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; registerRow: (id: number, node: HTMLDivElement | null) => void; isActive: boolean; isLinkedHighlight: boolean; audioPlaying: boolean; highlightKeyword?: string; }; const ActiveTranscriptRow = React.memo(({ item, meetingId, isOwner, isEditing, isSaving, speakerLabelMap, onPlay, onStartEdit, onDraftBlur, onDraftKeyDown, onSpeakerUpdated, registerRow, isActive, isLinkedHighlight, audioPlaying, highlightKeyword = '', }) => { const [draftValue, setDraftValue] = useState(item.content); const rowRef = useRef(null); useEffect(() => { if (isEditing) { setDraftValue(item.content); } }, [isEditing, item.content]); useEffect(() => { if ((isActive && audioPlaying) || (highlightKeyword && item.content.toLowerCase().includes(highlightKeyword.toLowerCase()))) { if (rowRef.current) { rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } }, [isActive, audioPlaying, highlightKeyword, item.content]); const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : ''; return ( onPlay(item.startTime)} >
{ rowRef.current = node; registerRow(item.id, node); }} > } 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} > {renderContentWithHighlight(item.content, highlightKeyword)}
)}
); }, (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.onPlay === nextProps.onPlay && prevProps.onStartEdit === nextProps.onStartEdit && prevProps.onDraftBlur === nextProps.onDraftBlur && prevProps.onDraftKeyDown === nextProps.onDraftKeyDown && prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated && prevProps.registerRow === nextProps.registerRow && prevProps.isActive === nextProps.isActive && prevProps.isLinkedHighlight === nextProps.isLinkedHighlight && prevProps.audioPlaying === nextProps.audioPlaying && prevProps.highlightKeyword === nextProps.highlightKeyword )); const MeetingDetail: React.FC = () => { const { message } = App.useApp(); 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 [meetingChapters, setMeetingChapters] = useState([]); const [loading, setLoading] = useState(true); const [editVisible, setEditVisible] = useState(false); const [summaryVisible, setSummaryVisible] = useState(false); const [summaryRecordVisible, setSummaryRecordVisible] = useState(false); const [actionLoading, setActionLoading] = useState(false); const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | 'transcript' | null>(null); const [isEditingSummary, setIsEditingSummary] = useState(false); const [summaryDraft, setSummaryDraft] = useState(''); const [expandKeywords, setExpandKeywords] = useState(false); const [expandSummary, setExpandSummary] = useState(false); const [selectedKeywords, setSelectedKeywords] = useState([]); const [workspaceTab, setWorkspaceTab] = useState('catalog'); const [addingHotwords, setAddingHotwords] = useState(false); const [editingTranscriptId, setEditingTranscriptId] = useState(null); const [savingTranscriptId, setSavingTranscriptId] = useState(null); const [llmModels, setLlmModels] = useState([]); const [prompts, setPrompts] = useState([]); const [, setUserList] = useState([]); const { items: speakerLabels } = useDict('biz_speaker_label'); const [sharePopoverOpen, setSharePopoverOpen] = useState(false); const [shareSaving, setShareSaving] = useState(false); const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false); const [sharePasswordDraft, setSharePasswordDraft] = useState(''); const [highlightKeyword, setHighlightKeyword] = useState(''); const [linkedTranscriptIds, setLinkedTranscriptIds] = useState([]); const [linkedChapterKey, setLinkedChapterKey] = useState(null); const audioRef = useRef(null); const [audioCurrentTime, setAudioCurrentTime] = useState(0); const [audioDuration, setAudioDuration] = useState(0); const [audioPlaying, setAudioPlaying] = useState(false); const [audioPlaybackRate, setAudioPlaybackRate] = useState(1); const emptyTranscriptNoticeShownRef = useRef(null); const audioPlaybackErrorShownRef = useRef(null); const summaryPdfRef = useRef(null); const transcriptItemRefs = useRef>({}); const pendingTranscriptScrollIdRef = useRef(null); const leftColumnRef = useRef(null); const transcriptSectionRef = useRef(null); const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false); const [floatingTranscriptPlayerLayout, setFloatingTranscriptPlayerLayout] = useState<{ left: number; width: number } | null>(null); const fetchData = useCallback(async (meetingId: number) => { try { const [detailRes, transcriptRes, chapterRes] = await Promise.all([ getMeetingDetail(meetingId), getTranscripts(meetingId), getMeetingChapters(meetingId), ]); setMeeting(detailRes.data.data); setTranscripts(transcriptRes.data.data || []); setMeetingChapters(chapterRes.data.data || []); } catch (error) { console.error(error); } finally { setLoading(false); } }, []); 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 meetingTags = useMemo( () => (meeting?.tags?.split(',').map((item) => item.trim()).filter(Boolean) || []), [meeting?.tags], ); const discussionItems = useMemo(() => { if (analysis.keyPoints.length) { return analysis.keyPoints; } return analysis.chapters.map((item) => ({ title: item.title, summary: item.summary, time: item.time, })); }, [analysis.chapters, analysis.keyPoints]); const speakerLabelMap = useMemo( () => new Map(speakerLabels.map((item) => [item.itemValue, item.itemLabel])), [speakerLabels], ); const previewAccessPassword = useMemo(() => (meeting?.accessPassword || '').trim(), [meeting?.accessPassword]); const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]); const audioDownloadFileName = useMemo(() => getMeetingAudioDownloadName(meeting), [meeting]); const audioDownloadFormatLabel = useMemo( () => resolveAudioExtension(playbackAudioUrl).toUpperCase(), [playbackAudioUrl], ); const keywordItems = useMemo( () => (analysis.keywords.length ? visibleKeywords : meetingTags), [analysis.keywords.length, meetingTags, visibleKeywords], ); const catalogChapterLinks = useMemo(() => { const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index])); const sourceChapters = meetingChapters.length ? meetingChapters : analysis.chapters.map((item) => ({ title: item.title, time: item.time, })); return sourceChapters.map((chapter, index) => { let matchedTranscripts: MeetingTranscriptVO[] = []; const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds) ? chapter.sourceTranscriptIds .map((item) => Number(item)) .filter((item) => Number.isFinite(item) && transcriptIdToIndex.has(item)) : []; if (sourceTranscriptIds.length) { matchedTranscripts = sourceTranscriptIds .map((item) => transcripts[transcriptIdToIndex.get(item)!]) .filter(Boolean); } else if (chapter.startTranscriptId && chapter.endTranscriptId) { const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId)); const endIndex = transcriptIdToIndex.get(Number(chapter.endTranscriptId)); if (startIndex !== undefined && endIndex !== undefined) { matchedTranscripts = transcripts.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex) + 1); } } else { const startMs = typeof chapter.startTime === 'number' ? chapter.startTime : parseChapterTimeToMs(chapter.time); const nextChapterStartMs = sourceChapters .slice(index + 1) .map((item) => (typeof item.startTime === 'number' ? item.startTime : parseChapterTimeToMs(item.time))) .find((item): item is number => item !== null && startMs !== null && item > startMs); if (startMs !== null) { const firstTranscriptIndex = transcripts.findIndex((item) => item.endTime > startMs); if (firstTranscriptIndex >= 0) { const lastTranscriptIndex = nextChapterStartMs === undefined ? transcripts.length : transcripts.findIndex((item) => item.startTime >= nextChapterStartMs); matchedTranscripts = transcripts.slice( firstTranscriptIndex, lastTranscriptIndex >= 0 ? lastTranscriptIndex : transcripts.length, ); } } } return { key: `${chapter.chapterNo ?? index}-${chapter.title || 'chapter'}`, title: chapter.title || `章节 ${index + 1}`, timeLabel: chapter.time || '--:--', transcriptIds: matchedTranscripts.map((item) => item.id), firstTranscriptId: matchedTranscripts[0]?.id ?? null, firstTranscriptStartTime: matchedTranscripts[0]?.startTime ?? null, }; }); }, [analysis.chapters, meetingChapters, transcripts]); const sharePreviewUrl = useMemo(() => { const meetingId = meeting?.id ?? (id ? Number(id) : NaN); return buildMeetingPreviewUrl(meetingId); }, [meeting?.id, id]); const meetingPreviewUrl = useMemo(() => { const meetingId = meeting?.id ?? (id ? Number(id) : NaN); return buildMeetingPreviewUrl(meetingId, previewAccessPassword); }, [meeting?.id, id, previewAccessPassword]); const isOwner = useMemo(() => { if (!meeting) return false; const profileStr = sessionStorage.getItem('userProfile'); if (profileStr) { const profile = JSON.parse(profileStr); return profile.isPlatformAdmin === true || profile.isTenantAdmin === true || profile.userId === meeting.creatorId; } return false; }, [meeting]); const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2; const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl; const emptyTranscriptFailureNotice = useMemo(() => { if (!meeting || meeting.status !== 4 || transcripts.length > 0) { return null; } return { title: '识别已结束,但没有生成可用转录', description: canRetryTranscription ? 'ASR 调用已返回,但当前没有写入任何转录文本,所以列表状态仍显示失败。你可以直接重新识别,或先检查录音是否静音、时长过短、音量过低。' : 'ASR 调用已返回,但当前没有写入任何转录文本,所以列表状态仍显示失败。建议先检查录音是否静音、时长过短、音量过低或音质异常。', hint: canRetryTranscription ? '可以直接点击“重新识别”继续处理。' : '当前没有可重试的音频入口,请先确认原始录音文件是否有效。', }; }, [canRetryTranscription, meeting, transcripts.length]); useEffect(() => { if (!playbackAudioUrl) { setShowFloatingTranscriptPlayer(false); setFloatingTranscriptPlayerLayout(null); return undefined; } const updateFloatingPlayerState = () => { const target = transcriptSectionRef.current; if (!target) { setShowFloatingTranscriptPlayer(false); setFloatingTranscriptPlayerLayout(null); return; } const rect = target.getBoundingClientRect(); const rootRect = leftColumnRef.current?.getBoundingClientRect(); setFloatingTranscriptPlayerLayout({ left: rect.left, width: rect.width, }); if (rootRect) { const isVisible = rect.bottom > rootRect.top + 80 && rect.top < rootRect.bottom - 40; setShowFloatingTranscriptPlayer(isVisible); return; } const isVisible = rect.bottom > 120 && rect.top < window.innerHeight - 80; setShowFloatingTranscriptPlayer(isVisible); }; updateFloatingPlayerState(); const target = transcriptSectionRef.current; const root = leftColumnRef.current; const resizeObserver = target ? new ResizeObserver(() => updateFloatingPlayerState()) : null; if (target && resizeObserver) { resizeObserver.observe(target); } window.addEventListener('resize', updateFloatingPlayerState); window.addEventListener('scroll', updateFloatingPlayerState, { passive: true }); root?.addEventListener('scroll', updateFloatingPlayerState, { passive: true }); return () => { window.removeEventListener('resize', updateFloatingPlayerState); window.removeEventListener('scroll', updateFloatingPlayerState); root?.removeEventListener('scroll', updateFloatingPlayerState); resizeObserver?.disconnect(); }; }, [meeting?.status, playbackAudioUrl]); useEffect(() => { if (!id) return; fetchData(Number(id)); loadAiConfigs(); loadUsers(); }, [id, fetchData]); useEffect(() => { setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item))); }, [analysis.keywords]); useEffect(() => { if (workspaceTab !== 'transcript') { return; } const pendingId = pendingTranscriptScrollIdRef.current; if (!pendingId) { return; } const frameId = window.requestAnimationFrame(() => { const target = transcriptItemRefs.current[pendingId]; if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'center' }); pendingTranscriptScrollIdRef.current = null; } }); return () => window.cancelAnimationFrame(frameId); }, [workspaceTab, transcripts, linkedTranscriptIds]); useEffect(() => { if (meeting?.audioSaveStatus === 'FAILED') { message.warning(meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'); } }, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]); useEffect(() => { if (!meeting?.id || !emptyTranscriptFailureNotice) { return; } if (emptyTranscriptNoticeShownRef.current === meeting.id) { return; } message.warning({ content: emptyTranscriptFailureNotice.title, duration: 4, }); emptyTranscriptNoticeShownRef.current = meeting.id; }, [emptyTranscriptFailureNotice, meeting?.id, message]); useEffect(() => { if (!sharePopoverOpen) { return; } const normalizedPassword = normalizeAccessPasswordInput(meeting?.accessPassword); setSharePasswordEnabled(!!normalizedPassword); setSharePasswordDraft(normalizedPassword); }, [sharePopoverOpen, meeting?.accessPassword]); 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: Number(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, userPrompt: values.userPrompt, }); message.success('已重新发起总结任务'); setSummaryVisible(false); fetchData(Number(id)); } catch (error) { console.error(error); } finally { setActionLoading(false); } }; const handleOpenSummaryDrawer = () => { summaryForm.setFieldsValue({ summaryModelId: summaryForm.getFieldValue('summaryModelId') ?? llmModels.find((model) => model.isDefault === 1)?.id ?? llmModels[0]?.id, promptId: summaryForm.getFieldValue('promptId') ?? prompts[0]?.id, userPrompt: meeting?.lastUserPrompt ?? '', }); setSummaryVisible(true); }; const seekTo = useCallback((timeMs: number) => { if (!audioRef.current) return; audioRef.current.currentTime = timeMs / 1000; audioRef.current.play(); }, []); const handleKeywordClick = useCallback((keyword: string) => { const firstMatch = transcripts.find((item) => item.content.toLowerCase().includes(keyword.toLowerCase()) ); if (firstMatch) { setWorkspaceTab('transcript'); setLinkedTranscriptIds([]); setLinkedChapterKey(null); setHighlightKeyword(keyword); seekTo(firstMatch.startTime); message.info(`已跳转至关键词 "${keyword}" 所在位置`); } else { message.warning(`在转录原文中未找到关键词 "${keyword}"`); } }, [transcripts, seekTo, message]); const handleTranscriptRowPlay = useCallback((timeMs: number) => { setLinkedTranscriptIds([]); setLinkedChapterKey(null); seekTo(timeMs); }, [seekTo]); const handleLocateChapterTranscript = useCallback((chapterIndex: number) => { const targetLink = catalogChapterLinks[chapterIndex]; if (!targetLink || !targetLink.transcriptIds.length || targetLink.firstTranscriptId === null || targetLink.firstTranscriptStartTime === null) { message.warning('当前章节暂未匹配到可关联的转录原文'); return; } setWorkspaceTab('transcript'); setHighlightKeyword(''); setLinkedTranscriptIds(targetLink.transcriptIds); setLinkedChapterKey(targetLink.key); pendingTranscriptScrollIdRef.current = targetLink.firstTranscriptId; seekTo(targetLink.firstTranscriptStartTime); }, [catalogChapterLinks, message, seekTo]); const handleRetryTranscription = async () => { setActionLoading(true); try { await retryMeetingTranscription(Number(id)); message.success('已重新提交识别任务'); await 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) => (async () => { let pinyinList: string[] = []; try { const pinyinRes = await getPinyinSuggestion(word); pinyinList = (pinyinRes.data?.data || []).map((item) => item.trim()).filter(Boolean); } catch { pinyinList = []; } return saveHotWord({ word, pinyinList, matchStrategy: 1, category: '', weight: 2, status: 1, 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 registerTranscriptRow = useCallback((transcriptId: number, node: HTMLDivElement | null) => { transcriptItemRefs.current[transcriptId] = node; }, []); const handleAudioPlaybackError = useCallback(() => { const currentAudioUrl = playbackAudioUrl || ''; if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) { return; } const normalizedUrl = currentAudioUrl.split('#')[0]?.split('?')[0]?.toLowerCase() || ''; const isM4a = normalizedUrl.endsWith('.m4a'); message.warning( isM4a ? '当前 m4a 文件在本机浏览器中无法直接播放。已确认文件与服务端响应基本正常,更可能是浏览器对该录音参数或容器实现的兼容性问题。建议优先使用 mp3、wav,或下载到本地播放。' : '当前音频文件无法播放,请检查文件是否损坏或格式是否兼容。', ); audioPlaybackErrorShownRef.current = currentAudioUrl; setAudioPlaying(false); }, [message, playbackAudioUrl]); 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); const handleError = () => handleAudioPlaybackError(); audio.addEventListener('loadedmetadata', handleLoadedMetadata); audio.addEventListener('durationchange', handleLoadedMetadata); audio.addEventListener('canplay', handleLoadedMetadata); audio.addEventListener('timeupdate', handleTimeUpdate); audio.addEventListener('play', handlePlay); audio.addEventListener('pause', handlePause); audio.addEventListener('ended', handleEnded); audio.addEventListener('error', handleError); handleLoadedMetadata(); return () => { audio.removeEventListener('loadedmetadata', handleLoadedMetadata); audio.removeEventListener('durationchange', handleLoadedMetadata); audio.removeEventListener('canplay', handleLoadedMetadata); audio.removeEventListener('timeupdate', handleTimeUpdate); audio.removeEventListener('play', handlePlay); audio.removeEventListener('pause', handlePause); audio.removeEventListener('ended', handleEnded); audio.removeEventListener('error', handleError); }; }, [audioPlaybackRate, meeting?.status, playbackAudioUrl, handleAudioPlaybackError]); 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); } }; const handleDownloadTranscript = async () => { if (!meeting) return; if (transcripts.length === 0) { message.warning('当前暂无可下载的会议转录'); return; } try { setDownloadLoading('transcript'); const res = await downloadMeetingTranscript(meeting.id); const contentType = res.headers['content-type'] || 'text/markdown; charset=UTF-8'; 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'], `${sanitizeDownloadFileName(meeting.title, 'meeting-transcript')}-Transcript.md`, ); document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.URL.revokeObjectURL(url); } catch (error) { console.error(error); message.error('转录下载失败'); } finally { setDownloadLoading(null); } }; const handleDownloadAudio = () => { if (!playbackAudioUrl) { message.warning('当前暂无可下载的会议录音'); return; } const anchor = document.createElement('a'); anchor.href = playbackAudioUrl; anchor.download = audioDownloadFileName; anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; document.body.appendChild(anchor); anchor.click(); anchor.remove(); }; const handleCopyPreviewLink = async () => { if (!sharePreviewUrl) { message.error('预览链接暂不可用'); return; } try { await copyMeetingLink(sharePreviewUrl); message.success('预览链接已复制'); } catch (error) { console.error(error); message.error('复制预览链接失败'); } }; const handleOpenPreview = () => { if (!sharePreviewUrl) { message.error('预览链接暂不可用'); return; } window.open(sharePreviewUrl, '_blank', 'noopener,noreferrer'); }; const handleSharePopoverOpenChange = (open: boolean) => { setSharePopoverOpen(open); }; const handleSharePasswordToggle = (checked: boolean) => { setSharePasswordEnabled(checked); setSharePasswordDraft((current) => { const normalizedCurrent = normalizeAccessPasswordInput(current); if (checked) { return normalizedCurrent || generateAccessPassword(); } return ''; }); }; const handleRegenerateSharePassword = () => { setSharePasswordEnabled(true); setSharePasswordDraft(generateAccessPassword()); }; const handleSaveShareAccess = async () => { if (!meeting) { return; } const normalizedPassword = normalizeAccessPasswordInput(sharePasswordDraft); if (sharePasswordEnabled && !ACCESS_PASSWORD_PATTERN.test(normalizedPassword)) { message.error('\u8bbf\u95ee\u5bc6\u7801\u9700\u4e3a 4 \u4f4d\u82f1\u6587\u6216\u6570\u5b57'); return; } setShareSaving(true); try { await updateMeetingBasic({ meetingId: meeting.id, accessPassword: sharePasswordEnabled ? normalizedPassword : '', }); const nextPassword = sharePasswordEnabled ? normalizedPassword : ''; setMeeting((current) => (current ? { ...current, accessPassword: nextPassword } : current)); message.success( sharePasswordEnabled ? '\u9884\u89c8\u5bc6\u7801\u5df2\u66f4\u65b0' : '\u9884\u89c8\u5bc6\u7801\u5df2\u5173\u95ed', ); setSharePopoverOpen(false); } catch (error) { console.error(error); message.error('\u4fdd\u5b58\u9884\u89c8\u5bc6\u7801\u5931\u8d25'); } finally { setShareSaving(false); } }; const shareQrContent = sharePreviewUrl ? (
{isOwner ? (
{'\u9884\u89c8\u5bc6\u7801'} {sharePasswordEnabled && previewAccessPassword ? `访问密码${previewAccessPassword}` : '关闭后将不需要访问密码'}
{sharePasswordEnabled ? (
setSharePasswordDraft(normalizeAccessPasswordInput(event.target.value))} />
) : null}
) : null}
{'\u4f7f\u7528\u624b\u673a\u626b\u7801\u540e\u5c06\u8df3\u8f6c\u5230\u4f1a\u8bae\u9884\u89c8\u9875\uff0c\u82e5\u5df2\u5f00\u542f\u5bc6\u7801\u9700\u624b\u52a8\u8f93\u5165\u3002'}
{sharePreviewUrl}
) : null; if (loading) { return (
); } if (!meeting) { return (
); } return (
{meeting.title} {isOwner && ( )}
{dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')} {meeting.participants || '未指定'}
)} extra={( {canRetrySummary && ( )} {canRetryTranscription && ( )} {isOwner && meeting.status === 2 && ( )} {(playbackAudioUrl || transcripts.length > 0 || (meeting.status === 3 && !!meeting.summaryContent)) && ( , onClick: handleDownloadAudio, }] : []), ...(transcripts.length > 0 ? [{ key: 'transcript', label: '下载转录 MD', icon: , onClick: handleDownloadTranscript, disabled: downloadLoading === 'transcript', }] : []), ...(meeting.status === 3 && !!meeting.summaryContent ? [ { key: 'pdf', label: '下载 PDF', icon: , onClick: () => handleDownloadSummary('pdf'), disabled: downloadLoading === 'pdf', }, { key: 'word', label: '下载 Word', icon: , onClick: () => handleDownloadSummary('word'), disabled: downloadLoading === 'word', }, ] : []), ], }} placement="bottomRight" > )} {shareQrContent ? ( ) : null} )} />
{meeting.status === 1 ? ( fetchData(meeting.id)} onProgressUpdate={(updated) => { if (updated.status !== meeting.status) { void fetchData(updated.id); } }} /> ) : (
关键词
{analysis.keywords.length > 9 ? ( ) : null} {isOwner && analysis.keywords.length > 0 ? ( ) : null}
{keywordItems.length ? ( keywordItems.map((tag) => { const isSelected = selectedKeywords.includes(tag); const isHighlighted = highlightKeyword === tag; return (
{ if (isOwner && analysis.keywords.length) { handleKeywordToggle(tag, !isSelected); } handleKeywordClick(tag); }} style={isHighlighted ? { borderColor: '#5f51ff', backgroundColor: 'rgba(95, 81, 255, 0.1)' } : {}} > #{tag} {isOwner && isSelected && }
); }) ) : ( 暂无关键词 )}
AI 智能总结
{meeting.summaryContent ? ( // ) : null} {meeting.summaryContent && isOwner ? ( ) : null}
{meeting.status === 2 ? (
fetchData(meeting.id)} compact />
) : meeting.summaryContent ? (
) : false ? ( <>
会议概述
{analysis.overview ? (
220 ? 'summary-copy summary-fade' : 'summary-copy'}> {analysis.overview}
{analysis.overview.length > 220 && ( )}
) : ( 暂无概述 )}
主要讨论点
{discussionItems.length ? (
{discussionItems.map((item, index) => (
{item.title || `讨论点 ${index + 1}`} {(item.speaker || item.time) && (
{item.speaker ? {item.speaker} : null} {item.time ? {item.time} : null}
)}
{item.summary || '暂无讨论摘要'}
))}
) : ( 暂无主要讨论点 )}
{analysis.todos.length ? (
待办事项
{analysis.todos.map((item, index) => (
{item}
))}
) : null} ) : (
)} {!emptyTranscriptFailureNotice && (
智能内容由 AI 模型生成,我们不对内容准确性和完整性作任何保证,也不代表我们的观点或态度
)}
原文}> {playbackAudioUrl && ( )} {emptyTranscriptFailureNotice && (
当前没有可展示的转录内容
{emptyTranscriptFailureNotice.description}
)} {meeting.audioSaveStatus === 'FAILED' && ( )} { const nextStartTime = transcripts[index + 1]?.startTime || Infinity; const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime; return ( ); }} locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} />
{(
{playbackAudioUrl && ( )}
{workspaceTab === 'catalog' ? (
{catalogChapterLinks.length ? ( catalogChapterLinks.map((chapter, index) => (
{chapter.timeLabel}
{chapter.title}
)) ) : ( )}
) : ( <> {emptyTranscriptFailureNotice && (
当前没有可展示的转录内容
{emptyTranscriptFailureNotice.description}
)} {meeting.audioSaveStatus === 'FAILED' && ( )} { const nextStartTime = transcripts[index + 1]?.startTime || Infinity; const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime; return ( ); }} locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} /> )}
)}
)}
{playbackAudioUrl && showFloatingTranscriptPlayer && floatingTranscriptPlayerLayout && (
{formatPlayerTime(audioCurrentTime)} {formatPlayerTime(audioDuration)}
)} {isOwner && ( setEditVisible(false)} confirmLoading={actionLoading} width={600} forceRender>
{llmModels.map((model) => ( ))} 重新总结会基于当前语音转录全文重新生成纪要,原有总结内容将被覆盖。 )}
); }; export default MeetingDetail;