imeeting/imeeting-h5/src/components/preview/MeetingPreviewView.tsx

696 lines
28 KiB
TypeScript

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<void>;
onCopyShareLink?: () => void | Promise<void>;
}
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<number, { label: string; className: string }> = {
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<HTMLAudioElement | null>(null);
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [pageTab, setPageTab] = useState<PreviewPageTab>("summary");
const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(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<number[]>([]);
const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(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<string>();
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<ChapterTranscriptLink[]>(() => {
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 (
<div className="meeting-preview-page">
<div className="meeting-preview-container">
<div className="meeting-preview-shell">
<div className="meeting-preview-top-hero">
<div className="meeting-preview-hero-logo">
<RobotOutlined/>
</div>
<div className="meeting-preview-hero-content">
<h1 className="meeting-preview-hero-title">{meeting.title || "未命名会议"}</h1>
<div className="meeting-preview-hero-meta">
<span className={`meeting-preview-status-tag ${statusMeta.className}`}>{statusMeta.label}</span>
</div>
</div>
</div>
<div className="meeting-preview-collapsible-section">
<div className="meeting-preview-collapsible-trigger"
onClick={() => setIsMetricsExpanded((value) => !value)}>
<div className="trigger-left">
<FileTextOutlined/>
<span>{TEXT.basicInfo}</span>
</div>
<div className="trigger-right">{isMetricsExpanded ? <UpOutlined/> : <DownOutlined/>}</div>
</div>
<div className={`meeting-preview-collapsible-content ${isMetricsExpanded ? "is-expanded" : ""}`}>
<div className="meeting-preview-metrics-grid">
<div className="metric-item">
<div className="metric-label">{TEXT.meetingTime}</div>
<div className="metric-value">
<CalendarOutlined style={{marginRight: 8, color: "var(--primary-blue)"}}/>
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : "未设置"}
</div>
</div>
<div className="metric-item">
<div className="metric-label">{TEXT.hostCreator}</div>
<div className="metric-value">
<UserOutlined style={{marginRight: 8, color: "var(--primary-blue)"}}/>
{meeting.creatorName || "未设置"}
</div>
</div>
<div className="metric-item">
<div className="metric-label">{TEXT.participantsCount}</div>
<div className="metric-value">
<TeamOutlined style={{marginRight: 8, color: "var(--primary-blue)"}}/>
{participants.length}
</div>
</div>
<div className="metric-item">
<div className="metric-label"></div>
<div className="metric-value">
<ClockCircleOutlined style={{marginRight: 8, color: "var(--primary-blue)"}}/>
{meetingDuration > 0 ? formatTotalDuration(meetingDuration) : "未设置"}
</div>
</div>
{tags.length > 0 ? (
<div className="metric-item metric-item-full">
<div className="metric-label">{TEXT.tags}</div>
<div className="metric-tags">
{tags.map((tag) => (
<span key={tag} className="metric-tag">
#{tag}
</span>
))}
</div>
</div>
) : null}
</div>
</div>
</div>
{editableShare ? (
<div className="meeting-preview-share-settings-compact">
{meeting.accessPassword ? (
<div className="share-password-display">
<span className="share-password-label">
<LockOutlined style={{marginRight: 6}}/>
访
</span>
<span className="share-password-value">{meeting.accessPassword}</span>
<Button type="link" size="small" onClick={() => {
onSharePasswordDraftChange?.(meeting.accessPassword || "");
setIsPasswordModalVisible(true);
}}>
/
</Button>
</div>
) : (
<Button
type="dashed"
icon={<LockOutlined/>}
onClick={() => {
onSharePasswordDraftChange?.("");
setIsPasswordModalVisible(true);
}}
className="set-password-btn"
>
访
</Button>
)}
</div>
) : null}
<div className="meeting-preview-share-bar">
<Button type="primary" size="large" icon={<ShareAltOutlined/>} onClick={() => void handleShareNow()}
className="share-btn-primary">
{TEXT.shareNow}
</Button>
<Button size="large" icon={<CopyOutlined/>} onClick={() => void handleCopyLink()}
className="share-btn-ghost">
{TEXT.copyLink}
</Button>
</div>
<div className="meeting-preview-content-card">
<div className="meeting-preview-tabs-container">
<Tabs
activeKey={pageTab}
onChange={(key) => setPageTab(key as PreviewPageTab)}
items={[
{key: "summary", label: TEXT.pageSummary},
...(aiCatalogEnabled ? [{key: "catalog", label: TEXT.pageCatalog}] : []),
{key: "transcript", label: TEXT.pageTranscript},
]}
/>
</div>
<div className="meeting-preview-tab-content">
{pageTab === "summary" ? (
<>
<div className="meeting-preview-summary-box">
<div className="meeting-preview-summary-section-title">{TEXT.keywordSection}</div>
<div className="meeting-preview-record-tags">
{analysis.keywords.length ? (
analysis.keywords.map((item) => (
<div key={item} className="meeting-preview-record-tag">
<span>#{item}</span>
</div>
))
) : (
<span className="meeting-preview-keywords-empty"></span>
)}
</div>
</div>
<div className="meeting-preview-markdown">
{meeting.summaryContent ? <ReactMarkdown>{meeting.summaryContent}</ReactMarkdown> :
<Empty description={TEXT.noSummary}/>}
</div>
</>
) : null}
{aiCatalogEnabled && pageTab === "catalog" ? (
<div className="meeting-preview-catalog-list">
{catalogChapterLinks.length ? (
catalogChapterLinks.map((chapter, index) => (
<div
key={chapter.key}
className={`meeting-preview-catalog-item-container ${linkedChapterKey === chapter.key ? "active" : ""}`}
>
<div className="meeting-preview-catalog-timeline-axis">
<div className="meeting-preview-catalog-timeline-dot"/>
<div className="meeting-preview-catalog-timeline-line"/>
</div>
<div className="meeting-preview-catalog-item-card"
onClick={() => handleLocateChapterTranscript(index)}>
<div className="meeting-preview-catalog-item-time">{chapter.timeLabel}</div>
<div className="meeting-preview-catalog-item-title-row">
<div className="meeting-preview-catalog-item-title">{chapter.title}</div>
<button
type="button"
className="meeting-preview-catalog-item-link"
onClick={(e) => {
e.stopPropagation();
handleLocateChapterTranscript(index);
}}
>
<LinkOutlined/> {TEXT.linkToTranscript}
</button>
</div>
</div>
</div>
))
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={TEXT.noCatalog}/>
)}
</div>
) : null}
{pageTab === "transcript" ? (
<>
<div className="meeting-preview-section-header">
<div className="meeting-preview-section-title">{TEXT.transcriptTitle}</div>
<div className="meeting-preview-section-extra">
<ClockCircleOutlined style={{marginRight: 6}}/>
{meetingDuration > 0 ? formatDurationRange(0, meetingDuration) : TEXT.noDuration}
</div>
</div>
{meeting.audioSaveStatus === "FAILED" ? (
<Alert type="warning" showIcon message={meeting.audioSaveMessage || TEXT.audioUnavailable}
style={{marginBottom: 16}}/>
) : null}
<div className="meeting-preview-transcript-list">
{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 (
<div
key={item.id}
ref={(node) => {
transcriptItemRefs.current[item.id] = node;
}}
className={`meeting-preview-transcript-item ${isActive ? "is-active" : ""} ${isLinked ? "is-linked" : ""}`}
onClick={() => {
handleTranscriptSeek(item);
setLinkedTranscriptIds([]);
setLinkedChapterKey(null);
}}
>
<div className="meeting-preview-transcript-avatar"
style={{backgroundColor: transcriptColorSeed(speakerKey)}}>
{speakerKey.slice(0, 1)}
</div>
<div className="meeting-preview-transcript-content">
<div className="meeting-preview-transcript-meta">
<span className="meeting-preview-transcript-speaker">{speakerKey}</span>
<span
className="meeting-preview-transcript-time">{formatDurationRange(item.startTime, item.endTime)}</span>
</div>
<div className="meeting-preview-transcript-text">{item.content || TEXT.noTranscript}</div>
</div>
</div>
);
})
) : (
<Empty description={TEXT.noTranscript}/>
)}
</div>
</>
) : null}
</div>
</div>
<div className="meeting-preview-footer">
<div className="meeting-preview-disclaimer">
<RobotOutlined/>
<span>{TEXT.disclaimer}</span>
</div>
</div>
</div>
</div>
<Modal
title="设置访问密码"
open={isPasswordModalVisible}
onOk={async () => {
if (onSaveSharePassword) {
await onSaveSharePassword();
}
setIsPasswordModalVisible(false);
}}
onCancel={() => {
setIsPasswordModalVisible(false);
onSharePasswordDraftChange?.(meeting.accessPassword || "");
}}
confirmLoading={shareSaving}
okText="保存"
cancelText="取消"
destroyOnClose
>
<div style={{marginBottom: 16, color: "var(--text-secondary)"}}>
{TEXT.shareSettingsHint}
</div>
<Input.Password
value={sharePasswordDraft}
placeholder={TEXT.passwordPlaceholder}
prefix={<LockOutlined/>}
onChange={(event) => onSharePasswordDraftChange?.(event.target.value)}
allowClear
/>
</Modal>
{playbackAudioUrl ? (
<div className="meeting-preview-audio-player-inline"
style={{display: pageTab === "transcript" ? "flex" : "none"}}>
<audio
ref={audioRef}
onTimeUpdate={handleAudioTimeUpdate}
onLoadedMetadata={() => setAudioDuration(audioRef.current?.duration || 0)}
onPlay={() => setAudioPlaying(true)}
onPause={() => setAudioPlaying(false)}
onEnded={() => setAudioPlaying(false)}
style={{display: "none"}}
preload="auto"
>
<source src={playbackAudioUrl} type={resolveAudioMimeType(playbackAudioUrl)}/>
</audio>
<div className="audio-player-content">
<button type="button" className="audio-play-btn" onClick={() => {
if (!audioRef.current) return;
if (audioPlaying) {
audioRef.current.pause();
} else {
void audioRef.current.play().catch(() => {
});
}
}}>
{audioPlaying ? <PauseOutlined/> : <CaretRightFilled/>}
</button>
<div className="audio-progress-container">
<div className="audio-time">{formatTotalDuration(audioCurrentTime * 1000)}</div>
<input
className="audio-range"
type="range"
min={0}
max={audioDuration || 0}
step={0.1}
value={Math.min(audioCurrentTime, audioDuration || 0)}
onChange={(e) => {
if (!audioRef.current) return;
const time = parseFloat(e.target.value);
audioRef.current.currentTime = time;
setAudioCurrentTime(time);
}}
/>
<div className="audio-time">{formatTotalDuration(audioDuration * 1000)}</div>
</div>
<button
type="button"
className="audio-speed-btn"
onClick={() => {
if (!audioRef.current) return;
const nextRate = audioPlaybackRate === 1 ? 1.5 : audioPlaybackRate === 1.5 ? 2 : 1;
audioRef.current.playbackRate = nextRate;
setAudioPlaybackRate(nextRate);
}}
>
{audioPlaybackRate}x
</button>
</div>
</div>
) : null}
</div>
);
}