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, Radio, 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, InfoCircleOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import ReactMarkdown from 'react-markdown'; import { downloadMeetingTranscript, downloadMeetingSummary, getMeetingDetail, getMeetingChapters, getMeetingProgress, getMeetingShareConfig, getTranscripts, MeetingChapterVO, MeetingProgress, SummaryDetailLevel, MeetingTranscriptVO, MeetingVO, reSummary, resolveAudioMimeType, resolveMeetingPlaybackAudioUrl, retryMeetingChapter, retryMeetingSummary, retryMeetingTranscription, retryScheduleMeeting, 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; }; type MeetingStateNotice = { title: string; description: string; type: 'info' | 'warning'; hasFallbackContent: boolean; scope: 'summary' | 'catalog' | 'global'; }; const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000; 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 = (baseUrl?: string, meetingId?: number, accessPassword?: string) => { const normalizedBaseUrl = (baseUrl || '').trim(); if (!normalizedBaseUrl || !meetingId || Number.isNaN(meetingId)) { return ''; } const safeBaseUrl = normalizedBaseUrl.endsWith('/') ? normalizedBaseUrl.slice(0, -1) : normalizedBaseUrl; const url = new URL(`${safeBaseUrl}/meetings/${meetingId}/preview`); 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; } type MeetingProgressPhase = 'queued' | 'asr' | 'chapter' | 'summary' | 'terminal'; const meetingProgressTerminalRefreshCache = new Map(); const meetingProgressPhaseRefreshCache = new Map(); const DETAIL_STAGE_STEP_ITEMS = [ { code: 'INITIALIZING', label: '数据初始化', hint: '完成会议数据准备' }, { code: 'TRANSCRIBING', label: '转译音频', hint: '完成语音转写' }, { code: 'SUMMARIZING', label: '生成总结', hint: '完成 AI 内容处理' }, { code: 'COMPLETED', label: '处理完成', hint: '已全部完成' }, ] as const; const resolveProgressPhase = (progress: MeetingProgress | null | undefined): MeetingProgressPhase => { const unifiedStatusCode = progress?.unifiedStatus?.statusCode; if (unifiedStatusCode?.startsWith('FAILED_') || unifiedStatusCode === 'COMPLETED') { return 'terminal'; } if (unifiedStatusCode === 'SUMMARIZING') { return 'summary'; } if (unifiedStatusCode === 'TRANSCRIBING') { return 'asr'; } if (unifiedStatusCode === 'INITIALIZING' || unifiedStatusCode === 'WAITING_UPLOAD') { return 'queued'; } const percent = progress?.percent ?? 0; if (percent < 0 || percent >= 100) { return 'terminal'; } if (percent >= 90) { return 'summary'; } if (percent >= 85) { return 'chapter'; } if (percent >= 5) { return 'asr'; } return 'queued'; }; const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void; onRefreshNeeded?: (phase: MeetingProgressPhase) => void; onProgressChange?: (progress: MeetingProgress | null) => void; compact?: boolean; inline?: boolean; }> = ({ meetingId, onComplete, onRefreshNeeded, onProgressChange, compact, inline }) => { const [progress, setProgress] = useState(null); const onCompleteRef = useRef(onComplete); const onRefreshNeededRef = useRef(onRefreshNeeded); const onProgressChangeRef = useRef(onProgressChange); useEffect(() => { onCompleteRef.current = onComplete; onRefreshNeededRef.current = onRefreshNeeded; onProgressChangeRef.current = onProgressChange; }, [onComplete, onRefreshNeeded, onProgressChange]); useEffect(() => { let completed = false; let timer: ReturnType | null = null; let requesting = false; const fetchProgress = async () => { if (completed || requesting) { return; } requesting = true; let shouldContinue = true; try { const progressRes = await getMeetingProgress(meetingId, { suppressErrorToast: true }); if (progressRes.data?.data) { const nextProgress = progressRes.data.data; setProgress(nextProgress); onProgressChangeRef.current?.(nextProgress); const phase = resolveProgressPhase(nextProgress); const previousPhase = meetingProgressPhaseRefreshCache.get(meetingId); meetingProgressPhaseRefreshCache.set(meetingId, phase); if ((phase === 'chapter' || phase === 'summary') && previousPhase !== phase) { onRefreshNeededRef.current?.(phase); } if (nextProgress.percent === 100 || nextProgress.percent < 0) { completed = true; shouldContinue = false; const terminalKey = `${nextProgress.updateAt}:${nextProgress.percent}`; if (meetingProgressTerminalRefreshCache.get(meetingId) !== terminalKey) { meetingProgressTerminalRefreshCache.set(meetingId, terminalKey); onCompleteRef.current?.(); } } else { meetingProgressTerminalRefreshCache.delete(meetingId); } } } catch { // ignore } finally { requesting = false; if (!completed && shouldContinue) { timer = setTimeout(() => { void fetchProgress(); }, 3000); } } }; void fetchProgress(); return () => { completed = true; if (timer) { clearTimeout(timer); } }; }, [meetingId]); const percent = progress?.percent || 0; const isError = percent < 0; const unifiedStatusText = progress?.unifiedStatus?.statusText; const unifiedStatusMessage = progress?.unifiedStatus?.message; const primaryStatusText = unifiedStatusText || (isError ? '处理失败' : '处理中'); 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 (inline) { return (
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在生成最新结果...'} {isError ? '失败' : `${percent}%`}
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
); } if (compact) { return (
AI 智能总结中
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在分析内容...'} {`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
); } return (
AI 智能分析中
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在准备计算资源...'} 分析进行中,请稍候,你可以先处理其他工作。
当前进度 {isError ? 'ERROR' : `${percent}%`} 预计剩余 {isError ? '--' : formatEta(progress?.eta)} 任务状态 {isError ? '失败' : '正常'}
); }; const DETAIL_STAGE_STEP_DISPLAY_ITEMS = [ { code: 'INITIALIZING', label: '数据初始化', hint: '完成会议数据准备' }, { code: 'TRANSCRIBING', label: '转译音频', hint: '完成语音转写' }, { code: 'SUMMARIZING', label: '生成总结', hint: '生成总结与 AI 目录' }, { code: 'COMPLETED', label: '处理完成', hint: '全部任务处理完成' }, ] as const; const UnifiedMeetingProgressDisplay: React.FC<{ progress: MeetingProgress | null; compact?: boolean; inline?: boolean; }> = ({ progress, compact, inline }) => { const percent = progress?.percent || 0; const isError = percent < 0; const unifiedStatusCode = progress?.unifiedStatus?.statusCode; const unifiedStatusText = progress?.unifiedStatus?.statusText; const unifiedStatusMessage = progress?.unifiedStatus?.message; const failedStageCode = progress?.unifiedStatus?.failedStageCode; const primaryStatusText = unifiedStatusText || (isError ? '处理失败' : '处理中'); 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} 分钟`; }; const resolveStageCode = () => { if (failedStageCode) { return failedStageCode; } if (unifiedStatusCode === 'WAITING_UPLOAD' || unifiedStatusCode === 'INITIALIZING') { return 'INITIALIZING'; } if (unifiedStatusCode === 'TRANSCRIBING') { return 'TRANSCRIBING'; } if (unifiedStatusCode === 'SUMMARIZING') { return 'SUMMARIZING'; } if (unifiedStatusCode === 'COMPLETED') { return 'COMPLETED'; } const phase = resolveProgressPhase(progress); if (phase === 'queued') { return 'INITIALIZING'; } if (phase === 'asr') { return 'TRANSCRIBING'; } if (phase === 'chapter' || phase === 'summary') { return 'SUMMARIZING'; } return 'COMPLETED'; }; const currentStageCode = resolveStageCode(); const currentStageIndex = Math.max(0, DETAIL_STAGE_STEP_DISPLAY_ITEMS.findIndex((item) => item.code === currentStageCode)); const isFailedStage = unifiedStatusCode?.startsWith('FAILED_') || isError; const isCompletedStage = unifiedStatusCode === 'COMPLETED' || percent === 100; const progressText = isError ? '--' : `${Math.max(percent, 0)}%`; const currentStageLabel = DETAIL_STAGE_STEP_DISPLAY_ITEMS[currentStageIndex]?.label || primaryStatusText; const currentStageDisplay = isFailedStage ? `${currentStageLabel}失败` : currentStageLabel; const helperText = unifiedStatusMessage || progress?.message || (isError ? '当前阶段执行失败,请稍后重试。' : '阶段状态已与安卓端统一。'); const renderStageTimeline = (options?: { compact?: boolean; inline?: boolean; }) => { const compactMode = options?.compact; const inlineMode = options?.inline; const containerStyle: React.CSSProperties = inlineMode ? { marginTop: 10, display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, } : compactMode ? { marginTop: 18, display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%', maxWidth: 560, } : { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, }; return (
{DETAIL_STAGE_STEP_DISPLAY_ITEMS.map((item, index) => { const isCurrent = index === currentStageIndex; const isDone = isCompletedStage || (!isFailedStage && index < currentStageIndex); const isFailed = isFailedStage && isCurrent; const dotBg = isFailed ? '#ff4d4f' : isDone ? '#52c41a' : isCurrent ? '#4f46e5' : '#d9d9d9'; const textColor = isFailed ? '#cf1322' : isDone || isCurrent ? '#1f1f1f' : '#8c8c8c'; const cardPadding = inlineMode ? '8px 6px' : compactMode ? '10px 8px' : '0'; const cardRadius = inlineMode || compactMode ? 12 : 0; const cardBorder = inlineMode || compactMode ? `1px solid ${isFailed ? '#ffccc7' : isCurrent ? '#c7d2fe' : '#e5e7eb'}` : 'none'; const cardBackground = inlineMode || compactMode ? isFailed ? '#fff2f0' : isCurrent ? '#eef2ff' : isDone ? '#f6ffed' : '#fafafa' : 'transparent'; return (
{isDone ? '√' : index + 1}
{isFailed && item.code === currentStageCode ? `${item.label}失败` : item.label}
{!compactMode && !inlineMode ? ( {item.hint} ) : null}
{!inlineMode && !compactMode && index < DETAIL_STAGE_STEP_DISPLAY_ITEMS.length - 1 ? (
) : null} ); })}
); }; if (inline) { return (
{helperText} {isError ? '失败' : `${percent}%`}
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`} {renderStageTimeline({ inline: true })}
); } if (compact) { return (
{primaryStatusText}
{helperText} {`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`} {renderStageTimeline({ compact: true })}
); } return (
{primaryStatusText} {helperText}
当前进度
{progressText}
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
{renderStageTimeline()} 当前阶段 {DETAIL_STAGE_STEP_DISPLAY_ITEMS[currentStageIndex]?.label || primaryStatusText} 任务状态 {isError ? '失败' : isCompletedStage ? '已完成' : '处理中'} 可查看内容 {[ progress?.unifiedStatus?.canViewTranscript ? '转录' : null, progress?.unifiedStatus?.canViewAiChapters ? '目录' : null, progress?.unifiedStatus?.canViewSummary ? '总结' : null, ].filter(Boolean).join(' / ') || '暂无'}
处理进度仅作辅助展示,阶段状态以统一状态机为准。
); }; 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 [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('transcript'); const [addingHotwords, setAddingHotwords] = useState(false); const [editingTranscriptId, setEditingTranscriptId] = useState(null); const [savingTranscriptId, setSavingTranscriptId] = useState(null); const [llmModels, setLlmModels] = useState([]); const [prompts, setPrompts] = useState([]); const [userList, 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 [meetingShareBaseUrl, setMeetingShareBaseUrl] = useState(''); 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 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 [generationProgress, setGenerationProgress] = useState(null); const autoOpenedCatalogAttemptRef = useRef(null); const fetchData = useCallback(async (meetingId: number) => { try { const [detailRes, transcriptRes, chapterRes, shareConfigRes, progressRes] = await Promise.all([ getMeetingDetail(meetingId), getTranscripts(meetingId), getMeetingChapters(meetingId), getMeetingShareConfig().catch(() => null), getMeetingProgress(meetingId, { suppressErrorToast: true }).catch(() => null), ]); setMeeting(detailRes.data.data); setTranscripts(transcriptRes.data.data || []); setMeetingChapters(chapterRes.data.data || []); setMeetingShareBaseUrl(shareConfigRes?.data?.data?.h5BaseUrl || ''); setGenerationProgress(progressRes?.data?.data || null); } 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: MeetingChapterVO[] = meetingChapters.length ? meetingChapters : analysis.chapters.map((item) => ({ title: item.title, time: item.time, } as MeetingChapterVO)); return sourceChapters.map((chapter, index) => { let matchedTranscripts: MeetingTranscriptVO[] = []; const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds) ? chapter.sourceTranscriptIds .map((item: number) => Number(item)) .filter((item: number) => Number.isFinite(item) && transcriptIdToIndex.has(item)) : []; if (sourceTranscriptIds.length) { matchedTranscripts = sourceTranscriptIds .map((item: number) => 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: MeetingChapterVO) => (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(meetingShareBaseUrl, meetingId); }, [meetingShareBaseUrl, meeting?.id, id]); const meetingPreviewUrl = useMemo(() => { const meetingId = meeting?.id ?? (id ? Number(id) : NaN); return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId, previewAccessPassword); }, [meetingShareBaseUrl, meeting?.id, id, previewAccessPassword]); const summaryModelDisplayName = useMemo(() => { const matchedModel = llmModels.find((item) => item.id === meeting?.summaryModelId); if (matchedModel?.modelName) { return matchedModel.modelName; } if (meeting?.summaryModelId) { return `模型 #${meeting.summaryModelId}`; } return '未配置'; }, [llmModels, meeting?.summaryModelId]); const promptDisplayName = useMemo(() => { const matchedPrompt = prompts.find((item) => item.id === meeting?.promptId); if (matchedPrompt?.templateName) { return matchedPrompt.templateName; } if (meeting?.promptId) { return `模板 #${meeting.promptId}`; } return '未配置'; }, [meeting?.promptId, prompts]); const hotWordGroupDisplayName = useMemo(() => { if (meeting?.hotWordGroupName?.trim()) { return meeting.hotWordGroupName.trim(); } if (meeting?.hotWordGroupId) { return `热词组 #${meeting.hotWordGroupId}`; } return '未使用'; }, [meeting?.hotWordGroupId, meeting?.hotWordGroupName]); 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 aiCatalogEnabled = meeting?.aiCatalogEnabled !== false; const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2 && meeting?.latestSummaryAttemptStatus !== 3 && (!aiCatalogEnabled || meeting?.latestChapterAttemptStatus !== 3); const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl; const canRetryFailedSummaryTask = isOwner && meeting?.latestSummaryAttemptStatus === 3 && meeting?.status !== 2; const canRetryFailedChapterTask = aiCatalogEnabled && isOwner && meeting?.latestChapterAttemptStatus === 3 && meeting?.status !== 2; const canRetrySchedule = isOwner && meeting?.status === 0 && (!generationProgress || generationProgress.percent <= 0) && !!generationProgress?.queuedAt && dayjs().diff(dayjs(generationProgress.queuedAt)) >= QUEUED_RETRY_THRESHOLD_MS; const handleRetrySchedule = async () => { if (!meeting) return; setActionLoading(true); try { const response = await retryScheduleMeeting(meeting.id); if (response.data?.data) { message.success("已触发重新调度"); } else { message.info("当前没有可重新调度的排队任务"); } await fetchData(meeting.id); } catch { message.error("重新调度失败"); } finally { setActionLoading(false); } }; const hasSummaryContent = Boolean(meeting?.summaryContent?.trim()); const hasCatalogContent = aiCatalogEnabled && catalogChapterLinks.length > 0; const generationFailureNotice = useMemo(() => { if (!meeting || meeting.status !== 4) { return null; } const hasFallbackContent = hasSummaryContent || hasCatalogContent; if (aiCatalogEnabled && meeting.latestChapterAttemptStatus === 3) { const detail = meeting.latestChapterAttemptErrorMsg || '章节生成失败'; return { title: hasFallbackContent ? '历史内容仍可查看' : '本次 AI 目录生成失败', description: hasFallbackContent ? `最近一次 AI 目录生成失败,当前展示的是最近一次成功生成的内容。失败原因:${detail}` : `AI 目录生成失败,当前没有可展示的历史目录或总结内容。失败原因:${detail}`, type: hasFallbackContent ? 'info' : 'warning', hasFallbackContent, scope: 'catalog', }; } if (meeting.latestSummaryAttemptStatus === 3) { const detail = meeting.latestSummaryAttemptErrorMsg || '总结生成失败'; return { title: hasFallbackContent ? '历史总结可用' : '本次总结生成失败', description: hasFallbackContent ? `最近一次重新总结失败,当前展示的是最近一次成功生成的总结内容。失败原因:${detail}` : `总结生成失败,当前没有可展示的历史总结内容。失败原因:${detail}`, type: hasFallbackContent ? 'info' : 'warning', hasFallbackContent, scope: 'summary', }; } return { title: '会议处理异常', description: '会议在处理过程中遇到了问题。您可以尝试重新发起识别或总结。', type: hasFallbackContent ? 'info' : 'warning', hasFallbackContent, scope: 'global', }; }, [aiCatalogEnabled, hasCatalogContent, hasSummaryContent, meeting]); const summaryPanelNotice = useMemo(() => { if (!meeting || !hasSummaryContent) { return null; } if (meeting.status === 2) { return { title: '正在生成新版总结', description: '当前展示的是上一版已成功生成的总结,系统会在新版完成后自动替换。', type: 'info', hasFallbackContent: true, scope: 'summary', }; } return null; }, [generationFailureNotice, hasSummaryContent, meeting]); const catalogPanelNotice = useMemo(() => { if (!aiCatalogEnabled || !generationFailureNotice || generationFailureNotice.scope !== 'catalog') { return null; } if (generationFailureNotice.hasFallbackContent && hasCatalogContent) { return { title: '当前展示的是历史成功目录', description: generationFailureNotice.description, type: generationFailureNotice.type, hasFallbackContent: true, scope: 'catalog', }; } return generationFailureNotice; }, [aiCatalogEnabled, generationFailureNotice, hasCatalogContent]); 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 (meeting?.status !== 1 && meeting?.status !== 2) { setGenerationProgress(null); autoOpenedCatalogAttemptRef.current = null; } }, [meeting?.id, meeting?.status]); useEffect(() => { if (!aiCatalogEnabled && workspaceTab === 'catalog') { setWorkspaceTab('transcript'); } }, [aiCatalogEnabled, workspaceTab]); useEffect(() => { const attemptKey = String(meeting?.latestChapterAttemptTaskId ?? meeting?.latestSummaryAttemptTaskId ?? ''); if (meeting?.status !== 2 || !attemptKey) { return; } if (!aiCatalogEnabled || (generationProgress?.percent ?? 0) < 88 || catalogChapterLinks.length === 0) { return; } if (autoOpenedCatalogAttemptRef.current === attemptKey) { return; } autoOpenedCatalogAttemptRef.current = attemptKey; setWorkspaceTab('catalog'); }, [ aiCatalogEnabled, catalogChapterLinks.length, generationProgress?.percent, meeting?.latestChapterAttemptTaskId, meeting?.latestSummaryAttemptTaskId, meeting?.status, ]); 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, summaryDetailLevel: values.summaryDetailLevel as SummaryDetailLevel, }); setMeeting((current) => current ? { ...current, status: 2, latestSummaryAttemptStatus: 0, latestSummaryAttemptErrorMsg: undefined, } : current); setGenerationProgress({ percent: 90, message: '已重新发起总结任务', updateAt: Date.now(), eta: 0, }); message.success('操作成功'); await fetchData(Number(id)); } catch (error) { console.error(error); } finally { setActionLoading(false); } }; const handleOpenSummaryDrawer = () => { summaryForm.setFieldsValue({ summaryModelId: summaryForm.getFieldValue('summaryModelId') ?? meeting?.summaryModelId ?? llmModels.find((model) => model.isDefault === 1)?.id ?? llmModels[0]?.id, promptId: summaryForm.getFieldValue('promptId') ?? meeting?.promptId ?? prompts[0]?.id, userPrompt: meeting?.lastUserPrompt ?? '', summaryDetailLevel: summaryForm.getFieldValue('summaryDetailLevel') ?? meeting?.summaryDetailLevel ?? 'STANDARD', }); 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 handleRetrySummaryTask = async () => { setActionLoading(true); try { await retryMeetingSummary(Number(id)); message.success('已重新提交总结任务'); setMeeting((current) => current ? { ...current, status: 2, latestSummaryAttemptStatus: 0, latestSummaryAttemptErrorMsg: undefined, } : current); await fetchData(Number(id)); } catch (error) { console.error(error); } finally { setActionLoading(false); } }; const handleRetryChapterTask = async () => { setActionLoading(true); try { await retryMeetingChapter(Number(id)); message.success('已重新提交 AI 目录任务'); setMeeting((current) => current ? { ...current, status: 2, latestChapterAttemptStatus: 0, latestChapterAttemptErrorMsg: undefined, } : current); 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 || '未指定'}
总结模型 {summaryModelDisplayName} 会议模板 {promptDisplayName} 热词组 {hotWordGroupDisplayName}
)} extra={( {canRetrySchedule && ( )} {canRetrySummary && ( )} {canRetryFailedChapterTask && ( )} {canRetryFailedSummaryTask && ( )} {canRetryTranscription && ( )} {isOwner && meeting.status === 2 && ( )} {(playbackAudioUrl || transcripts.length > 0 || hasSummaryContent) && ( , onClick: handleDownloadAudio, }] : []), ...(transcripts.length > 0 ? [{ key: 'transcript', label: '下载转录 MD', icon: , onClick: handleDownloadTranscript, disabled: downloadLoading === 'transcript', }] : []), ...(hasSummaryContent ? [ { 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 === 0 || meeting.status === 1 ? ( <>
fetchData(meeting.id)} onRefreshNeeded={() => { void fetchData(meeting.id); }} onProgressChange={setGenerationProgress} />
) : ( <>
{(generationFailureNotice || emptyTranscriptFailureNotice || meeting.audioSaveStatus === 'FAILED') && ( {generationFailureNotice?.title || emptyTranscriptFailureNotice?.title || '会议处理提醒'}
} description={
{generationFailureNotice?.description || emptyTranscriptFailureNotice?.description}
{meeting.audioSaveStatus === 'FAILED' && (
{meeting.audioSaveMessage || '录音保存失败,当前无法播放'}
)} {canRetryTranscription && (
{emptyTranscriptFailureNotice?.hint}
)}
} /> )} {(meeting.status === 1 || meeting.status === 2) && (
fetchData(meeting.id)} onRefreshNeeded={() => { void fetchData(meeting.id); }} onProgressChange={setGenerationProgress} />
)} {meeting.status === 2 && !hasSummaryContent ? (
) : hasSummaryContent ? (
会议总结
{summaryPanelNotice && (
{summaryPanelNotice.description}
{meeting.status === 2 ? ( ) : null}
} /> )} {/* Keywords placed between title and overview */}
关键词
{isOwner && analysis.keywords.length > 0 && ( )}
{keywordItems.length ? ( keywordItems.map((tag) => { const isSelected = selectedKeywords.includes(tag); return (
{ if (isOwner && analysis.keywords.length) { handleKeywordToggle(tag, !isSelected); } }} > #{tag} {isOwner && isSelected && }
); }) ) : ( 暂无关键词 )}
会议概述
{isOwner ? ( isEditingSummary ? ( ) : ( ) ) : null}
{isEditingSummary ? ( setSummaryDraft(e.target.value)} autoSize={{ minRows: 10 }} className="summary-inline-edit" /> ) : ( {analysis.overview || meeting.summaryContent} )}
{discussionItems.length > 0 && !isEditingSummary && (
主要讨论点
{discussionItems.map((item, index) => (
{item.title || `讨论点 ${index + 1}`}
{item.summary}
))}
)}
) : (
)}
{!emptyTranscriptFailureNotice && (
智能内容由 AI 模型生成,我们不对内容准确性和完整性作任何保证,也不代表我们的观点或态度
)}
原文}> {playbackAudioUrl && ( )} {emptyTranscriptFailureNotice && (
当前没有可展示的转录内容
{emptyTranscriptFailureNotice.description}
)} { const nextStartTime = transcripts[index + 1]?.startTime || Infinity; const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime; return ( ); }} locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} />
{(
{playbackAudioUrl && ( )}
{aiCatalogEnabled && ( )}
{aiCatalogEnabled && workspaceTab === 'catalog' ? (
{catalogPanelNotice && ( )} {catalogChapterLinks.length ? ( catalogChapterLinks.map((chapter, index) => (
handleLocateChapterTranscript(index)} style={{ cursor: 'pointer' }} >
{chapter.timeLabel}
{chapter.title}
)) ) : meeting.status === 2 ? ( ) : ( )}
) : ( <> {emptyTranscriptFailureNotice && (
当前没有可展示的转录内容
{emptyTranscriptFailureNotice.description}
)} { 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>
{userList.map((u) => ( ))}
)} {isOwner && ( setSummaryVisible(false)} open={summaryVisible} extra={} forceRender >
详细 标准 简洁 重新总结会基于当前语音转录全文重新生成纪要,原有总结内容将被覆盖。
)}
); }; export default MeetingDetail;