954 lines
36 KiB
TypeScript
954 lines
36 KiB
TypeScript
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>
|
||
);
|
||
}
|