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

2009 lines
68 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,
Breadcrumb,
Button,
Card,
Checkbox,
Col,
Divider,
Drawer,
Empty,
Form,
Input,
List,
message,
Modal,
Popover,
Progress,
Row,
Select,
Skeleton,
Space,
Tag,
Typography,
} from 'antd';
import {
AudioOutlined,
CaretRightFilled,
ClockCircleOutlined,
DownloadOutlined,
EditOutlined,
FastForwardOutlined,
LeftOutlined,
LoadingOutlined,
PauseOutlined,
RobotOutlined,
SyncOutlined,
UserOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown';
import {
downloadMeetingSummary,
getMeetingDetail,
getMeetingProgress,
getTranscripts,
MeetingProgress,
MeetingTranscriptVO,
MeetingVO,
reSummary,
updateMeetingBasic,
updateMeetingTranscript,
updateMeetingSummary,
updateSpeakerInfo,
} from '../../api/business/meeting';
import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel';
import { getHotWordPage, 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';
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[];
};
const ANALYSIS_EMPTY: MeetingAnalysis = {
overview: '',
keywords: [],
chapters: [],
speakerSummaries: [],
keyPoints: [],
todos: [],
};
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')}`;
}
const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void }> = ({ meetingId, onComplete }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
useEffect(() => {
const fetchProgress = async () => {
try {
const res = await getMeetingProgress(meetingId);
if (res.data?.data) {
setProgress(res.data.data);
if (res.data.data.percent === 100) {
onComplete();
}
}
} catch {
// ignore
}
};
fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meetingId, onComplete]);
const percent = progress?.percent || 0;
const isError = percent < 0;
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} 分钟`;
};
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' }}
width={180}
strokeWidth={8}
/>
<div style={{ marginTop: 32 }}>
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}>
{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 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 { 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>
);
};
type ActiveTranscriptRowProps = {
item: MeetingTranscriptVO;
meetingId: number;
isOwner: boolean;
isEditing: boolean;
isSaving: boolean;
speakerLabelMap: Map<string, string>;
onSeek: (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;
};
const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
item,
meetingId,
isOwner,
isEditing,
isSaving,
speakerLabelMap,
onSeek,
onStartEdit,
onDraftBlur,
onDraftKeyDown,
onSpeakerUpdated,
}) => {
const [draftValue, setDraftValue] = useState(item.content || '');
const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : '';
useEffect(() => {
if (isEditing) {
setDraftValue(item.content || '');
}
}, [isEditing, item.content]);
return (
<List.Item className="transcript-row" onClick={() => onSeek(item.startTime)}>
<div className="transcript-time">{formatTime(item.startTime)}</div>
<div className="transcript-entry">
<div className="transcript-meta">
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
{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}
>
{item.content}
</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.onSeek === nextProps.onSeek
&& prevProps.onStartEdit === nextProps.onStartEdit
&& prevProps.onDraftBlur === nextProps.onDraftBlur
&& prevProps.onDraftKeyDown === nextProps.onDraftKeyDown
&& prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated
));
const MeetingDetail: React.FC = () => {
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 [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' | null>(null);
const [isEditingSummary, setIsEditingSummary] = useState(false);
const [summaryDraft, setSummaryDraft] = useState('');
const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters');
const [expandKeywords, setExpandKeywords] = useState(false);
const [expandSummary, setExpandSummary] = useState(false);
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [addingHotwords, setAddingHotwords] = useState(false);
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
const [audioPlaying, setAudioPlaying] = useState(false);
const [audioPlaybackRate, setAudioPlaybackRate] = useState(1);
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [, setUserList] = useState<SysUser[]>([]);
const { items: speakerLabels } = useDict('biz_speaker_label');
const audioRef = useRef<HTMLAudioElement>(null);
const summaryPdfRef = useRef<HTMLDivElement>(null);
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 speakerLabelMap = useMemo(
() => new Map(speakerLabels.map((item) => [item.itemValue, item.itemLabel])),
[speakerLabels],
);
const isOwner = useMemo(() => {
if (!meeting) return false;
const profileStr = sessionStorage.getItem('userProfile');
if (profileStr) {
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true || profile.userId === meeting.creatorId;
}
return false;
}, [meeting]);
const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2;
useEffect(() => {
if (!id) return;
fetchData(Number(id));
loadAiConfigs();
loadUsers();
}, [id]);
useEffect(() => {
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
}, [analysis.keywords]);
useEffect(() => {
if (meeting?.audioSaveStatus === 'FAILED') {
message.warning(meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。');
}
}, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]);
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);
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('play', handlePlay);
audio.addEventListener('pause', handlePause);
audio.addEventListener('ended', handleEnded);
handleLoadedMetadata();
return () => {
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('play', handlePlay);
audio.removeEventListener('pause', handlePause);
audio.removeEventListener('ended', handleEnded);
};
}, [meeting?.audioUrl, audioPlaybackRate]);
const fetchData = useCallback(async (meetingId: number) => {
try {
const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
setMeeting(detailRes.data.data);
setTranscripts(transcriptRes.data.data || []);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}, []);
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: meeting?.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,
});
message.success('已重新发起总结任务');
setSummaryVisible(false);
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) =>
saveHotWord({
word,
pinyinList: [],
matchStrategy: 1,
category: '',
weight: 2,
status: 1,
isPublic: 0,
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 seekTo = useCallback((timeMs: number) => {
if (!audioRef.current) return;
audioRef.current.currentTime = timeMs / 1000;
audioRef.current.play();
}, []);
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);
}
};
if (loading) {
return (
<div style={{ padding: 24 }}>
<Skeleton active />
</div>
);
}
if (!meeting) {
return (
<div style={{ padding: 24 }}>
<Empty description="会议不存在" />
</div>
);
}
return (
<div style={{ padding: 24, height: 'calc(100vh - 64px)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Breadcrumb style={{ marginBottom: 16 }}>
<Breadcrumb.Item>
<a onClick={() => navigate('/meetings')}></a>
</Breadcrumb.Item>
<Breadcrumb.Item></Breadcrumb.Item>
</Breadcrumb>
<Card style={{ marginBottom: 16, flexShrink: 0 }} bodyStyle={{ padding: '16px 24px' }}>
<Row justify="space-between" align="middle">
<Col>
<Space direction="vertical" size={4}>
<Title level={4} style={{ margin: 0 }}>
{meeting.title}
{isOwner && (
<EditOutlined style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff', marginLeft: 8 }} onClick={handleEditMeeting} />
)}
</Title>
<Space split={<Divider type="vertical" />}>
<Text type="secondary">
<ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}
</Text>
<Space>
{meeting.tags?.split(',').filter(Boolean).map((tag) => (
<Tag key={tag} color="blue">{tag}</Tag>
))}
</Space>
<Text type="secondary">
<UserOutlined /> {meeting.participants || '未指定'}
</Text>
</Space>
</Space>
</Col>
<Col>
<Space>
{canRetrySummary && (
<Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>
</Button>
)}
{isOwner && meeting.status === 2 && (
<Button icon={<LoadingOutlined />} type="primary" ghost disabled loading>
</Button>
)}
{meeting.status === 3 && !!meeting.summaryContent && (
<>
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('pdf')} loading={downloadLoading === 'pdf'}>
PDF
</Button>
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('word')} loading={downloadLoading === 'word'}>
Word
</Button>
</>
)}
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
</Button>
</Space>
</Col>
</Row>
</Card>
<div style={{ flex: 1, minHeight: 0 }}>
{meeting.status === 1 || meeting.status === 2 ? (
<MeetingProgressDisplay meetingId={meeting.id} onComplete={() => fetchData(meeting.id)} />
) : (
<Row gutter={24} style={{ height: '100%' }}>
<Col xs={24} lg={14} style={{ height: '100%' }}>
<div className="detail-side-column detail-left-column">
<Card className="left-flow-card summary-panel" bordered={false}>
<div className="summary-head">
<div className="summary-title">
<RobotOutlined />
<span></span>
{isOwner && analysis.keywords.length > 0 && (
<Button
size="small"
type="primary"
ghost
disabled={!selectedKeywords.length}
loading={addingHotwords}
onClick={handleAddSelectedHotwords}
>
</Button>
)}
</div>
<div className="summary-actions">
<span className={`status-pill ${hasAnalysis ? 'success' : 'warning'}`}>{hasAnalysis ? '已生成' : '待生成'}</span>
</div>
</div>
<div className="summary-block">
<div className="summary-section-head">
<span></span>
</div>
{analysis.keywords.length ? (
<>
<div className="record-tags">
{visibleKeywords.map((tag) => (
<label key={tag} className={`tag selectable-tag ${selectedKeywords.includes(tag) ? 'selected' : ''}`}>
{isOwner && (
<Checkbox checked={selectedKeywords.includes(tag)} onChange={(event) => handleKeywordToggle(tag, event.target.checked)} />
)}
<span>{tag}</span>
</label>
))}
</div>
{analysis.keywords.length > 9 && (
<button type="button" className="summary-link" onClick={() => setExpandKeywords((value) => !value)}>
{expandKeywords ? '收起' : '展开全部'}
</button>
)}
</>
) : (
<Text type="secondary"></Text>
)}
</div>
<div className="summary-block">
<div className="summary-section-head">
<span></span>
</div>
{analysis.overview ? (
<>
<div className={!expandSummary && analysis.overview.length > 220 ? 'summary-copy summary-fade' : 'summary-copy'}>
{analysis.overview}
</div>
{analysis.overview.length > 220 && (
<button type="button" className="summary-link" onClick={() => setExpandSummary((value) => !value)}>
{expandSummary ? '收起' : '展开全部'}
</button>
)}
</>
) : (
<Text type="secondary"></Text>
)}
</div>
<div className="summary-block">
<div className="segmented-tabs">
<button type="button" className={summaryTab === 'chapters' ? 'active' : ''} onClick={() => setSummaryTab('chapters')}>
</button>
<button type="button" className={summaryTab === 'speakers' ? 'active' : ''} onClick={() => setSummaryTab('speakers')}>
</button>
<button type="button" className={summaryTab === 'actions' ? 'active' : ''} onClick={() => setSummaryTab('actions')}>
</button>
<button type="button" className={summaryTab === 'todos' ? 'active' : ''} onClick={() => setSummaryTab('todos')}>
</button>
</div>
{summaryTab === 'chapters' && (
<div className="chapter-list">
{analysis.chapters.length ? (
analysis.chapters.map((item, index) => (
<div className="chapter-item" key={`${item.title}-${index}`}>
<div className="chapter-time">{item.time || '--:--'}</div>
<div className="chapter-card">
<strong>{item.title || `章节 ${index + 1}`}</strong>
<span>{item.summary || '暂无章节描述'}</span>
</div>
</div>
))
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无章节速览" />
)}
</div>
)}
{summaryTab === 'speakers' && (
<div className="speaker-summary-list">
{analysis.speakerSummaries.length ? (
analysis.speakerSummaries.map((item, index) => (
<div className="speaker-summary-card" key={`${item.speaker}-${index}`}>
<div className="speaker-summary-side">
<div className="speaker-avatar">{(item.speaker || '发').slice(0, 1)}</div>
<div className="speaker-summary-name">{item.speaker || `发言人${index + 1}`}</div>
</div>
<div className="speaker-summary-body">
<div className="speaker-summary-meta"></div>
<div>{item.summary || '暂无发言总结'}</div>
</div>
</div>
))
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无发言总结" />
)}
</div>
)}
{summaryTab === 'actions' && (
<div className="keypoint-list">
{analysis.keyPoints.length ? (
analysis.keyPoints.map((item, index) => (
<div className="keypoint-card" key={`${item.title}-${index}`}>
<div className="keypoint-badge">{String(index + 1).padStart(2, '0')}</div>
<div className="keypoint-content">
<strong>{item.title || `要点 ${index + 1}`}</strong>
<span>{item.summary || '暂无要点说明'}</span>
{(item.speaker || item.time) && (
<div className="keypoint-meta">
{item.speaker ? <span className="summary-tag">{item.speaker}</span> : null}
{item.time ? <span className="summary-tag">{item.time}</span> : null}
</div>
)}
</div>
</div>
))
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无要点回顾" />
)}
</div>
)}
{summaryTab === 'todos' && (
<div className="todo-list">
{analysis.todos.length ? (
analysis.todos.map((item, index) => (
<div className="todo-item" key={`${item}-${index}`}>
<span className="todo-dot" />
<span>{item}</span>
</div>
))
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待办事项" />
)}
</div>
)}
</div>
</Card>
<div className="section-divider-note">
<div className="section-divider-line" />
<div className="section-divider-text">
AI
</div>
<div className="section-divider-line" />
</div>
<Card className="left-flow-card" bordered={false} title={<span><AudioOutlined /> </span>}>
{meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} style={{ display: 'none' }} preload="metadata" />}
{meeting.audioSaveStatus === 'FAILED' && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
/>
)}
<List
dataSource={transcripts}
renderItem={(item) => (
<ActiveTranscriptRow
item={item}
meetingId={meeting.id}
isOwner={isOwner}
isEditing={editingTranscriptId === item.id}
isSaving={savingTranscriptId === item.id}
speakerLabelMap={speakerLabelMap}
onSeek={seekTo}
onStartEdit={handleStartEditTranscript}
onDraftBlur={handleTranscriptDraftBlur}
onDraftKeyDown={handleTranscriptDraftKeyDown}
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
/>
)}
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
/>
{meeting.audioUrl && (
<div className="transcript-player">
<button type="button" className="player-main-btn" onClick={toggleAudioPlayback} aria-label="toggle-audio">
{audioPlaying ? <PauseOutlined /> : <CaretRightFilled />}
</button>
<div className="player-progress-shell">
<div className="player-time-row">
<span>{formatPlayerTime(audioCurrentTime)}</span>
<span>{formatPlayerTime(audioDuration)}</span>
</div>
<input
className="player-range"
type="range"
min={0}
max={audioDuration || 0}
step={0.1}
value={Math.min(audioCurrentTime, audioDuration || 0)}
onChange={handleAudioProgressChange}
/>
</div>
<button type="button" className="player-ghost-btn" onClick={cyclePlaybackRate}>
<FastForwardOutlined />
{audioPlaybackRate}x
</button>
</div>
)}
</Card>
</div>
</Col>
<Col xs={24} lg={10} style={{ height: '100%' }}>
<div className="detail-side-column ai-summary-column">
<Card
className="left-flow-card"
bordered={false}
title={<span><RobotOutlined /> AI </span>}
extra={
meeting.summaryContent && isOwner && (
<Space>
{isEditingSummary ? (
<>
<Button size="small" onClick={() => setIsEditingSummary(false)}></Button>
<Button size="small" type="primary" onClick={handleSaveSummary} loading={actionLoading}></Button>
</>
) : (
<Button
size="small"
type="link"
icon={<EditOutlined />}
onClick={() => {
setSummaryDraft(meeting.summaryContent || '');
setIsEditingSummary(true);
}}
>
</Button>
)}
</Space>
)
}
style={{ height: '100%' }}
bodyStyle={{ padding: 24, height: '100%', overflowY: 'auto', overflowX: 'hidden', minWidth: 0 }}
>
<div ref={summaryPdfRef} className="markdown-shell">
{meeting.summaryContent ? (
isEditingSummary ? (
<Input.TextArea
value={summaryDraft}
onChange={(event) => setSummaryDraft(event.target.value)}
style={{ height: '100%', resize: 'none' }}
/>
) : (
<div className="markdown-body summary-markdown">
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
</div>
)
) : (
<div style={{ textAlign: 'center', marginTop: 100 }}>
{meeting.status === 2 ? (
<Space direction="vertical">
<LoadingOutlined style={{ fontSize: 24 }} spin />
<Text type="secondary">...</Text>
</Space>
) : (
<Empty description="暂无总结" />
)}
</div>
)}
</div>
</Card>
</div>
</Col>
</Row>
)}
</div>
<style>{`
.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: 16px;
}
.left-flow-card {
min-width: 0;
border-radius: 22px;
border: 1px solid rgba(228, 232, 245, 0.96);
box-shadow: 0 16px 34px rgba(127, 139, 186, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 255, 0.98));
}
.ai-summary-column,
.ai-summary-column .left-flow-card {
overflow: hidden;
}
.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: 22px;
padding: 20px 22px 22px;
}
.summary-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.summary-title {
display: inline-flex;
align-items: center;
gap: 10px;
color: #7b63ff;
font-size: 24px;
font-weight: 800;
}
.summary-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.status-pill {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.status-pill.success {
background: rgba(82, 196, 26, 0.12);
color: #389e0d;
}
.status-pill.warning {
background: rgba(250, 173, 20, 0.14);
color: #d48806;
}
.summary-block {
display: flex;
flex-direction: column;
gap: 12px;
}
.summary-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 20px;
font-weight: 700;
color: #2f3760;
}
.record-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tag,
.summary-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 10px;
background: rgba(107, 115, 255, 0.08);
color: #6a72ff;
font-size: 13px;
font-weight: 600;
}
.selectable-tag {
border: 1px solid transparent;
cursor: pointer;
}
.selectable-tag.selected {
border-color: rgba(106, 114, 255, 0.28);
background: rgba(107, 115, 255, 0.14);
}
.selectable-tag .ant-checkbox {
margin-inline-end: 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;
}
.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: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: 14px;
align-items: flex-start;
}
.chapter-time,
.transcript-time {
position: relative;
padding-top: 10px;
color: #58627f;
font-size: 14px;
font-weight: 700;
}
.chapter-time::after,
.transcript-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: grid;
grid-template-columns: 48px minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.keypoint-badge {
width: 36px;
height: 36px;
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: grid !important;
grid-template-columns: 72px minmax(0, 1fr);
justify-content: flex-start !important;
align-items: flex-start !important;
gap: 12px;
padding: 12px 0 !important;
border-bottom: 0 !important;
cursor: pointer;
}
.transcript-row:not(:last-child) .transcript-time::before {
content: "";
position: absolute;
top: 30px;
left: 38px;
width: 1px;
height: calc(100% + 12px);
background: rgba(218, 223, 243, 0.96);
}
.transcript-entry {
justify-self: start;
text-align: left;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
width: 100%;
min-width: 0;
}
.transcript-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
color: #8e98b8;
}
.transcript-avatar {
background: linear-gradient(135deg, #7a84ff, #9363ff) !important;
}
.transcript-speaker {
color: #5e698d;
font-weight: 700;
}
.transcript-speaker.editable {
color: #6470ff;
cursor: pointer;
}
.transcript-bubble {
display: block;
align-self: stretch;
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 14px 18px;
border-radius: 16px;
background: #ffffff;
border: 1px solid rgba(234, 238, 248, 1);
box-shadow: 0 12px 28px rgba(137, 149, 193, 0.08);
color: #3f496a;
line-height: 1.86;
white-space: pre-wrap;
}
.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 {
position: sticky;
bottom: 0;
z-index: 3;
display: flex;
align-items: center;
gap: 16px;
margin-top: 18px;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(249, 250, 255, 0.98), rgba(239, 242, 255, 0.98));
border: 1px solid rgba(224, 229, 247, 0.98);
box-shadow: 0 14px 30px rgba(145, 158, 212, 0.18);
backdrop-filter: blur(14px);
}
.player-main-btn,
.player-ghost-btn {
border: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.player-main-btn {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #6e75ff, #8268ff);
color: #fff;
font-size: 18px;
box-shadow: 0 12px 24px rgba(108, 117, 255, 0.28);
}
.player-ghost-btn {
gap: 6px;
padding: 0 12px;
height: 38px;
border-radius: 12px;
background: rgba(107, 115, 255, 0.08);
color: #5e67ff;
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;
}
.markdown-shell {
height: 100%;
min-height: 0;
min-width: 0;
width: 100%;
overflow-x: hidden;
padding-bottom: 28px;
box-sizing: border-box;
}
.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) {
.detail-side-column {
height: auto;
}
.detail-left-column {
overflow: visible;
}
.transcript-player {
position: static;
}
.section-divider-text {
white-space: normal;
text-align: center;
}
}
`}</style>
{isOwner && (
<Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600}>
<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>
<Text type="warning"> ID </Text>
</Form>
</Modal>
)}
{isOwner && (
<Drawer
title="重新生成 AI 总结"
width={400}
onClose={() => setSummaryVisible(false)}
open={summaryVisible}
extra={<Button type="primary" onClick={handleReSummary} loading={actionLoading}></Button>}
>
<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>
<Divider />
<Text type="secondary"></Text>
</Form>
</Drawer>
)}
</div>
);
};
export default MeetingDetail;