import { useEffect, useMemo, useRef, useState } from "react"; import { Alert, Button, Empty, Input, Result, Skeleton, Tabs, message } from "antd"; import { useParams, useSearchParams } from "react-router-dom"; import { AudioOutlined, CalendarOutlined, CaretRightFilled, ClockCircleOutlined, CopyOutlined, FileTextOutlined, LockOutlined, PauseOutlined, RobotOutlined, ShareAltOutlined, TeamOutlined, UserOutlined, DownOutlined, UpOutlined, LinkOutlined, } from "@ant-design/icons"; import dayjs from "dayjs"; import ReactMarkdown from "react-markdown"; import { getMeetingPreviewAccess, getPublicMeetingPreview, resolveAudioMimeType, resolveMeetingPlaybackAudioUrl, type MeetingChapterVO, type MeetingTranscriptVO, type MeetingVO, } from "../../api/business/meeting"; import { buildMeetingAnalysis } from "./meetingAnalysis"; import "./MeetingPreview.css"; type AnalysisTab = "chapters" | "speakers" | "actions" | "todos"; type PreviewPageTab = "summary" | "catalog" | "transcript"; const TEXT = { statusTranscribing: "转写中", statusSummarizing: "总结中", statusCompleted: "已完成", statusPending: "待处理", hintTranscribing: "会议内容仍在整理中,预览会持续补全。", hintSummarizing: "AI 正在生成会议总结,已完成内容会优先展示。", hintCompleted: "会议纪要、分析和转录内容已生成完成。", hintPending: "当前会议尚未生成完整内容,请稍后重试。", missingMeetingId: "未提供会议编号", loadFailed: "会议预览加载失败", noMeetingData: "未找到会议数据", previewLabel: "会议预览", untitledMeeting: "未命名会议", meetingTime: "会议时间", hostCreator: "主持/创建", participantsCount: "参会人数", tagsCount: "标签数量", notSet: "未设置", notFilled: "未填写", pageSummary: "AI 纪要", pageCatalog: "AI 目录", pageTranscript: "转录原文", copyLink: "复制链接", shareNow: "立即分享", shareCopied: "预览链接已复制", shareFallbackCopied: "当前设备不支持系统分享,已为你复制链接", shareFailed: "分享失败,请先复制链接", accessCheck: "访问校验", passwordRequired: "该会议需要访问密码", passwordHint: "请输入会议的 访问密码 后继续访问预览内容。", passwordPlaceholder: "请输入 访问密码", openPreview: "进入预览", invalidPassword: "访问密码错误", basicInfo: "基本信息", meetingOverview: "会议概况", creator: "创建人", host: "主持人", createdAt: "创建时间", audioStatus: "音频状态", participants: "人", tags: "会议标签", aiAnalysis: "AI 目录", analysis: "会议分析", previewExtra: "预览页仅读展示", audioPlaybackWarning: "音频保存失败,可能影响回放。", summaryOverview: "全文概要", summaryEmpty: "暂无概要内容", analysisChapters: "章节", analysisSpeakers: "发言人", analysisKeyPoints: "关键要点", analysisTodos: "待办事项", noChapterAnalysis: "暂无章节分析", noSpeakerAnalysis: "暂无发言人分析", noKeyPoints: "暂无关键要点", noTodos: "暂无待办事项", chapterFallback: "章节", speakerFallback: "发言人", speakerSummary: "发言概述", keyPointFallback: "要点", noChapterSummary: "暂无章节描述", noSpeakerSummary: "暂无发言总结", noKeyPointSummary: "暂无要点说明", summarySection: "会议纪要", fullSummary: "完整纪要", noSummary: "暂无会议纪要", transcriptSection: "会议转录", transcriptTitle: "逐段转录", noDuration: "暂无时长", audioUnavailable: "音频文件不可用,仅展示转录内容。", noTranscript: "暂无转录内容", unknownSpeaker: "未知发言人", disclaimer: "智能内容由用户会议内容 + AI 模型生成,我们不对内容准确性和完整性做任何保证,亦不代表我们的观点或态度", shareText: "我向你分享了一个会议预览链接", audioSaved: "已保存", audioSaveFailed: "保存失败", audioUploaded: "已上传", audioNotSaved: "未保存", linkToTranscript: "关联原文", noCatalog: "暂无 AI 目录", }; type ChapterTranscriptLink = { key: string; title: string; timeLabel: string; transcriptIds: number[]; firstTranscriptId: number | null; firstTranscriptStartTime: number | null; }; 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; } const STATUS_META: Record = { 1: { label: TEXT.statusTranscribing, className: "is-processing", hint: TEXT.hintTranscribing }, 2: { label: TEXT.statusSummarizing, className: "is-processing", hint: TEXT.hintSummarizing }, 3: { label: TEXT.statusCompleted, className: "is-complete", hint: TEXT.hintCompleted }, }; function formatDurationRange(startTime?: number, endTime?: number) { const format = (milliseconds?: number) => { const safeMs = Math.max(0, milliseconds || 0); const totalSeconds = Math.floor(safeMs / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; }; return `${format(startTime)} - ${format(endTime)}`; } function splitDisplayItems(value?: string) { return (value || "") .split(",") .map((item) => item.trim()) .filter(Boolean); } function transcriptColorSeed(speakerKey: string) { const palette = ["#315f8b", "#b86432", "#557a46", "#6d4fa7", "#a33f57", "#0f766e"]; const score = Array.from(speakerKey).reduce((sum, char) => sum + char.charCodeAt(0), 0); return palette[score % palette.length]; } async function copyText(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); } export default function MeetingPreview() { const { id } = useParams(); const [searchParams] = useSearchParams(); const audioRef = useRef(null); const audioPlaybackErrorShownRef = useRef(null); const transcriptItemRefs = useRef>({}); const [meeting, setMeeting] = useState(null); const [transcripts, setTranscripts] = useState([]); const [meetingChapters, setMeetingChapters] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [analysisTab, setAnalysisTab] = useState("speakers"); const [pageTab, setPageTab] = useState("summary"); const [activeTranscriptId, setActiveTranscriptId] = useState(null); const [passwordRequired, setPasswordRequired] = useState(false); const [passwordVerified, setPasswordVerified] = useState(false); const [accessPassword, setAccessPassword] = useState(""); const [passwordError, setPasswordError] = useState(""); const [audioPlaying, setAudioPlaying] = useState(false); const [audioCurrentTime, setAudioCurrentTime] = useState(0); const [audioDuration, setAudioDuration] = useState(0); const [audioPlaybackRate, setAudioPlaybackRate] = useState(1); const [isMetricsExpanded, setIsMetricsExpanded] = useState(false); const [linkedTranscriptIds, setLinkedTranscriptIds] = useState([]); const [linkedChapterKey, setLinkedChapterKey] = useState(null); const [isMobile, setIsMobile] = useState(() => typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false, ); const presetAccessPassword = useMemo(() => (searchParams.get("accessPassword") || "").trim(), [searchParams]); useEffect(() => { let mounted = true; const load = async () => { if (!id) { setError(TEXT.missingMeetingId); setLoading(false); return; } setLoading(true); setError(""); setMeeting(null); setTranscripts([]); setMeetingChapters([]); setPasswordRequired(false); setPasswordVerified(false); setAccessPassword(presetAccessPassword); setPasswordError(""); try { const meetingId = Number(id); const accessRes = await getMeetingPreviewAccess(meetingId); if (!mounted) { return; } const requiresPassword = !!accessRes.data.data.passwordRequired; setPasswordRequired(requiresPassword); if (requiresPassword) { if (!presetAccessPassword) { setLoading(false); return; } try { const previewRes = await getPublicMeetingPreview(meetingId, presetAccessPassword); if (!mounted) { return; } setMeeting(previewRes.data.data.meeting); setTranscripts(previewRes.data.data.transcripts || []); setMeetingChapters(previewRes.data.data.chapters || []); setPasswordVerified(true); return; } catch (requestError: any) { if (!mounted) { return; } setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword); setPasswordVerified(false); setLoading(false); return; } } const previewRes = await getPublicMeetingPreview(meetingId); if (!mounted) { return; } setMeeting(previewRes.data.data.meeting); setTranscripts(previewRes.data.data.transcripts || []); setMeetingChapters(previewRes.data.data.chapters || []); setPasswordVerified(true); } catch (requestError: any) { if (!mounted) { return; } setError(requestError?.response?.data?.msg || requestError?.msg || TEXT.loadFailed); } finally { if (mounted) { setLoading(false); } } }; load(); return () => { mounted = false; }; }, [id, presetAccessPassword]); useEffect(() => { if (typeof window === "undefined") { return; } const mediaQuery = window.matchMedia("(max-width: 767px)"); const handleChange = (event: MediaQueryListEvent) => { setIsMobile(event.matches); }; setIsMobile(mediaQuery.matches); mediaQuery.addEventListener("change", handleChange); return () => { mediaQuery.removeEventListener("change", handleChange); }; }, []); const analysis = useMemo( () => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""), [meeting?.analysis, meeting?.summaryContent, meeting?.tags], ); const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]); const transcriptSpeakers = useMemo(() => { const speakers = transcripts .map((item) => item.speakerName || item.speakerLabel || item.speakerId || "") .map((item) => item.trim()) .filter(Boolean); return Array.from(new Set(speakers)); }, [transcripts]); const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]); const keywords = useMemo(() => analysis.keywords || [], [analysis.keywords]); const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]); const statusMeta = STATUS_META[meeting?.status || 0] || { label: TEXT.statusPending, className: "is-warning", hint: TEXT.hintPending, }; const shareUrl = typeof window !== "undefined" ? window.location.href : ""; const participantCountValue = isMobile && transcriptSpeakers.length > 0 ? transcriptSpeakers.length : participants.length; const meetingDuration = useMemo(() => { if (transcripts.length > 0) { const last = transcripts[transcripts.length - 1]; return last.endTime || 0; } return 0; }, [transcripts]); 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, })); return sourceChapters.map((chapter, index) => { let matchedTranscripts: MeetingTranscriptVO[] = []; const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds) ? chapter.sourceTranscriptIds .map((item) => Number(item)) .filter((item) => Number.isFinite(item) && transcriptIdToIndex.has(item)) : []; if (sourceTranscriptIds.length) { matchedTranscripts = sourceTranscriptIds .map((item) => transcripts[transcriptIdToIndex.get(item)!]) .filter(Boolean); } else if (chapter.startTranscriptId && chapter.endTranscriptId) { const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId)); const endIndex = transcriptIdToIndex.get(Number(chapter.endTranscriptId)); if (startIndex !== undefined && endIndex !== undefined) { matchedTranscripts = transcripts.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex) + 1); } } else { const startMs = typeof chapter.startTime === "number" ? chapter.startTime : parseChapterTimeToMs(chapter.time); const nextChapterStartMs = sourceChapters .slice(index + 1) .map((item) => (typeof item.startTime === "number" ? item.startTime : parseChapterTimeToMs(item.time))) .find((item): item is number => item !== null && startMs !== null && item > startMs); if (startMs !== null) { const firstTranscriptIndex = transcripts.findIndex((item) => item.endTime > startMs); if (firstTranscriptIndex >= 0) { const lastTranscriptIndex = nextChapterStartMs === undefined ? transcripts.length : transcripts.findIndex((item) => item.startTime >= nextChapterStartMs); matchedTranscripts = transcripts.slice( firstTranscriptIndex, lastTranscriptIndex >= 0 ? lastTranscriptIndex : transcripts.length, ); } } } return { key: `${chapter.chapterNo ?? index}-${chapter.title || "chapter"}`, title: chapter.title || `章节 ${index + 1}`, timeLabel: chapter.time || "--:--", transcriptIds: matchedTranscripts.map((item) => item.id), firstTranscriptId: matchedTranscripts[0]?.id ?? null, firstTranscriptStartTime: matchedTranscripts[0]?.startTime ?? null, }; }); }, [analysis.chapters, meetingChapters, transcripts]); useEffect(() => { if (!activeTranscriptId) { return; } const target = transcriptItemRefs.current[activeTranscriptId]; if (!target) { return; } // 使用 center 模式确保当前说话段落始终位于视口中央,避免被底部的浮动控件遮挡 target.scrollIntoView({ behavior: "smooth", block: "center" }); }, [activeTranscriptId]); const handleTranscriptSeek = (item: MeetingTranscriptVO) => { if (!audioRef.current) { return; } audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000); audioRef.current.play().catch(() => {}); }; const handleLocateChapterTranscript = (index: number) => { const link = catalogChapterLinks[index]; if (link && link.firstTranscriptId) { setPageTab("transcript"); setLinkedTranscriptIds(link.transcriptIds); setLinkedChapterKey(link.key); setActiveTranscriptId(link.firstTranscriptId); // 自动跳转并播放音频 if (audioRef.current && link.firstTranscriptStartTime !== null) { audioRef.current.currentTime = Math.max(0, link.firstTranscriptStartTime / 1000); audioRef.current.play().catch(() => { // 部分浏览器(尤其是移动端)可能会拦截非直接交互触发的播放 // 但由于这是由用户点击目录项触发的,通常会被允许 }); } } }; const toggleAudioPlayback = () => { if (!audioRef.current) return; if (audioPlaying) { audioRef.current.pause(); } else { audioRef.current.play().catch(() => {}); } }; const handleAudioProgressChange = (e: React.ChangeEvent) => { if (!audioRef.current) return; const time = parseFloat(e.target.value); audioRef.current.currentTime = time; setAudioCurrentTime(time); }; const cyclePlaybackRate = () => { if (!audioRef.current) return; const nextRate = audioPlaybackRate === 1 ? 1.5 : audioPlaybackRate === 1.5 ? 2 : 1; audioRef.current.playbackRate = nextRate; setAudioPlaybackRate(nextRate); }; const formatPlayerTime = (seconds: number) => { if (!seconds || isNaN(seconds)) return '00:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; }; const handleAudioTimeUpdate = () => { if (!audioRef.current) return; const currentSeconds = audioRef.current.currentTime; setAudioCurrentTime(currentSeconds); // Also update duration if it's available now if (audioRef.current.duration && audioDuration !== audioRef.current.duration) { setAudioDuration(audioRef.current.duration); } if (transcripts.length === 0) return; const currentMs = currentSeconds * 1000; const currentItem = transcripts.find( (item) => currentMs >= (item.startTime || 0) && currentMs <= (item.endTime || 0), ); setActiveTranscriptId(currentItem?.id || null); }; const handleAudioEnded = () => { setAudioPlaying(false); }; const handleAudioPlay = () => setAudioPlaying(true); const handleAudioPause = () => setAudioPlaying(false); const handleAudioLoadedMetadata = () => { if (audioRef.current) { setAudioDuration(audioRef.current.duration); } }; const handleAudioError = () => { 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,或下载到本地播放。" : TEXT.audioUnavailable, ); audioPlaybackErrorShownRef.current = currentAudioUrl; setAudioPlaying(false); }; const handlePasswordSubmit = async () => { if (!id) { return; } setLoading(true); setPasswordError(""); try { const previewRes = await getPublicMeetingPreview(Number(id), accessPassword.trim()); setMeeting(previewRes.data.data.meeting); setTranscripts(previewRes.data.data.transcripts || []); setMeetingChapters(previewRes.data.data.chapters || []); setPasswordVerified(true); } catch (requestError: any) { setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword); } finally { setLoading(false); } }; const handleCopyLink = async () => { try { await copyText(shareUrl); message.success(TEXT.shareCopied); } catch { message.error(TEXT.shareFailed); } }; const handleShareNow = async () => { try { if (navigator.share) { await navigator.share({ title: meeting?.title || TEXT.previewLabel, text: TEXT.shareText, url: shareUrl, }); return; } await copyText(shareUrl); message.success(TEXT.shareFallbackCopied); } catch { message.error(TEXT.shareFailed); } }; if (loading && (!passwordRequired || passwordVerified)) { return (
); } if (passwordRequired && !passwordVerified) { return (

{TEXT.passwordRequired}

{TEXT.passwordHint}

setAccessPassword(event.target.value)} onPressEnter={handlePasswordSubmit} prefix={} className="modern-password-input" />
{passwordError && (
)}
Secure Access • Powered by iMeeting AI
); } if (error) { return (
); } if (!meeting) { return (
); } const summaryTabContent = (
关键词
{keywords.length ? ( keywords.map((item) => (
#{item}
)) ) : ( 暂无关键词 )}
{meeting.summaryContent ? ( {meeting.summaryContent} ) : ( )}
); const catalogTabContent = (
{/*
*/} {/* */} {/* {TEXT.aiAnalysis}*/} {/*
*/}

{TEXT.pageCatalog}

{catalogChapterLinks.length ? ( catalogChapterLinks.map((chapter, index) => (
handleLocateChapterTranscript(index)} >
{chapter.timeLabel}
{chapter.title}
)) ) : ( )}
); const transcriptTabContent = (
{/*
*/} {/* */} {/* {TEXT.transcriptSection}*/} {/*
*/}

{TEXT.transcriptTitle}

{meetingDuration > 0 ? formatDurationRange(0, meetingDuration) : TEXT.noDuration}
{meeting.audioSaveStatus === "FAILED" ? ( ) : null}
{transcripts.length > 0 ? ( transcripts.map((item) => { const speakerKey = item.speakerName || item.speakerLabel || item.speakerId || "speaker"; const isLinked = linkedTranscriptIds.includes(item.id); const isActive = activeTranscriptId === item.id; return (
{ transcriptItemRefs.current[item.id] = node; }} className={`meeting-preview-transcript-item ${isActive ? "is-active" : ""} ${isLinked ? "is-linked" : ""}`} onClick={() => { handleTranscriptSeek(item); setLinkedTranscriptIds([]); // Clear linked highlight on manual seek setLinkedChapterKey(null); }} >
{(speakerKey || "S").slice(0, 1)}
{speakerKey} {formatDurationRange(item.startTime, item.endTime)}
{item.content || TEXT.noTranscript}
); }) ) : ( )}
); const formatTotalDuration = (ms: number) => { const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) { return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; } return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; }; return (
{/* Header Title Section */}

{meeting.title || TEXT.untitledMeeting}

{statusMeta.label} {/*ID: {meeting.id}*/}
{/* Collapsible Basic Info Section */}
setIsMetricsExpanded(!isMetricsExpanded)} >
{TEXT.basicInfo}
{isMetricsExpanded ? : }
{TEXT.meetingTime}
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : TEXT.notSet}
{TEXT.hostCreator}
{meeting.creatorName || TEXT.notSet}
{TEXT.participantsCount}
{participantCountValue} {TEXT.participants}
会议时长
{meetingDuration > 0 ? formatTotalDuration(meetingDuration) : TEXT.notSet}
{tags.length > 0 && (
{TEXT.tags}
{tags.map(tag => ( #{tag} ))}
)}
{/* Sharing Buttons Bar */}
{/* Main Content Area */}
setPageTab(key as PreviewPageTab)} items={[ { key: "summary", label: TEXT.pageSummary }, { key: "catalog", label: TEXT.pageCatalog }, { key: "transcript", label: TEXT.pageTranscript }, ]} />
{pageTab === "summary" ? summaryTabContent : null} {pageTab === "catalog" ? catalogTabContent : null} {pageTab === "transcript" ? transcriptTabContent : null}
{TEXT.disclaimer}
{/* Floating Audio Player - Permanent mount, visibility controlled */} {playbackAudioUrl && (
{formatPlayerTime(audioCurrentTime)}
{formatPlayerTime(audioDuration)}
)}
); }