import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Alert, Avatar, Breadcrumb, Button, Card, Checkbox, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd'; import { AudioOutlined, CaretRightFilled, ClockCircleOutlined, CopyOutlined, DownloadOutlined, EditOutlined, FastForwardOutlined, LeftOutlined, LinkOutlined, LoadingOutlined, PauseOutlined, QrcodeOutlined, RobotOutlined, SyncOutlined, UserOutlined, PlusOutlined, CheckCircleFilled, EllipsisOutlined, FilePdfOutlined, FileWordOutlined, ShareAltOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import ReactMarkdown from 'react-markdown'; import { downloadMeetingSummary, getMeetingDetail, getMeetingProgress, getTranscripts, 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'; const { Title, Text } = Typography; const { Option } = Select; type AnalysisChapter = { time?: string; title: string; summary: string; }; type AnalysisSpeakerSummary = { speaker: string; summary: string; }; type AnalysisKeyPoint = { title: string; summary: string; speaker?: string; time?: string; }; type MeetingAnalysis = { overview: string; keywords: string[]; chapters: AnalysisChapter[]; speakerSummaries: AnalysisSpeakerSummary[]; keyPoints: AnalysisKeyPoint[]; todos: string[]; }; const ANALYSIS_EMPTY: MeetingAnalysis = { overview: '', keywords: [], chapters: [], speakerSummaries: [], keyPoints: [], todos: [], }; const 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')}`; } 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 }} />
角色标签
); }; type ActiveTranscriptRowProps = { item: MeetingTranscriptVO; meetingId: number; isOwner: boolean; isEditing: boolean; isSaving: boolean; speakerLabelMap: Map; onSeek: (timeMs: number) => void; onStartEdit: (item: MeetingTranscriptVO, event: React.MouseEvent) => void; onDraftBlur: (item: MeetingTranscriptVO, value: string) => void; onDraftKeyDown: (item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent) => void; onSpeakerUpdated: () => void; isActive: boolean; audioPlaying: boolean; }; const ActiveTranscriptRow = React.memo(({ item, meetingId, isOwner, isEditing, isSaving, speakerLabelMap, onSeek, onStartEdit, onDraftBlur, onDraftKeyDown, onSpeakerUpdated, isActive, audioPlaying, }) => { const [draftValue, setDraftValue] = useState(item.content); const rowRef = useRef(null); useEffect(() => { if (isEditing) { setDraftValue(item.content); } }, [isEditing, item.content]); useEffect(() => { if (isActive && audioPlaying && rowRef.current) { rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [isActive, audioPlaying]); const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : ''; return ( onSeek(item.startTime)}>
} className="transcript-avatar" />
{isOwner ? ( )} title="编辑发言人" trigger="click" > event.stopPropagation()}> {item.speakerName || item.speakerId || '发言人'} ) : ( {item.speakerName || item.speakerId || '发言人'} )} {formatTime(item.startTime)} {speakerTagLabel && {speakerTagLabel}}
{isEditing ? (
event.stopPropagation()} > setDraftValue(event.target.value)} onKeyDown={(event) => onDraftKeyDown(item, draftValue, event)} onBlur={(event) => { event.stopPropagation(); onDraftBlur(item, draftValue); }} autoSize={{ minRows: 1, maxRows: 8 }} className="transcript-bubble-input" bordered={false} />
) : (
onStartEdit(item, event) : undefined} > {item.content}
)}
); }, (prevProps, nextProps) => ( prevProps.item === nextProps.item && prevProps.meetingId === nextProps.meetingId && prevProps.isOwner === nextProps.isOwner && prevProps.isEditing === nextProps.isEditing && prevProps.isSaving === nextProps.isSaving && prevProps.speakerLabelMap === nextProps.speakerLabelMap && prevProps.onSeek === nextProps.onSeek && prevProps.onStartEdit === nextProps.onStartEdit && prevProps.onDraftBlur === nextProps.onDraftBlur && prevProps.onDraftKeyDown === nextProps.onDraftKeyDown && prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated && prevProps.isActive === nextProps.isActive && prevProps.audioPlaying === nextProps.audioPlaying )); 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 [loading, setLoading] = useState(true); const [editVisible, setEditVisible] = useState(false); const [summaryVisible, setSummaryVisible] = useState(false); const [actionLoading, setActionLoading] = useState(false); const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | null>(null); const [isEditingSummary, setIsEditingSummary] = useState(false); const [summaryDraft, setSummaryDraft] = useState(''); const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters'); const [expandKeywords, setExpandKeywords] = useState(false); const [expandSummary, setExpandSummary] = useState(false); const [selectedKeywords, setSelectedKeywords] = useState([]); const [addingHotwords, setAddingHotwords] = useState(false); const [editingTranscriptId, setEditingTranscriptId] = useState(null); const [savingTranscriptId, setSavingTranscriptId] = useState(null); const [audioCurrentTime, setAudioCurrentTime] = useState(0); const [audioDuration, setAudioDuration] = useState(0); const [audioPlaying, setAudioPlaying] = useState(false); const [audioPlaybackRate, setAudioPlaybackRate] = useState(1); const [llmModels, setLlmModels] = useState([]); const [prompts, setPrompts] = useState([]); const [, setUserList] = useState([]); const { items: speakerLabels } = useDict('biz_speaker_label'); const [sharePopoverOpen, setSharePopoverOpen] = useState(false); const [shareSaving, setShareSaving] = useState(false); const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false); const [sharePasswordDraft, setSharePasswordDraft] = useState(''); const emptyTranscriptNoticeShownRef = useRef(null); const audioPlaybackErrorShownRef = useRef(null); const audioRef = useRef(null); const summaryPdfRef = useRef(null); const transcriptItemRefs = useRef>({}); 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] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]); setMeeting(detailRes.data.data); setTranscripts(transcriptRes.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 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 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 (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 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 seekTo = useCallback((timeMs: number) => { if (!audioRef.current) return; audioRef.current.currentTime = timeMs / 1000; audioRef.current.play(); }, []); 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 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 (
navigate('/meetings')}>会议中心 }, { title: '会议详情' } ]} /> {meeting.title} {isOwner && ( <EditOutlined style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff', marginLeft: 8 }} onClick={handleEditMeeting} /> )} }> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')} {meeting.tags?.split(',').filter(Boolean).map((tag) => ( {tag} ))} {meeting.participants || '未指定'} {canRetrySummary && ( )} {canRetryTranscription && ( )} {isOwner && meeting.status === 2 && ( )} {(playbackAudioUrl || (meeting.status === 3 && !!meeting.summaryContent)) && ( , onClick: handleDownloadAudio, }] : []), ...(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); } }} /> ) : (
{!emptyTranscriptFailureNotice && (
智能速览
{hasAnalysis ? '已生成' : '待生成'}
关键词 {isOwner && analysis.keywords.length > 0 && ( )}
{analysis.keywords.length ? ( <>
{visibleKeywords.map((tag) => { const isSelected = selectedKeywords.includes(tag); return (
isOwner && handleKeywordToggle(tag, !isSelected)} > {tag} {isOwner && isSelected && }
); })}
{analysis.keywords.length > 9 && ( )} ) : ( 暂无关键词 )}
全文概要
{analysis.overview ? ( <>
220 ? 'summary-copy summary-fade' : 'summary-copy'}> {analysis.overview}
{analysis.overview.length > 220 && ( )} ) : ( 暂无概要 )}
{summaryTab === 'chapters' && (
{analysis.chapters.length ? ( analysis.chapters.map((item, index) => (
{item.time || '--:--'}
{item.title || `章节 ${index + 1}`} {item.summary || '暂无章节描述'}
)) ) : ( )}
)} {summaryTab === 'speakers' && (
{analysis.speakerSummaries.length ? ( analysis.speakerSummaries.map((item, index) => (
{(item.speaker || '发').slice(0, 1)}
{item.speaker || `发言人${index + 1}`}
发言概述
{item.summary || '暂无发言总结'}
)) ) : ( )}
)} {summaryTab === 'actions' && (
{analysis.keyPoints.length ? ( analysis.keyPoints.map((item, index) => (
{String(index + 1).padStart(2, '0')}
{item.title || `要点 ${index + 1}`} {item.summary || '暂无要点说明'} {(item.speaker || item.time) && (
{item.speaker ? {item.speaker} : null} {item.time ? {item.time} : null}
)}
)) ) : ( )}
)} {summaryTab === 'todos' && (
{analysis.todos.length ? ( analysis.todos.map((item, index) => (
{item}
)) ) : ( )}
)}
)} {!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 ? '识别任务进行中...' : '暂无数据' }} />
{!emptyTranscriptFailureNotice && (
AI 总结} extra={ meeting.summaryContent && isOwner && ( {isEditingSummary ? ( <> ) : ( )} ) } style={{ height: '100%' }} styles={{ body: { padding: 24, height: '100%', overflowY: 'auto', overflowX: 'hidden', minWidth: 0 } }} >
{meeting.status === 2 ? (
fetchData(meeting.id)} compact />
) : meeting.summaryContent ? ( isEditingSummary ? ( setSummaryDraft(event.target.value)} style={{ height: '100%', resize: 'none' }} /> ) : (
{meeting.summaryContent}
) ) : (
)}
)} )}
{playbackAudioUrl && showFloatingTranscriptPlayer && floatingTranscriptPlayerLayout && (
{formatPlayerTime(audioCurrentTime)} {formatPlayerTime(audioDuration)}
)} {isOwner && ( setEditVisible(false)} confirmLoading={actionLoading} width={600} forceRender>
{llmModels.map((model) => ( ))} 重新总结会基于当前语音转录全文重新生成纪要,原有总结内容将被覆盖。 )}
); }; export default MeetingDetail;