2026-04-15 09:55:57 +00:00
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
2026-05-09 09:33:00 +00:00
|
|
|
|
import { Alert, Button, Empty, Input, Result, Skeleton, Tabs, message } from "antd";
|
2026-04-16 02:55:10 +00:00
|
|
|
|
import { useParams, useSearchParams } from "react-router-dom";
|
2026-04-15 09:55:57 +00:00
|
|
|
|
import {
|
|
|
|
|
|
AudioOutlined,
|
|
|
|
|
|
CalendarOutlined,
|
2026-04-16 05:05:50 +00:00
|
|
|
|
CaretRightFilled,
|
2026-04-15 09:55:57 +00:00
|
|
|
|
ClockCircleOutlined,
|
2026-04-16 01:41:22 +00:00
|
|
|
|
CopyOutlined,
|
2026-04-15 09:55:57 +00:00
|
|
|
|
FileTextOutlined,
|
|
|
|
|
|
LockOutlined,
|
2026-04-16 05:05:50 +00:00
|
|
|
|
PauseOutlined,
|
2026-04-15 09:55:57 +00:00
|
|
|
|
RobotOutlined,
|
2026-04-16 01:41:22 +00:00
|
|
|
|
ShareAltOutlined,
|
2026-04-15 09:55:57 +00:00
|
|
|
|
TeamOutlined,
|
|
|
|
|
|
UserOutlined,
|
2026-04-17 03:30:22 +00:00
|
|
|
|
DownOutlined,
|
|
|
|
|
|
UpOutlined,
|
2026-05-09 09:33:00 +00:00
|
|
|
|
LinkOutlined,
|
2026-04-15 09:55:57 +00:00
|
|
|
|
} from "@ant-design/icons";
|
|
|
|
|
|
import dayjs from "dayjs";
|
|
|
|
|
|
import ReactMarkdown from "react-markdown";
|
|
|
|
|
|
import {
|
|
|
|
|
|
getMeetingPreviewAccess,
|
|
|
|
|
|
getPublicMeetingPreview,
|
2026-04-27 02:39:34 +00:00
|
|
|
|
resolveAudioMimeType,
|
2026-04-27 07:16:08 +00:00
|
|
|
|
resolveMeetingPlaybackAudioUrl,
|
2026-05-09 09:33:00 +00:00
|
|
|
|
type MeetingChapterVO,
|
2026-04-15 09:55:57 +00:00
|
|
|
|
type MeetingTranscriptVO,
|
|
|
|
|
|
type MeetingVO,
|
|
|
|
|
|
} from "../../api/business/meeting";
|
|
|
|
|
|
import { buildMeetingAnalysis } from "./meetingAnalysis";
|
|
|
|
|
|
import "./MeetingPreview.css";
|
|
|
|
|
|
|
|
|
|
|
|
type AnalysisTab = "chapters" | "speakers" | "actions" | "todos";
|
2026-05-09 09:33:00 +00:00
|
|
|
|
type PreviewPageTab = "summary" | "catalog" | "transcript";
|
2026-04-16 01:41:22 +00:00
|
|
|
|
|
|
|
|
|
|
const TEXT = {
|
2026-05-09 09:33:00 +00:00
|
|
|
|
statusTranscribing: "转写中",
|
|
|
|
|
|
statusSummarizing: "总结中",
|
|
|
|
|
|
statusCompleted: "已完成",
|
|
|
|
|
|
statusPending: "待处理",
|
|
|
|
|
|
hintTranscribing: "会议内容仍在整理中,预览会持续补全。",
|
|
|
|
|
|
hintSummarizing: "AI 正在生成会议总结,已完成内容会优先展示。",
|
|
|
|
|
|
hintCompleted: "会议纪要、分析和转录内容已生成完成。",
|
|
|
|
|
|
hintPending: "当前会议尚未生成完整内容,请稍后重试。",
|
|
|
|
|
|
missingMeetingId: "未提供会议编号",
|
|
|
|
|
|
loadFailed: "会议预览加载失败",
|
|
|
|
|
|
noMeetingData: "未找到会议数据",
|
|
|
|
|
|
previewLabel: "会议预览",
|
|
|
|
|
|
untitledMeeting: "未命名会议",
|
|
|
|
|
|
meetingTime: "会议时间",
|
|
|
|
|
|
hostCreator: "主持/创建",
|
|
|
|
|
|
participantsCount: "参会人数",
|
|
|
|
|
|
tagsCount: "标签数量",
|
|
|
|
|
|
notSet: "未设置",
|
|
|
|
|
|
notFilled: "未填写",
|
|
|
|
|
|
pageSummary: "AI 纪要",
|
|
|
|
|
|
pageCatalog: "AI 目录",
|
|
|
|
|
|
pageTranscript: "转录原文",
|
|
|
|
|
|
copyLink: "复制链接",
|
|
|
|
|
|
shareNow: "立即分享",
|
|
|
|
|
|
shareCopied: "预览链接已复制",
|
|
|
|
|
|
shareFallbackCopied: "当前设备不支持系统分享,已为你复制链接",
|
|
|
|
|
|
shareFailed: "分享失败,请先复制链接",
|
|
|
|
|
|
accessCheck: "访问校验",
|
|
|
|
|
|
passwordRequired: "该会议需要访问密码",
|
|
|
|
|
|
passwordHint: "请输入会议的 access_password 后继续访问预览内容。",
|
|
|
|
|
|
passwordPlaceholder: "请输入 access_password",
|
|
|
|
|
|
openPreview: "进入预览",
|
|
|
|
|
|
invalidPassword: "访问密码错误",
|
|
|
|
|
|
basicInfo: "基本信息",
|
|
|
|
|
|
meetingOverview: "会议概况",
|
|
|
|
|
|
creator: "创建人",
|
|
|
|
|
|
host: "主持人",
|
|
|
|
|
|
createdAt: "创建时间",
|
|
|
|
|
|
audioStatus: "音频状态",
|
|
|
|
|
|
participants: "人",
|
|
|
|
|
|
tags: "会议标签",
|
|
|
|
|
|
aiAnalysis: "AI 目录",
|
|
|
|
|
|
analysis: "会议分析",
|
|
|
|
|
|
previewExtra: "预览页仅读展示",
|
|
|
|
|
|
audioPlaybackWarning: "音频保存失败,可能影响回放。",
|
|
|
|
|
|
summaryOverview: "全文概要",
|
|
|
|
|
|
summaryEmpty: "暂无概要内容",
|
|
|
|
|
|
analysisChapters: "章节",
|
|
|
|
|
|
analysisSpeakers: "发言人",
|
|
|
|
|
|
analysisKeyPoints: "关键要点",
|
|
|
|
|
|
analysisTodos: "待办事项",
|
|
|
|
|
|
noChapterAnalysis: "暂无章节分析",
|
|
|
|
|
|
noSpeakerAnalysis: "暂无发言人分析",
|
|
|
|
|
|
noKeyPoints: "暂无关键要点",
|
|
|
|
|
|
noTodos: "暂无待办事项",
|
|
|
|
|
|
chapterFallback: "章节",
|
|
|
|
|
|
speakerFallback: "发言人",
|
|
|
|
|
|
speakerSummary: "发言概述",
|
|
|
|
|
|
keyPointFallback: "要点",
|
|
|
|
|
|
noChapterSummary: "暂无章节描述",
|
|
|
|
|
|
noSpeakerSummary: "暂无发言总结",
|
|
|
|
|
|
noKeyPointSummary: "暂无要点说明",
|
|
|
|
|
|
summarySection: "会议纪要",
|
|
|
|
|
|
fullSummary: "完整纪要",
|
|
|
|
|
|
noSummary: "暂无会议纪要",
|
|
|
|
|
|
transcriptSection: "会议转录",
|
|
|
|
|
|
transcriptTitle: "逐段转录",
|
|
|
|
|
|
noDuration: "暂无时长",
|
|
|
|
|
|
audioUnavailable: "音频文件不可用,仅展示转录内容。",
|
|
|
|
|
|
noTranscript: "暂无转录内容",
|
|
|
|
|
|
unknownSpeaker: "未知发言人",
|
2026-04-16 05:05:50 +00:00
|
|
|
|
disclaimer: "智能内容由用户会议内容 + AI 模型生成,我们不对内容准确性和完整性做任何保证,亦不代表我们的观点或态度",
|
2026-05-09 09:33:00 +00:00
|
|
|
|
shareText: "我向你分享了一个会议预览链接",
|
|
|
|
|
|
audioSaved: "已保存",
|
|
|
|
|
|
audioSaveFailed: "保存失败",
|
|
|
|
|
|
audioUploaded: "已上传",
|
|
|
|
|
|
audioNotSaved: "未保存",
|
|
|
|
|
|
linkToTranscript: "关联原文",
|
|
|
|
|
|
noCatalog: "暂无 AI 目录",
|
2026-04-16 01:41:22 +00:00
|
|
|
|
};
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
type ChapterTranscriptLink = {
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
timeLabel: string;
|
|
|
|
|
|
transcriptIds: number[];
|
|
|
|
|
|
firstTranscriptId: number | null;
|
|
|
|
|
|
firstTranscriptStartTime: number | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function parseChapterTimeToMs(value?: string) {
|
|
|
|
|
|
const raw = String(value || "").trim();
|
|
|
|
|
|
if (!raw) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const matched = raw.match(/(\d{1,2}:\d{2}(?::\d{2})?)/)?.[1];
|
|
|
|
|
|
if (!matched) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const parts = matched.split(":").map((item) => Number(item));
|
|
|
|
|
|
if (parts.some((item) => Number.isNaN(item))) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const totalSeconds =
|
|
|
|
|
|
parts.length === 3 ? parts[0] * 3600 + parts[1] * 60 + parts[2] : parts[0] * 60 + parts[1];
|
|
|
|
|
|
|
|
|
|
|
|
return totalSeconds * 1000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 09:55:57 +00:00
|
|
|
|
const STATUS_META: Record<number, { label: string; className: string; hint: string }> = {
|
2026-04-16 01:41:22 +00:00
|
|
|
|
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 },
|
2026-04-15 09:55:57 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 || "")
|
2026-04-16 01:41:22 +00:00
|
|
|
|
.split(",")
|
2026-04-15 09:55:57 +00:00
|
|
|
|
.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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 01:41:22 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 09:55:57 +00:00
|
|
|
|
export default function MeetingPreview() {
|
|
|
|
|
|
const { id } = useParams();
|
2026-04-16 02:55:10 +00:00
|
|
|
|
const [searchParams] = useSearchParams();
|
2026-04-15 09:55:57 +00:00
|
|
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
2026-04-27 02:39:34 +00:00
|
|
|
|
const audioPlaybackErrorShownRef = useRef<string | null>(null);
|
2026-04-16 01:41:22 +00:00
|
|
|
|
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
2026-04-15 09:55:57 +00:00
|
|
|
|
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
|
|
|
|
|
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
|
2026-05-09 09:33:00 +00:00
|
|
|
|
const [meetingChapters, setMeetingChapters] = useState<MeetingChapterVO[]>([]);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState("");
|
2026-05-09 09:33:00 +00:00
|
|
|
|
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("speakers");
|
2026-04-16 01:41:22 +00:00
|
|
|
|
const [pageTab, setPageTab] = useState<PreviewPageTab>("summary");
|
2026-04-15 09:55:57 +00:00
|
|
|
|
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("");
|
2026-04-16 05:05:50 +00:00
|
|
|
|
const [audioPlaying, setAudioPlaying] = useState(false);
|
|
|
|
|
|
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
|
|
|
|
|
const [audioDuration, setAudioDuration] = useState(0);
|
|
|
|
|
|
const [audioPlaybackRate, setAudioPlaybackRate] = useState(1);
|
2026-04-17 03:30:22 +00:00
|
|
|
|
const [isMetricsExpanded, setIsMetricsExpanded] = useState(false);
|
2026-05-09 09:33:00 +00:00
|
|
|
|
const [linkedTranscriptIds, setLinkedTranscriptIds] = useState<number[]>([]);
|
|
|
|
|
|
const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(null);
|
2026-04-16 01:41:22 +00:00
|
|
|
|
const [isMobile, setIsMobile] = useState(() =>
|
|
|
|
|
|
typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false,
|
|
|
|
|
|
);
|
2026-04-16 02:55:10 +00:00
|
|
|
|
const presetAccessPassword = useMemo(() => (searchParams.get("accessPassword") || "").trim(), [searchParams]);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let mounted = true;
|
|
|
|
|
|
|
2026-04-16 01:41:22 +00:00
|
|
|
|
const load = async () => {
|
2026-04-15 09:55:57 +00:00
|
|
|
|
if (!id) {
|
2026-04-16 01:41:22 +00:00
|
|
|
|
setError(TEXT.missingMeetingId);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
setLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError("");
|
2026-04-16 02:55:10 +00:00
|
|
|
|
setMeeting(null);
|
|
|
|
|
|
setTranscripts([]);
|
2026-05-09 09:33:00 +00:00
|
|
|
|
setMeetingChapters([]);
|
2026-04-16 02:55:10 +00:00
|
|
|
|
setPasswordRequired(false);
|
|
|
|
|
|
setPasswordVerified(false);
|
|
|
|
|
|
setAccessPassword(presetAccessPassword);
|
|
|
|
|
|
setPasswordError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const meetingId = Number(id);
|
|
|
|
|
|
const accessRes = await getMeetingPreviewAccess(meetingId);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
if (!mounted) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 02:55:10 +00:00
|
|
|
|
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 || []);
|
2026-05-09 09:33:00 +00:00
|
|
|
|
setMeetingChapters(previewRes.data.data.chapters || []);
|
2026-04-16 02:55:10 +00:00
|
|
|
|
setPasswordVerified(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
} catch (requestError: any) {
|
|
|
|
|
|
if (!mounted) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword);
|
|
|
|
|
|
setPasswordVerified(false);
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
2026-04-16 02:55:10 +00:00
|
|
|
|
const previewRes = await getPublicMeetingPreview(meetingId);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
if (!mounted) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setMeeting(previewRes.data.data.meeting);
|
|
|
|
|
|
setTranscripts(previewRes.data.data.transcripts || []);
|
2026-05-09 09:33:00 +00:00
|
|
|
|
setMeetingChapters(previewRes.data.data.chapters || []);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
setPasswordVerified(true);
|
|
|
|
|
|
} catch (requestError: any) {
|
|
|
|
|
|
if (!mounted) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 01:41:22 +00:00
|
|
|
|
setError(requestError?.response?.data?.msg || requestError?.msg || TEXT.loadFailed);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-16 01:41:22 +00:00
|
|
|
|
load();
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
mounted = false;
|
|
|
|
|
|
};
|
2026-04-16 02:55:10 +00:00
|
|
|
|
}, [id, presetAccessPassword]);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
2026-04-16 01:41:22 +00:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-04-15 09:55:57 +00:00
|
|
|
|
const analysis = useMemo(
|
|
|
|
|
|
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""),
|
|
|
|
|
|
[meeting?.analysis, meeting?.summaryContent, meeting?.tags],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]);
|
2026-04-16 01:41:22 +00:00
|
|
|
|
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]);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]);
|
2026-04-16 01:41:22 +00:00
|
|
|
|
const keywords = useMemo(() => analysis.keywords || [], [analysis.keywords]);
|
2026-04-27 07:16:08 +00:00
|
|
|
|
const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
const statusMeta = STATUS_META[meeting?.status || 0] || {
|
2026-04-16 01:41:22 +00:00
|
|
|
|
label: TEXT.statusPending,
|
2026-04-15 09:55:57 +00:00
|
|
|
|
className: "is-warning",
|
2026-04-16 01:41:22 +00:00
|
|
|
|
hint: TEXT.hintPending,
|
2026-04-15 09:55:57 +00:00
|
|
|
|
};
|
2026-04-16 01:41:22 +00:00
|
|
|
|
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
|
|
|
|
|
|
const participantCountValue =
|
|
|
|
|
|
isMobile && transcriptSpeakers.length > 0 ? transcriptSpeakers.length : participants.length;
|
|
|
|
|
|
|
2026-04-16 05:05:50 +00:00
|
|
|
|
const meetingDuration = useMemo(() => {
|
|
|
|
|
|
if (transcripts.length > 0) {
|
|
|
|
|
|
const last = transcripts[transcripts.length - 1];
|
|
|
|
|
|
return last.endTime || 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}, [transcripts]);
|
|
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
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(item))
|
|
|
|
|
|
.filter((item) => Number.isFinite(item) && transcriptIdToIndex.has(item))
|
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
|
|
if (sourceTranscriptIds.length) {
|
|
|
|
|
|
matchedTranscripts = sourceTranscriptIds
|
|
|
|
|
|
.map((item) => transcripts[transcriptIdToIndex.get(item)!])
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
} else if (chapter.startTranscriptId && chapter.endTranscriptId) {
|
|
|
|
|
|
const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId));
|
|
|
|
|
|
const endIndex = transcriptIdToIndex.get(Number(chapter.endTranscriptId));
|
|
|
|
|
|
if (startIndex !== undefined && endIndex !== undefined) {
|
|
|
|
|
|
matchedTranscripts = transcripts.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex) + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const startMs = typeof chapter.startTime === "number" ? chapter.startTime : parseChapterTimeToMs(chapter.time);
|
|
|
|
|
|
const nextChapterStartMs = sourceChapters
|
|
|
|
|
|
.slice(index + 1)
|
|
|
|
|
|
.map((item) => (typeof item.startTime === "number" ? item.startTime : parseChapterTimeToMs(item.time)))
|
|
|
|
|
|
.find((item): item is number => item !== null && startMs !== null && item > startMs);
|
|
|
|
|
|
|
|
|
|
|
|
if (startMs !== null) {
|
|
|
|
|
|
const firstTranscriptIndex = transcripts.findIndex((item) => item.endTime > startMs);
|
|
|
|
|
|
if (firstTranscriptIndex >= 0) {
|
|
|
|
|
|
const lastTranscriptIndex =
|
|
|
|
|
|
nextChapterStartMs === undefined
|
|
|
|
|
|
? transcripts.length
|
|
|
|
|
|
: transcripts.findIndex((item) => item.startTime >= nextChapterStartMs);
|
|
|
|
|
|
matchedTranscripts = transcripts.slice(
|
|
|
|
|
|
firstTranscriptIndex,
|
|
|
|
|
|
lastTranscriptIndex >= 0 ? lastTranscriptIndex : transcripts.length,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: `${chapter.chapterNo ?? index}-${chapter.title || "chapter"}`,
|
|
|
|
|
|
title: chapter.title || `章节 ${index + 1}`,
|
|
|
|
|
|
timeLabel: chapter.time || "--:--",
|
|
|
|
|
|
transcriptIds: matchedTranscripts.map((item) => item.id),
|
|
|
|
|
|
firstTranscriptId: matchedTranscripts[0]?.id ?? null,
|
|
|
|
|
|
firstTranscriptStartTime: matchedTranscripts[0]?.startTime ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [analysis.chapters, meetingChapters, transcripts]);
|
|
|
|
|
|
|
2026-04-16 01:41:22 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!activeTranscriptId) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const target = transcriptItemRefs.current[activeTranscriptId];
|
|
|
|
|
|
if (!target) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
// 使用 center 模式确保当前说话段落始终位于视口中央,避免被底部的浮动控件遮挡
|
|
|
|
|
|
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
2026-04-16 01:41:22 +00:00
|
|
|
|
}, [activeTranscriptId]);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
|
|
|
|
|
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
|
|
|
|
|
|
if (!audioRef.current) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000);
|
|
|
|
|
|
audioRef.current.play().catch(() => {});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
const handleLocateChapterTranscript = (index: number) => {
|
|
|
|
|
|
const link = catalogChapterLinks[index];
|
|
|
|
|
|
if (link && link.firstTranscriptId) {
|
|
|
|
|
|
setPageTab("transcript");
|
|
|
|
|
|
setLinkedTranscriptIds(link.transcriptIds);
|
|
|
|
|
|
setLinkedChapterKey(link.key);
|
|
|
|
|
|
setActiveTranscriptId(link.firstTranscriptId);
|
|
|
|
|
|
|
|
|
|
|
|
// 自动跳转并播放音频
|
|
|
|
|
|
if (audioRef.current && link.firstTranscriptStartTime !== null) {
|
|
|
|
|
|
audioRef.current.currentTime = Math.max(0, link.firstTranscriptStartTime / 1000);
|
|
|
|
|
|
audioRef.current.play().catch(() => {
|
|
|
|
|
|
// 部分浏览器(尤其是移动端)可能会拦截非直接交互触发的播放
|
|
|
|
|
|
// 但由于这是由用户点击目录项触发的,通常会被允许
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-16 05:05:50 +00:00
|
|
|
|
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')}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-15 09:55:57 +00:00
|
|
|
|
const handleAudioTimeUpdate = () => {
|
2026-04-16 05:05:50 +00:00
|
|
|
|
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);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 05:05:50 +00:00
|
|
|
|
if (transcripts.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
const currentMs = currentSeconds * 1000;
|
2026-04-15 09:55:57 +00:00
|
|
|
|
const currentItem = transcripts.find(
|
|
|
|
|
|
(item) => currentMs >= (item.startTime || 0) && currentMs <= (item.endTime || 0),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
setActiveTranscriptId(currentItem?.id || null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-16 05:05:50 +00:00
|
|
|
|
const handleAudioEnded = () => {
|
|
|
|
|
|
setAudioPlaying(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleAudioPlay = () => setAudioPlaying(true);
|
|
|
|
|
|
const handleAudioPause = () => setAudioPlaying(false);
|
|
|
|
|
|
const handleAudioLoadedMetadata = () => {
|
|
|
|
|
|
if (audioRef.current) {
|
|
|
|
|
|
setAudioDuration(audioRef.current.duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-27 02:39:34 +00:00
|
|
|
|
const handleAudioError = () => {
|
2026-04-27 07:16:08 +00:00
|
|
|
|
const currentAudioUrl = playbackAudioUrl || "";
|
2026-04-27 02:39:34 +00:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-15 09:55:57 +00:00
|
|
|
|
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 || []);
|
2026-05-09 09:33:00 +00:00
|
|
|
|
setMeetingChapters(previewRes.data.data.chapters || []);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
setPasswordVerified(true);
|
|
|
|
|
|
} catch (requestError: any) {
|
2026-04-16 01:41:22 +00:00
|
|
|
|
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-16 01:41:22 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-15 09:55:57 +00:00
|
|
|
|
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 />
|
2026-04-16 01:41:22 +00:00
|
|
|
|
{TEXT.accessCheck}
|
2026-04-15 09:55:57 +00:00
|
|
|
|
</div>
|
2026-04-16 01:41:22 +00:00
|
|
|
|
<h2 className="meeting-preview-section-title">{TEXT.passwordRequired}</h2>
|
2026-04-15 09:55:57 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-16 01:41:22 +00:00
|
|
|
|
<p className="meeting-preview-subtitle">{TEXT.passwordHint}</p>
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
|
|
|
|
|
<div className="meeting-preview-password-form">
|
|
|
|
|
|
<Input.Password
|
|
|
|
|
|
value={accessPassword}
|
2026-04-16 01:41:22 +00:00
|
|
|
|
placeholder={TEXT.passwordPlaceholder}
|
2026-04-15 09:55:57 +00:00
|
|
|
|
onChange={(event) => setAccessPassword(event.target.value)}
|
|
|
|
|
|
onPressEnter={handlePasswordSubmit}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button type="primary" onClick={handlePasswordSubmit} loading={loading} disabled={!accessPassword.trim()}>
|
2026-04-16 01:41:22 +00:00
|
|
|
|
{TEXT.openPreview}
|
2026-04-15 09:55:57 +00:00
|
|
|
|
</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">
|
2026-04-16 01:41:22 +00:00
|
|
|
|
<Result status="error" title={TEXT.loadFailed} subTitle={error} />
|
2026-04-15 09:55:57 +00:00
|
|
|
|
</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">
|
2026-04-16 01:41:22 +00:00
|
|
|
|
<Empty description={TEXT.noMeetingData} />
|
2026-04-15 09:55:57 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
const summaryTabContent = (
|
2026-04-16 01:41:22 +00:00
|
|
|
|
<div className="meeting-preview-tab-panel">
|
|
|
|
|
|
<section className="meeting-preview-card meeting-preview-section">
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<div className="meeting-preview-summary-box">
|
|
|
|
|
|
<div className="meeting-preview-summary-section">
|
|
|
|
|
|
<div className="meeting-preview-summary-section-title">关键词</div>
|
|
|
|
|
|
<div className="meeting-preview-record-tags">
|
|
|
|
|
|
{keywords.length ? (
|
|
|
|
|
|
keywords.map((item) => (
|
|
|
|
|
|
<div key={item} className="meeting-preview-record-tag">
|
|
|
|
|
|
<span>#{item}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="meeting-preview-keywords-empty">暂无关键词</span>
|
|
|
|
|
|
)}
|
2026-04-15 09:55:57 +00:00
|
|
|
|
</div>
|
2026-04-16 01:41:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<div className="meeting-preview-markdown">
|
|
|
|
|
|
{meeting.summaryContent ? (
|
|
|
|
|
|
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Empty description={TEXT.noSummary} />
|
|
|
|
|
|
)}
|
2026-04-16 01:41:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
const catalogTabContent = (
|
|
|
|
|
|
<div className="meeting-preview-tab-panel">
|
2026-04-16 01:41:22 +00:00
|
|
|
|
<section className="meeting-preview-card meeting-preview-section">
|
|
|
|
|
|
<div className="meeting-preview-section-header">
|
|
|
|
|
|
<div>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
{/*<div className="meeting-preview-section-kicker">*/}
|
|
|
|
|
|
{/* <RobotOutlined />*/}
|
|
|
|
|
|
{/* {TEXT.aiAnalysis}*/}
|
|
|
|
|
|
{/*</div>*/}
|
|
|
|
|
|
<h2 className="meeting-preview-section-title">{TEXT.pageCatalog}</h2>
|
2026-04-16 01:41:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
))
|
2026-04-16 01:41:22 +00:00
|
|
|
|
) : (
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={TEXT.noCatalog} />
|
2026-04-16 01:41:22 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
const transcriptTabContent = (
|
2026-04-16 01:41:22 +00:00
|
|
|
|
<div className="meeting-preview-tab-panel">
|
|
|
|
|
|
<section className="meeting-preview-card meeting-preview-section">
|
|
|
|
|
|
<div className="meeting-preview-section-header">
|
|
|
|
|
|
<div>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
{/*<div className="meeting-preview-section-kicker">*/}
|
|
|
|
|
|
{/* <AudioOutlined />*/}
|
|
|
|
|
|
{/* {TEXT.transcriptSection}*/}
|
|
|
|
|
|
{/*</div>*/}
|
2026-04-16 01:41:22 +00:00
|
|
|
|
<h2 className="meeting-preview-section-title">{TEXT.transcriptTitle}</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="meeting-preview-section-extra">
|
|
|
|
|
|
<ClockCircleOutlined style={{ marginRight: 6 }} />
|
2026-04-16 05:05:50 +00:00
|
|
|
|
{meetingDuration > 0 ? formatDurationRange(0, meetingDuration) : TEXT.noDuration}
|
2026-04-16 01:41:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
2026-04-16 01:41:22 +00:00
|
|
|
|
{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";
|
2026-05-09 09:33:00 +00:00
|
|
|
|
const isLinked = linkedTranscriptIds.includes(item.id);
|
|
|
|
|
|
const isActive = activeTranscriptId === item.id;
|
|
|
|
|
|
|
2026-04-16 01:41:22 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
ref={(node) => {
|
|
|
|
|
|
transcriptItemRefs.current[item.id] = node;
|
|
|
|
|
|
}}
|
2026-05-09 09:33:00 +00:00
|
|
|
|
className={`meeting-preview-transcript-item ${isActive ? "is-active" : ""} ${isLinked ? "is-linked" : ""}`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
handleTranscriptSeek(item);
|
|
|
|
|
|
setLinkedTranscriptIds([]); // Clear linked highlight on manual seek
|
|
|
|
|
|
setLinkedChapterKey(null);
|
|
|
|
|
|
}}
|
2026-04-16 01:41:22 +00:00
|
|
|
|
>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<div
|
|
|
|
|
|
className="meeting-preview-transcript-avatar"
|
|
|
|
|
|
style={{ backgroundColor: transcriptColorSeed(speakerKey) }}
|
|
|
|
|
|
>
|
2026-04-16 05:05:50 +00:00
|
|
|
|
{(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>
|
2026-04-15 09:55:57 +00:00
|
|
|
|
</div>
|
2026-04-16 05:05:50 +00:00
|
|
|
|
<div className="meeting-preview-transcript-text">
|
|
|
|
|
|
{item.content || TEXT.noTranscript}
|
2026-04-15 09:55:57 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-16 01:41:22 +00:00
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Empty description={TEXT.noTranscript} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
const formatTotalDuration = (ms: number) => {
|
|
|
|
|
|
const totalSeconds = Math.floor(ms / 1000);
|
|
|
|
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
|
|
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
|
|
|
|
const seconds = totalSeconds % 60;
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
if (hours > 0) {
|
|
|
|
|
|
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={`meeting-preview-page ${isMobile ? "is-mobile" : "is-desktop"}`}>
|
|
|
|
|
|
<div className="meeting-preview-container">
|
|
|
|
|
|
<div className="meeting-preview-shell">
|
|
|
|
|
|
{/* Header Title Section */}
|
|
|
|
|
|
<div className="meeting-preview-top-hero">
|
|
|
|
|
|
<div className="meeting-preview-hero-logo">
|
|
|
|
|
|
<RobotOutlined />
|
2026-04-15 09:55:57 +00:00
|
|
|
|
</div>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<div className="meeting-preview-hero-content">
|
|
|
|
|
|
<h1 className="meeting-preview-hero-title">{meeting.title || TEXT.untitledMeeting}</h1>
|
|
|
|
|
|
<div className="meeting-preview-hero-meta">
|
|
|
|
|
|
<span className={`meeting-preview-status-tag ${statusMeta.className}`}>
|
|
|
|
|
|
{statusMeta.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{/*<span className="meeting-preview-hero-id">ID: {meeting.id}</span>*/}
|
|
|
|
|
|
</div>
|
2026-04-16 01:41:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
{/* Collapsible Basic Info Section */}
|
|
|
|
|
|
<div className="meeting-preview-collapsible-section">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="meeting-preview-collapsible-trigger"
|
|
|
|
|
|
onClick={() => setIsMetricsExpanded(!isMetricsExpanded)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="trigger-left">
|
|
|
|
|
|
<FileTextOutlined />
|
|
|
|
|
|
<span>{TEXT.basicInfo}</span>
|
2026-04-17 03:30:22 +00:00
|
|
|
|
</div>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<div className="trigger-right">
|
|
|
|
|
|
{isMetricsExpanded ? <UpOutlined /> : <DownOutlined />}
|
2026-04-17 03:00:50 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
|
|
|
|
|
|
<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") : TEXT.notSet}
|
|
|
|
|
|
</div>
|
2026-04-17 03:30:22 +00:00
|
|
|
|
</div>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<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 || TEXT.notSet}
|
|
|
|
|
|
</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)' }} />
|
|
|
|
|
|
{participantCountValue} {TEXT.participants}
|
|
|
|
|
|
</div>
|
2026-04-17 03:30:22 +00:00
|
|
|
|
</div>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<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) : TEXT.notSet}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
2026-04-17 03:30:22 +00:00
|
|
|
|
</div>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
</div>
|
2026-04-17 03:30:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
{/* Sharing Buttons Bar */}
|
|
|
|
|
|
<div className="meeting-preview-share-bar">
|
2026-04-17 03:30:22 +00:00
|
|
|
|
<Button
|
2026-05-09 09:33:00 +00:00
|
|
|
|
type="primary"
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
icon={<ShareAltOutlined />}
|
|
|
|
|
|
onClick={handleShareNow}
|
|
|
|
|
|
className="share-btn-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
{TEXT.shareNow}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
icon={<CopyOutlined />}
|
|
|
|
|
|
onClick={handleCopyLink}
|
|
|
|
|
|
className="share-btn-ghost"
|
2026-04-17 03:30:22 +00:00
|
|
|
|
>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
{TEXT.copyLink}
|
2026-04-17 03:30:22 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-04-15 09:55:57 +00:00
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<div className="meeting-preview-layout-full">
|
|
|
|
|
|
{/* Main Content Area */}
|
|
|
|
|
|
<main className="meeting-preview-main">
|
|
|
|
|
|
<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 },
|
|
|
|
|
|
{ key: "catalog", label: TEXT.pageCatalog },
|
|
|
|
|
|
{ key: "transcript", label: TEXT.pageTranscript },
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="meeting-preview-tab-content">
|
|
|
|
|
|
{pageTab === "summary" ? summaryTabContent : null}
|
|
|
|
|
|
{pageTab === "catalog" ? catalogTabContent : null}
|
|
|
|
|
|
{pageTab === "transcript" ? transcriptTabContent : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="meeting-preview-footer">
|
|
|
|
|
|
<div className="meeting-preview-disclaimer">
|
|
|
|
|
|
<RobotOutlined />
|
|
|
|
|
|
<span>{TEXT.disclaimer}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-15 09:55:57 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-16 05:05:50 +00:00
|
|
|
|
|
2026-05-09 09:33:00 +00:00
|
|
|
|
{/* Floating Audio Player - Permanent mount, visibility controlled */}
|
2026-04-27 07:16:08 +00:00
|
|
|
|
{playbackAudioUrl && (
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<div
|
|
|
|
|
|
className="meeting-preview-audio-player-inline"
|
|
|
|
|
|
style={{ display: pageTab === "transcript" ? "flex" : "none" }}
|
2026-04-27 02:39:34 +00:00
|
|
|
|
>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="audio-player-content">
|
|
|
|
|
|
<button type="button" className="audio-play-btn" onClick={toggleAudioPlayback}>
|
2026-04-16 05:05:50 +00:00
|
|
|
|
{audioPlaying ? <PauseOutlined /> : <CaretRightFilled />}
|
|
|
|
|
|
</button>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<div className="audio-progress-container">
|
|
|
|
|
|
<div className="audio-time">{formatPlayerTime(audioCurrentTime)}</div>
|
2026-04-16 05:05:50 +00:00
|
|
|
|
<input
|
2026-05-09 09:33:00 +00:00
|
|
|
|
className="audio-range"
|
2026-04-16 05:05:50 +00:00
|
|
|
|
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%` }}
|
|
|
|
|
|
/>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<div className="audio-time">{formatPlayerTime(audioDuration)}</div>
|
2026-04-16 05:05:50 +00:00
|
|
|
|
</div>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
<button type="button" className="audio-speed-btn" onClick={cyclePlaybackRate}>
|
2026-04-16 05:05:50 +00:00
|
|
|
|
{audioPlaybackRate}x
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-05-09 09:33:00 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-04-15 09:55:57 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|