imeeting/frontend/src/pages/business/MeetingPreview.tsx

954 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Button, Empty, Input, Result, Segmented, Skeleton, Tabs, Tag, message } from "antd";
import { useParams, useSearchParams } from "react-router-dom";
import {
AudioOutlined,
CalendarOutlined,
CaretRightFilled,
ClockCircleOutlined,
CopyOutlined,
FastForwardOutlined,
FileTextOutlined,
LockOutlined,
PauseOutlined,
RobotOutlined,
ShareAltOutlined,
TeamOutlined,
UserOutlined,
DownOutlined,
UpOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import ReactMarkdown from "react-markdown";
import {
getMeetingPreviewAccess,
getPublicMeetingPreview,
resolveAudioMimeType,
resolveMeetingPlaybackAudioUrl,
type MeetingTranscriptVO,
type MeetingVO,
} from "../../api/business/meeting";
import { buildMeetingAnalysis } from "./meetingAnalysis";
import "./MeetingPreview.css";
type AnalysisTab = "chapters" | "speakers" | "actions" | "todos";
type PreviewPageTab = "summary" | "transcript";
const TEXT = {
statusTranscribing: "\u8f6c\u5199\u4e2d",
statusSummarizing: "\u603b\u7ed3\u4e2d",
statusCompleted: "\u5df2\u5b8c\u6210",
statusPending: "\u5f85\u5904\u7406",
hintTranscribing: "\u4f1a\u8bae\u5185\u5bb9\u4ecd\u5728\u6574\u7406\u4e2d\uff0c\u9884\u89c8\u4f1a\u6301\u7eed\u8865\u5168\u3002",
hintSummarizing: "AI \u6b63\u5728\u751f\u6210\u4f1a\u8bae\u603b\u7ed3\uff0c\u5df2\u5b8c\u6210\u5185\u5bb9\u4f1a\u4f18\u5148\u5c55\u793a\u3002",
hintCompleted: "\u4f1a\u8bae\u7eaa\u8981\u3001\u5206\u6790\u548c\u8f6c\u5f55\u5185\u5bb9\u5df2\u751f\u6210\u5b8c\u6210\u3002",
hintPending: "\u5f53\u524d\u4f1a\u8bae\u5c1a\u672a\u751f\u6210\u5b8c\u6574\u5185\u5bb9\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002",
missingMeetingId: "\u672a\u63d0\u4f9b\u4f1a\u8bae\u7f16\u53f7",
loadFailed: "\u4f1a\u8bae\u9884\u89c8\u52a0\u8f7d\u5931\u8d25",
noMeetingData: "\u672a\u627e\u5230\u4f1a\u8bae\u6570\u636e",
previewLabel: "\u4f1a\u8bae\u9884\u89c8",
untitledMeeting: "\u672a\u547d\u540d\u4f1a\u8bae",
meetingTime: "\u4f1a\u8bae\u65f6\u95f4",
hostCreator: "\u4e3b\u6301/\u521b\u5efa",
participantsCount: "\u53c2\u4f1a\u4eba\u6570",
tagsCount: "\u6807\u7b7e\u6570\u91cf",
notSet: "\u672a\u8bbe\u7f6e",
notFilled: "\u672a\u586b\u5199",
pageSummary: "\u603b\u7ed3\u4e0e\u5206\u6790",
pageTranscript: "\u8f6c\u5f55\u4e0e\u97f3\u9891",
copyLink: "\u590d\u5236\u94fe\u63a5",
shareNow: "\u7acb\u5373\u5206\u4eab",
shareCopied: "\u9884\u89c8\u94fe\u63a5\u5df2\u590d\u5236",
shareFallbackCopied: "\u5f53\u524d\u8bbe\u5907\u4e0d\u652f\u6301\u7cfb\u7edf\u5206\u4eab\uff0c\u5df2\u4e3a\u4f60\u590d\u5236\u94fe\u63a5",
shareFailed: "\u5206\u4eab\u5931\u8d25\uff0c\u8bf7\u5148\u590d\u5236\u94fe\u63a5",
accessCheck: "\u8bbf\u95ee\u6821\u9a8c",
passwordRequired: "\u8be5\u4f1a\u8bae\u9700\u8981\u8bbf\u95ee\u5bc6\u7801",
passwordHint: "\u8bf7\u8f93\u5165\u4f1a\u8bae\u7684 access_password \u540e\u7ee7\u7eed\u8bbf\u95ee\u9884\u89c8\u5185\u5bb9\u3002",
passwordPlaceholder: "\u8bf7\u8f93\u5165 access_password",
openPreview: "\u8fdb\u5165\u9884\u89c8",
invalidPassword: "\u8bbf\u95ee\u5bc6\u7801\u9519\u8bef",
basicInfo: "\u57fa\u672c\u4fe1\u606f",
meetingOverview: "\u4f1a\u8bae\u6982\u51b5",
creator: "\u521b\u5efa\u4eba",
host: "\u4e3b\u6301\u4eba",
createdAt: "\u521b\u5efa\u65f6\u95f4",
audioStatus: "\u97f3\u9891\u72b6\u6001",
participants: "\u53c2\u4f1a\u4eba\u5458",
tags: "\u4f1a\u8bae\u6807\u7b7e",
aiAnalysis: "AI \u5206\u6790",
analysis: "\u4f1a\u8bae\u5206\u6790",
previewExtra: "\u9884\u89c8\u9875\u4ec5\u8bfb\u5c55\u793a",
audioPlaybackWarning: "\u97f3\u9891\u4fdd\u5b58\u5931\u8d25\uff0c\u53ef\u80fd\u5f71\u54cd\u56de\u653e\u3002",
summaryOverview: "\u5168\u6587\u6982\u8981",
summaryEmpty: "\u6682\u65e0\u6982\u8981\u5185\u5bb9",
analysisChapters: "\u7ae0\u8282",
analysisSpeakers: "\u53d1\u8a00\u4eba",
analysisKeyPoints: "\u5173\u952e\u8981\u70b9",
analysisTodos: "\u5f85\u529e\u4e8b\u9879",
noChapterAnalysis: "\u6682\u65e0\u7ae0\u8282\u5206\u6790",
noSpeakerAnalysis: "\u6682\u65e0\u53d1\u8a00\u4eba\u5206\u6790",
noKeyPoints: "\u6682\u65e0\u5173\u952e\u8981\u70b9",
noTodos: "\u6682\u65e0\u5f85\u529e\u4e8b\u9879",
chapterFallback: "\u7ae0\u8282",
speakerFallback: "\u53d1\u8a00\u4eba",
speakerSummary: "\u53d1\u8a00\u6982\u8ff0",
keyPointFallback: "\u8981\u70b9",
noChapterSummary: "\u6682\u65e0\u7ae0\u8282\u63cf\u8ff0",
noSpeakerSummary: "\u6682\u65e0\u53d1\u8a00\u603b\u7ed3",
noKeyPointSummary: "\u6682\u65e0\u8981\u70b9\u8bf4\u660e",
summarySection: "\u4f1a\u8bae\u7eaa\u8981",
fullSummary: "\u5b8c\u6574\u7eaa\u8981",
noSummary: "\u6682\u65e0\u4f1a\u8bae\u7eaa\u8981",
transcriptSection: "\u4f1a\u8bae\u8f6c\u5f55",
transcriptTitle: "\u9010\u6bb5\u8f6c\u5f55",
noDuration: "\u6682\u65e0\u65f6\u957f",
audioUnavailable: "\u97f3\u9891\u6587\u4ef6\u4e0d\u53ef\u7528\uff0c\u4ec5\u5c55\u793a\u8f6c\u5f55\u5185\u5bb9\u3002",
noTranscript: "\u6682\u65e0\u8f6c\u5f55\u5185\u5bb9",
unknownSpeaker: "\u672a\u77e5\u53d1\u8a00\u4eba",
disclaimer: "智能内容由用户会议内容 + AI 模型生成,我们不对内容准确性和完整性做任何保证,亦不代表我们的观点或态度",
shareText: "\u6211\u5411\u4f60\u5206\u4eab\u4e86\u4e00\u4e2a\u4f1a\u8bae\u9884\u89c8\u94fe\u63a5",
audioSaved: "\u5df2\u4fdd\u5b58",
audioSaveFailed: "\u4fdd\u5b58\u5931\u8d25",
audioUploaded: "\u5df2\u4e0a\u4f20",
audioNotSaved: "\u672a\u4fdd\u5b58",
};
const STATUS_META: Record<number, { label: string; className: string; hint: string }> = {
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<HTMLAudioElement | null>(null);
const audioPlaybackErrorShownRef = useRef<string | null>(null);
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("chapters");
const [pageTab, setPageTab] = useState<PreviewPageTab>("summary");
const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(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 [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([]);
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 || []);
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 || []);
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 audioStatusLabel = useMemo(() => {
if (meeting?.audioSaveStatus === "SUCCESS") {
return TEXT.audioSaved;
}
if (meeting?.audioSaveStatus === "FAILED") {
return TEXT.audioSaveFailed;
}
if (meeting?.audioUrl) {
return TEXT.audioUploaded;
}
return TEXT.audioNotSaved;
}, [meeting?.audioSaveStatus, meeting?.audioUrl]);
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]);
useEffect(() => {
if (!activeTranscriptId) {
return;
}
const target = transcriptItemRefs.current[activeTranscriptId];
if (!target) {
return;
}
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [activeTranscriptId]);
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
if (!audioRef.current) {
return;
}
audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 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<HTMLInputElement>) => {
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 renderMeetingTitle = (title?: string) => {
const safeTitle = title || TEXT.untitledMeeting;
return safeTitle.split(/(\d+)/).map((part, index) =>
/\d+/.test(part) ? (
<span key={`${part}-${index}`} className="meeting-preview-title-number">
{part}
</span>
) : (
<span key={`${part}-${index}`}>{part}</span>
),
);
};
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 || []);
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 (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-loading">
<div className="meeting-preview-card meeting-preview-hero">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 10 }} />
</div>
</div>
</div>
);
}
if (passwordRequired && !passwordVerified) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section meeting-preview-password-gate">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<LockOutlined />
{TEXT.accessCheck}
</div>
<h2 className="meeting-preview-section-title">{TEXT.passwordRequired}</h2>
</div>
</div>
<p className="meeting-preview-subtitle">{TEXT.passwordHint}</p>
<div className="meeting-preview-password-form">
<Input.Password
value={accessPassword}
placeholder={TEXT.passwordPlaceholder}
onChange={(event) => setAccessPassword(event.target.value)}
onPressEnter={handlePasswordSubmit}
/>
<Button type="primary" onClick={handlePasswordSubmit} loading={loading} disabled={!accessPassword.trim()}>
{TEXT.openPreview}
</Button>
</div>
{passwordError ? <Alert type="error" showIcon message={passwordError} /> : null}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Result status="error" title={TEXT.loadFailed} subTitle={error} />
</div>
</div>
</div>
);
}
if (!meeting) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Empty description={TEXT.noMeetingData} />
</div>
</div>
</div>
);
}
const summaryTab = (
<div className="meeting-preview-tab-panel">
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<RobotOutlined />
{TEXT.aiAnalysis}
</div>
<h2 className="meeting-preview-section-title">{TEXT.analysis}</h2>
</div>
<div className="meeting-preview-section-extra">{TEXT.previewExtra}</div>
</div>
{meeting.audioSaveStatus === "FAILED" ? (
<Alert
className="meeting-preview-alert"
type="warning"
showIcon
message={meeting.audioSaveMessage || TEXT.audioPlaybackWarning}
/>
) : null}
{keywords.length > 0 ? (
<div className="meeting-preview-tags">
{keywords.map((item) => (
<span key={item} className="meeting-preview-tag">
{item}
</span>
))}
</div>
) : null}
<div className="meeting-preview-overview">
<div className="meeting-preview-overview-label">{TEXT.summaryOverview}</div>
<p className="meeting-preview-overview-copy">{analysis.overview || TEXT.summaryEmpty}</p>
</div>
<div className="meeting-preview-analysis-tabs">
{[
{ label: TEXT.analysisChapters, value: "chapters" },
{ label: TEXT.analysisSpeakers, value: "speakers" },
{ label: TEXT.analysisKeyPoints, value: "actions" },
{ label: TEXT.analysisTodos, value: "todos" },
].map((tab) => (
<div
key={tab.value}
className={`meeting-preview-analysis-tab ${analysisTab === tab.value ? 'active' : ''}`}
onClick={() => setAnalysisTab(tab.value as AnalysisTab)}
>
{tab.label}
</div>
))}
</div>
<div className="meeting-preview-analysis-panel">
{analysisTab === "chapters" ? (
analysis.chapters.length > 0 ? (
analysis.chapters.map((item, index) => (
<div className="meeting-preview-chapter" key={`${item.title || "chapter"}-${index}`}>
<div className="meeting-preview-chapter-time">{item.time || "--:--"}</div>
<div>
<strong className="meeting-preview-item-title">{item.title || `${TEXT.chapterFallback} ${index + 1}`}</strong>
<span className="meeting-preview-item-copy">{item.summary || TEXT.noChapterSummary}</span>
</div>
</div>
))
) : (
<div className="meeting-preview-list-empty">{TEXT.noChapterAnalysis}</div>
)
) : null}
{analysisTab === "speakers" ? (
analysis.speakerSummaries.length > 0 ? (
analysis.speakerSummaries.map((item, index) => (
<div className="meeting-preview-speaker-card" key={`${item.speaker || "speaker"}-${index}`}>
<div className="meeting-preview-speaker-head">
<div className="meeting-preview-speaker-avatar">{(item.speaker || "S").slice(0, 1)}</div>
<div>
<div className="meeting-preview-speaker-name">{item.speaker || `${TEXT.speakerFallback} ${index + 1}`}</div>
<div className="meeting-preview-speaker-role">{TEXT.speakerSummary}</div>
</div>
</div>
<div className="meeting-preview-item-copy">{item.summary || TEXT.noSpeakerSummary}</div>
</div>
))
) : (
<div className="meeting-preview-list-empty">{TEXT.noSpeakerAnalysis}</div>
)
) : null}
{analysisTab === "actions" ? (
analysis.keyPoints.length > 0 ? (
analysis.keyPoints.map((item, index) => (
<div className="meeting-preview-keypoint" key={`${item.title || "key-point"}-${index}`}>
<div className="meeting-preview-keypoint-index">{String(index + 1).padStart(2, "0")}</div>
<div>
<strong className="meeting-preview-item-title">{item.title || `${TEXT.keyPointFallback} ${index + 1}`}</strong>
<span className="meeting-preview-item-copy">{item.summary || TEXT.noKeyPointSummary}</span>
{(item.speaker || item.time) ? (
<div className="meeting-preview-item-meta">
{item.speaker ? <span className="meeting-preview-meta-pill">{item.speaker}</span> : null}
{item.time ? <span className="meeting-preview-meta-pill">{item.time}</span> : null}
</div>
) : null}
</div>
</div>
))
) : (
<div className="meeting-preview-list-empty">{TEXT.noKeyPoints}</div>
)
) : null}
{analysisTab === "todos" ? (
analysis.todos.length > 0 ? (
analysis.todos.map((item, index) => (
<div className="meeting-preview-todo" key={`${item}-${index}`}>
<span className="meeting-preview-todo-dot" />
<span className="meeting-preview-item-copy">{item}</span>
</div>
))
) : (
<div className="meeting-preview-list-empty">{TEXT.noTodos}</div>
)
) : null}
</div>
</section>
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<FileTextOutlined />
{TEXT.summarySection}
</div>
<h2 className="meeting-preview-section-title">{TEXT.fullSummary}</h2>
</div>
</div>
<div className="meeting-preview-markdown">
{meeting.summaryContent ? (
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
) : (
<Empty description={TEXT.noSummary} />
)}
</div>
</section>
</div>
);
const transcriptTab = (
<div className="meeting-preview-tab-panel">
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
<div className="meeting-preview-section-kicker">
<AudioOutlined />
{TEXT.transcriptSection}
</div>
<h2 className="meeting-preview-section-title">{TEXT.transcriptTitle}</h2>
</div>
<div className="meeting-preview-section-extra">
<ClockCircleOutlined style={{ marginRight: 6 }} />
{meetingDuration > 0 ? formatDurationRange(0, meetingDuration) : TEXT.noDuration}
</div>
</div>
{meeting.audioSaveStatus === "FAILED" ? (
<Alert
className="meeting-preview-alert"
type="warning"
showIcon
message={meeting.audioSaveMessage || TEXT.audioUnavailable}
/>
) : null}
<div className="meeting-preview-transcript-list">
{transcripts.length > 0 ? (
transcripts.map((item) => {
const speakerKey = item.speakerName || item.speakerLabel || item.speakerId || "speaker";
return (
<div
key={item.id}
ref={(node) => {
transcriptItemRefs.current[item.id] = node;
}}
className={`meeting-preview-transcript-item ${activeTranscriptId === item.id ? "is-active" : ""}`}
onClick={() => handleTranscriptSeek(item)}
>
<div className="meeting-preview-transcript-avatar">
{(speakerKey || "S").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>
</section>
</div>
);
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell">
<section className="meeting-preview-card meeting-preview-hero">
<div className="meeting-preview-eyebrow">
<div className="meeting-preview-eyebrow-label">
<FileTextOutlined />
{TEXT.previewLabel}
</div>
<span className={`meeting-preview-status ${statusMeta.className}`}>{statusMeta.label}</span>
</div>
<div className="meeting-preview-hero-toolbar">
<div>
<h1 className="meeting-preview-title">{renderMeetingTitle(meeting.title)}</h1>
<p className="meeting-preview-subtitle">{statusMeta.hint}</p>
</div>
<div className="meeting-preview-hero-actions">
<Button icon={<CopyOutlined />} onClick={handleCopyLink}>
{TEXT.copyLink}
</Button>
<Button type="primary" icon={<ShareAltOutlined />} onClick={handleShareNow}>
{TEXT.shareNow}
</Button>
</div>
</div>
<div style={{ display: isMetricsExpanded ? 'block' : 'none' }}>
<div className="meeting-preview-metrics">
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">{TEXT.meetingTime}</span>
<span className="meeting-preview-metric-value">
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY.MM.DD HH:mm") : TEXT.notSet}
</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">{TEXT.createdAt}</span>
<span className="meeting-preview-metric-value">
{meeting.createdAt ? dayjs(meeting.createdAt).format("YYYY.MM.DD HH:mm") : TEXT.notSet}
</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">{TEXT.creator}</span>
<span className="meeting-preview-metric-value">{meeting.creatorName || TEXT.notSet}</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">{TEXT.host}</span>
<span className="meeting-preview-metric-value">{meeting.hostName || TEXT.notSet}</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label">{TEXT.participantsCount}</span>
<span className="meeting-preview-metric-value">{participantCountValue || TEXT.notFilled}</span>
</div>
<div className="meeting-preview-metric">
<span className="meeting-preview-metric-label"></span>
<span className="meeting-preview-metric-value">{meetingDuration > 0 ? formatDurationRange(0, meetingDuration).split(' - ')[1] : TEXT.noDuration}</span>
</div>
</div>
{participants.length > 0 ? (
<div className="meeting-preview-overview" style={{ marginTop: 20 }}>
<div className="meeting-preview-overview-label">{TEXT.participants}</div>
<div className="meeting-preview-tags">
{participants.map((item) => (
<span key={item} className="meeting-preview-tag">
<TeamOutlined style={{ marginRight: 8 }} />
{item}
</span>
))}
</div>
</div>
) : null}
{tags.length > 0 ? (
<div className="meeting-preview-overview" style={{ marginTop: participants.length > 0 ? 12 : 20 }}>
<div className="meeting-preview-overview-label">{TEXT.tags}</div>
<div className="meeting-preview-tags">
{tags.map((item) => (
<Tag key={item} bordered={false} className="meeting-preview-tag">
{item}
</Tag>
))}
</div>
</div>
) : null}
</div>
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Button
type="text"
block
onClick={() => setIsMetricsExpanded(!isMetricsExpanded)}
style={{ color: 'var(--preview-muted)', height: 40, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
icon={isMetricsExpanded ? <UpOutlined /> : <DownOutlined />}
>
{isMetricsExpanded ? '收起基础信息' : '展开基础信息'}
</Button>
</div>
</section>
<div className="meeting-preview-panels">
<section className="meeting-preview-card meeting-preview-section">
<Tabs
className="meeting-preview-page-tabs"
activeKey={pageTab}
onChange={(key) => setPageTab(key as PreviewPageTab)}
items={[
{ key: "summary", label: TEXT.pageSummary, children: summaryTab },
{ key: "transcript", label: TEXT.pageTranscript, children: transcriptTab },
]}
/>
</section>
</div>
<div className="meeting-preview-disclaimer">
<RobotOutlined style={{ marginRight: 8 }} />
{TEXT.disclaimer}
</div>
</div>
{playbackAudioUrl && (
<audio
ref={audioRef}
onTimeUpdate={handleAudioTimeUpdate}
onPlay={handleAudioPlay}
onPause={handleAudioPause}
onEnded={handleAudioEnded}
onLoadedMetadata={handleAudioLoadedMetadata}
onError={handleAudioError}
style={{ display: 'none' }}
preload="auto"
>
<source src={playbackAudioUrl} type={resolveAudioMimeType(playbackAudioUrl)} />
</audio>
)}
{playbackAudioUrl && pageTab === 'transcript' ? (
<>
<div style={{ height: 100, flexShrink: 0, pointerEvents: 'none' }} />
<div className="transcript-player">
<button type="button" className="player-main-btn" onClick={toggleAudioPlayback} aria-label="toggle-audio">
{audioPlaying ? <PauseOutlined /> : <CaretRightFilled />}
</button>
<div className="player-progress-shell">
<div className="player-time-row">
<span>{formatPlayerTime(audioCurrentTime)}</span>
<span>{formatPlayerTime(audioDuration)}</span>
</div>
<input
className="player-range"
type="range"
min={0}
max={audioDuration || 0}
step={0.1}
value={Math.min(audioCurrentTime, audioDuration || 0)}
onChange={handleAudioProgressChange}
style={{ backgroundSize: `${audioDuration ? (audioCurrentTime / audioDuration) * 100 : 0}% 100%` }}
/>
</div>
<button type="button" className="player-ghost-btn" onClick={cyclePlaybackRate}>
<FastForwardOutlined />
{audioPlaybackRate}x
</button>
</div>
</>
) : null}
</div>
);
}