2026-03-27 02:30:48 +00:00
|
|
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
2026-03-05 09:52:08 +00:00
|
|
|
|
import {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
Avatar,
|
|
|
|
|
|
Breadcrumb,
|
|
|
|
|
|
Button,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
Card,
|
2026-03-27 05:50:01 +00:00
|
|
|
|
Checkbox,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
Col,
|
|
|
|
|
|
Divider,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
Drawer,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
Empty,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
Form,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
Input,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
List,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
message,
|
|
|
|
|
|
Modal,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
Popover,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
Progress,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
Row,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
Skeleton,
|
|
|
|
|
|
Space,
|
|
|
|
|
|
Tag,
|
|
|
|
|
|
Typography,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
} from 'antd';
|
|
|
|
|
|
import {
|
|
|
|
|
|
AudioOutlined,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
CaretRightFilled,
|
|
|
|
|
|
ClockCircleOutlined,
|
|
|
|
|
|
DownloadOutlined,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
EditOutlined,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
FastForwardOutlined,
|
|
|
|
|
|
LeftOutlined,
|
|
|
|
|
|
LoadingOutlined,
|
|
|
|
|
|
PauseOutlined,
|
|
|
|
|
|
RobotOutlined,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
SyncOutlined,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
UserOutlined,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
} from '@ant-design/icons';
|
2026-03-02 11:59:47 +00:00
|
|
|
|
import dayjs from 'dayjs';
|
2026-03-27 02:30:48 +00:00
|
|
|
|
import ReactMarkdown from 'react-markdown';
|
2026-03-05 09:52:08 +00:00
|
|
|
|
import {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
downloadMeetingSummary,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
getMeetingDetail,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
getMeetingProgress,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
getTranscripts,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
MeetingProgress,
|
|
|
|
|
|
MeetingTranscriptVO,
|
|
|
|
|
|
MeetingVO,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
reSummary,
|
2026-03-26 03:18:44 +00:00
|
|
|
|
updateMeetingBasic,
|
2026-03-27 05:50:01 +00:00
|
|
|
|
updateMeetingTranscript,
|
2026-03-26 03:18:44 +00:00
|
|
|
|
updateMeetingSummary,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
updateSpeakerInfo,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
} from '../../api/business/meeting';
|
2026-03-27 02:30:48 +00:00
|
|
|
|
import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel';
|
2026-03-27 05:50:01 +00:00
|
|
|
|
import { getHotWordPage, saveHotWord } from '../../api/business/hotword';
|
2026-03-02 11:59:47 +00:00
|
|
|
|
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
|
|
|
|
|
import { listUsers } from '../../api';
|
2026-03-27 02:30:48 +00:00
|
|
|
|
import { useDict } from '../../hooks/useDict';
|
2026-03-02 11:59:47 +00:00
|
|
|
|
import { SysUser } from '../../types';
|
|
|
|
|
|
|
|
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
|
|
const { Option } = Select;
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
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')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 09:19:41 +00:00
|
|
|
|
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);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
if (res.data?.data) {
|
2026-03-04 09:19:41 +00:00
|
|
|
|
setProgress(res.data.data);
|
|
|
|
|
|
if (res.data.data.percent === 100) {
|
|
|
|
|
|
onComplete();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
2026-03-05 09:52:08 +00:00
|
|
|
|
}
|
2026-03-04 09:19:41 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
fetchProgress();
|
|
|
|
|
|
const timer = setInterval(fetchProgress, 3000);
|
|
|
|
|
|
return () => clearInterval(timer);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
}, [meetingId, onComplete]);
|
2026-03-04 09:19:41 +00:00
|
|
|
|
|
|
|
|
|
|
const percent = progress?.percent || 0;
|
|
|
|
|
|
const isError = percent < 0;
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const formatEta = (seconds?: number) => {
|
2026-03-05 01:36:41 +00:00
|
|
|
|
if (!seconds || seconds <= 0) return '正在分析中';
|
2026-03-27 02:30:48 +00:00
|
|
|
|
if (seconds < 60) return `${seconds} 秒`;
|
|
|
|
|
|
const minutes = Math.floor(seconds / 60);
|
|
|
|
|
|
const remainSeconds = seconds % 60;
|
|
|
|
|
|
return remainSeconds > 0 ? `${minutes} 分 ${remainSeconds} 秒` : `${minutes} 分钟`;
|
2026-03-04 12:59:49 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 09:19:41 +00:00
|
|
|
|
return (
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
background: '#fff',
|
|
|
|
|
|
borderRadius: 16,
|
|
|
|
|
|
padding: 40,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
<div style={{ width: '100%', maxWidth: 600, textAlign: 'center' }}>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Title level={3} style={{ marginBottom: 24 }}>
|
|
|
|
|
|
AI 智能分析中
|
|
|
|
|
|
</Title>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Progress
|
|
|
|
|
|
type="circle"
|
|
|
|
|
|
percent={isError ? 100 : percent}
|
|
|
|
|
|
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
strokeColor={isError ? '#ff4d4f' : { '0%': '#6c73ff', '100%': '#8d63ff' }}
|
2026-03-04 09:19:41 +00:00
|
|
|
|
width={180}
|
|
|
|
|
|
strokeWidth={8}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div style={{ marginTop: 32 }}>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
{progress?.message || '正在准备计算资源...'}
|
|
|
|
|
|
</Text>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Text type="secondary">分析过程中请稍候,你可以先处理其他工作。</Text>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<Divider style={{ margin: '32px 0' }} />
|
|
|
|
|
|
<Row gutter={24}>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Space direction="vertical" size={0}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text type="secondary">当前进度</Text>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
<Title level={4} style={{ margin: 0 }}>{isError ? 'ERROR' : `${percent}%`}</Title>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Space direction="vertical" size={0}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text type="secondary">预计剩余</Text>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Title level={4} style={{ margin: 0 }}>{isError ? '--' : formatEta(progress?.eta)}</Title>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Space direction="vertical" size={0}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text type="secondary">任务状态</Text>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>{isError ? '已中断' : '正常'}</Title>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
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');
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const handleSave = async (event: React.MouseEvent) => {
|
|
|
|
|
|
event.stopPropagation();
|
2026-03-02 11:59:47 +00:00
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateSpeakerInfo({ meetingId, speakerId, newName: name, label });
|
2026-03-27 02:30:48 +00:00
|
|
|
|
message.success('发言人信息已更新');
|
2026-03-02 11:59:47 +00:00
|
|
|
|
onSuccess();
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<div style={{ width: 250, padding: '8px 4px' }} onClick={(event) => event.stopPropagation()}>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
<div style={{ marginBottom: 12 }}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text type="secondary">发言人姓名</Text>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} />
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ marginBottom: 16 }}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text type="secondary">角色标签</Text>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Select value={label} onChange={setLabel} placeholder="选择角色" style={{ width: '100%', marginTop: 4 }} size="small" allowClear>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
{speakerLabels.map((item) => (
|
|
|
|
|
|
<Select.Option key={item.itemValue} value={item.itemValue}>
|
|
|
|
|
|
{item.itemLabel}
|
|
|
|
|
|
</Select.Option>
|
|
|
|
|
|
))}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Button type="primary" size="small" block onClick={handleSave} loading={loading}>
|
|
|
|
|
|
同步到全局
|
|
|
|
|
|
</Button>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const MeetingDetail: React.FC = () => {
|
|
|
|
|
|
const { id } = useParams<{ id: string }>();
|
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const [form] = Form.useForm();
|
|
|
|
|
|
const [summaryForm] = Form.useForm();
|
2026-03-05 09:52:08 +00:00
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
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);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | null>(null);
|
2026-03-06 05:45:56 +00:00
|
|
|
|
const [isEditingSummary, setIsEditingSummary] = useState(false);
|
|
|
|
|
|
const [summaryDraft, setSummaryDraft] = useState('');
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters');
|
|
|
|
|
|
const [expandKeywords, setExpandKeywords] = useState(false);
|
|
|
|
|
|
const [expandSummary, setExpandSummary] = useState(false);
|
2026-03-27 05:50:01 +00:00
|
|
|
|
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
|
|
|
|
|
|
const [addingHotwords, setAddingHotwords] = useState(false);
|
|
|
|
|
|
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
|
|
|
|
|
|
const [transcriptDraft, setTranscriptDraft] = useState('');
|
|
|
|
|
|
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
|
|
|
|
|
const [audioDuration, setAudioDuration] = useState(0);
|
|
|
|
|
|
const [audioPlaying, setAudioPlaying] = useState(false);
|
|
|
|
|
|
const [audioPlaybackRate, setAudioPlaybackRate] = useState(1);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
|
|
|
|
|
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
const [, setUserList] = useState<SysUser[]>([]);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const { items: speakerLabels } = useDict('biz_speaker_label');
|
2026-03-05 09:52:08 +00:00
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
const summaryPdfRef = useRef<HTMLDivElement>(null);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
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 isOwner = useMemo(() => {
|
2026-03-02 11:59:47 +00:00
|
|
|
|
if (!meeting) return false;
|
2026-03-05 09:52:08 +00:00
|
|
|
|
const profileStr = sessionStorage.getItem('userProfile');
|
2026-03-02 11:59:47 +00:00
|
|
|
|
if (profileStr) {
|
|
|
|
|
|
const profile = JSON.parse(profileStr);
|
|
|
|
|
|
return profile.isPlatformAdmin === true || profile.userId === meeting.creatorId;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}, [meeting]);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
|
2026-03-12 12:39:49 +00:00
|
|
|
|
const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2;
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
if (!id) return;
|
|
|
|
|
|
fetchData(Number(id));
|
|
|
|
|
|
loadAiConfigs();
|
|
|
|
|
|
loadUsers();
|
2026-03-02 11:59:47 +00:00
|
|
|
|
}, [id]);
|
|
|
|
|
|
|
2026-03-27 05:50:01 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
|
|
|
|
|
|
}, [analysis.keywords]);
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const fetchData = async (meetingId: number) => {
|
|
|
|
|
|
try {
|
2026-03-05 09:52:08 +00:00
|
|
|
|
const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
setMeeting(detailRes.data.data);
|
|
|
|
|
|
setTranscripts(transcriptRes.data.data || []);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadAiConfigs = async () => {
|
|
|
|
|
|
try {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const [modelRes, promptRes, defaultRes] = await Promise.all([
|
2026-03-02 11:59:47 +00:00
|
|
|
|
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
|
|
|
|
|
|
getPromptPage({ current: 1, size: 100 }),
|
2026-03-05 09:52:08 +00:00
|
|
|
|
getAiModelDefault('LLM'),
|
2026-03-02 11:59:47 +00:00
|
|
|
|
]);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
setLlmModels(modelRes.data.data.records.filter((item) => item.status === 1));
|
|
|
|
|
|
setPrompts(promptRes.data.data.records.filter((item) => item.status === 1));
|
|
|
|
|
|
summaryForm.setFieldsValue({ summaryModelId: defaultRes.data.data?.id });
|
|
|
|
|
|
} catch {
|
2026-03-05 09:52:08 +00:00
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadUsers = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const users = await listUsers();
|
|
|
|
|
|
setUserList(users || []);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch {
|
2026-03-05 09:52:08 +00:00
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleEditMeeting = () => {
|
|
|
|
|
|
if (!meeting || !isOwner) return;
|
|
|
|
|
|
form.setFieldsValue({
|
|
|
|
|
|
...meeting,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
tags: meeting.tags?.split(',').filter(Boolean),
|
2026-03-02 11:59:47 +00:00
|
|
|
|
});
|
|
|
|
|
|
setEditVisible(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleUpdateBasic = async () => {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const values = await form.validateFields();
|
2026-03-02 11:59:47 +00:00
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
2026-03-26 03:18:44 +00:00
|
|
|
|
await updateMeetingBasic({
|
2026-03-27 02:30:48 +00:00
|
|
|
|
...values,
|
2026-03-26 03:18:44 +00:00
|
|
|
|
meetingId: meeting?.id,
|
2026-03-27 02:30:48 +00:00
|
|
|
|
tags: values.tags?.join(','),
|
2026-03-02 11:59:47 +00:00
|
|
|
|
});
|
|
|
|
|
|
message.success('会议信息已更新');
|
|
|
|
|
|
setEditVisible(false);
|
|
|
|
|
|
fetchData(Number(id));
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-06 05:45:56 +00:00
|
|
|
|
const handleSaveSummary = async () => {
|
|
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
2026-03-26 03:18:44 +00:00
|
|
|
|
await updateMeetingSummary({
|
|
|
|
|
|
meetingId: meeting?.id,
|
2026-03-06 05:45:56 +00:00
|
|
|
|
summaryContent: summaryDraft,
|
|
|
|
|
|
});
|
|
|
|
|
|
message.success('总结内容已更新');
|
|
|
|
|
|
setIsEditingSummary(false);
|
|
|
|
|
|
fetchData(Number(id));
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
2026-03-06 05:45:56 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
const handleReSummary = async () => {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const values = await summaryForm.validateFields();
|
2026-03-02 11:59:47 +00:00
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await reSummary({
|
|
|
|
|
|
meetingId: Number(id),
|
2026-03-27 02:30:48 +00:00
|
|
|
|
summaryModelId: values.summaryModelId,
|
|
|
|
|
|
promptId: values.promptId,
|
2026-03-02 11:59:47 +00:00
|
|
|
|
});
|
|
|
|
|
|
message.success('已重新发起总结任务');
|
|
|
|
|
|
setSummaryVisible(false);
|
|
|
|
|
|
fetchData(Number(id));
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 05:50:01 +00:00
|
|
|
|
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 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 handleAddSelectedHotwords = async () => {
|
|
|
|
|
|
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
|
|
|
|
|
|
if (!keywords.length) {
|
|
|
|
|
|
message.warning('Please select keywords first');
|
|
|
|
|
|
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('Selected keywords already exist in hot words');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
|
toCreate.map((word) =>
|
|
|
|
|
|
saveHotWord({
|
|
|
|
|
|
word,
|
|
|
|
|
|
pinyinList: [],
|
|
|
|
|
|
matchStrategy: 1,
|
|
|
|
|
|
category: '',
|
|
|
|
|
|
weight: 2,
|
|
|
|
|
|
status: 1,
|
|
|
|
|
|
isPublic: 0,
|
|
|
|
|
|
remark: meeting ? `From meeting: ${meeting.title}` : 'From meeting keywords',
|
|
|
|
|
|
}),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const skippedCount = keywords.length - toCreate.length;
|
|
|
|
|
|
message.success(
|
|
|
|
|
|
skippedCount > 0
|
|
|
|
|
|
? `Added ${toCreate.length} hot words, skipped ${skippedCount} duplicates`
|
|
|
|
|
|
: `Added ${toCreate.length} hot words`,
|
|
|
|
|
|
);
|
|
|
|
|
|
setSelectedKeywords([]);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setAddingHotwords(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleStartEditTranscript = (item: MeetingTranscriptVO, event: React.MouseEvent) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
setEditingTranscriptId(item.id);
|
|
|
|
|
|
setTranscriptDraft(item.content || '');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCancelEditTranscript = (event?: React.SyntheticEvent) => {
|
|
|
|
|
|
event?.stopPropagation();
|
|
|
|
|
|
setEditingTranscriptId(null);
|
|
|
|
|
|
setTranscriptDraft('');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSaveTranscript = async (item: MeetingTranscriptVO, nextContent?: string) => {
|
|
|
|
|
|
const content = (nextContent ?? transcriptDraft).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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleTranscriptDraftKeyDown = (item: MeetingTranscriptVO, event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
|
|
|
|
if (event.key === 'Escape') {
|
|
|
|
|
|
handleCancelEditTranscript();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
void handleSaveTranscript(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const seekTo = (timeMs: number) => {
|
|
|
|
|
|
if (!audioRef.current) return;
|
|
|
|
|
|
audioRef.current.currentTime = timeMs / 1000;
|
|
|
|
|
|
audioRef.current.play();
|
2026-03-02 11:59:47 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const toggleAudioPlayback = () => {
|
|
|
|
|
|
if (!audioRef.current) return;
|
|
|
|
|
|
if (audioRef.current.paused) {
|
2026-03-02 11:59:47 +00:00
|
|
|
|
audioRef.current.play();
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} 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;
|
2026-03-02 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const cyclePlaybackRate = () => {
|
|
|
|
|
|
if (!audioRef.current) return;
|
|
|
|
|
|
const rates = [1, 1.25, 1.5, 2];
|
|
|
|
|
|
const currentIndex = rates.findIndex((item) => item === audioPlaybackRate);
|
|
|
|
|
|
const nextRate = rates[(currentIndex + 1) % rates.length];
|
|
|
|
|
|
audioRef.current.playbackRate = nextRate;
|
|
|
|
|
|
setAudioPlaybackRate(nextRate);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-05 09:52:08 +00:00
|
|
|
|
const getFileNameFromDisposition = (disposition?: string, fallback?: string) => {
|
|
|
|
|
|
if (!disposition) return fallback || 'summary';
|
|
|
|
|
|
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
|
|
|
|
|
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const normalMatch = disposition.match(/filename="?([^";]+)"?/i);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
return normalMatch?.[1] || fallback || 'summary';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDownloadSummary = async (format: 'pdf' | 'word') => {
|
|
|
|
|
|
if (!meeting) return;
|
|
|
|
|
|
if (!meeting.summaryContent) {
|
2026-03-27 02:30:48 +00:00
|
|
|
|
message.warning('当前暂无可下载的 AI 总结');
|
2026-03-05 09:52:08 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-06 01:59:29 +00:00
|
|
|
|
setDownloadLoading(format);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
const res = await downloadMeetingSummary(meeting.id, format);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const contentType =
|
2026-03-05 09:52:08 +00:00
|
|
|
|
res.headers['content-type'] ||
|
|
|
|
|
|
(format === 'pdf'
|
|
|
|
|
|
? 'application/pdf'
|
|
|
|
|
|
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
|
|
|
|
|
|
|
|
|
|
if (contentType.includes('application/json')) {
|
|
|
|
|
|
const text = await (res.data as Blob).text();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const json = JSON.parse(text);
|
|
|
|
|
|
message.error(json?.msg || '下载失败');
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
message.error('下载失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const blob = new Blob([res.data], { type: contentType });
|
|
|
|
|
|
const url = window.URL.createObjectURL(blob);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
const anchor = document.createElement('a');
|
|
|
|
|
|
anchor.href = url;
|
|
|
|
|
|
anchor.download = getFileNameFromDisposition(
|
2026-03-05 09:52:08 +00:00
|
|
|
|
res.headers['content-disposition'],
|
2026-03-27 02:30:48 +00:00
|
|
|
|
`${(meeting.title || 'meeting').replace(/[\\/:*?"<>|\r\n]/g, '_')}-AI纪要.${format === 'pdf' ? 'pdf' : 'docx'}`,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
document.body.appendChild(anchor);
|
|
|
|
|
|
anchor.click();
|
|
|
|
|
|
anchor.remove();
|
2026-03-05 09:52:08 +00:00
|
|
|
|
window.URL.revokeObjectURL(url);
|
2026-03-27 02:30:48 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
message.error(`${format.toUpperCase()} 下载失败`);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setDownloadLoading(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: 24 }}>
|
|
|
|
|
|
<Skeleton active />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!meeting) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: 24 }}>
|
|
|
|
|
|
<Empty description="会议不存在" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<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>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
<Breadcrumb.Item>会议详情</Breadcrumb.Item>
|
|
|
|
|
|
</Breadcrumb>
|
|
|
|
|
|
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Card style={{ marginBottom: 16, flexShrink: 0 }} bodyStyle={{ padding: '16px 24px' }}>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
<Row justify="space-between" align="middle">
|
|
|
|
|
|
<Col>
|
|
|
|
|
|
<Space direction="vertical" size={4}>
|
|
|
|
|
|
<Title level={4} style={{ margin: 0 }}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
{meeting.title}
|
|
|
|
|
|
{isOwner && (
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<EditOutlined style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff', marginLeft: 8 }} onClick={handleEditMeeting} />
|
2026-03-05 09:52:08 +00:00
|
|
|
|
)}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Title>
|
|
|
|
|
|
<Space split={<Divider type="vertical" />}>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<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>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col>
|
|
|
|
|
|
<Space>
|
2026-03-12 12:39:49 +00:00
|
|
|
|
{canRetrySummary && (
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>
|
2026-03-05 01:36:41 +00:00
|
|
|
|
重新总结
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{isOwner && meeting.status === 2 && (
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Button icon={<LoadingOutlined />} type="primary" ghost disabled loading>
|
2026-03-05 01:36:41 +00:00
|
|
|
|
正在总结
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-03-05 09:52:08 +00:00
|
|
|
|
{meeting.status === 3 && !!meeting.summaryContent && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('pdf')} loading={downloadLoading === 'pdf'}>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
下载 PDF
|
2026-03-05 09:52:08 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('word')} loading={downloadLoading === 'word'}>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
下载 Word
|
2026-03-05 09:52:08 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
|
|
|
|
|
|
返回列表
|
|
|
|
|
|
</Button>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ flex: 1, minHeight: 0 }}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
{meeting.status === 1 || meeting.status === 2 ? (
|
2026-03-04 09:19:41 +00:00
|
|
|
|
<MeetingProgressDisplay meetingId={meeting.id} onComplete={() => fetchData(meeting.id)} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Row gutter={24} style={{ height: '100%' }}>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<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>
|
2026-03-27 05:50:01 +00:00
|
|
|
|
{isOwner && analysis.keywords.length > 0 && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
ghost
|
|
|
|
|
|
disabled={!selectedKeywords.length}
|
|
|
|
|
|
loading={addingHotwords}
|
|
|
|
|
|
onClick={handleAddSelectedHotwords}
|
|
|
|
|
|
>
|
|
|
|
|
|
一键加入热词
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</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) => (
|
2026-03-27 05:50:01 +00:00
|
|
|
|
<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>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
))}
|
|
|
|
|
|
</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) => (
|
2026-03-27 02:52:01 +00:00
|
|
|
|
<div className="todo-item" key={`${item}-${index}`}>
|
|
|
|
|
|
<span className="todo-dot" />
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<span>{item}</span>
|
2026-03-27 02:52:01 +00:00
|
|
|
|
</div>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
))
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<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" />}
|
|
|
|
|
|
<List
|
|
|
|
|
|
dataSource={transcripts}
|
|
|
|
|
|
renderItem={(item) => (
|
|
|
|
|
|
<List.Item className="transcript-row" onClick={() => seekTo(item.startTime)}>
|
|
|
|
|
|
<div className="transcript-time">{formatTime(item.startTime)}</div>
|
|
|
|
|
|
<div className="transcript-entry">
|
|
|
|
|
|
<div className="transcript-meta">
|
|
|
|
|
|
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
2026-03-05 09:52:08 +00:00
|
|
|
|
{isOwner ? (
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
content={
|
|
|
|
|
|
<SpeakerEditor
|
|
|
|
|
|
meetingId={meeting.id}
|
|
|
|
|
|
speakerId={item.speakerId}
|
|
|
|
|
|
initialName={item.speakerName}
|
|
|
|
|
|
initialLabel={item.speakerLabel}
|
|
|
|
|
|
onSuccess={() => fetchData(meeting.id)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
title="编辑发言人"
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<span className="transcript-speaker editable" onClick={(event) => event.stopPropagation()}>
|
|
|
|
|
|
{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: 12 }} />
|
2026-03-05 09:52:08 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
) : (
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<span className="transcript-speaker">{item.speakerName || item.speakerId || '发言人'}</span>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
)}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Text type="secondary">{formatTime(item.startTime)}</Text>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
{item.speakerLabel && (
|
|
|
|
|
|
<Tag color="blue">
|
2026-03-27 02:30:48 +00:00
|
|
|
|
{speakerLabels.find((label) => label.itemValue === item.speakerLabel)?.itemLabel || item.speakerLabel}
|
2026-03-05 09:52:08 +00:00
|
|
|
|
</Tag>
|
|
|
|
|
|
)}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</div>
|
2026-03-27 05:50:01 +00:00
|
|
|
|
{editingTranscriptId === item.id ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="transcript-bubble transcript-bubble-editing"
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input.TextArea
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
value={transcriptDraft}
|
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
|
onChange={(event) => setTranscriptDraft(event.target.value)}
|
|
|
|
|
|
onKeyDown={(event) => handleTranscriptDraftKeyDown(item, event as unknown as React.KeyboardEvent<HTMLDivElement>)}
|
|
|
|
|
|
onBlur={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
void handleSaveTranscript(item, event.target.value);
|
|
|
|
|
|
}}
|
|
|
|
|
|
autoSize={{ minRows: 1, maxRows: 8 }}
|
|
|
|
|
|
className="transcript-bubble-input"
|
|
|
|
|
|
bordered={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{false && false && <div className="transcript-bubble-actions">
|
|
|
|
|
|
<Text type="secondary" className="transcript-bubble-hint">
|
|
|
|
|
|
Ctrl+Enter 保存,Esc 取消
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<Button size="small" onClick={handleCancelEditTranscript}>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
loading={savingTranscriptId === item.id}
|
|
|
|
|
|
onClick={() => handleSaveTranscript(item)}
|
|
|
|
|
|
>
|
|
|
|
|
|
保存
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
|
|
|
|
|
|
onDoubleClick={isOwner ? (event) => handleStartEditTranscript(item, event) : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.content}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</List.Item>
|
|
|
|
|
|
)}
|
|
|
|
|
|
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>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
)}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Col>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
|
|
|
|
|
|
<Col xs={24} lg={10} style={{ height: '100%' }}>
|
2026-03-27 02:52:01 +00:00
|
|
|
|
<div className="detail-side-column ai-summary-column">
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<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>
|
2026-03-06 05:45:56 +00:00
|
|
|
|
)
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
style={{ height: '100%' }}
|
2026-03-27 02:52:01 +00:00
|
|
|
|
bodyStyle={{ padding: 24, height: '100%', overflowY: 'auto', overflowX: 'hidden', minWidth: 0 }}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
>
|
|
|
|
|
|
<div ref={summaryPdfRef} className="markdown-shell">
|
|
|
|
|
|
{meeting.summaryContent ? (
|
|
|
|
|
|
isEditingSummary ? (
|
|
|
|
|
|
<Input.TextArea
|
|
|
|
|
|
value={summaryDraft}
|
|
|
|
|
|
onChange={(event) => setSummaryDraft(event.target.value)}
|
|
|
|
|
|
style={{ height: '100%', resize: 'none' }}
|
|
|
|
|
|
/>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
) : (
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<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>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
)}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</div>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
|
2026-03-04 09:19:41 +00:00
|
|
|
|
<style>{`
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.detail-side-column {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
min-height: 0;
|
2026-03-27 02:52:01 +00:00
|
|
|
|
min-width: 0;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.detail-left-column {
|
|
|
|
|
|
overflow-y: auto;
|
2026-03-27 02:52:01 +00:00
|
|
|
|
overflow-x: hidden;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
padding-right: 6px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.left-flow-card {
|
2026-03-27 02:52:01 +00:00
|
|
|
|
min-width: 0;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-03-27 02:52:01 +00:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-03-27 05:50:01 +00:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
2026-03-27 02:52:01 +00:00
|
|
|
|
.todo-dot {
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
margin-top: 9px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: #6e76ff;
|
|
|
|
|
|
flex: 0 0 auto;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.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;
|
2026-03-27 05:50:01 +00:00
|
|
|
|
align-items: stretch;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
gap: 10px;
|
2026-03-27 05:50:01 +00:00
|
|
|
|
width: 100%;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
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 {
|
2026-03-27 05:50:01 +00:00
|
|
|
|
display: block;
|
|
|
|
|
|
align-self: stretch;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
box-sizing: border-box;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-27 05:50:01 +00:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-03-27 02:30:48 +00:00
|
|
|
|
.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;
|
2026-03-27 02:52:01 +00:00
|
|
|
|
min-width: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
|
padding-bottom: 28px;
|
|
|
|
|
|
box-sizing: border-box;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.summary-markdown {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
line-height: 1.9;
|
|
|
|
|
|
color: #3e4768;
|
2026-03-27 02:52:01 +00:00
|
|
|
|
min-width: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
|
padding-bottom: 12px;
|
|
|
|
|
|
box-sizing: border-box;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.markdown-body {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
line-height: 1.8;
|
|
|
|
|
|
color: #333;
|
2026-03-27 02:52:01 +00:00
|
|
|
|
min-width: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
|
word-break: break-word;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.markdown-body p {
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.markdown-body h1,
|
|
|
|
|
|
.markdown-body h2,
|
|
|
|
|
|
.markdown-body h3 {
|
|
|
|
|
|
margin-top: 24px;
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
font-weight: 600;
|
2026-03-27 02:52:01 +00:00
|
|
|
|
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;
|
2026-03-27 02:30:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
@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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-04 09:19:41 +00:00
|
|
|
|
`}</style>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
{isOwner && (
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600}>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<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>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Form>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{isOwner && (
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Drawer
|
|
|
|
|
|
title="重新生成 AI 总结"
|
|
|
|
|
|
width={400}
|
|
|
|
|
|
onClose={() => setSummaryVisible(false)}
|
|
|
|
|
|
open={summaryVisible}
|
|
|
|
|
|
extra={<Button type="primary" onClick={handleReSummary} loading={actionLoading}>开始总结</Button>}
|
|
|
|
|
|
>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
<Form form={summaryForm} layout="vertical">
|
|
|
|
|
|
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
|
|
|
|
|
|
<Select placeholder="选择 LLM 模型">
|
2026-03-27 02:30:48 +00:00
|
|
|
|
{llmModels.map((model) => (
|
|
|
|
|
|
<Option key={model.id} value={model.id}>
|
|
|
|
|
|
{model.modelName} {model.isDefault === 1 && <Tag color="gold" style={{ marginLeft: 4 }}>默认</Tag>}
|
2026-03-05 09:52:08 +00:00
|
|
|
|
</Option>
|
|
|
|
|
|
))}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Select>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item name="promptId" label="提示词模板" rules={[{ required: true }]}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Select placeholder="选择模板">
|
2026-03-27 02:30:48 +00:00
|
|
|
|
{prompts.map((prompt) => (
|
|
|
|
|
|
<Option key={prompt.id} value={prompt.id}>{prompt.templateName}</Option>
|
|
|
|
|
|
))}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Select>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Divider />
|
2026-03-27 02:30:48 +00:00
|
|
|
|
<Text type="secondary">重新总结会基于当前语音转写全文重新生成纪要,原有总结内容将被覆盖。</Text>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Form>
|
|
|
|
|
|
</Drawer>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default MeetingDetail;
|