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

4327 lines
151 KiB
TypeScript
Raw Normal View History

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Alert, Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Radio, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd';
2026-03-05 09:52:08 +00:00
import {
AudioOutlined,
CaretRightFilled,
ClockCircleOutlined,
CopyOutlined,
DownloadOutlined,
2026-03-05 09:52:08 +00:00
EditOutlined,
FastForwardOutlined,
FileTextOutlined,
LeftOutlined,
LinkOutlined,
LoadingOutlined,
PauseOutlined,
2026-03-05 09:52:08 +00:00
SyncOutlined,
UserOutlined,
CheckCircleFilled,
FilePdfOutlined,
FileWordOutlined,
ShareAltOutlined, InfoCircleOutlined,
2026-03-05 09:52:08 +00:00
} from '@ant-design/icons';
import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown';
2026-03-05 09:52:08 +00:00
import {
downloadMeetingTranscript,
downloadMeetingSummary,
2026-03-05 09:52:08 +00:00
getMeetingDetail,
getMeetingChapters,
getMeetingProgress,
2026-06-05 07:07:45 +00:00
getMeetingShareConfig,
2026-03-05 09:52:08 +00:00
getTranscripts,
MeetingChapterVO,
MeetingProgress,
SummaryDetailLevel,
MeetingTranscriptVO,
MeetingVO,
2026-03-05 09:52:08 +00:00
reSummary,
resolveAudioMimeType,
resolveMeetingPlaybackAudioUrl,
retryMeetingChapter,
retryMeetingSummary,
retryMeetingTranscription,
retryScheduleMeeting,
updateMeetingBasic,
updateMeetingTranscript,
updateMeetingSummary,
updateSpeakerInfo,
2026-03-05 09:52:08 +00:00
} from '../../api/business/meeting';
import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel';
import { getHotWordPage, getPinyinSuggestion, saveHotWord } from '../../api/business/hotword';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { listUsers } from '../../api';
import { useDict } from '../../hooks/useDict';
import { SysUser } from '../../types';
import PageContainer from "../../components/shared/PageContainer";
2026-06-30 05:37:33 +00:00
import SectionCard from "../../components/shared/SectionCard";
import "./MeetingDetail.css";
const { Title, Text } = Typography;
const { Option } = Select;
type AnalysisChapter = {
time?: string;
title: string;
summary: string;
};
type AnalysisSpeakerSummary = {
speaker: string;
summary: string;
};
type AnalysisKeyPoint = {
title: string;
summary: string;
speaker?: string;
time?: string;
};
type MeetingAnalysis = {
overview: string;
keywords: string[];
chapters: AnalysisChapter[];
speakerSummaries: AnalysisSpeakerSummary[];
keyPoints: AnalysisKeyPoint[];
todos: string[];
};
type WorkspaceTab = 'catalog' | 'transcript';
type ChapterTranscriptLink = {
key: string;
title: string;
timeLabel: string;
transcriptIds: number[];
firstTranscriptId: number | null;
firstTranscriptStartTime: number | null;
};
type MeetingStateNotice = {
title: string;
description: string;
type: 'info' | 'warning';
hasFallbackContent: boolean;
scope: 'summary' | 'catalog' | 'global';
};
const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000;
const ANALYSIS_EMPTY: MeetingAnalysis = {
overview: '',
keywords: [],
chapters: [],
speakerSummaries: [],
keyPoints: [],
todos: [],
};
const ACCESS_PASSWORD_PATTERN = /^[A-Za-z0-9]{4}$/;
const ACCESS_PASSWORD_SOURCE = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const copyMeetingLink = async (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);
};
const generateAccessPassword = () =>
Array.from({ length: 4 }, () => ACCESS_PASSWORD_SOURCE[Math.floor(Math.random() * ACCESS_PASSWORD_SOURCE.length)]).join('');
const normalizeAccessPasswordInput = (value?: string | null) => (value || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 4);
const sanitizeDownloadFileName = (value?: string | null, fallback = 'meeting-recording') => {
const normalized = (value || '').replace(/[\\/:*?"<>|\r\n]/g, '_').trim();
return normalized || fallback;
};
const resolveAudioExtension = (audioUrl?: string) => {
const normalizedUrl = audioUrl?.split('#')[0]?.split('?')[0] || '';
return normalizedUrl.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() || 'mp3';
};
2026-07-02 09:29:39 +00:00
const normalizeTagDisplay = (tag: string) => tag.replace(/^#+\s*/, '').trim();
const getMeetingAudioDownloadName = (meeting?: Pick<MeetingVO, 'title' | 'audioUrl' | 'playbackAudioUrl'> | null) => {
const audioUrl = resolveMeetingPlaybackAudioUrl(meeting);
const extension = resolveAudioExtension(audioUrl);
return `${sanitizeDownloadFileName(meeting?.title)}-录音.${extension}`;
};
2026-06-05 07:07:45 +00:00
const buildMeetingPreviewUrl = (baseUrl?: string, meetingId?: number, accessPassword?: string) => {
const normalizedBaseUrl = (baseUrl || '').trim();
if (!normalizedBaseUrl || !meetingId || Number.isNaN(meetingId)) {
return '';
}
2026-06-05 07:07:45 +00:00
const safeBaseUrl = normalizedBaseUrl.endsWith('/') ? normalizedBaseUrl.slice(0, -1) : normalizedBaseUrl;
const url = new URL(`${safeBaseUrl}/meetings/${meetingId}/preview`);
const normalizedPassword = (accessPassword || '').trim();
if (normalizedPassword) {
url.searchParams.set('accessPassword', normalizedPassword);
}
return url.toString();
};
const splitLines = (value?: string | null) =>
(value || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const parseLooseJson = (raw?: string | null) => {
const input = (raw || '').trim();
if (!input) return null;
const tryParse = (text: string) => {
try {
return JSON.parse(text);
} catch {
return null;
}
};
const direct = tryParse(input);
if (direct && typeof direct === 'object') return direct;
const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim();
if (fenced) {
const fencedParsed = tryParse(fenced);
if (fencedParsed && typeof fencedParsed === 'object') return fencedParsed;
}
const start = input.indexOf('{');
const end = input.lastIndexOf('}');
if (start >= 0 && end > start) {
const wrapped = tryParse(input.slice(start, end + 1));
if (wrapped && typeof wrapped === 'object') return wrapped;
}
return null;
};
const extractSection = (markdown: string, aliases: string[]) => {
const lines = markdown.split(/\r?\n/);
const lowerAliases = aliases.map((item) => item.toLowerCase());
const cleanHeading = (line: string) => line.replace(/^#{1,6}\s*/, '').trim().toLowerCase();
let start = -1;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index].trim();
if (!line.startsWith('#')) continue;
const heading = cleanHeading(line);
if (lowerAliases.some((alias) => heading.includes(alias))) {
start = index + 1;
break;
}
}
if (start < 0) return '';
const buffer: string[] = [];
for (let index = start; index < lines.length; index += 1) {
const line = lines[index];
if (line.trim().startsWith('#')) break;
buffer.push(line);
}
return buffer.join('\n').trim();
};
const parseBulletList = (content?: string | null) =>
splitLines(content)
.map((line) => line.replace(/^[-*鈥s]+/, '').replace(/^\d+[.)]\s*/, '').trim())
.filter(Boolean);
const parseOverviewSection = (markdown: string) =>
extractSection(markdown, ['全文概要', '概要', '摘要', '概览']) || markdown.replace(/^---[\s\S]*?---/, '').trim();
const parseKeywordsSection = (markdown: string, tags: string) => {
const section = extractSection(markdown, ['关键词', '关键字', '标签']);
const fromSection = parseBulletList(section)
.flatMap((line) => line.split(/[,、/]/))
.map((item) => item.trim())
.filter(Boolean);
if (fromSection.length) {
return Array.from(new Set(fromSection)).slice(0, 12);
}
return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(Boolean))).slice(0, 12);
};
const buildMeetingAnalysis = (
sourceAnalysis: MeetingVO['analysis'] | undefined,
summaryContent: string | undefined,
tags: string,
): MeetingAnalysis => {
const parseStructured = (parsed: Record<string, any>): MeetingAnalysis => {
const chapters = Array.isArray(parsed.chapters) ? parsed.chapters : [];
const speakerSummaries = Array.isArray(parsed.speakerSummaries) ? parsed.speakerSummaries : [];
const keyPoints = Array.isArray(parsed.keyPoints) ? parsed.keyPoints : [];
const todos = Array.isArray(parsed.todos)
? parsed.todos
: Array.isArray(parsed.actionItems)
? parsed.actionItems
: [];
return {
overview: String(parsed.overview || '').trim(),
keywords: Array.from(
new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(Boolean)),
).slice(0, 12),
chapters: chapters
.map((item: any) => ({
time: item?.time ? String(item.time).trim() : undefined,
title: String(item?.title || '').trim(),
summary: String(item?.summary || '').trim(),
}))
.filter((item: AnalysisChapter) => item.title || item.summary),
speakerSummaries: speakerSummaries
.map((item: any) => ({
speaker: String(item?.speaker || '').trim(),
summary: String(item?.summary || '').trim(),
}))
.filter((item: AnalysisSpeakerSummary) => item.speaker || item.summary),
keyPoints: keyPoints
.map((item: any) => ({
title: String(item?.title || '').trim(),
summary: String(item?.summary || '').trim(),
speaker: item?.speaker ? String(item.speaker).trim() : undefined,
time: item?.time ? String(item.time).trim() : undefined,
}))
.filter((item: AnalysisKeyPoint) => item.title || item.summary),
todos: todos.map((item: any) => String(item).trim()).filter(Boolean).slice(0, 10),
};
};
if (sourceAnalysis) {
return parseStructured(sourceAnalysis as Record<string, any>);
}
const raw = (summaryContent || '').trim();
if (!raw && !tags) return ANALYSIS_EMPTY;
const loose = parseLooseJson(raw);
if (loose) {
return parseStructured(loose);
}
return {
overview: parseOverviewSection(raw),
keywords: parseKeywordsSection(raw, tags),
chapters: [],
speakerSummaries: [],
keyPoints: [],
todos: [],
};
};
function formatTime(ms: number) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainSeconds = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`;
}
function formatPlayerTime(seconds: number) {
const safeSeconds = Math.max(0, Math.floor(seconds || 0));
const hours = Math.floor(safeSeconds / 3600);
const minutes = Math.floor((safeSeconds % 3600) / 60);
const remainSeconds = safeSeconds % 60;
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`;
}
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;
}
type MeetingProgressPhase = 'queued' | 'asr' | 'chapter' | 'summary' | 'terminal';
const meetingProgressTerminalRefreshCache = new Map<number, string>();
const meetingProgressPhaseRefreshCache = new Map<number, MeetingProgressPhase>();
const resolveProgressPhase = (progress: MeetingProgress | null | undefined): MeetingProgressPhase => {
const unifiedStatusCode = progress?.unifiedStatus?.statusCode;
if (unifiedStatusCode?.startsWith('FAILED_') || unifiedStatusCode === 'COMPLETED') {
return 'terminal';
}
if (unifiedStatusCode === 'SUMMARIZING') {
return 'summary';
}
if (unifiedStatusCode === 'TRANSCRIBING') {
return 'asr';
}
if (unifiedStatusCode === 'INITIALIZING' || unifiedStatusCode === 'WAITING_UPLOAD') {
return 'queued';
}
const percent = progress?.percent ?? 0;
if (percent < 0 || percent >= 100) {
return 'terminal';
}
if (percent >= 90) {
return 'summary';
}
if (percent >= 85) {
return 'chapter';
}
if (percent >= 5) {
return 'asr';
}
return 'queued';
};
const MeetingProgressDisplay: React.FC<{
meetingId: number;
onComplete: () => void;
onRefreshNeeded?: (phase: MeetingProgressPhase) => void;
onProgressChange?: (progress: MeetingProgress | null) => void;
compact?: boolean;
inline?: boolean;
}> = ({ meetingId, onComplete, onRefreshNeeded, onProgressChange, compact, inline }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
const onCompleteRef = useRef(onComplete);
const onRefreshNeededRef = useRef(onRefreshNeeded);
const onProgressChangeRef = useRef(onProgressChange);
useEffect(() => {
onCompleteRef.current = onComplete;
onRefreshNeededRef.current = onRefreshNeeded;
onProgressChangeRef.current = onProgressChange;
}, [onComplete, onRefreshNeeded, onProgressChange]);
useEffect(() => {
let completed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let requesting = false;
const fetchProgress = async () => {
if (completed || requesting) {
return;
}
requesting = true;
let shouldContinue = true;
try {
const progressRes = await getMeetingProgress(meetingId, { suppressErrorToast: true });
if (progressRes.data?.data) {
const nextProgress = progressRes.data.data;
setProgress(nextProgress);
onProgressChangeRef.current?.(nextProgress);
const phase = resolveProgressPhase(nextProgress);
const previousPhase = meetingProgressPhaseRefreshCache.get(meetingId);
meetingProgressPhaseRefreshCache.set(meetingId, phase);
if ((phase === 'chapter' || phase === 'summary') && previousPhase !== phase) {
onRefreshNeededRef.current?.(phase);
}
if (nextProgress.percent === 100 || nextProgress.percent < 0) {
completed = true;
shouldContinue = false;
const terminalKey = `${nextProgress.updateAt}:${nextProgress.percent}`;
if (meetingProgressTerminalRefreshCache.get(meetingId) !== terminalKey) {
meetingProgressTerminalRefreshCache.set(meetingId, terminalKey);
onCompleteRef.current?.();
}
} else {
meetingProgressTerminalRefreshCache.delete(meetingId);
}
}
} catch {
// ignore
} finally {
requesting = false;
if (!completed && shouldContinue) {
timer = setTimeout(() => {
void fetchProgress();
}, 3000);
}
2026-03-05 09:52:08 +00:00
}
};
void fetchProgress();
return () => {
completed = true;
if (timer) {
clearTimeout(timer);
}
};
}, [meetingId]);
const percent = progress?.percent || 0;
const isError = percent < 0;
const unifiedStatusText = progress?.unifiedStatus?.statusText;
const unifiedStatusMessage = progress?.unifiedStatus?.message;
const formatEta = (seconds?: number) => {
if (!seconds || seconds <= 0) return '计算中';
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
const remainSeconds = seconds % 60;
return remainSeconds > 0 ? `${minutes}${remainSeconds}` : `${minutes} 分钟`;
};
if (inline) {
return (
<div
style={{
marginTop: 12,
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${isError ? '#ffccc7' : '#d6dbff'}`,
background: isError ? '#fff2f0' : '#f7f8ff',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginBottom: 8,
}}
>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5' }}>
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在生成最新结果...'}
</Text>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5', whiteSpace: 'nowrap' }}>
{isError ? '失败' : `${percent}%`}
</Text>
</div>
<Progress
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : '#6c73ff'}
showInfo={false}
size="small"
style={{ marginBottom: 6 }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
</div>
);
}
if (compact) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
textAlign: 'center',
}}
>
<Title level={4} style={{ marginBottom: 24 }}>
AI
</Title>
<Progress
type="circle"
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : { '0%': '#6c73ff', '100%': '#8d63ff' }}
size={140}
strokeWidth={10}
/>
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 16, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 4 }}>
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在分析内容...'}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
</div>
</div>
);
}
return (
2026-03-05 09:52:08 +00:00
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
background: '#fff',
borderRadius: 16,
padding: 40,
}}
>
<div style={{ width: '100%', maxWidth: 600, textAlign: 'center' }}>
<Title level={3} style={{ marginBottom: 24 }}>
AI
</Title>
2026-03-05 09:52:08 +00:00
<Progress
type="circle"
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : { '0%': '#6c73ff', '100%': '#8d63ff' }}
size={180}
strokeWidth={8}
/>
<div style={{ marginTop: 32 }}>
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}>
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在准备计算资源...'}
</Text>
<Text type="secondary"></Text>
</div>
<Divider style={{ margin: '32px 0' }} />
<Row gutter={24}>
<Col span={8}>
<Space direction="vertical" size={0}>
2026-03-05 09:52:08 +00:00
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>
{isError ? 'ERROR' : `${percent}%`}
</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
2026-03-05 09:52:08 +00:00
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>
{isError ? '--' : formatEta(progress?.eta)}
</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
2026-03-05 09:52:08 +00:00
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>
{isError ? '失败' : '正常'}
</Title>
</Space>
</Col>
</Row>
</div>
</div>
);
};
const DETAIL_STAGE_STEP_DISPLAY_ITEMS = [
{ code: 'INITIALIZING', label: '数据初始化', hint: '完成会议数据准备' },
{ code: 'TRANSCRIBING', label: '转译音频', hint: '完成语音转写' },
{ code: 'SUMMARIZING', label: '生成总结', hint: '生成总结与 AI 目录' },
{ code: 'COMPLETED', label: '处理完成', hint: '全部任务处理完成' },
] as const;
const UnifiedMeetingProgressDisplay: React.FC<{
progress: MeetingProgress | null;
compact?: boolean;
inline?: boolean;
}> = ({ progress, compact, inline }) => {
const percent = progress?.percent || 0;
const isError = percent < 0;
const unifiedStatusCode = progress?.unifiedStatus?.statusCode;
const unifiedStatusText = progress?.unifiedStatus?.statusText;
const unifiedStatusMessage = progress?.unifiedStatus?.message;
const failedStageCode = progress?.unifiedStatus?.failedStageCode;
const primaryStatusText = unifiedStatusText || (isError ? '处理失败' : '处理中');
const formatEta = (seconds?: number) => {
if (!seconds || seconds <= 0) return '计算中';
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
const remainSeconds = seconds % 60;
return remainSeconds > 0 ? `${minutes}${remainSeconds}` : `${minutes} 分钟`;
};
const resolveStageCode = () => {
if (failedStageCode) {
return failedStageCode;
}
if (unifiedStatusCode === 'WAITING_UPLOAD' || unifiedStatusCode === 'INITIALIZING') {
return 'INITIALIZING';
}
if (unifiedStatusCode === 'TRANSCRIBING') {
return 'TRANSCRIBING';
}
if (unifiedStatusCode === 'SUMMARIZING') {
return 'SUMMARIZING';
}
if (unifiedStatusCode === 'COMPLETED') {
return 'COMPLETED';
}
const phase = resolveProgressPhase(progress);
if (phase === 'queued') {
return 'INITIALIZING';
}
if (phase === 'asr') {
return 'TRANSCRIBING';
}
if (phase === 'chapter' || phase === 'summary') {
return 'SUMMARIZING';
}
return 'COMPLETED';
};
const currentStageCode = resolveStageCode();
const currentStageIndex = Math.max(0, DETAIL_STAGE_STEP_DISPLAY_ITEMS.findIndex((item) => item.code === currentStageCode));
const isFailedStage = unifiedStatusCode?.startsWith('FAILED_') || isError;
const isCompletedStage = unifiedStatusCode === 'COMPLETED' || percent === 100;
const progressText = isError ? '--' : `${Math.max(percent, 0)}%`;
const helperText = unifiedStatusMessage || progress?.message || (isError ? '当前阶段执行失败,请稍后重试。' : '阶段状态已与安卓端统一。');
const renderStageTimeline = (options?: {
compact?: boolean;
inline?: boolean;
}) => {
const compactMode = options?.compact;
const inlineMode = options?.inline;
const containerStyle: React.CSSProperties = inlineMode
? {
marginTop: 10,
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 8,
}
: compactMode
? {
marginTop: 18,
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 8,
width: '100%',
maxWidth: 560,
}
: {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
};
return (
<div style={containerStyle}>
{DETAIL_STAGE_STEP_DISPLAY_ITEMS.map((item, index) => {
const isCurrent = index === currentStageIndex;
const isDone = isCompletedStage || (!isFailedStage && index < currentStageIndex);
const isFailed = isFailedStage && isCurrent;
const dotBg = isFailed ? '#ff4d4f' : isDone ? '#52c41a' : isCurrent ? '#4f46e5' : '#d9d9d9';
const textColor = isFailed ? '#cf1322' : isDone || isCurrent ? '#1f1f1f' : '#8c8c8c';
const cardPadding = inlineMode ? '8px 6px' : compactMode ? '10px 8px' : '0';
const cardRadius = inlineMode || compactMode ? 12 : 0;
const cardBorder = inlineMode || compactMode
? `1px solid ${isFailed ? '#ffccc7' : isCurrent ? '#c7d2fe' : '#e5e7eb'}`
: 'none';
const cardBackground = inlineMode || compactMode
? isFailed ? '#fff2f0' : isCurrent ? '#eef2ff' : isDone ? '#f6ffed' : '#fafafa'
: 'transparent';
return (
<React.Fragment key={item.code}>
<div
style={{
flex: 1,
minWidth: 0,
padding: cardPadding,
borderRadius: cardRadius,
border: cardBorder,
background: cardBackground,
textAlign: 'center',
}}
>
<div
style={{
width: compactMode || inlineMode ? 28 : 36,
height: compactMode || inlineMode ? 28 : 36,
margin: `0 auto ${compactMode || inlineMode ? 8 : 12}px`,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: dotBg,
color: '#fff',
fontWeight: 700,
boxShadow: isCurrent ? `0 0 0 ${compactMode || inlineMode ? 4 : 6}px ${isFailed ? 'rgba(255,77,79,0.12)' : 'rgba(79,70,229,0.12)'}` : 'none',
}}
>
{isDone ? '√' : index + 1}
</div>
<div
style={{
fontSize: compactMode || inlineMode ? 12 : 15,
fontWeight: 600,
color: textColor,
marginBottom: compactMode || inlineMode ? 0 : 6,
}}
>
{isFailed && item.code === currentStageCode ? `${item.label}失败` : item.label}
</div>
{!compactMode && !inlineMode ? (
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.6 }}>
{item.hint}
</Text>
) : null}
</div>
{!inlineMode && !compactMode && index < DETAIL_STAGE_STEP_DISPLAY_ITEMS.length - 1 ? (
<div
style={{
flex: 1,
height: 2,
marginTop: 17,
background: (!isFailedStage && index < currentStageIndex) || isCompletedStage ? '#52c41a' : '#e5e7eb',
}}
/>
) : null}
</React.Fragment>
);
})}
</div>
);
};
if (inline) {
return (
<div
style={{
marginTop: 12,
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${isError ? '#ffccc7' : '#d6dbff'}`,
background: isError ? '#fff2f0' : '#f7f8ff',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginBottom: 8,
}}
>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5' }}>
{helperText}
</Text>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5', whiteSpace: 'nowrap' }}>
{isError ? '失败' : `${percent}%`}
</Text>
</div>
<Progress
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : '#6c73ff'}
showInfo={false}
size="small"
style={{ marginBottom: 6 }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
{renderStageTimeline({ inline: true })}
</div>
);
}
if (compact) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
textAlign: 'center',
}}
>
<Title level={4} style={{ marginBottom: 24 }}>
{primaryStatusText}
</Title>
<Progress
type="circle"
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : { '0%': '#6c73ff', '100%': '#8d63ff' }}
size={140}
strokeWidth={10}
/>
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 16, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 4 }}>
{helperText}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
{renderStageTimeline({ compact: true })}
</div>
</div>
);
}
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
background: '#fff',
borderRadius: 16,
padding: 40,
}}
>
<div style={{ width: '100%', maxWidth: 760, textAlign: 'center' }}>
<Title level={3} style={{ marginBottom: 8 }}>
{primaryStatusText}
</Title>
<Text type="secondary" style={{ fontSize: 15 }}>
{helperText}
</Text>
<div
style={{
marginTop: 28,
marginBottom: 32,
padding: '20px 24px',
borderRadius: 20,
background: isError ? 'linear-gradient(135deg, #fff2f0, #fff7f6)' : 'linear-gradient(135deg, #f5f7ff, #faf8ff)',
border: `1px solid ${isError ? '#ffccc7' : '#d9ddff'}`,
}}
>
<Text type="secondary" style={{ fontSize: 13 }}>
</Text>
<div
style={{
marginTop: 6,
fontSize: 40,
lineHeight: 1,
fontWeight: 700,
color: isError ? '#cf1322' : '#4f46e5',
}}
>
{progressText}
</div>
<Progress
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : '#6c73ff'}
showInfo={false}
style={{ marginTop: 18, marginBottom: 12 }}
/>
<Text type="secondary" style={{ fontSize: 13 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
</div>
{renderStageTimeline()}
<Divider style={{ margin: '32px 0 24px' }} />
<Row gutter={24}>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>
{DETAIL_STAGE_STEP_DISPLAY_ITEMS[currentStageIndex]?.label || primaryStatusText}
</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>
{isError ? '失败' : isCompletedStage ? '已完成' : '处理中'}
</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0, color: '#4f46e5' }}>
{[
progress?.unifiedStatus?.canViewTranscript ? '转录' : null,
progress?.unifiedStatus?.canViewAiChapters ? '目录' : null,
progress?.unifiedStatus?.canViewSummary ? '总结' : null,
].filter(Boolean).join(' / ') || '暂无'}
</Title>
</Space>
</Col>
</Row>
<div style={{ marginTop: 20 }}>
<Text type="secondary"></Text>
</div>
</div>
</div>
);
};
const SpeakerEditor: React.FC<{
meetingId: number;
speakerId: string;
initialName: string;
initialLabel: string;
onSuccess: () => void;
}> = ({ meetingId, speakerId, initialName, initialLabel, onSuccess }) => {
const [name, setName] = useState(initialName || speakerId);
const [label, setLabel] = useState(initialLabel);
const [loading, setLoading] = useState(false);
const { message } = App.useApp();
const { items: speakerLabels } = useDict('biz_speaker_label');
const handleSave = async (event: React.MouseEvent) => {
event.stopPropagation();
setLoading(true);
try {
await updateSpeakerInfo({ meetingId, speakerId, newName: name, label });
message.success('发言人信息已更新');
onSuccess();
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return (
<div style={{ width: 250, padding: '8px 4px' }} onClick={(event) => event.stopPropagation()}>
<div style={{ marginBottom: 12 }}>
2026-03-05 09:52:08 +00:00
<Text type="secondary"></Text>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} />
</div>
<div style={{ marginBottom: 16 }}>
2026-03-05 09:52:08 +00:00
<Text type="secondary"></Text>
<Select value={label} onChange={setLabel} placeholder="选择角色" style={{ width: '100%', marginTop: 4 }} size="small" allowClear>
2026-03-05 09:52:08 +00:00
{speakerLabels.map((item) => (
<Select.Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Select.Option>
))}
</Select>
</div>
2026-03-05 09:52:08 +00:00
<Button type="primary" size="small" block onClick={handleSave} loading={loading}>
</Button>
</div>
);
};
const renderContentWithHighlight = (content: string, keyword: string) => {
if (!keyword || !content.toLowerCase().includes(keyword.toLowerCase())) {
return content;
}
const parts = content.split(new RegExp(`(${keyword})`, 'gi'));
return (
<>
{parts.map((part, i) => (
part.toLowerCase() === keyword.toLowerCase() ? (
<span key={i} className="highlight-text">{part}</span>
) : part
))}
</>
);
};
type ActiveTranscriptRowProps = {
item: MeetingTranscriptVO;
meetingId: number;
isOwner: boolean;
isEditing: boolean;
isSaving: boolean;
speakerLabelMap: Map<string, string>;
onPlay: (timeMs: number) => void;
onStartEdit: (item: MeetingTranscriptVO, event: React.MouseEvent) => void;
onDraftBlur: (item: MeetingTranscriptVO, value: string) => void;
onDraftKeyDown: (item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onSpeakerUpdated: () => void;
registerRow: (id: number, node: HTMLDivElement | null) => void;
isActive: boolean;
isLinkedHighlight: boolean;
audioPlaying: boolean;
highlightKeyword?: string;
};
const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
item,
meetingId,
isOwner,
isEditing,
isSaving,
speakerLabelMap,
onPlay,
onStartEdit,
onDraftBlur,
onDraftKeyDown,
onSpeakerUpdated,
registerRow,
isActive,
isLinkedHighlight,
audioPlaying,
highlightKeyword = '',
}) => {
const [draftValue, setDraftValue] = useState(item.content);
const rowRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (isEditing) {
setDraftValue(item.content);
}
}, [isEditing, item.content]);
useEffect(() => {
if ((isActive && audioPlaying) || (highlightKeyword && item.content.toLowerCase().includes(highlightKeyword.toLowerCase()))) {
if (rowRef.current) {
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, [isActive, audioPlaying, highlightKeyword, item.content]);
const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : '';
return (
<List.Item
className={`transcript-row ${isActive ? 'active' : ''} ${isLinkedHighlight ? 'linked' : ''}`}
onClick={() => onPlay(item.startTime)}
>
<div
className="transcript-entry"
ref={(node) => {
rowRef.current = node;
registerRow(item.id, node);
}}
>
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
<div className="transcript-content-wrap">
<div className="transcript-meta">
{isOwner ? (
<Popover
content={(
<SpeakerEditor
meetingId={meetingId}
speakerId={item.speakerId}
initialName={item.speakerName}
initialLabel={item.speakerLabel}
onSuccess={onSpeakerUpdated}
/>
)}
title="编辑发言人"
trigger="click"
>
<span className="transcript-speaker editable" onClick={(event) => event.stopPropagation()}>
{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: 12 }} />
</span>
</Popover>
) : (
<span className="transcript-speaker">{item.speakerName || item.speakerId || '发言人'}</span>
)}
<Text type="secondary">{formatTime(item.startTime)}</Text>
{speakerTagLabel && <Tag color="blue">{speakerTagLabel}</Tag>}
</div>
{isEditing ? (
<div
className={`transcript-bubble transcript-bubble-editing ${isSaving ? 'is-saving' : ''}`}
onClick={(event) => event.stopPropagation()}
>
<Input.TextArea
autoFocus
value={draftValue}
style={{ width: '100%' }}
onChange={(event) => setDraftValue(event.target.value)}
onKeyDown={(event) => onDraftKeyDown(item, draftValue, event)}
onBlur={(event) => {
event.stopPropagation();
onDraftBlur(item, draftValue);
}}
autoSize={{ minRows: 1, maxRows: 8 }}
className="transcript-bubble-input"
bordered={false}
/>
</div>
) : (
<div
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
onDoubleClick={isOwner ? (event) => onStartEdit(item, event) : undefined}
>
{renderContentWithHighlight(item.content, highlightKeyword)}
</div>
)}
</div>
</div>
</List.Item>
);
}, (prevProps, nextProps) => (
prevProps.item === nextProps.item
&& prevProps.meetingId === nextProps.meetingId
&& prevProps.isOwner === nextProps.isOwner
&& prevProps.isEditing === nextProps.isEditing
&& prevProps.isSaving === nextProps.isSaving
&& prevProps.speakerLabelMap === nextProps.speakerLabelMap
&& prevProps.onPlay === nextProps.onPlay
&& prevProps.onStartEdit === nextProps.onStartEdit
&& prevProps.onDraftBlur === nextProps.onDraftBlur
&& prevProps.onDraftKeyDown === nextProps.onDraftKeyDown
&& prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated
&& prevProps.registerRow === nextProps.registerRow
&& prevProps.isActive === nextProps.isActive
&& prevProps.isLinkedHighlight === nextProps.isLinkedHighlight
&& prevProps.audioPlaying === nextProps.audioPlaying
&& prevProps.highlightKeyword === nextProps.highlightKeyword
));
const MeetingDetail: React.FC = () => {
const { message } = App.useApp();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [form] = Form.useForm();
const [summaryForm] = Form.useForm();
2026-03-05 09:52:08 +00:00
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
const [meetingChapters, setMeetingChapters] = useState<MeetingChapterVO[]>([]);
const [loading, setLoading] = useState(true);
const [editVisible, setEditVisible] = useState(false);
const [summaryVisible, setSummaryVisible] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | 'transcript' | null>(null);
2026-03-06 05:45:56 +00:00
const [isEditingSummary, setIsEditingSummary] = useState(false);
const [summaryDraft, setSummaryDraft] = useState('');
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('transcript');
const [addingHotwords, setAddingHotwords] = useState(false);
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [userList, setUserList] = useState<SysUser[]>([]);
const { items: speakerLabels } = useDict('biz_speaker_label');
const [sharePopoverOpen, setSharePopoverOpen] = useState(false);
const [shareSaving, setShareSaving] = useState(false);
const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false);
const [sharePasswordDraft, setSharePasswordDraft] = useState('');
const [highlightKeyword, setHighlightKeyword] = useState('');
const [linkedTranscriptIds, setLinkedTranscriptIds] = useState<number[]>([]);
const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(null);
2026-06-05 07:07:45 +00:00
const [meetingShareBaseUrl, setMeetingShareBaseUrl] = useState('');
const audioRef = useRef<HTMLAudioElement>(null);
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
const [audioPlaying, setAudioPlaying] = useState(false);
const [audioPlaybackRate, setAudioPlaybackRate] = useState(1);
const emptyTranscriptNoticeShownRef = useRef<number | null>(null);
const audioPlaybackErrorShownRef = useRef<string | null>(null);
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const pendingTranscriptScrollIdRef = useRef<number | null>(null);
const leftColumnRef = useRef<HTMLDivElement>(null);
const transcriptSectionRef = useRef<HTMLDivElement>(null);
const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false);
const [floatingTranscriptPlayerLayout, setFloatingTranscriptPlayerLayout] = useState<{ left: number; width: number } | null>(null);
const [generationProgress, setGenerationProgress] = useState<MeetingProgress | null>(null);
const autoOpenedCatalogAttemptRef = useRef<string | null>(null);
const fetchData = useCallback(async (meetingId: number) => {
try {
const [detailRes, transcriptRes, chapterRes, shareConfigRes, progressRes] = await Promise.all([
getMeetingDetail(meetingId),
getTranscripts(meetingId),
getMeetingChapters(meetingId),
2026-06-05 07:07:45 +00:00
getMeetingShareConfig().catch(() => null),
getMeetingProgress(meetingId, { suppressErrorToast: true }).catch(() => null),
]);
setMeeting(detailRes.data.data);
setTranscripts(transcriptRes.data.data || []);
setMeetingChapters(chapterRes.data.data || []);
2026-06-05 07:07:45 +00:00
setMeetingShareBaseUrl(shareConfigRes?.data?.data?.h5BaseUrl || '');
setGenerationProgress(progressRes?.data?.data || null);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}, []);
const analysis = useMemo(
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ''),
[meeting?.analysis, meeting?.summaryContent, meeting?.tags],
);
2026-07-02 01:30:48 +00:00
const expandKeywords = false;
const visibleKeywords = expandKeywords ? analysis.keywords : analysis.keywords.slice(0, 9);
const meetingTags = useMemo(
() => (meeting?.tags?.split(',').map((item) => item.trim()).filter(Boolean) || []),
[meeting?.tags],
);
const discussionItems = useMemo(() => {
if (analysis.keyPoints.length) {
return analysis.keyPoints;
}
return analysis.chapters.map((item) => ({
title: item.title,
summary: item.summary,
time: item.time,
}));
}, [analysis.chapters, analysis.keyPoints]);
const speakerLabelMap = useMemo(
() => new Map(speakerLabels.map((item) => [item.itemValue, item.itemLabel])),
[speakerLabels],
);
const previewAccessPassword = useMemo(() => (meeting?.accessPassword || '').trim(), [meeting?.accessPassword]);
const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]);
const audioDownloadFileName = useMemo(() => getMeetingAudioDownloadName(meeting), [meeting]);
const audioDownloadFormatLabel = useMemo(
() => resolveAudioExtension(playbackAudioUrl).toUpperCase(),
[playbackAudioUrl],
);
const keywordItems = useMemo(
() => (analysis.keywords.length ? visibleKeywords : meetingTags),
[analysis.keywords.length, meetingTags, visibleKeywords],
);
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,
} as MeetingChapterVO));
return sourceChapters.map((chapter, index) => {
let matchedTranscripts: MeetingTranscriptVO[] = [];
const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds)
? chapter.sourceTranscriptIds
.map((item: number) => Number(item))
.filter((item: number) => Number.isFinite(item) && transcriptIdToIndex.has(item))
: [];
if (sourceTranscriptIds.length) {
matchedTranscripts = sourceTranscriptIds
.map((item: number) => transcripts[transcriptIdToIndex.get(item)!])
.filter(Boolean);
} else if (chapter.startTranscriptId && chapter.endTranscriptId) {
const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId));
const endIndex = transcriptIdToIndex.get(Number(chapter.endTranscriptId));
if (startIndex !== undefined && endIndex !== undefined) {
matchedTranscripts = transcripts.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex) + 1);
}
} else {
const startMs = typeof chapter.startTime === 'number' ? chapter.startTime : parseChapterTimeToMs(chapter.time);
const nextChapterStartMs = sourceChapters
.slice(index + 1)
.map((item: MeetingChapterVO) => (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]);
const sharePreviewUrl = useMemo(() => {
const meetingId = meeting?.id ?? (id ? Number(id) : NaN);
2026-06-05 07:07:45 +00:00
return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId);
}, [meetingShareBaseUrl, meeting?.id, id]);
const summaryModelDisplayName = useMemo(() => {
const matchedModel = llmModels.find((item) => item.id === meeting?.summaryModelId);
if (matchedModel?.modelName) {
return matchedModel.modelName;
}
if (meeting?.summaryModelId) {
return `模型 #${meeting.summaryModelId}`;
}
return '未配置';
}, [llmModels, meeting?.summaryModelId]);
const promptDisplayName = useMemo(() => {
const matchedPrompt = prompts.find((item) => item.id === meeting?.promptId);
if (matchedPrompt?.templateName) {
return matchedPrompt.templateName;
}
if (meeting?.promptId) {
return `模板 #${meeting.promptId}`;
}
return '未配置';
}, [meeting?.promptId, prompts]);
const hotWordGroupDisplayName = useMemo(() => {
if (meeting?.hotWordGroupName?.trim()) {
return meeting.hotWordGroupName.trim();
}
if (meeting?.hotWordGroupId) {
return `热词组 #${meeting.hotWordGroupId}`;
}
return '未使用';
}, [meeting?.hotWordGroupId, meeting?.hotWordGroupName]);
const isOwner = useMemo(() => {
if (!meeting) return false;
2026-03-05 09:52:08 +00:00
const profileStr = sessionStorage.getItem('userProfile');
if (profileStr) {
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true || profile.isTenantAdmin === true || profile.userId === meeting.creatorId;
}
return false;
}, [meeting]);
const aiCatalogEnabled = meeting?.aiCatalogEnabled !== false;
const canRetrySummary = isOwner
&& transcripts.length > 0
&& meeting?.status !== 1
&& meeting?.status !== 2
&& meeting?.latestSummaryAttemptStatus !== 3
&& (!aiCatalogEnabled || meeting?.latestChapterAttemptStatus !== 3);
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
const canRetryFailedSummaryTask = isOwner && meeting?.latestSummaryAttemptStatus === 3 && meeting?.status !== 2;
const canRetryFailedChapterTask = aiCatalogEnabled && isOwner && meeting?.latestChapterAttemptStatus === 3 && meeting?.status !== 2;
const canRetrySchedule = isOwner && meeting?.status === 0 && (!generationProgress || generationProgress.percent <= 0) && !!generationProgress?.queuedAt && dayjs().diff(dayjs(generationProgress.queuedAt)) >= QUEUED_RETRY_THRESHOLD_MS;
const handleRetrySchedule = async () => {
if (!meeting) return;
setActionLoading(true);
try {
const response = await retryScheduleMeeting(meeting.id);
if (response.data?.data) {
message.success("已触发重新调度");
} else {
message.info("当前没有可重新调度的排队任务");
}
await fetchData(meeting.id);
} catch {
message.error("重新调度失败");
} finally {
setActionLoading(false);
}
};
const hasSummaryContent = Boolean(meeting?.summaryContent?.trim());
const hasCatalogContent = aiCatalogEnabled && catalogChapterLinks.length > 0;
const generationFailureNotice = useMemo<MeetingStateNotice | null>(() => {
if (!meeting || meeting.status !== 4) {
return null;
}
const hasFallbackContent = hasSummaryContent || hasCatalogContent;
if (aiCatalogEnabled && meeting.latestChapterAttemptStatus === 3) {
const detail = meeting.latestChapterAttemptErrorMsg || '章节生成失败';
return {
title: hasFallbackContent ? '历史内容仍可查看' : '本次 AI 目录生成失败',
description: hasFallbackContent
? `最近一次 AI 目录生成失败,当前展示的是最近一次成功生成的内容。失败原因:${detail}`
: `AI 目录生成失败,当前没有可展示的历史目录或总结内容。失败原因:${detail}`,
type: hasFallbackContent ? 'info' : 'warning',
hasFallbackContent,
scope: 'catalog',
};
}
if (meeting.latestSummaryAttemptStatus === 3) {
const detail = meeting.latestSummaryAttemptErrorMsg || '总结生成失败';
return {
title: hasFallbackContent ? '历史总结可用' : '本次总结生成失败',
description: hasFallbackContent
? `最近一次重新总结失败,当前展示的是最近一次成功生成的总结内容。失败原因:${detail}`
: `总结生成失败,当前没有可展示的历史总结内容。失败原因:${detail}`,
type: hasFallbackContent ? 'info' : 'warning',
hasFallbackContent,
scope: 'summary',
};
}
return {
title: '会议处理异常',
description: '会议在处理过程中遇到了问题。您可以尝试重新发起识别或总结。',
type: hasFallbackContent ? 'info' : 'warning',
hasFallbackContent,
scope: 'global',
};
}, [aiCatalogEnabled, hasCatalogContent, hasSummaryContent, meeting]);
const summaryPanelNotice = useMemo<MeetingStateNotice | null>(() => {
if (!meeting || !hasSummaryContent) {
return null;
}
if (meeting.status === 2) {
return {
title: '正在生成新版总结',
description: '当前展示的是上一版已成功生成的总结,系统会在新版完成后自动替换。',
type: 'info',
hasFallbackContent: true,
scope: 'summary',
};
}
return null;
}, [generationFailureNotice, hasSummaryContent, meeting]);
const catalogPanelNotice = useMemo<MeetingStateNotice | null>(() => {
if (!aiCatalogEnabled || !generationFailureNotice || generationFailureNotice.scope !== 'catalog') {
return null;
}
if (generationFailureNotice.hasFallbackContent && hasCatalogContent) {
return {
title: '当前展示的是历史成功目录',
description: generationFailureNotice.description,
type: generationFailureNotice.type,
hasFallbackContent: true,
scope: 'catalog',
};
}
return generationFailureNotice;
}, [aiCatalogEnabled, generationFailureNotice, hasCatalogContent]);
const emptyTranscriptFailureNotice = useMemo(() => {
if (!meeting || meeting.status !== 4 || transcripts.length > 0) {
return null;
}
return {
title: '识别已结束,但没有生成可用转录',
description: canRetryTranscription
? 'ASR 调用已返回,但当前没有写入任何转录文本,所以列表状态仍显示失败。你可以直接重新识别,或先检查录音是否静音、时长过短、音量过低。'
: 'ASR 调用已返回,但当前没有写入任何转录文本,所以列表状态仍显示失败。建议先检查录音是否静音、时长过短、音量过低或音质异常。',
hint: canRetryTranscription ? '可以直接点击“重新识别”继续处理。' : '当前没有可重试的音频入口,请先确认原始录音文件是否有效。',
};
}, [canRetryTranscription, meeting, transcripts.length]);
useEffect(() => {
if (meeting?.status !== 1 && meeting?.status !== 2) {
setGenerationProgress(null);
autoOpenedCatalogAttemptRef.current = null;
}
}, [meeting?.id, meeting?.status]);
useEffect(() => {
if (!aiCatalogEnabled && workspaceTab === 'catalog') {
setWorkspaceTab('transcript');
}
}, [aiCatalogEnabled, workspaceTab]);
useEffect(() => {
const attemptKey = String(meeting?.latestChapterAttemptTaskId ?? meeting?.latestSummaryAttemptTaskId ?? '');
if (meeting?.status !== 2 || !attemptKey) {
return;
}
if (!aiCatalogEnabled || (generationProgress?.percent ?? 0) < 88 || catalogChapterLinks.length === 0) {
return;
}
if (autoOpenedCatalogAttemptRef.current === attemptKey) {
return;
}
autoOpenedCatalogAttemptRef.current = attemptKey;
setWorkspaceTab('catalog');
}, [
aiCatalogEnabled,
catalogChapterLinks.length,
generationProgress?.percent,
meeting?.latestChapterAttemptTaskId,
meeting?.latestSummaryAttemptTaskId,
meeting?.status,
]);
useEffect(() => {
if (!playbackAudioUrl) {
setShowFloatingTranscriptPlayer(false);
setFloatingTranscriptPlayerLayout(null);
return undefined;
}
const updateFloatingPlayerState = () => {
const target = transcriptSectionRef.current;
if (!target) {
setShowFloatingTranscriptPlayer(false);
setFloatingTranscriptPlayerLayout(null);
return;
}
const rect = target.getBoundingClientRect();
const rootRect = leftColumnRef.current?.getBoundingClientRect();
setFloatingTranscriptPlayerLayout({
left: rect.left,
width: rect.width,
});
if (rootRect) {
const isVisible = rect.bottom > rootRect.top + 80 && rect.top < rootRect.bottom - 40;
setShowFloatingTranscriptPlayer(isVisible);
return;
}
const isVisible = rect.bottom > 120 && rect.top < window.innerHeight - 80;
setShowFloatingTranscriptPlayer(isVisible);
};
updateFloatingPlayerState();
const target = transcriptSectionRef.current;
const root = leftColumnRef.current;
const resizeObserver = target ? new ResizeObserver(() => updateFloatingPlayerState()) : null;
if (target && resizeObserver) {
resizeObserver.observe(target);
}
window.addEventListener('resize', updateFloatingPlayerState);
window.addEventListener('scroll', updateFloatingPlayerState, { passive: true });
root?.addEventListener('scroll', updateFloatingPlayerState, { passive: true });
return () => {
window.removeEventListener('resize', updateFloatingPlayerState);
window.removeEventListener('scroll', updateFloatingPlayerState);
root?.removeEventListener('scroll', updateFloatingPlayerState);
resizeObserver?.disconnect();
};
}, [meeting?.status, playbackAudioUrl]);
useEffect(() => {
if (!id) return;
fetchData(Number(id));
loadAiConfigs();
loadUsers();
}, [id, fetchData]);
useEffect(() => {
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
}, [analysis.keywords]);
useEffect(() => {
if (workspaceTab !== 'transcript') {
return;
}
const pendingId = pendingTranscriptScrollIdRef.current;
if (!pendingId) {
return;
}
const frameId = window.requestAnimationFrame(() => {
const target = transcriptItemRefs.current[pendingId];
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
pendingTranscriptScrollIdRef.current = null;
}
});
return () => window.cancelAnimationFrame(frameId);
}, [workspaceTab, transcripts, linkedTranscriptIds]);
useEffect(() => {
if (meeting?.audioSaveStatus === 'FAILED') {
message.warning(meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。');
}
}, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]);
useEffect(() => {
if (!meeting?.id || !emptyTranscriptFailureNotice) {
return;
}
if (emptyTranscriptNoticeShownRef.current === meeting.id) {
return;
}
message.warning({
content: emptyTranscriptFailureNotice.title,
duration: 4,
});
emptyTranscriptNoticeShownRef.current = meeting.id;
}, [emptyTranscriptFailureNotice, meeting?.id, message]);
useEffect(() => {
if (!sharePopoverOpen) {
return;
}
const normalizedPassword = normalizeAccessPasswordInput(meeting?.accessPassword);
setSharePasswordEnabled(!!normalizedPassword);
setSharePasswordDraft(normalizedPassword);
}, [sharePopoverOpen, meeting?.accessPassword]);
const loadAiConfigs = async () => {
try {
const [modelRes, promptRes, defaultRes] = await Promise.all([
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
getPromptPage({ current: 1, size: 100 }),
2026-03-05 09:52:08 +00:00
getAiModelDefault('LLM'),
]);
2026-06-30 05:37:33 +00:00
setLlmModels((modelRes.data?.data?.records || []).filter((item) => item.status === 1));
setPrompts((promptRes.data?.data?.records || []).filter((item) => item.status === 1));
summaryForm.setFieldsValue({ summaryModelId: defaultRes.data.data?.id });
} catch {
2026-03-05 09:52:08 +00:00
// ignore
}
};
const loadUsers = async () => {
try {
const users = await listUsers();
setUserList(users || []);
} catch {
2026-03-05 09:52:08 +00:00
// ignore
}
};
const handleEditMeeting = () => {
if (!meeting || !isOwner) return;
form.setFieldsValue({
...meeting,
2026-03-05 09:52:08 +00:00
tags: meeting.tags?.split(',').filter(Boolean),
});
setEditVisible(true);
};
const handleUpdateBasic = async () => {
const values = await form.validateFields();
setActionLoading(true);
try {
await updateMeetingBasic({
...values,
meetingId: meeting?.id,
tags: values.tags?.join(','),
});
message.success('会议信息已更新');
setEditVisible(false);
fetchData(Number(id));
} catch (error) {
console.error(error);
} finally {
setActionLoading(false);
}
};
2026-03-06 05:45:56 +00:00
const handleSaveSummary = async () => {
setActionLoading(true);
try {
await updateMeetingSummary({
meetingId: Number(id),
2026-03-06 05:45:56 +00:00
summaryContent: summaryDraft,
});
message.success('总结内容已更新');
setIsEditingSummary(false);
fetchData(Number(id));
} catch (error) {
console.error(error);
2026-03-06 05:45:56 +00:00
} finally {
setActionLoading(false);
}
};
const handleReSummary = async () => {
const values = await summaryForm.validateFields();
setActionLoading(true);
try {
await reSummary({
meetingId: Number(id),
summaryModelId: values.summaryModelId,
promptId: values.promptId,
userPrompt: values.userPrompt,
summaryDetailLevel: values.summaryDetailLevel as SummaryDetailLevel,
});
setMeeting((current) => current ? {
...current,
status: 2,
latestSummaryAttemptStatus: 0,
latestSummaryAttemptErrorMsg: undefined,
} : current);
setGenerationProgress({
percent: 90,
message: '已重新发起总结任务',
updateAt: Date.now(),
eta: 0,
});
message.success('操作成功');
await fetchData(Number(id));
} catch (error) {
console.error(error);
} finally {
setActionLoading(false);
}
};
const handleOpenSummaryDrawer = () => {
summaryForm.setFieldsValue({
summaryModelId:
summaryForm.getFieldValue('summaryModelId') ??
meeting?.summaryModelId ??
llmModels.find((model) => model.isDefault === 1)?.id ??
llmModels[0]?.id,
promptId:
summaryForm.getFieldValue('promptId') ??
meeting?.promptId ??
prompts[0]?.id,
userPrompt: meeting?.lastUserPrompt ?? '',
summaryDetailLevel:
summaryForm.getFieldValue('summaryDetailLevel') ??
meeting?.summaryDetailLevel ??
'STANDARD',
});
setSummaryVisible(true);
};
const seekTo = useCallback((timeMs: number) => {
if (!audioRef.current) return;
audioRef.current.currentTime = timeMs / 1000;
audioRef.current.play();
}, []);
const handleTranscriptRowPlay = useCallback((timeMs: number) => {
setLinkedTranscriptIds([]);
setLinkedChapterKey(null);
seekTo(timeMs);
}, [seekTo]);
const handleLocateChapterTranscript = useCallback((chapterIndex: number) => {
const targetLink = catalogChapterLinks[chapterIndex];
if (!targetLink || !targetLink.transcriptIds.length || targetLink.firstTranscriptId === null || targetLink.firstTranscriptStartTime === null) {
message.warning('当前章节暂未匹配到可关联的转录原文');
return;
}
setWorkspaceTab('transcript');
setHighlightKeyword('');
setLinkedTranscriptIds(targetLink.transcriptIds);
setLinkedChapterKey(targetLink.key);
pendingTranscriptScrollIdRef.current = targetLink.firstTranscriptId;
seekTo(targetLink.firstTranscriptStartTime);
}, [catalogChapterLinks, message, seekTo]);
const handleRetryTranscription = async () => {
setActionLoading(true);
try {
await retryMeetingTranscription(Number(id));
message.success('已重新提交识别任务');
await fetchData(Number(id));
} catch (error) {
console.error(error);
} finally {
setActionLoading(false);
}
};
const handleRetrySummaryTask = async () => {
setActionLoading(true);
try {
await retryMeetingSummary(Number(id));
message.success('已重新提交总结任务');
setMeeting((current) => current ? {
...current,
status: 2,
latestSummaryAttemptStatus: 0,
latestSummaryAttemptErrorMsg: undefined,
} : current);
await fetchData(Number(id));
} catch (error) {
console.error(error);
} finally {
setActionLoading(false);
}
};
const handleRetryChapterTask = async () => {
setActionLoading(true);
try {
await retryMeetingChapter(Number(id));
message.success('已重新提交 AI 目录任务');
setMeeting((current) => current ? {
...current,
status: 2,
latestChapterAttemptStatus: 0,
latestChapterAttemptErrorMsg: undefined,
} : current);
await fetchData(Number(id));
} catch (error) {
console.error(error);
} finally {
setActionLoading(false);
}
};
const handleKeywordToggle = (keyword: string, checked: boolean) => {
setSelectedKeywords((current) => {
if (checked) {
return current.includes(keyword) ? current : [...current, keyword];
}
return current.filter((item) => item !== keyword);
});
};
const handleAddSelectedHotwords = async () => {
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
if (!keywords.length) {
message.warning('请先选择关键词');
return;
}
setAddingHotwords(true);
try {
const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' });
const existingWords = new Set(
(existingRes.data?.data?.records || [])
.map((item) => item.word?.trim())
.filter(Boolean),
);
const toCreate = keywords.filter((item) => !existingWords.has(item));
if (!toCreate.length) {
message.info('所选关键词已存在于热词库');
return;
}
await Promise.all(
toCreate.map((word) =>
(async () => {
let pinyinList: string[] = [];
try {
const pinyinRes = await getPinyinSuggestion(word);
pinyinList = (pinyinRes.data?.data || []).map((item) => item.trim()).filter(Boolean);
} catch {
pinyinList = [];
}
return saveHotWord({
word,
pinyinList,
matchStrategy: 1,
category: '',
weight: 2,
status: 1,
remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词',
});
})(),
),
);
const skippedCount = keywords.length - toCreate.length;
message.success(
skippedCount > 0
? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项`
: `已新增 ${toCreate.length} 个热词`,
);
setSelectedKeywords([]);
} catch (error) {
console.error(error);
} finally {
setAddingHotwords(false);
}
};
const handleStartEditTranscript = useCallback((item: MeetingTranscriptVO, event: React.MouseEvent) => {
event.stopPropagation();
setEditingTranscriptId(item.id);
}, []);
const handleCancelEditTranscript = useCallback((event?: React.SyntheticEvent) => {
event?.stopPropagation();
setEditingTranscriptId(null);
}, []);
const handleSaveTranscript = useCallback(async (item: MeetingTranscriptVO, nextContent?: string) => {
const content = (nextContent ?? item.content ?? '').trim();
if (!content) {
message.warning('转录内容不能为空');
return;
}
if (!meeting) return;
if (content === (item.content || '').trim()) {
handleCancelEditTranscript();
return;
}
setSavingTranscriptId(item.id);
try {
await updateMeetingTranscript({
meetingId: meeting.id,
transcriptId: item.id,
content,
});
message.success('原文已更新,如需同步摘要请重新总结');
handleCancelEditTranscript();
await fetchData(meeting.id);
} catch (error) {
console.error(error);
} finally {
setSavingTranscriptId(null);
}
}, [fetchData, handleCancelEditTranscript, meeting]);
const handleTranscriptDraftKeyDown = useCallback((item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Escape') {
handleCancelEditTranscript();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault();
void handleSaveTranscript(item, value);
}
}, [handleCancelEditTranscript, handleSaveTranscript]);
const handleTranscriptDraftBlur = useCallback((item: MeetingTranscriptVO, value: string) => {
void handleSaveTranscript(item, value);
}, [handleSaveTranscript]);
const handleTranscriptSpeakerUpdated = useCallback(() => {
if (!meeting) return;
void fetchData(meeting.id);
}, [fetchData, meeting]);
const registerTranscriptRow = useCallback((transcriptId: number, node: HTMLDivElement | null) => {
transcriptItemRefs.current[transcriptId] = node;
}, []);
const handleAudioPlaybackError = useCallback(() => {
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或下载到本地播放。'
: '当前音频文件无法播放,请检查文件是否损坏或格式是否兼容。',
);
audioPlaybackErrorShownRef.current = currentAudioUrl;
setAudioPlaying(false);
}, [message, playbackAudioUrl]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return undefined;
const handleLoadedMetadata = () => {
setAudioDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
setAudioCurrentTime(audio.currentTime || 0);
audio.playbackRate = audioPlaybackRate;
};
const handleTimeUpdate = () => {
setAudioCurrentTime(audio.currentTime || 0);
};
const handlePlay = () => setAudioPlaying(true);
const handlePause = () => setAudioPlaying(false);
const handleEnded = () => setAudioPlaying(false);
const handleError = () => handleAudioPlaybackError();
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
audio.addEventListener('durationchange', handleLoadedMetadata);
audio.addEventListener('canplay', handleLoadedMetadata);
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('play', handlePlay);
audio.addEventListener('pause', handlePause);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('error', handleError);
handleLoadedMetadata();
return () => {
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('durationchange', handleLoadedMetadata);
audio.removeEventListener('canplay', handleLoadedMetadata);
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('play', handlePlay);
audio.removeEventListener('pause', handlePause);
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('error', handleError);
};
}, [audioPlaybackRate, meeting?.status, playbackAudioUrl, handleAudioPlaybackError]);
const toggleAudioPlayback = () => {
if (!audioRef.current) return;
if (audioRef.current.paused) {
audioRef.current.play();
} else {
audioRef.current.pause();
}
};
const handleAudioProgressChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const nextTime = Number(event.target.value || 0);
setAudioCurrentTime(nextTime);
if (audioRef.current) {
audioRef.current.currentTime = nextTime;
}
};
const cyclePlaybackRate = () => {
if (!audioRef.current) return;
const rates = [1, 1.25, 1.5, 2];
const currentIndex = rates.findIndex((item) => item === audioPlaybackRate);
const nextRate = rates[(currentIndex + 1) % rates.length];
audioRef.current.playbackRate = nextRate;
setAudioPlaybackRate(nextRate);
};
2026-03-05 09:52:08 +00:00
const getFileNameFromDisposition = (disposition?: string, fallback?: string) => {
if (!disposition) return fallback || 'summary';
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
const normalMatch = disposition.match(/filename="?([^";]+)"?/i);
2026-03-05 09:52:08 +00:00
return normalMatch?.[1] || fallback || 'summary';
};
const handleDownloadSummary = async (format: 'pdf' | 'word') => {
if (!meeting) return;
if (!meeting.summaryContent) {
message.warning('当前暂无可下载的 AI 总结');
2026-03-05 09:52:08 +00:00
return;
}
try {
2026-03-06 01:59:29 +00:00
setDownloadLoading(format);
2026-03-05 09:52:08 +00:00
const res = await downloadMeetingSummary(meeting.id, format);
const contentType =
2026-03-05 09:52:08 +00:00
res.headers['content-type'] ||
(format === 'pdf'
? 'application/pdf'
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
if (contentType.includes('application/json')) {
const text = await (res.data as Blob).text();
try {
const json = JSON.parse(text);
message.error(json?.msg || '下载失败');
} catch {
message.error('下载失败');
}
return;
}
const blob = new Blob([res.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = getFileNameFromDisposition(
2026-03-05 09:52:08 +00:00
res.headers['content-disposition'],
`${(meeting.title || 'meeting').replace(/[\\/:*?"<>|\r\n]/g, '_')}-AI纪要.${format === 'pdf' ? 'pdf' : 'docx'}`,
2026-03-05 09:52:08 +00:00
);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
2026-03-05 09:52:08 +00:00
window.URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
message.error(`${format.toUpperCase()} 下载失败`);
2026-03-05 09:52:08 +00:00
} finally {
setDownloadLoading(null);
}
};
const handleDownloadTranscript = async () => {
if (!meeting) return;
if (transcripts.length === 0) {
message.warning('当前暂无可下载的会议转录');
return;
}
try {
setDownloadLoading('transcript');
const res = await downloadMeetingTranscript(meeting.id);
const contentType = res.headers['content-type'] || 'text/markdown; charset=UTF-8';
if (contentType.includes('application/json')) {
const text = await (res.data as Blob).text();
try {
const json = JSON.parse(text);
message.error(json?.msg || '下载失败');
} catch {
message.error('下载失败');
}
return;
}
const blob = new Blob([res.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = getFileNameFromDisposition(
res.headers['content-disposition'],
`${sanitizeDownloadFileName(meeting.title, 'meeting-transcript')}-Transcript.md`,
);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
message.error('转录下载失败');
} finally {
setDownloadLoading(null);
}
};
const handleDownloadAudio = () => {
if (!playbackAudioUrl) {
message.warning('当前暂无可下载的会议录音');
return;
}
const anchor = document.createElement('a');
anchor.href = playbackAudioUrl;
anchor.download = audioDownloadFileName;
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
};
const handleCopyPreviewLink = async () => {
if (!sharePreviewUrl) {
message.error('预览链接暂不可用');
return;
}
try {
await copyMeetingLink(sharePreviewUrl);
message.success('预览链接已复制');
} catch (error) {
console.error(error);
message.error('复制预览链接失败');
}
};
const handleOpenPreview = () => {
if (!sharePreviewUrl) {
message.error('预览链接暂不可用');
return;
}
window.open(sharePreviewUrl, '_blank', 'noopener,noreferrer');
};
const handleSharePopoverOpenChange = (open: boolean) => {
setSharePopoverOpen(open);
};
const handleSharePasswordToggle = (checked: boolean) => {
setSharePasswordEnabled(checked);
setSharePasswordDraft((current) => {
const normalizedCurrent = normalizeAccessPasswordInput(current);
if (checked) {
return normalizedCurrent || generateAccessPassword();
}
return '';
});
};
const handleRegenerateSharePassword = () => {
setSharePasswordEnabled(true);
setSharePasswordDraft(generateAccessPassword());
};
const handleSaveShareAccess = async () => {
if (!meeting) {
return;
}
const normalizedPassword = normalizeAccessPasswordInput(sharePasswordDraft);
if (sharePasswordEnabled && !ACCESS_PASSWORD_PATTERN.test(normalizedPassword)) {
message.error('\u8bbf\u95ee\u5bc6\u7801\u9700\u4e3a 4 \u4f4d\u82f1\u6587\u6216\u6570\u5b57');
return;
}
setShareSaving(true);
try {
await updateMeetingBasic({
meetingId: meeting.id,
accessPassword: sharePasswordEnabled ? normalizedPassword : '',
2026-06-30 05:37:33 +00:00
summaryModelId: summaryForm.getFieldValue('summaryModelId') ?? meeting.summaryModelId,
});
const nextPassword = sharePasswordEnabled ? normalizedPassword : '';
setMeeting((current) => (current ? { ...current, accessPassword: nextPassword } : current));
message.success(
sharePasswordEnabled
? '\u9884\u89c8\u5bc6\u7801\u5df2\u66f4\u65b0'
: '\u9884\u89c8\u5bc6\u7801\u5df2\u5173\u95ed',
);
setSharePopoverOpen(false);
} catch (error) {
console.error(error);
message.error('\u4fdd\u5b58\u9884\u89c8\u5bc6\u7801\u5931\u8d25');
} finally {
setShareSaving(false);
}
};
const shareQrContent = sharePreviewUrl ? (
<div className="meeting-share-card">
{isOwner ? (
<div className="meeting-share-settings">
<div className="meeting-share-settings-row">
<div className="meeting-share-settings-copy">
<strong>{'\u9884\u89c8\u5bc6\u7801'}</strong>
<span>
{sharePasswordEnabled && previewAccessPassword
? `访问密码${previewAccessPassword}`
: '关闭后将不需要访问密码'}
</span>
</div>
<Switch
checked={sharePasswordEnabled}
checkedChildren={'\u5f00\u542f'}
unCheckedChildren={'\u5173\u95ed'}
onChange={handleSharePasswordToggle}
/>
</div>
{sharePasswordEnabled ? (
<div className="meeting-share-settings-actions">
<Input
value={sharePasswordDraft}
maxLength={4}
placeholder={'\u4f8b\u5982 A7K2'}
onChange={(event) => setSharePasswordDraft(normalizeAccessPasswordInput(event.target.value))}
/>
<Button onClick={handleRegenerateSharePassword}>{'\u91cd\u7f6e\u9ed8\u8ba4'}</Button>
</div>
) : null}
<Button type="primary" loading={shareSaving} onClick={handleSaveShareAccess}>
{'\u4fdd\u5b58\u5bc6\u7801\u8bbe\u7f6e'}
</Button>
</div>
) : null}
<div className="meeting-share-qr-wrap">
<QRCode
value={sharePreviewUrl}
type="svg"
size={172}
color="#1677ff"
bgColor="transparent"
errorLevel="H"
bordered={false}
/>
</div>
<div className="meeting-share-caption">
{'\u4f7f\u7528\u624b\u673a\u626b\u7801\u540e\u5c06\u8df3\u8f6c\u5230\u4f1a\u8bae\u9884\u89c8\u9875\uff0c\u82e5\u5df2\u5f00\u542f\u5bc6\u7801\u9700\u624b\u52a8\u8f93\u5165\u3002'}
</div>
<div className="meeting-share-link-box">
<LinkOutlined />
<span title={sharePreviewUrl}>{sharePreviewUrl}</span>
</div>
<div className="meeting-share-actions">
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyPreviewLink}>
{'\u590d\u5236\u94fe\u63a5'}
</Button>
<Button size="small" type="primary" ghost onClick={handleOpenPreview}>
{'\u6253\u5f00\u9884\u89c8'}
</Button>
</div>
</div>
) : null;
if (loading) {
return (
<div style={{ padding: 24 }}>
<Skeleton active />
</div>
);
}
if (!meeting) {
return (
<div style={{ padding: 24 }}>
<Empty description="会议不存在" />
</div>
);
}
2026-07-02 09:29:39 +00:00
const playerProgressPercent = audioDuration > 0
? Math.min(100, Math.max(0, (audioCurrentTime / audioDuration) * 100))
: 0;
return (
2026-06-30 05:37:33 +00:00
<PageContainer title={null} className="meeting-detail-page-v2">
<SectionCard
className="meeting-detail-section-card"
contentClassName="meeting-detail-section-content"
title={(
<div className="meeting-detail-title-wrap">
<div className="meeting-detail-title-icon">
<FileTextOutlined />
</div>
<div className="meeting-detail-title-copy">
<div className="meeting-detail-title-row">
<span className="meeting-detail-title-text">{meeting.title}</span>
2026-03-05 09:52:08 +00:00
{isOwner && (
<button type="button" className="meeting-detail-title-edit" onClick={handleEditMeeting} aria-label="编辑会议信息">
<EditOutlined />
</button>
2026-03-05 09:52:08 +00:00
)}
</div>
<div className="meeting-detail-meta-row">
<span>
<ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}
</span>
2026-07-02 09:29:39 +00:00
<span>
<UserOutlined /> {meeting.participants || '未指定'}
</span>
</div>
<div className="meeting-detail-config-row">
<span className="meeting-detail-config-item">
<Text type="secondary"></Text>
<strong>{summaryModelDisplayName}</strong>
</span>
<span className="meeting-detail-config-item">
<Text type="secondary"></Text>
<strong>{promptDisplayName}</strong>
</span>
<span className="meeting-detail-config-item">
<Text type="secondary"></Text>
<strong>{hotWordGroupDisplayName}</strong>
</span>
</div>
</div>
</div>
)}
extra={(
<Space size={10} wrap>
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
</Button>
{canRetrySchedule && (
<Button icon={<SyncOutlined />} onClick={handleRetrySchedule} loading={actionLoading}>
</Button>
)}
{canRetrySummary && (
<Button icon={<SyncOutlined />} onClick={handleOpenSummaryDrawer} disabled={actionLoading}>
</Button>
)}
{canRetryFailedChapterTask && (
<Button icon={<SyncOutlined />} onClick={handleRetryChapterTask} loading={actionLoading}>
AI
</Button>
)}
{canRetryFailedSummaryTask && (
<Button icon={<SyncOutlined />} onClick={handleRetrySummaryTask} loading={actionLoading}>
</Button>
)}
{canRetryTranscription && (
<Button icon={<SyncOutlined />} onClick={handleRetryTranscription} loading={actionLoading}>
</Button>
)}
{isOwner && meeting.status === 2 && (
<Button icon={<LoadingOutlined />} disabled loading>
</Button>
)}
{(playbackAudioUrl || transcripts.length > 0 || hasSummaryContent) && (
<Dropdown
menu={{
items: [
...(playbackAudioUrl ? [{
key: 'audio',
label: `下载录音 (${audioDownloadFormatLabel})`,
icon: <AudioOutlined />,
onClick: handleDownloadAudio,
}] : []),
...(transcripts.length > 0 ? [{
key: 'transcript',
label: '下载转录 MD',
icon: <DownloadOutlined />,
onClick: handleDownloadTranscript,
disabled: downloadLoading === 'transcript',
}] : []),
...(hasSummaryContent ? [
{
key: 'pdf',
label: '下载 PDF',
icon: <FilePdfOutlined />,
onClick: () => handleDownloadSummary('pdf'),
disabled: downloadLoading === 'pdf',
},
{
key: 'word',
label: '下载 Word',
icon: <FileWordOutlined />,
onClick: () => handleDownloadSummary('word'),
disabled: downloadLoading === 'word',
},
] : []),
],
}}
placement="bottomRight"
>
<Button icon={<DownloadOutlined />} loading={!!downloadLoading}>
</Button>
</Dropdown>
)}
{shareQrContent ? (
<Popover
content={shareQrContent}
trigger="click"
open={sharePopoverOpen}
onOpenChange={handleSharePopoverOpenChange}
placement="bottomRight"
overlayClassName="meeting-share-popover"
>
<Button type="primary" icon={<ShareAltOutlined />}>
</Button>
</Popover>
) : null}
</Space>
)}
2026-06-30 05:37:33 +00:00
>
<div className="meeting-detail-workspace">
{meeting.status === 0 || meeting.status === 1 ? (
<>
<div style={{ display: 'none' }}>
<MeetingProgressDisplay
meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)}
onRefreshNeeded={() => { void fetchData(meeting.id); }}
onProgressChange={setGenerationProgress}
/>
</div>
<UnifiedMeetingProgressDisplay
progress={generationProgress}
/>
</>
) : (
<>
<Row gutter={24} style={{ height: '100%' }}>
<Col xs={24} xl={13} style={{ height: '100%' }}>
<div className="detail-side-column detail-left-column">
{(generationFailureNotice || emptyTranscriptFailureNotice || meeting.audioSaveStatus === 'FAILED') && (
<Alert
type={generationFailureNotice?.type || 'warning'}
showIcon
style={{ marginBottom: 16, borderRadius: 12 }}
message={
<div style={{ fontWeight: 600 }}>
{generationFailureNotice?.title || emptyTranscriptFailureNotice?.title || '会议处理提醒'}
</div>
}
description={
<div style={{ fontSize: 13, marginTop: 4 }}>
<div>{generationFailureNotice?.description || emptyTranscriptFailureNotice?.description}</div>
{meeting.audioSaveStatus === 'FAILED' && (
<div style={{ marginTop: 4, color: '#d48806' }}>
<InfoCircleOutlined style={{ marginRight: 4 }} />
{meeting.audioSaveMessage || '录音保存失败,当前无法播放'}
</div>
)}
{canRetryTranscription && (
<div style={{ marginTop: 8 }}>
{emptyTranscriptFailureNotice?.hint}
</div>
)}
</div>
}
/>
)}
<Card className="left-flow-card summary-panel" variant="borderless">
{(meeting.status === 1 || meeting.status === 2) && (
<div style={{ display: 'none' }}>
<MeetingProgressDisplay
meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)}
onRefreshNeeded={() => { void fetchData(meeting.id); }}
onProgressChange={setGenerationProgress}
/>
</div>
)}
{meeting.status === 2 && !hasSummaryContent ? (
<div className="summary-progress-shell">
<UnifiedMeetingProgressDisplay
progress={generationProgress}
compact
/>
</div>
) : hasSummaryContent ? (
<div className="summary-content-box">
<div className="summary-head">
<div className="summary-title"></div>
</div>
{summaryPanelNotice && (
<Alert
type={summaryPanelNotice.type}
showIcon
style={{ marginBottom: 20, borderRadius: 14 }}
message={summaryPanelNotice.title}
description={
<div>
<div>{summaryPanelNotice.description}</div>
{meeting.status === 2 ? (
<UnifiedMeetingProgressDisplay
progress={generationProgress}
inline
/>
) : null}
</div>
}
/>
)}
{/* Keywords placed between title and overview */}
<div className="summary-section" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div className="summary-section-title" style={{ marginBottom: 0 }}></div>
{isOwner && analysis.keywords.length > 0 && (
<Button
size="small"
type="primary"
ghost
disabled={!selectedKeywords.length}
loading={addingHotwords}
onClick={handleAddSelectedHotwords}
>
{selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
</Button>
)}
</div>
<div className="record-tags">
{keywordItems.length ? (
keywordItems.map((tag) => {
const isSelected = selectedKeywords.includes(tag);
return (
<div
key={tag}
className={`tag selectable-tag ${isSelected ? 'selected' : ''}`}
onClick={() => {
if (isOwner && analysis.keywords.length) {
handleKeywordToggle(tag, !isSelected);
}
}}
>
2026-07-02 09:29:39 +00:00
<span>{normalizeTagDisplay(tag)}</span>
{isOwner && isSelected && <CheckCircleFilled style={{ fontSize: 12 }} />}
</div>
);
})
) : (
<Text type="secondary"></Text>
)}
</div>
</div>
<div className="summary-section">
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12
}}>
<div className="summary-section-title" style={{marginBottom: 0}}></div>
{isOwner ? (
isEditingSummary ? (
<Space size={8}>
<Button onClick={() => setIsEditingSummary(false)}></Button>
<Button type="primary" onClick={handleSaveSummary} loading={actionLoading}></Button>
</Space>
) : (
<Button
size="small"
type="primary"
ghost
icon={<EditOutlined/>}
onClick={() => {
setSummaryDraft(meeting.summaryContent || '');
setIsEditingSummary(true);
}}
>
</Button>
)
) : null}
</div>
<div className="markdown-body summary-markdown">
{isEditingSummary ? (
<Input.TextArea
value={summaryDraft}
onChange={(e) => setSummaryDraft(e.target.value)}
autoSize={{ minRows: 10 }}
className="summary-inline-edit"
/>
) : (
<ReactMarkdown>
{analysis.overview || meeting.summaryContent}
</ReactMarkdown>
)}
</div>
</div>
{discussionItems.length > 0 && !isEditingSummary && (
<div className="summary-section" style={{ marginTop: 24 }}>
<div className="summary-section-title"></div>
<div className="discussion-list">
{discussionItems.map((item, index) => (
<div className="discussion-item" key={`${item.title}-${index}`}>
<div className="discussion-marker" />
<div className="discussion-body">
<div className="discussion-title-row">
<strong>{item.title || `讨论点 ${index + 1}`}</strong>
</div>
<div className="discussion-copy">{item.summary}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="summary-empty-state">
<Empty description="暂无智能总结内容" />
</div>
)}
</Card>
{!emptyTranscriptFailureNotice && (
<div className="section-divider-note">
<div className="section-divider-line" />
<div className="section-divider-text">
AI
</div>
<div className="section-divider-line" />
</div>
)}
<div ref={transcriptSectionRef} className="transcript-player-anchor">
2026-07-01 14:00:19 +00:00
<Card className="left-flow-card" variant="borderless" title={<Space size={8}><AudioOutlined /></Space>}>
{playbackAudioUrl && (
<audio ref={audioRef} style={{ display: 'none' }} preload="auto">
<source src={playbackAudioUrl} type={resolveAudioMimeType(playbackAudioUrl)} />
</audio>
)}
{emptyTranscriptFailureNotice && (
<div className="empty-transcript-inline-note">
<div className="empty-transcript-inline-note__title"></div>
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
</div>
)}
<List
className="transcript-list"
dataSource={transcripts}
style={{ paddingBottom: playbackAudioUrl ? 156 : 0 }}
renderItem={(item, index) => {
const nextStartTime = transcripts[index + 1]?.startTime || Infinity;
const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime;
return (
<ActiveTranscriptRow
item={item}
meetingId={meeting.id}
isOwner={isOwner}
isEditing={editingTranscriptId === item.id}
isSaving={savingTranscriptId === item.id}
speakerLabelMap={speakerLabelMap}
onPlay={handleTranscriptRowPlay}
onStartEdit={handleStartEditTranscript}
onDraftBlur={handleTranscriptDraftBlur}
onDraftKeyDown={handleTranscriptDraftKeyDown}
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
registerRow={registerTranscriptRow}
isActive={isActive}
isLinkedHighlight={linkedTranscriptIds.includes(item.id)}
audioPlaying={audioPlaying}
/>
);
}}
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
/>
</Card>
</div>
</div>
</Col>
<Col xs={24} xl={11} style={{ height: '100%' }}>
<div ref={leftColumnRef} className="detail-side-column detail-right-column">
{(
<div ref={transcriptSectionRef} className="transcript-player-anchor">
<Card
className="left-flow-card transcript-workspace-card"
variant="borderless"
style={{ height: '100%' }}
styles={{ body: { padding: 0, height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 } }}
>
{playbackAudioUrl && (
<audio ref={audioRef} style={{ display: 'none' }} preload="auto">
<source src={playbackAudioUrl} type={resolveAudioMimeType(playbackAudioUrl)} />
</audio>
)}
2026-07-02 09:29:39 +00:00
<div className="transcript-panel-header">
<div className="transcript-stage-tabs" role="tablist" aria-label="内容切换">
{aiCatalogEnabled && (
<button
type="button"
role="tab"
aria-selected={workspaceTab === 'catalog'}
className={workspaceTab === 'catalog' ? 'active' : ''}
onClick={() => setWorkspaceTab('catalog')}
>
AI
</button>
)}
<button
type="button"
2026-07-02 09:29:39 +00:00
role="tab"
aria-selected={workspaceTab === 'transcript'}
className={workspaceTab === 'transcript' ? 'active' : ''}
onClick={() => setWorkspaceTab('transcript')}
>
2026-07-02 09:29:39 +00:00
</button>
2026-07-02 09:29:39 +00:00
</div>
</div>
<div className="transcript-scroll-shell">
{aiCatalogEnabled && workspaceTab === 'catalog' ? (
<div className="catalog-list">
{catalogPanelNotice && (
<Alert
type={catalogPanelNotice.type}
showIcon
style={{ marginBottom: 16 }}
message="AI 目录生成失败"
description={catalogPanelNotice.description}
/>
)}
{catalogChapterLinks.length ? (
catalogChapterLinks.map((chapter, index) => (
<div
key={chapter.key}
className={`catalog-item-container ${linkedChapterKey === chapter.key ? 'active' : ''}`}
>
<div className="catalog-timeline-axis">
<div className="catalog-timeline-dot" />
<div className="catalog-timeline-line" />
</div>
<div
className="catalog-item-card"
onClick={() => handleLocateChapterTranscript(index)}
style={{ cursor: 'pointer' }}
>
<div className="catalog-item-time">{chapter.timeLabel}</div>
<div className="catalog-item-title-row" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginTop: 4 }}>
<div className="catalog-item-title">{chapter.title}</div>
<button
type="button"
className="catalog-item-link"
onClick={(e) => {
e.stopPropagation();
handleLocateChapterTranscript(index);
}}
>
<LinkOutlined />
</button>
</div>
</div>
</div> ))
) : meeting.status === 2 ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="AI 目录生成中"
description={generationProgress?.message || '正在生成 AI 目录,章节完成后会自动展示。'}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 AI 目录" />
)}
</div>
) : (
<>
{emptyTranscriptFailureNotice && (
<div className="empty-transcript-inline-note">
<div className="empty-transcript-inline-note__title"></div>
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
</div>
)}
<List
className="transcript-list"
dataSource={transcripts}
style={{ paddingBottom: playbackAudioUrl ? 156 : 0 }}
renderItem={(item, index) => {
const nextStartTime = transcripts[index + 1]?.startTime || Infinity;
const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime;
return (
<ActiveTranscriptRow
item={item}
meetingId={meeting.id}
isOwner={isOwner}
isEditing={editingTranscriptId === item.id}
isSaving={savingTranscriptId === item.id}
speakerLabelMap={speakerLabelMap}
onPlay={handleTranscriptRowPlay}
onStartEdit={handleStartEditTranscript}
onDraftBlur={handleTranscriptDraftBlur}
onDraftKeyDown={handleTranscriptDraftKeyDown}
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
registerRow={registerTranscriptRow}
isActive={isActive}
isLinkedHighlight={linkedTranscriptIds.includes(item.id)}
audioPlaying={audioPlaying}
highlightKeyword={highlightKeyword}
/>
);
}}
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
/>
</>
)}
</div>
</Card>
</div>
)}
</div>
</Col>
</Row>
</>
)}
2026-06-30 05:37:33 +00:00
</div>
</SectionCard>
2026-03-05 09:52:08 +00:00
{playbackAudioUrl && showFloatingTranscriptPlayer && floatingTranscriptPlayerLayout && (
<div
className="transcript-player transcript-player--floating"
style={{ left: floatingTranscriptPlayerLayout.left, width: floatingTranscriptPlayerLayout.width }}
>
<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)}
2026-07-02 09:29:39 +00:00
style={{ '--player-progress': `${playerProgressPercent}%` } as React.CSSProperties}
onChange={handleAudioProgressChange}
/>
</div>
<button type="button" className="player-ghost-btn" onClick={cyclePlaybackRate}>
<FastForwardOutlined />
{audioPlaybackRate}x
</button>
</div>
)}
<style>{`
@keyframes highlight-pulse {
0% { box-shadow: 0 0 0 rgba(95, 81, 255, 0); transform: scale(1); }
50% { box-shadow: 0 0 15px rgba(95, 81, 255, 0.3); transform: scale(1.02); }
100% { box-shadow: 0 0 0 rgba(95, 81, 255, 0); transform: scale(1); }
}
.highlight-text {
background: linear-gradient(120deg, rgba(95, 81, 255, 0.15) 0%, rgba(108, 140, 255, 0.1) 100%);
border-bottom: 2px solid #5f51ff;
border-radius: 3px;
padding: 1px 4px;
color: #4335eb;
font-weight: 600;
transition: all 0.3s ease;
display: inline-block;
line-height: 1.2;
margin: 0 1px;
cursor: pointer;
animation: highlight-pulse 2s infinite ease-in-out;
}
.highlight-text:hover {
background: rgba(95, 81, 255, 0.25);
transform: translateY(-1px);
}
/* 当转录行处于活动状态时,调整高亮样式以保持可读性 */
.ant-list-item.transcript-row.active .highlight-text {
2026-07-02 09:29:39 +00:00
background: linear-gradient(120deg, rgba(95, 81, 255, 0.15) 0%, rgba(108, 140, 255, 0.1) 100%);
border-bottom-color: #5f51ff;
color: #4335eb;
text-shadow: none;
}
.summary-keyword-link {
color: #5f51ff;
cursor: pointer;
font-weight: 500;
padding: 0 2px;
border-radius: 2px;
transition: all 0.2s;
}
.summary-keyword-link:hover {
background: rgba(95, 81, 255, 0.1);
text-decoration: underline;
}
.meeting-detail-page {
padding: 24px;
height: calc(100vh - 64px);
overflow: hidden;
display: flex;
flex-direction: column;
background:
radial-gradient(circle at 20% 20%, rgba(95, 81, 255, 0.05) 0%, transparent 40%),
radial-gradient(circle at 80% 80%, rgba(108, 140, 255, 0.05) 0%, transparent 40%),
#fbfcfd;
position: relative;
}
.meeting-detail-page::before {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3%3Ffilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
opacity: 0.015;
pointer-events: none;
}
.meeting-detail-page-header {
margin-bottom: 18px;
padding: 24px 32px;
border-radius: 28px;
border: 1px solid rgba(220, 226, 242, 0.5);
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.03);
backdrop-filter: blur(20px);
}
.meeting-detail-title-wrap {
display: flex;
align-items: flex-start;
gap: 16px;
}
.meeting-detail-title-icon {
width: 42px;
height: 42px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
background: linear-gradient(135deg, #5f51ff, #6c8cff);
box-shadow: 0 10px 24px rgba(95, 81, 255, 0.22);
flex-shrink: 0;
}
.meeting-detail-title-copy {
display: grid;
gap: 6px;
}
.meeting-detail-title-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.meeting-detail-title-text {
color: #1a1f36;
font-size: 24px;
line-height: 1.2;
font-weight: 800;
letter-spacing: -0.03em;
}
.meeting-detail-title-edit {
width: 28px;
height: 28px;
border: 0;
border-radius: 50%;
background: rgba(95, 81, 255, 0.08);
color: #5f51ff;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.meeting-detail-meta-row {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
color: #6e7695;
font-size: 13px;
}
.meeting-detail-config-row {
display: flex;
flex-wrap: wrap;
gap: 10px 12px;
margin-top: 2px;
}
.meeting-detail-config-item {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(244, 246, 255, 0.92);
border: 1px solid rgba(227, 232, 247, 0.95);
color: #58627f;
font-size: 13px;
line-height: 1;
}
.meeting-detail-config-item strong {
color: #313a5a;
font-weight: 700;
}
.meeting-detail-workspace {
flex: 1;
min-height: 0;
}
.detail-side-column {
height: 100%;
min-height: 0;
min-width: 0;
}
.detail-left-column {
overflow-y: auto;
overflow-x: hidden;
padding-right: 6px;
display: flex;
flex-direction: column;
gap: 18px;
}
.detail-left-column > .section-divider-note,
.detail-left-column > .transcript-player-anchor {
display: none;
}
.detail-right-column {
overflow: hidden;
display: flex;
flex-direction: column;
}
.left-flow-card {
min-width: 0;
border-radius: 24px;
border: 1px solid rgba(228, 232, 245, 0.96);
box-shadow: 0 18px 38px rgba(127, 139, 186, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 255, 0.98));
}
.left-flow-card .ant-card-head,
.left-flow-card .ant-card-body {
min-width: 0;
}
.summary-panel .ant-card-body {
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px;
}
.summary-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.summary-title {
display: inline-flex;
align-items: center;
gap: 8px;
color: #1a1f36;
font-size: 18px;
font-weight: 800;
}
.summary-content-box {
background: #f8faff;
border: 1px solid #eef1f9;
border-radius: 16px;
padding: 24px;
}
.summary-section-title {
color: #9aa0bd;
font-size: 14px;
font-weight: 700;
margin-bottom: 12px;
letter-spacing: 0.02em;
}
.summary-markdown {
font-size: 15px;
line-height: 1.8;
color: #2d3553;
}
.summary-markdown p {
margin-bottom: 16px;
}
.summary-markdown ul {
padding-left: 20px;
margin-bottom: 16px;
}
.summary-markdown li {
margin-bottom: 8px;
position: relative;
}
.summary-markdown li::marker {
color: #5f51ff;
}
.keyword-panel .ant-card-body {
display: grid;
gap: 16px;
padding: 20px 24px 24px;
}
.keyword-panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.keyword-panel-title {
color: #2d3553;
font-size: 16px;
font-weight: 800;
letter-spacing: 0.04em;
}
.brief-section {
display: grid;
gap: 14px;
padding: 18px 18px 0;
border-top: 1px solid rgba(230, 235, 247, 0.95);
}
.brief-section:first-of-type {
padding-top: 0;
border-top: 0;
}
.brief-section-title {
color: #5d6685;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.06em;
}
.meeting-share-popover .ant-popover-inner {
padding: 0;
overflow: hidden;
border-radius: 24px;
box-shadow: 0 24px 56px rgba(76, 61, 39, 0.18);
background:
radial-gradient(circle at top right, rgba(82, 164, 255, 0.12), transparent 32%),
radial-gradient(circle at top left, rgba(252, 208, 157, 0.18), transparent 26%),
linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(248, 241, 231, 0.98));
}
.empty-transcript-hero {
margin-bottom: 16px;
padding: 18px 20px;
border-radius: 24px;
border: 1px solid rgba(220, 170, 77, 0.24);
background:
radial-gradient(circle at top right, rgba(255, 233, 188, 0.72), transparent 34%),
linear-gradient(135deg, rgba(255, 252, 245, 0.98), rgba(255, 245, 223, 0.98));
box-shadow: 0 22px 48px rgba(188, 148, 68, 0.14);
}
.empty-transcript-hero__badge {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(122, 80, 14, 0.08);
color: #9a6700;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.empty-transcript-hero__content {
margin-top: 14px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.empty-transcript-hero__title {
color: #7a4f0f;
font-size: 20px;
font-weight: 800;
line-height: 1.3;
}
.empty-transcript-hero__description {
margin-top: 8px;
max-width: 760px;
color: #7a5b27;
font-size: 14px;
line-height: 1.8;
}
.empty-transcript-hero__hint {
margin-top: 8px;
color: #b26a00;
font-size: 13px;
font-weight: 600;
}
.empty-transcript-hero__actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 10px;
}
.empty-transcript-player {
margin-top: 16px;
max-width: 480px;
background: rgba(255, 255, 255, 0.8);
}
.empty-transcript-inline-note {
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(220, 170, 77, 0.22);
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(255, 245, 224, 0.92));
}
.empty-transcript-inline-note__title {
color: #8a5a00;
font-size: 14px;
font-weight: 700;
}
.empty-transcript-inline-note__text {
margin-top: 6px;
color: #876537;
font-size: 13px;
line-height: 1.7;
}
.meeting-share-popover .ant-popover-inner-content {
padding: 0;
}
.meeting-share-card {
width: min(82vw, 300px);
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
color: #33261c;
}
.meeting-share-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.meeting-share-kicker {
color: rgba(72, 53, 39, 0.64);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.meeting-share-title {
margin-top: 6px;
font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
font-size: 22px;
line-height: 1.08;
color: #33261c;
}
.meeting-share-pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(49, 95, 139, 0.08);
color: #315f8b;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.meeting-share-qr-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(84, 57, 31, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.meeting-share-caption {
color: rgba(72, 53, 39, 0.76);
font-size: 13px;
line-height: 1.7;
}
.meeting-share-settings {
display: grid;
gap: 12px;
padding: 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(84, 57, 31, 0.08);
}
.meeting-share-settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.meeting-share-settings-copy {
display: grid;
gap: 4px;
}
.meeting-share-settings-copy strong {
color: #33261c;
font-size: 14px;
}
.meeting-share-settings-copy span {
color: rgba(72, 53, 39, 0.72);
font-size: 12px;
line-height: 1.6;
}
.meeting-share-settings-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.meeting-share-link-box {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(84, 57, 31, 0.08);
color: #315f8b;
font-size: 12px;
}
.meeting-share-link-box span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meeting-share-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.record-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tag,
.summary-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 999px;
background: rgba(22, 119, 255, 0.06);
color: var(--app-primary-color);
border: 1px solid rgba(22, 119, 255, 0.15);
font-size: 13px;
font-weight: 500;
}
.selectable-tag {
cursor: pointer;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.selectable-tag:hover {
background: rgba(22, 119, 255, 0.12);
border-color: rgba(22, 119, 255, 0.3);
transform: translateY(-1px);
}
.selectable-tag.selected {
background: linear-gradient(135deg, var(--app-primary-color), #36cfc9);
color: #ffffff;
border-color: transparent;
font-weight: 600;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.24);
}
.selectable-tag.selected:hover {
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.35);
transform: translateY(-2px);
}
.summary-copy {
color: #465072;
font-size: 14px;
line-height: 1.92;
position: relative;
white-space: pre-wrap;
}
.summary-fade {
max-height: 162px;
overflow: hidden;
}
.summary-fade::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 52px;
background: linear-gradient(180deg, rgba(248, 250, 255, 0), rgba(248, 250, 255, 0.98));
}
.summary-link {
width: fit-content;
padding: 0;
border: 0;
background: transparent;
color: #6470ff;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.discussion-list {
display: grid;
gap: 16px;
}
.discussion-item {
display: grid;
grid-template-columns: 12px minmax(0, 1fr);
gap: 14px;
align-items: flex-start;
}
.discussion-marker {
width: 8px;
height: 8px;
margin-top: 10px;
border-radius: 50%;
background: linear-gradient(135deg, #5f51ff, #7f8dff);
box-shadow: 0 0 0 5px rgba(95, 81, 255, 0.08);
}
.discussion-body {
display: grid;
gap: 8px;
color: #4f5876;
line-height: 1.8;
}
.discussion-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.discussion-title-row strong {
color: #233053;
font-size: 15px;
}
.discussion-copy {
color: #4f5876;
line-height: 1.86;
}
.discussion-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.transcript-workspace-card {
height: 100%;
overflow: hidden;
}
.transcript-keyword-bar {
padding: 18px 18px 12px;
border-bottom: 1px solid rgba(228, 232, 245, 0.92);
display: grid;
gap: 14px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 255, 0.92));
}
.transcript-keyword-label {
color: #2d3553;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.04em;
}
.transcript-keyword-body {
display: grid;
gap: 12px;
}
.transcript-keyword-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.transcript-stage-tabs {
display: flex;
align-items: center;
gap: 32px;
padding: 0 24px;
min-height: 56px;
border-bottom: 1px solid #eef1f9;
background: #ffffff;
}
.transcript-stage-tabs button {
padding: 0;
border: 0;
background: transparent;
color: #6e7695;
font-size: 16px;
font-weight: 700;
height: 100%;
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.transcript-stage-tabs button:hover {
color: #5f51ff;
}
.transcript-stage-tabs button.active {
color: #5f51ff;
}
.transcript-stage-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: #5f51ff;
border-radius: 99px 99px 0 0;
}
.transcript-scroll-shell {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 18px 18px 0;
}
.catalog-list {
display: flex;
flex-direction: column;
gap: 0;
padding: 10px 0 20px;
}
.catalog-item-container {
display: flex;
gap: 20px;
padding: 0 10px;
}
.catalog-timeline-axis {
display: flex;
flex-direction: column;
align-items: center;
width: 20px;
flex-shrink: 0;
position: relative;
}
.catalog-timeline-dot {
width: 10px;
height: 10px;
background: #5f51ff;
border-radius: 50%;
margin-top: 24px;
z-index: 2;
transition: all 0.3s ease;
}
.catalog-timeline-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: #eef1f9;
z-index: 1;
}
.catalog-item-container:first-child .catalog-timeline-line {
top: 24px;
}
.catalog-item-container:last-child .catalog-timeline-line {
bottom: calc(100% - 34px);
}
.catalog-item-card {
flex: 1;
background: #ffffff;
border: 1px solid #eef1f9;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 20px;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.catalog-item-card:hover {
background: #f9faff;
border-color: rgba(95, 81, 255, 0.2);
}
.catalog-item-link {
opacity: 0;
visibility: hidden;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid transparent;
background: rgba(95, 81, 255, 0.06);
color: #5f51ff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s ease;
}
.catalog-item-container:hover .catalog-item-link {
opacity: 1;
visibility: visible;
}
.summary-inline-edit {
width: 100% !important;
border-radius: 12px !important;
border: 1px solid #d9d9d9 !important;
padding: 12px 16px !important;
font-size: 15px !important;
line-height: 1.8 !important;
color: #2d3553 !important;
background: #fff !important;
}
.summary-inline-edit:focus {
border-color: #5f51ff !important;
box-shadow: 0 0 0 2px rgba(95, 81, 255, 0.1) !important;
}
.catalog-item-link:hover {
background: rgba(95, 81, 255, 0.12);
border-color: rgba(95, 81, 255, 0.2);
}
.tag,
.summary-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 99px;
background: #ffffff;
color: #6e7695;
border: 1px solid #eef1f9;
font-size: 13px;
font-weight: 500;
transition: all 0.2s ease;
}
.selectable-tag:hover {
border-color: #5f51ff;
color: #5f51ff;
background: rgba(95, 81, 255, 0.02);
}
.selectable-tag.selected {
border-color: #5f51ff;
background: rgba(95, 81, 255, 0.06);
color: #5f51ff;
}
.selectable-tag.highlighted-tag {
border-color: #5f51ff;
background: rgba(95, 81, 255, 0.05);
color: #5f51ff;
}
.segmented-tabs {
display: flex;
gap: 28px;
align-items: center;
flex-wrap: wrap;
padding-bottom: 8px;
border-bottom: 1px solid rgba(226, 231, 245, 0.95);
}
.segmented-tabs button {
padding: 0 0 10px;
border: 0;
background: transparent;
font-size: 14px;
font-weight: 700;
color: #9aa0bd;
cursor: pointer;
position: relative;
white-space: nowrap;
transition: color 0.2s ease;
}
.segmented-tabs button:hover {
color: #5a638f;
}
.segmented-tabs button.active {
color: #3a4476;
}
.segmented-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -9px;
height: 2px;
border-radius: 999px;
background: #6b73ff;
}
.chapter-list,
.speaker-summary-list,
.keypoint-list,
.todo-list {
display: flex;
flex-direction: column;
gap: 14px;
padding-top: 12px;
}
.chapter-item {
display: flex;
gap: 14px;
align-items: flex-start;
}
.chapter-time {
position: relative;
padding-top: 10px;
color: #58627f;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
width: 64px;
}
.chapter-time::after {
content: "";
display: inline-block;
width: 8px;
height: 8px;
margin-left: 8px;
border-radius: 50%;
background: #6e76ff;
vertical-align: middle;
}
.chapter-card,
.speaker-summary-card,
.keypoint-card {
border-radius: 16px;
padding: 14px 16px;
background: rgba(244, 246, 255, 0.96);
border: 1px solid rgba(231, 235, 248, 0.98);
}
.chapter-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.chapter-card strong,
.keypoint-content strong {
color: #414b78;
font-size: 15px;
}
.chapter-card span,
.speaker-summary-body,
.keypoint-content span {
color: #67718f;
line-height: 1.75;
}
.speaker-summary-card {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.speaker-summary-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.speaker-avatar {
width: 48px;
height: 48px;
border-radius: 16px;
background: linear-gradient(135deg, #7480ff, #8c63ff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
}
.speaker-summary-name {
color: #3d4774;
font-weight: 700;
}
.speaker-summary-meta,
.keypoint-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
color: #96a0c1;
font-size: 12px;
}
.keypoint-card {
display: flex;
gap: 14px;
align-items: start;
}
.keypoint-badge {
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 12px;
background: rgba(107, 115, 255, 0.12);
color: #5c64ff;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
}
.keypoint-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.todo-item {
display: flex;
align-items: flex-start;
gap: 10px;
color: #495271;
line-height: 1.7;
padding: 12px 14px;
border-radius: 14px;
background: rgba(247, 249, 255, 0.98);
border: 1px solid rgba(232, 236, 248, 0.98);
}
.todo-dot {
width: 8px;
height: 8px;
margin-top: 9px;
border-radius: 50%;
background: #6e76ff;
flex: 0 0 auto;
}
.section-divider-note {
display: flex;
align-items: center;
gap: 14px;
color: #c0c6dc;
font-size: 12px;
padding: 2px 4px;
}
.section-divider-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, rgba(220, 225, 240, 0), rgba(220, 225, 240, 0.92), rgba(220, 225, 240, 0));
}
.section-divider-text {
white-space: nowrap;
}
.ant-list-item.transcript-row {
display: flex !important;
justify-content: flex-start !important;
align-items: flex-start !important;
gap: 12px;
padding: 12px 0 !important;
border-bottom: 0 !important;
cursor: pointer;
}
.ant-list-item.transcript-row.linked .transcript-bubble,
.ant-list-item.transcript-row.linked .transcript-bubble-editing {
2026-07-02 09:29:39 +00:00
background: #ffffff;
border-color: rgba(60, 112, 245, 0.28);
color: #2d3553;
box-shadow: 0 10px 24px rgba(60, 112, 245, 0.08);
}
.ant-list-item.transcript-row.active .transcript-bubble,
.ant-list-item.transcript-row.active .transcript-bubble-editing {
2026-07-02 09:29:39 +00:00
border-color: rgba(60, 112, 245, 0.34);
background: #ffffff;
color: #2d3553;
box-shadow: 0 12px 28px rgba(60, 112, 245, 0.12);
}
.transcript-entry {
flex: 1;
justify-self: start;
text-align: left;
display: flex;
align-items: flex-start;
gap: 16px;
width: 100%;
min-width: 0;
}
.transcript-content-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.transcript-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
color: #8e98b8;
font-size: 12px;
}
.transcript-avatar {
flex-shrink: 0;
background: linear-gradient(135deg, #d9ebff, #eaf2ff) !important;
color: #4362ff !important;
margin-top: 4px;
}
.transcript-speaker {
color: var(--app-text-main);
font-weight: 700;
font-size: 14px;
}
.transcript-speaker.editable {
color: var(--app-primary-color);
cursor: pointer;
}
.transcript-bubble {
display: block;
align-self: stretch;
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 16px 22px;
border-radius: 20px;
background: #ffffff;
border: 1px solid rgba(228, 232, 245, 0.6);
color: #2d3553;
line-height: 1.8;
font-size: 15px;
white-space: pre-wrap;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
}
.transcript-bubble:hover {
border-color: rgba(95, 81, 255, 0.2);
box-shadow: 0 12px 24px rgba(95, 81, 255, 0.05);
transform: translateY(-1px);
}
.transcript-bubble.editable {
cursor: text;
}
.transcript-bubble.editable:hover {
border-color: rgba(100, 112, 255, 0.24);
}
.transcript-bubble-editing {
display: block;
align-self: stretch;
width: 100%;
max-width: 100%;
box-sizing: border-box;
outline: none;
}
.transcript-bubble-input {
display: block;
width: 100% !important;
max-width: 100%;
}
.transcript-bubble-input .ant-input-affix-wrapper,
.transcript-bubble-input .ant-input-textarea,
.transcript-bubble-editing .ant-input-textarea,
.transcript-bubble-editing .ant-input-textarea-show-count {
display: block;
width: 100% !important;
max-width: 100%;
}
.transcript-bubble-input .ant-input {
display: block;
width: 100% !important;
min-height: 1.86em;
padding: 0;
border: 0;
background: transparent;
color: #3f496a;
line-height: 1.86;
white-space: pre-wrap;
resize: none;
box-shadow: none;
direction: ltr;
text-align: left;
}
.transcript-bubble-input .ant-input:focus,
.transcript-bubble-input .ant-input:focus-within {
box-shadow: none;
}
.transcript-bubble-actions {
display: none;
}
.transcript-bubble-hint {
display: none;
}
.transcript-player-anchor {
position: relative;
flex: 1;
min-height: 0;
display: flex;
}
.transcript-player-anchor > .transcript-workspace-card {
flex: 1;
}
.transcript-list {
min-width: 0;
padding-top: 4px;
}
.transcript-player {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(225, 231, 246, 0.95);
box-shadow: 0 18px 44px rgba(72, 86, 128, 0.16);
backdrop-filter: blur(24px);
}
.transcript-player--floating {
position: fixed;
bottom: 84px;
z-index: 100;
max-width: calc(100vw - 32px);
}
.player-main-btn,
.player-ghost-btn {
border: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.player-main-btn {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #5f51ff, #6b7dff);
color: #fff;
font-size: 18px;
box-shadow: 0 10px 24px rgba(95, 81, 255, 0.28);
}
.player-ghost-btn {
gap: 6px;
padding: 0 12px;
height: 38px;
border-radius: 12px;
background: rgba(95, 81, 255, 0.08);
color: #5f51ff;
font-weight: 700;
}
.player-progress-shell {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.player-time-row {
display: flex;
align-items: center;
justify-content: space-between;
color: #8b94b5;
font-size: 12px;
font-weight: 700;
}
.player-range {
width: 100%;
appearance: none;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(108, 117, 255, 0.92), rgba(108, 117, 255, 0.92)) 0/0% 100% no-repeat,
linear-gradient(90deg, rgba(219, 224, 247, 0.9), rgba(231, 235, 250, 0.95));
outline: none;
}
.player-range::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
border: 3px solid #6e75ff;
box-shadow: 0 4px 12px rgba(110, 117, 255, 0.24);
cursor: pointer;
}
.player-range::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
border: 3px solid #6e75ff;
box-shadow: 0 4px 12px rgba(110, 117, 255, 0.24);
cursor: pointer;
}
.summary-markdown {
font-size: 14px;
line-height: 1.9;
color: #3e4768;
min-width: 0;
width: 100%;
max-width: 100%;
overflow-x: hidden;
padding-bottom: 12px;
box-sizing: border-box;
}
.markdown-body {
font-size: 14px;
line-height: 1.8;
color: #333;
min-width: 0;
width: 100%;
box-sizing: border-box;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.markdown-body p {
margin-bottom: 16px;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
overflow-wrap: anywhere;
word-break: break-word;
}
.markdown-body ul,
.markdown-body ol,
.markdown-body li,
.markdown-body blockquote,
.markdown-body table,
.markdown-body tr,
.markdown-body td,
.markdown-body th,
.markdown-body a,
.markdown-body code,
.markdown-body pre {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.markdown-body pre {
overflow-x: auto;
padding: 12px 14px;
border-radius: 12px;
background: rgba(245, 247, 255, 0.96);
}
.markdown-body table {
display: block;
overflow-x: auto;
}
.markdown-body img {
max-width: 100%;
height: auto;
}
@media (max-width: 1200px) {
.detail-left-column {
padding-right: 0;
}
.speaker-summary-card {
grid-template-columns: 1fr;
}
}
@media (max-width: 992px) {
.empty-transcript-hero__content {
flex-direction: column;
}
.empty-transcript-hero__actions {
width: 100%;
flex-wrap: wrap;
}
.meeting-share-card {
width: min(86vw, 292px);
}
.detail-side-column {
height: auto;
}
.detail-left-column {
overflow: visible;
}
.transcript-player--floating {
bottom: 72px;
max-width: calc(100vw - 24px);
}
.section-divider-text {
white-space: normal;
text-align: center;
}
}
`}</style>
{isOwner && (
<Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600} forceRender>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="tags" label="业务标签">
<Select mode="tags" placeholder="输入标签后回车" />
</Form.Item>
<Form.Item name="participantIds" label="参会人员">
<Select mode="multiple" placeholder="请选择参会人" showSearch optionFilterProp="children">
{userList.map((u) => (
<Option key={u.userId} value={u.userId}>
<Space>
<Avatar size="small" icon={<UserOutlined />} />
{u.displayName || u.username}
</Space>
</Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
)}
{isOwner && (
2026-03-05 09:52:08 +00:00
<Drawer
title="重新生成 AI 总结"
width={400}
onClose={() => setSummaryVisible(false)}
open={summaryVisible}
extra={<Button type="primary" onClick={handleReSummary} loading={actionLoading}></Button>}
forceRender
2026-03-05 09:52:08 +00:00
>
<Form form={summaryForm} layout="vertical">
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
<Select placeholder="选择 LLM 模型">
{llmModels.map((model) => (
<Option key={model.id} value={model.id}>
{model.modelName} {model.isDefault === 1 && <Tag color="gold" style={{ marginLeft: 4 }}></Tag>}
2026-03-05 09:52:08 +00:00
</Option>
))}
</Select>
</Form.Item>
<Form.Item name="promptId" label="提示词模板" rules={[{ required: true }]}>
2026-03-05 09:52:08 +00:00
<Select placeholder="选择模板">
{prompts.map((prompt) => (
<Option key={prompt.id} value={prompt.id}>{prompt.templateName}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="summaryDetailLevel" label="总结详细程度" rules={[{ required: true }]}>
<Radio.Group>
<Radio value="DETAILED"></Radio>
<Radio value="STANDARD"></Radio>
<Radio value="BRIEF"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item
name="userPrompt"
label="用户提示词"
extra="可选,用于补充本次重新总结的关注重点、表达偏好或输出侧重点"
>
<Input.TextArea
placeholder="例如:请重点突出结论、待办事项和风险点"
autoSize={{ minRows: 4, maxRows: 8 }}
showCount
maxLength={1000}
/>
</Form.Item>
<Divider />
<Text type="secondary"></Text>
</Form>
</Drawer>
)}
2026-06-30 05:37:33 +00:00
</PageContainer>
);
};
export default MeetingDetail;