feat: 添加会议章节和目录功能

- 在 `PublicMeetingPreviewVO` 中添加 `chapters` 字段
- 更新 `MeetingPreview` 组件以支持章节和目录展示
- 添加新的 `pageCatalog` 选项卡,展示 AI 生成的目录
- 实现章节与转录内容的关联和跳转功能
- 优化关键词和总结内容的展示布局
dev_na
chenhao 2026-05-09 17:33:00 +08:00
parent 38edf9dad6
commit beab0edabe
5 changed files with 1128 additions and 1069 deletions

View File

@ -53,6 +53,7 @@ public class MeetingPublicPreviewController {
data.getMeeting().setAccessPassword(null);
}
data.setTranscripts(meetingQueryService.getTranscripts(id));
data.setChapters(meetingQueryService.getChapters(id));
return ApiResponse.ok(data);
} catch (RuntimeException ex) {
return ApiResponse.error(ex.getMessage());

View File

@ -3,9 +3,11 @@ package com.imeeting.dto.biz;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class PublicMeetingPreviewVO {
private MeetingVO meeting;
private List<MeetingTranscriptVO> transcripts;
private List<Map<String, Object>> chapters;
}

View File

@ -266,6 +266,7 @@ export interface MeetingChapterVO {
export interface PublicMeetingPreviewVO {
meeting: MeetingVO;
transcripts: MeetingTranscriptVO[];
chapters?: MeetingChapterVO[];
}
export const getMeetingDetail = (id: number) => {

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Button, Empty, Input, Result, Segmented, Skeleton, Tabs, Tag, message } from "antd";
import { Alert, Button, Empty, Input, Result, Skeleton, Tabs, message } from "antd";
import { useParams, useSearchParams } from "react-router-dom";
import {
AudioOutlined,
@ -7,7 +7,6 @@ import {
CaretRightFilled,
ClockCircleOutlined,
CopyOutlined,
FastForwardOutlined,
FileTextOutlined,
LockOutlined,
PauseOutlined,
@ -17,6 +16,7 @@ import {
UserOutlined,
DownOutlined,
UpOutlined,
LinkOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import ReactMarkdown from "react-markdown";
@ -25,6 +25,7 @@ import {
getPublicMeetingPreview,
resolveAudioMimeType,
resolveMeetingPlaybackAudioUrl,
type MeetingChapterVO,
type MeetingTranscriptVO,
type MeetingVO,
} from "../../api/business/meeting";
@ -32,87 +33,117 @@ import { buildMeetingAnalysis } from "./meetingAnalysis";
import "./MeetingPreview.css";
type AnalysisTab = "chapters" | "speakers" | "actions" | "todos";
type PreviewPageTab = "summary" | "transcript";
type PreviewPageTab = "summary" | "catalog" | "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",
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: "未知发言人",
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",
shareText: "我向你分享了一个会议预览链接",
audioSaved: "已保存",
audioSaveFailed: "保存失败",
audioUploaded: "已上传",
audioNotSaved: "未保存",
linkToTranscript: "关联原文",
noCatalog: "暂无 AI 目录",
};
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;
}
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 },
@ -169,9 +200,10 @@ export default function MeetingPreview() {
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
const [meetingChapters, setMeetingChapters] = useState<MeetingChapterVO[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("chapters");
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("speakers");
const [pageTab, setPageTab] = useState<PreviewPageTab>("summary");
const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(null);
const [passwordRequired, setPasswordRequired] = useState(false);
@ -183,6 +215,8 @@ export default function MeetingPreview() {
const [audioDuration, setAudioDuration] = useState(0);
const [audioPlaybackRate, setAudioPlaybackRate] = useState(1);
const [isMetricsExpanded, setIsMetricsExpanded] = useState(false);
const [linkedTranscriptIds, setLinkedTranscriptIds] = useState<number[]>([]);
const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(null);
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false,
);
@ -202,6 +236,7 @@ export default function MeetingPreview() {
setError("");
setMeeting(null);
setTranscripts([]);
setMeetingChapters([]);
setPasswordRequired(false);
setPasswordVerified(false);
setAccessPassword(presetAccessPassword);
@ -228,6 +263,7 @@ export default function MeetingPreview() {
}
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
return;
} catch (requestError: any) {
@ -248,6 +284,7 @@ export default function MeetingPreview() {
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
} catch (requestError: any) {
if (!mounted) {
@ -307,18 +344,6 @@ export default function MeetingPreview() {
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;
@ -331,6 +356,66 @@ export default function MeetingPreview() {
return 0;
}, [transcripts]);
const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => {
const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index]));
const sourceChapters: MeetingChapterVO[] = meetingChapters.length
? meetingChapters
: analysis.chapters.map((item) => ({
title: item.title,
time: item.time,
}));
return sourceChapters.map((chapter, index) => {
let matchedTranscripts: MeetingTranscriptVO[] = [];
const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds)
? chapter.sourceTranscriptIds
.map((item) => Number(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]);
useEffect(() => {
if (!activeTranscriptId) {
return;
@ -341,7 +426,8 @@ export default function MeetingPreview() {
return;
}
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
// 使用 center 模式确保当前说话段落始终位于视口中央,避免被底部的浮动控件遮挡
target.scrollIntoView({ behavior: "smooth", block: "center" });
}, [activeTranscriptId]);
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
@ -353,6 +439,25 @@ export default function MeetingPreview() {
audioRef.current.play().catch(() => {});
};
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(() => {
// 部分浏览器(尤其是移动端)可能会拦截非直接交互触发的播放
// 但由于这是由用户点击目录项触发的,通常会被允许
});
}
}
};
const toggleAudioPlayback = () => {
if (!audioRef.current) return;
if (audioPlaying) {
@ -431,19 +536,6 @@ export default function MeetingPreview() {
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;
@ -455,6 +547,7 @@ export default function MeetingPreview() {
const previewRes = await getPublicMeetingPreview(Number(id), accessPassword.trim());
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
} catch (requestError: any) {
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword);
@ -568,143 +661,24 @@ export default function MeetingPreview() {
);
}
const summaryTab = (
const summaryTabContent = (
<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 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>
))
) : (
<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>
<span className="meeting-preview-keywords-empty"></span>
)}
</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">
@ -718,15 +692,68 @@ export default function MeetingPreview() {
</div>
);
const transcriptTab = (
const catalogTabContent = (
<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 className="meeting-preview-section-kicker">*/}
{/* <RobotOutlined />*/}
{/* {TEXT.aiAnalysis}*/}
{/*</div>*/}
<h2 className="meeting-preview-section-title">{TEXT.pageCatalog}</h2>
</div>
</div>
<div className="meeting-preview-catalog-list">
{catalogChapterLinks.length ? (
catalogChapterLinks.map((chapter, index) => (
<div
key={chapter.key}
className={`meeting-preview-catalog-item-container ${linkedChapterKey === chapter.key ? 'active' : ''}`}
>
<div className="meeting-preview-catalog-timeline-axis">
<div className="meeting-preview-catalog-timeline-dot" />
<div className="meeting-preview-catalog-timeline-line" />
</div>
<div
className="meeting-preview-catalog-item-card"
onClick={() => handleLocateChapterTranscript(index)}
>
<div className="meeting-preview-catalog-item-time">{chapter.timeLabel}</div>
<div className="meeting-preview-catalog-item-title-row">
<div className="meeting-preview-catalog-item-title">{chapter.title}</div>
<button
type="button"
className="meeting-preview-catalog-item-link"
onClick={(e) => {
e.stopPropagation();
handleLocateChapterTranscript(index);
}}
>
<LinkOutlined /> {TEXT.linkToTranscript}
</button>
</div>
</div>
</div>
))
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={TEXT.noCatalog} />
)}
</div>
</section>
</div>
);
const transcriptTabContent = (
<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">
@ -748,16 +775,26 @@ export default function MeetingPreview() {
{transcripts.length > 0 ? (
transcripts.map((item) => {
const speakerKey = item.speakerName || item.speakerLabel || item.speakerId || "speaker";
const isLinked = linkedTranscriptIds.includes(item.id);
const isActive = activeTranscriptId === item.id;
return (
<div
key={item.id}
ref={(node) => {
transcriptItemRefs.current[item.id] = node;
}}
className={`meeting-preview-transcript-item ${activeTranscriptId === item.id ? "is-active" : ""}`}
onClick={() => handleTranscriptSeek(item)}
className={`meeting-preview-transcript-item ${isActive ? "is-active" : ""} ${isLinked ? "is-linked" : ""}`}
onClick={() => {
handleTranscriptSeek(item);
setLinkedTranscriptIds([]); // Clear linked highlight on manual seek
setLinkedChapterKey(null);
}}
>
<div
className="meeting-preview-transcript-avatar"
style={{ backgroundColor: transcriptColorSeed(speakerKey) }}
>
<div className="meeting-preview-transcript-avatar">
{(speakerKey || "S").slice(0, 1)}
</div>
<div className="meeting-preview-transcript-content">
@ -782,127 +819,158 @@ export default function MeetingPreview() {
</div>
);
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;
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">
<div className={`meeting-preview-page ${isMobile ? "is-mobile" : "is-desktop"}`}>
<div className="meeting-preview-container">
<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}
{/* Header Title Section */}
<div className="meeting-preview-top-hero">
<div className="meeting-preview-hero-logo">
<RobotOutlined />
</div>
<div className="meeting-preview-hero-content">
<h1 className="meeting-preview-hero-title">{meeting.title || 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>
</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>
{/* 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>
</div>
<div className="meeting-preview-hero-actions">
<Button icon={<CopyOutlined />} onClick={handleCopyLink}>
{TEXT.copyLink}
</Button>
<Button type="primary" icon={<ShareAltOutlined />} onClick={handleShareNow}>
<div className="trigger-right">
{isMetricsExpanded ? <UpOutlined /> : <DownOutlined />}
</div>
</div>
<div className={`meeting-preview-collapsible-content ${isMetricsExpanded ? 'is-expanded' : ''}`}>
<div className="meeting-preview-metrics-grid">
<div className="metric-item">
<div className="metric-label">{TEXT.meetingTime}</div>
<div className="metric-value">
<CalendarOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : TEXT.notSet}
</div>
</div>
<div className="metric-item">
<div className="metric-label">{TEXT.hostCreator}</div>
<div className="metric-value">
<UserOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meeting.creatorName || 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>
</div>
<div className="metric-item">
<div className="metric-label"></div>
<div className="metric-value">
<ClockCircleOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meetingDuration > 0 ? formatTotalDuration(meetingDuration) : 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>
)}
</div>
</div>
</div>
{/* Sharing Buttons Bar */}
<div className="meeting-preview-share-bar">
<Button
type="primary"
size="large"
icon={<ShareAltOutlined />}
onClick={handleShareNow}
className="share-btn-primary"
>
{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 />}
size="large"
icon={<CopyOutlined />}
onClick={handleCopyLink}
className="share-btn-ghost"
>
{isMetricsExpanded ? '收起基础信息' : '展开基础信息'}
{TEXT.copyLink}
</Button>
</div>
</section>
<div className="meeting-preview-panels">
<section className="meeting-preview-card meeting-preview-section">
<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
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 },
{ key: "summary", label: TEXT.pageSummary },
{ key: "catalog", label: TEXT.pageCatalog },
{ key: "transcript", label: TEXT.pageTranscript },
]}
/>
</section>
</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 style={{ marginRight: 8 }} />
{TEXT.disclaimer}
<RobotOutlined />
<span>{TEXT.disclaimer}</span>
</div>
</div>
</div>
</div>
{/* Floating Audio Player - Permanent mount, visibility controlled */}
{playbackAudioUrl && (
<div
className="meeting-preview-audio-player-inline"
style={{ display: pageTab === "transcript" ? "flex" : "none" }}
>
<audio
ref={audioRef}
onTimeUpdate={handleAudioTimeUpdate}
@ -911,27 +979,20 @@ export default function MeetingPreview() {
onEnded={handleAudioEnded}
onLoadedMetadata={handleAudioLoadedMetadata}
onError={handleAudioError}
style={{ display: 'none' }}
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">
<div className="audio-player-content">
<button type="button" className="audio-play-btn" onClick={toggleAudioPlayback}>
{audioPlaying ? <PauseOutlined /> : <CaretRightFilled />}
</button>
<div className="player-progress-shell">
<div className="player-time-row">
<span>{formatPlayerTime(audioCurrentTime)}</span>
<span>{formatPlayerTime(audioDuration)}</span>
</div>
<div className="audio-progress-container">
<div className="audio-time">{formatPlayerTime(audioCurrentTime)}</div>
<input
className="player-range"
className="audio-range"
type="range"
min={0}
max={audioDuration || 0}
@ -940,14 +1001,14 @@ export default function MeetingPreview() {
onChange={handleAudioProgressChange}
style={{ backgroundSize: `${audioDuration ? (audioCurrentTime / audioDuration) * 100 : 0}% 100%` }}
/>
<div className="audio-time">{formatPlayerTime(audioDuration)}</div>
</div>
<button type="button" className="player-ghost-btn" onClick={cyclePlaybackRate}>
<FastForwardOutlined />
<button type="button" className="audio-speed-btn" onClick={cyclePlaybackRate}>
{audioPlaybackRate}x
</button>
</div>
</>
) : null}
</div>
)}
</div>
);
}