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

4351 lines
152 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

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

import 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';
import {
AudioOutlined,
CaretRightFilled,
ClockCircleOutlined,
CopyOutlined,
DownloadOutlined,
EditOutlined,
FastForwardOutlined,
FileTextOutlined,
LeftOutlined,
LinkOutlined,
LoadingOutlined,
PauseOutlined,
RobotOutlined,
SyncOutlined,
UserOutlined,
PlusOutlined,
CheckCircleFilled,
FilePdfOutlined,
FileWordOutlined,
ShareAltOutlined, InfoCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown';
import {
downloadMeetingTranscript,
downloadMeetingSummary,
getMeetingDetail,
getMeetingChapters,
getMeetingProgress,
getMeetingShareConfig,
getTranscripts,
MeetingChapterVO,
MeetingProgress,
SummaryDetailLevel,
MeetingTranscriptVO,
MeetingVO,
reSummary,
resolveAudioMimeType,
resolveMeetingPlaybackAudioUrl,
retryMeetingChapter,
retryMeetingSummary,
retryMeetingTranscription,
retryScheduleMeeting,
updateMeetingBasic,
updateMeetingTranscript,
updateMeetingSummary,
updateSpeakerInfo,
} 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 PageHeader from '../../components/shared/PageHeader';
import PageContainer from "../../components/shared/PageContainer";
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';
};
const getMeetingAudioDownloadName = (meeting?: Pick<MeetingVO, 'title' | 'audioUrl' | 'playbackAudioUrl'> | null) => {
const audioUrl = resolveMeetingPlaybackAudioUrl(meeting);
const extension = resolveAudioExtension(audioUrl);
return `${sanitizeDownloadFileName(meeting?.title)}-录音.${extension}`;
};
const buildMeetingPreviewUrl = (baseUrl?: string, meetingId?: number, accessPassword?: string) => {
const normalizedBaseUrl = (baseUrl || '').trim();
if (!normalizedBaseUrl || !meetingId || Number.isNaN(meetingId)) {
return '';
}
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 DETAIL_STAGE_STEP_ITEMS = [
{ code: 'INITIALIZING', label: '数据初始化', hint: '完成会议数据准备' },
{ code: 'TRANSCRIBING', label: '转译音频', hint: '完成语音转写' },
{ code: 'SUMMARIZING', label: '生成总结', hint: '完成 AI 内容处理' },
{ code: 'COMPLETED', label: '处理完成', hint: '已全部完成' },
] as const;
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);
}
}
};
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 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} 分钟`;
};
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 (
<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>
<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}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>
{isError ? 'ERROR' : `${percent}%`}
</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<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}>
<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 currentStageLabel = DETAIL_STAGE_STEP_DISPLAY_ITEMS[currentStageIndex]?.label || primaryStatusText;
const currentStageDisplay = isFailedStage ? `${currentStageLabel}失败` : currentStageLabel;
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 }}>
<Text type="secondary"></Text>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} />
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary"></Text>
<Select value={label} onChange={setLabel} placeholder="选择角色" style={{ width: '100%', marginTop: 4 }} size="small" allowClear>
{speakerLabels.map((item) => (
<Select.Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Select.Option>
))}
</Select>
</div>
<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();
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);
const [isEditingSummary, setIsEditingSummary] = useState(false);
const [summaryDraft, setSummaryDraft] = useState('');
const [expandKeywords, setExpandKeywords] = useState(false);
const [expandSummary, setExpandSummary] = useState(false);
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);
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),
getMeetingShareConfig().catch(() => null),
getMeetingProgress(meetingId, { suppressErrorToast: true }).catch(() => null),
]);
setMeeting(detailRes.data.data);
setTranscripts(transcriptRes.data.data || []);
setMeetingChapters(chapterRes.data.data || []);
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],
);
const hasAnalysis = !!(
analysis.overview ||
analysis.keywords.length ||
analysis.chapters.length ||
analysis.speakerSummaries.length ||
analysis.keyPoints.length ||
analysis.todos.length
);
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);
return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId);
}, [meetingShareBaseUrl, meeting?.id, id]);
const meetingPreviewUrl = useMemo(() => {
const meetingId = meeting?.id ?? (id ? Number(id) : NaN);
return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId, previewAccessPassword);
}, [meetingShareBaseUrl, meeting?.id, id, previewAccessPassword]);
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;
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 }),
getAiModelDefault('LLM'),
]);
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 {
// ignore
}
};
const loadUsers = async () => {
try {
const users = await listUsers();
setUserList(users || []);
} catch {
// ignore
}
};
const handleEditMeeting = () => {
if (!meeting || !isOwner) return;
form.setFieldsValue({
...meeting,
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);
}
};
const handleSaveSummary = async () => {
setActionLoading(true);
try {
await updateMeetingSummary({
meetingId: Number(id),
summaryContent: summaryDraft,
});
message.success('总结内容已更新');
setIsEditingSummary(false);
fetchData(Number(id));
} catch (error) {
console.error(error);
} 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 handleKeywordClick = useCallback((keyword: string) => {
const firstMatch = transcripts.find((item) =>
item.content.toLowerCase().includes(keyword.toLowerCase())
);
if (firstMatch) {
setWorkspaceTab('transcript');
setLinkedTranscriptIds([]);
setLinkedChapterKey(null);
setHighlightKeyword(keyword);
seekTo(firstMatch.startTime);
message.info(`已跳转至关键词 "${keyword}" 所在位置`);
} else {
message.warning(`在转录原文中未找到关键词 "${keyword}"`);
}
}, [transcripts, seekTo, message]);
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);
};
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);
return normalMatch?.[1] || fallback || 'summary';
};
const handleDownloadSummary = async (format: 'pdf' | 'word') => {
if (!meeting) return;
if (!meeting.summaryContent) {
message.warning('当前暂无可下载的 AI 总结');
return;
}
try {
setDownloadLoading(format);
const res = await downloadMeetingSummary(meeting.id, format);
const contentType =
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(
res.headers['content-disposition'],
`${(meeting.title || 'meeting').replace(/[\\/:*?"<>|\r\n]/g, '_')}-AI纪要.${format === 'pdf' ? 'pdf' : 'docx'}`,
);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
message.error(`${format.toUpperCase()} 下载失败`);
} 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 : '',
});
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>
);
}
return (
<div className="meeting-detail-page">
<PageHeader
className="meeting-detail-page-header"
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>
{isOwner && (
<button type="button" className="meeting-detail-title-edit" onClick={handleEditMeeting} aria-label="编辑会议信息">
<EditOutlined />
</button>
)}
</div>
<div className="meeting-detail-meta-row">
<span>
<ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}
</span>
<span>{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>
)}
/>
<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);
}
}}
>
<span>#{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">
<Card className="left-flow-card" variant="borderless" title={<span><AudioOutlined /> </span>}>
{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>
)}
<div className="transcript-stage-tabs">
{aiCatalogEnabled && (
<button
type="button"
className={workspaceTab === 'catalog' ? 'active' : ''}
onClick={() => setWorkspaceTab('catalog')}
>
AI
</button>
)}
<button
type="button"
className={workspaceTab === 'transcript' ? 'active' : ''}
onClick={() => setWorkspaceTab('transcript')}
>
</button>
</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>
</>
)}
</div>
{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)}
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 {
background: rgba(255, 255, 255, 0.2);
border-bottom-color: #fff;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
animation: 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 {
background: linear-gradient(135deg, rgba(95, 81, 255, 0.08), rgba(108, 140, 255, 0.06));
border-color: rgba(95, 81, 255, 0.2);
box-shadow: 0 10px 24px rgba(95, 81, 255, 0.06);
}
.ant-list-item.transcript-row.active .transcript-bubble,
.ant-list-item.transcript-row.active .transcript-bubble-editing {
border-color: rgba(95, 81, 255, 0.16);
background: linear-gradient(135deg, #5b41ff, #6a5cff);
color: #ffffff;
box-shadow: 0 12px 28px rgba(95, 81, 255, 0.2);
}
.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 && (
<Drawer
title="重新生成 AI 总结"
width={400}
onClose={() => setSummaryVisible(false)}
open={summaryVisible}
extra={<Button type="primary" onClick={handleReSummary} loading={actionLoading}></Button>}
forceRender
>
<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>}
</Option>
))}
</Select>
</Form.Item>
<Form.Item name="promptId" label="提示词模板" rules={[{ required: true }]}>
<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>
)}
</div>
);
};
export default MeetingDetail;