import {useEffect, useMemo, useRef, useState} from "react"; import { AudioOutlined, CalendarOutlined, CaretRightFilled, ClockCircleOutlined, CopyOutlined, DownOutlined, FileTextOutlined, LinkOutlined, LockOutlined, PauseOutlined, RobotOutlined, ShareAltOutlined, TeamOutlined, UpOutlined, UserOutlined, } from "@ant-design/icons"; import {Alert, Button, Empty, Input, Tabs, message, Modal} from "antd"; import dayjs from "dayjs"; import ReactMarkdown from "react-markdown"; import {resolveAudioMimeType, resolveMeetingPlaybackAudioUrl} from "@/api/meeting"; import {buildMeetingAnalysis} from "@/components/preview/meetingAnalysis"; import type {MeetingChapterVO, MeetingTranscriptVO, MeetingVO} from "@/types"; import "./MeetingPreviewView.css"; type PreviewPageTab = "summary" | "catalog" | "transcript"; type ChapterTranscriptLink = { key: string; title: string; timeLabel: string; transcriptIds: number[]; firstTranscriptId: number | null; firstTranscriptStartTime: number | null; }; interface MeetingPreviewViewProps { meeting: MeetingVO; transcripts: MeetingTranscriptVO[]; meetingChapters: MeetingChapterVO[]; shareUrl: string; editableShare?: boolean; sharePasswordDraft?: string; shareSaving?: boolean; onSharePasswordDraftChange?: (value: string) => void; onSaveSharePassword?: () => void | Promise; onCopyShareLink?: () => void | Promise; } const TEXT = { statusTranscribing: "转写中", statusSummarizing: "总结中", statusCompleted: "已完成", statusPending: "待处理", pageSummary: "AI 纪要", pageCatalog: "AI 目录", pageTranscript: "转录原文", copyLink: "复制链接", shareNow: "立即分享", shareCopied: "预览链接已复制", shareFallbackCopied: "当前设备不支持系统分享,已为你复制链接", shareFailed: "分享失败,请先复制链接", basicInfo: "基本信息", meetingTime: "会议时间", hostCreator: "主持/创建", participantsCount: "参会人数", tags: "会议标签", noSummary: "暂无会议纪要", noCatalog: "暂无 AI 目录", noTranscript: "暂无转录内容", noDuration: "暂无时长", audioUnavailable: "音频文件不可用,仅展示转录内容。", transcriptTitle: "逐段转录", keywordSection: "关键词", linkToTranscript: "关联原文", shareSettings: "分享访问设置", shareSettingsHint: "当前登录用户可直接查看,访问密码仅对分享出去的 H5 预览链接生效。", saveSharePassword: "保存访问密码", passwordPlaceholder: "为空表示取消访问密码", disclaimer: "智能内容由用户会议内容与 AI 模型生成,我们不对内容准确性和完整性做任何保证。", }; const STATUS_META: Record = { 1: {label: TEXT.statusTranscribing, className: "is-processing"}, 2: {label: TEXT.statusSummarizing, className: "is-processing"}, 3: {label: TEXT.statusCompleted, className: "is-complete"}, }; 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; } 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); } 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 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")}`; } export default function MeetingPreviewView({ meeting, transcripts, meetingChapters, shareUrl, editableShare = false, sharePasswordDraft = "", shareSaving = false, onSharePasswordDraftChange, onSaveSharePassword, onCopyShareLink, }: MeetingPreviewViewProps) { const audioRef = useRef(null); const transcriptItemRefs = useRef>({}); const [pageTab, setPageTab] = useState("summary"); const [activeTranscriptId, setActiveTranscriptId] = useState(null); 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 [isPasswordModalVisible, setIsPasswordModalVisible] = useState(false); const analysis = useMemo( () => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""), [meeting?.analysis, meeting?.summaryContent, meeting?.tags], ); const participants = useMemo(() => { const list = splitDisplayItems(meeting?.participants); if (list.length > 0) return list; const uniqueSpeakers = new Set(); for (const item of transcripts) { const speakerKey = item.speakerName || item.speakerLabel || item.speakerId; if (speakerKey) { uniqueSpeakers.add(speakerKey); } } return Array.from(uniqueSpeakers); }, [meeting?.participants, transcripts]); const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]); const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]); const aiCatalogEnabled = meeting?.aiCatalogEnabled !== false; const statusMeta = STATUS_META[meeting?.status || 0] || { label: TEXT.statusPending, className: "is-warning", }; const meetingDuration = useMemo(() => { if (transcripts.length > 0) { return transcripts[transcripts.length - 1]?.endTime || 0; } return meeting.duration || 0; }, [meeting.duration, 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) => 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) => (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 || 0) > startMs); if (firstTranscriptIndex >= 0) { const lastTranscriptIndex = nextChapterStartMs === undefined ? transcripts.length : transcripts.findIndex((item) => (item.startTime || 0) >= 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 (!aiCatalogEnabled && pageTab === "catalog") { setPageTab("summary"); } }, [aiCatalogEnabled, pageTab]); const handleTranscriptSeek = (item: MeetingTranscriptVO) => { if (!audioRef.current) return; audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000); void audioRef.current.play().catch(() => { }); }; const handleLocateChapterTranscript = (index: number) => { const link = catalogChapterLinks[index]; if (!link || !link.firstTranscriptId) return; setPageTab("transcript"); setLinkedTranscriptIds(link.transcriptIds); setLinkedChapterKey(link.key); setActiveTranscriptId(link.firstTranscriptId); const target = transcriptItemRefs.current[link.firstTranscriptId]; target?.scrollIntoView({behavior: "smooth", block: "center"}); if (audioRef.current && link.firstTranscriptStartTime !== null) { audioRef.current.currentTime = Math.max(0, link.firstTranscriptStartTime / 1000); void audioRef.current.play().catch(() => { }); } }; const handleAudioTimeUpdate = () => { if (!audioRef.current) return; const currentSeconds = audioRef.current.currentTime; setAudioCurrentTime(currentSeconds); if (audioRef.current.duration && audioDuration !== audioRef.current.duration) { setAudioDuration(audioRef.current.duration); } const currentMs = currentSeconds * 1000; const currentItem = transcripts.find( (item) => currentMs >= (item.startTime || 0) && currentMs <= (item.endTime || 0), ); setActiveTranscriptId(currentItem?.id || null); }; const handleCopyLink = async () => { if (onCopyShareLink) { await onCopyShareLink(); return; } 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: "我向你分享了一个会议预览链接", url: shareUrl, }); return; } await copyText(shareUrl); message.success(TEXT.shareFallbackCopied); } catch { message.error(TEXT.shareFailed); } }; return (

{meeting.title || "未命名会议"}

{statusMeta.label}
setIsMetricsExpanded((value) => !value)}>
{TEXT.basicInfo}
{isMetricsExpanded ? : }
{TEXT.meetingTime}
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : "未设置"}
{TEXT.hostCreator}
{meeting.creatorName || "未设置"}
{TEXT.participantsCount}
{participants.length} 人
会议时长
{meetingDuration > 0 ? formatTotalDuration(meetingDuration) : "未设置"}
{tags.length > 0 ? (
{TEXT.tags}
{tags.map((tag) => ( #{tag} ))}
) : null}
{editableShare ? (
{meeting.accessPassword ? (
访问密码 {meeting.accessPassword}
) : ( )}
) : null}
setPageTab(key as PreviewPageTab)} items={[ {key: "summary", label: TEXT.pageSummary}, ...(aiCatalogEnabled ? [{key: "catalog", label: TEXT.pageCatalog}] : []), {key: "transcript", label: TEXT.pageTranscript}, ]} />
{pageTab === "summary" ? ( <>
{TEXT.keywordSection}
{analysis.keywords.length ? ( analysis.keywords.map((item) => (
#{item}
)) ) : ( 暂无关键词 )}
{meeting.summaryContent ? {meeting.summaryContent} : }
) : null} {aiCatalogEnabled && pageTab === "catalog" ? (
{catalogChapterLinks.length ? ( catalogChapterLinks.map((chapter, index) => (
handleLocateChapterTranscript(index)}>
{chapter.timeLabel}
{chapter.title}
)) ) : ( )}
) : null} {pageTab === "transcript" ? ( <>
{TEXT.transcriptTitle}
{meetingDuration > 0 ? formatDurationRange(0, meetingDuration) : TEXT.noDuration}
{meeting.audioSaveStatus === "FAILED" ? ( ) : null}
{transcripts.length ? ( 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([]); setLinkedChapterKey(null); }} >
{speakerKey.slice(0, 1)}
{speakerKey} {formatDurationRange(item.startTime, item.endTime)}
{item.content || TEXT.noTranscript}
); }) ) : ( )}
) : null}
{TEXT.disclaimer}
{ if (onSaveSharePassword) { await onSaveSharePassword(); } setIsPasswordModalVisible(false); }} onCancel={() => { setIsPasswordModalVisible(false); onSharePasswordDraftChange?.(meeting.accessPassword || ""); }} confirmLoading={shareSaving} okText="保存" cancelText="取消" destroyOnClose >
{TEXT.shareSettingsHint}
} onChange={(event) => onSharePasswordDraftChange?.(event.target.value)} allowClear />
{playbackAudioUrl ? (
{formatTotalDuration(audioCurrentTime * 1000)}
{ if (!audioRef.current) return; const time = parseFloat(e.target.value); audioRef.current.currentTime = time; setAudioCurrentTime(time); }} />
{formatTotalDuration(audioDuration * 1000)}
) : null}
); }