2026-03-05 09:52:08 +00:00
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
2026-03-02 11:59:47 +00:00
|
|
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
2026-03-05 09:52:08 +00:00
|
|
|
|
import {
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Row,
|
|
|
|
|
|
Col,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
Tag,
|
|
|
|
|
|
Space,
|
|
|
|
|
|
Divider,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Skeleton,
|
|
|
|
|
|
Empty,
|
|
|
|
|
|
List,
|
|
|
|
|
|
Avatar,
|
|
|
|
|
|
Breadcrumb,
|
|
|
|
|
|
Popover,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
message,
|
|
|
|
|
|
Drawer,
|
|
|
|
|
|
Form,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
Progress,
|
|
|
|
|
|
} from 'antd';
|
|
|
|
|
|
import {
|
|
|
|
|
|
LeftOutlined,
|
|
|
|
|
|
UserOutlined,
|
|
|
|
|
|
ClockCircleOutlined,
|
|
|
|
|
|
AudioOutlined,
|
|
|
|
|
|
RobotOutlined,
|
|
|
|
|
|
LoadingOutlined,
|
|
|
|
|
|
EditOutlined,
|
|
|
|
|
|
SyncOutlined,
|
|
|
|
|
|
DownloadOutlined,
|
|
|
|
|
|
} from '@ant-design/icons';
|
2026-03-02 11:59:47 +00:00
|
|
|
|
import ReactMarkdown from 'react-markdown';
|
|
|
|
|
|
import dayjs from 'dayjs';
|
2026-03-05 09:52:08 +00:00
|
|
|
|
import jsPDF from 'jspdf';
|
|
|
|
|
|
import html2canvas from 'html2canvas';
|
|
|
|
|
|
import {
|
|
|
|
|
|
getMeetingDetail,
|
|
|
|
|
|
getTranscripts,
|
|
|
|
|
|
updateSpeakerInfo,
|
|
|
|
|
|
reSummary,
|
|
|
|
|
|
updateMeeting,
|
|
|
|
|
|
MeetingVO,
|
|
|
|
|
|
MeetingTranscriptVO,
|
|
|
|
|
|
getMeetingProgress,
|
|
|
|
|
|
MeetingProgress,
|
|
|
|
|
|
downloadMeetingSummary,
|
|
|
|
|
|
} from '../../api/business/meeting';
|
2026-03-02 11:59:47 +00:00
|
|
|
|
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
|
|
|
|
|
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
|
|
|
|
|
import { useDict } from '../../hooks/useDict';
|
|
|
|
|
|
import { listUsers } from '../../api';
|
|
|
|
|
|
import { SysUser } from '../../types';
|
|
|
|
|
|
|
|
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
|
|
const { Option } = Select;
|
|
|
|
|
|
|
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-05 09:52:08 +00:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// ignore polling errors
|
|
|
|
|
|
}
|
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-04 12:59:49 +00:00
|
|
|
|
const formatETA = (seconds?: number) => {
|
2026-03-05 01:36:41 +00:00
|
|
|
|
if (!seconds || seconds <= 0) return '正在分析中';
|
2026-03-04 12:59:49 +00:00
|
|
|
|
if (seconds < 60) return `${seconds}秒`;
|
|
|
|
|
|
const m = Math.floor(seconds / 60);
|
|
|
|
|
|
const s = seconds % 60;
|
|
|
|
|
|
return s > 0 ? `${m}分${s}秒` : `${m}分钟`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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' }}>
|
|
|
|
|
|
<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-04 09:19:41 +00:00
|
|
|
|
strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }}
|
|
|
|
|
|
width={180}
|
|
|
|
|
|
strokeWidth={8}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div style={{ marginTop: 32 }}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text
|
|
|
|
|
|
strong
|
|
|
|
|
|
style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#1890ff', display: 'block', marginBottom: 8 }}
|
|
|
|
|
|
>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
{progress?.message || '正在准备计算资源...'}
|
|
|
|
|
|
</Text>
|
2026-03-05 09:52:08 +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-04 12:59:49 +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>
|
|
|
|
|
|
<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');
|
|
|
|
|
|
|
|
|
|
|
|
const handleSave = async (e: React.MouseEvent) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateSpeakerInfo({ meetingId, speakerId, newName: name, label });
|
|
|
|
|
|
message.success('发言人信息已全局更新');
|
|
|
|
|
|
onSuccess();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<div style={{ width: 250, padding: '8px 4px' }} onClick={(e) => e.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>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={name}
|
|
|
|
|
|
onChange={(e) => setName(e.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>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
))}
|
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-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
|
|
|
|
|
|
|
|
|
|
const isOwner = React.useMemo(() => {
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (id) {
|
|
|
|
|
|
fetchData(Number(id));
|
|
|
|
|
|
loadAiConfigs();
|
|
|
|
|
|
loadUsers();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [id]);
|
|
|
|
|
|
|
|
|
|
|
|
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 || []);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadAiConfigs = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [mRes, pRes, dRes] = await Promise.all([
|
|
|
|
|
|
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
|
|
|
|
|
|
getPromptPage({ current: 1, size: 100 }),
|
2026-03-05 09:52:08 +00:00
|
|
|
|
getAiModelDefault('LLM'),
|
2026-03-02 11:59:47 +00:00
|
|
|
|
]);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
setLlmModels(mRes.data.data.records.filter((m) => m.status === 1));
|
|
|
|
|
|
setPrompts(pRes.data.data.records.filter((p) => p.status === 1));
|
2026-03-02 11:59:47 +00:00
|
|
|
|
summaryForm.setFieldsValue({ summaryModelId: dRes.data.data?.id });
|
2026-03-05 09:52:08 +00:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadUsers = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const users = await listUsers();
|
|
|
|
|
|
setUserList(users || []);
|
2026-03-05 09:52:08 +00:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// 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 () => {
|
|
|
|
|
|
const vals = await form.validateFields();
|
|
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateMeeting({
|
|
|
|
|
|
...vals,
|
|
|
|
|
|
id: meeting?.id,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
tags: vals.tags?.join(','),
|
2026-03-02 11:59:47 +00:00
|
|
|
|
});
|
|
|
|
|
|
message.success('会议信息已更新');
|
|
|
|
|
|
setEditVisible(false);
|
|
|
|
|
|
fetchData(Number(id));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleReSummary = async () => {
|
|
|
|
|
|
const vals = await summaryForm.validateFields();
|
|
|
|
|
|
setActionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await reSummary({
|
|
|
|
|
|
meetingId: Number(id),
|
|
|
|
|
|
summaryModelId: vals.summaryModelId,
|
2026-03-05 09:52:08 +00:00
|
|
|
|
promptId: vals.promptId,
|
2026-03-02 11:59:47 +00:00
|
|
|
|
});
|
|
|
|
|
|
message.success('已重新发起总结任务');
|
|
|
|
|
|
setSummaryVisible(false);
|
|
|
|
|
|
fetchData(Number(id));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatTime = (ms: number) => {
|
|
|
|
|
|
const seconds = Math.floor(ms / 1000);
|
|
|
|
|
|
const m = Math.floor(seconds / 60);
|
|
|
|
|
|
const s = seconds % 60;
|
|
|
|
|
|
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const seekTo = (timeMs: number) => {
|
|
|
|
|
|
if (audioRef.current) {
|
|
|
|
|
|
audioRef.current.currentTime = timeMs / 1000;
|
|
|
|
|
|
audioRef.current.play();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-05 09:52:08 +00:00
|
|
|
|
const getFileNameFromDisposition = (disposition?: string, fallback?: string) => {
|
|
|
|
|
|
if (!disposition) return fallback || 'summary';
|
|
|
|
|
|
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
|
|
|
|
|
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
|
|
|
|
|
|
const normalMatch = disposition.match(/filename=\"?([^\";]+)\"?/i);
|
|
|
|
|
|
return normalMatch?.[1] || fallback || 'summary';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDownloadSummary = async (format: 'pdf' | 'word') => {
|
|
|
|
|
|
if (!meeting) return;
|
|
|
|
|
|
if (!meeting.summaryContent) {
|
|
|
|
|
|
message.warning('当前暂无可下载的AI总结');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (format === 'pdf') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setDownloadLoading('pdf');
|
|
|
|
|
|
if (!summaryPdfRef.current) {
|
|
|
|
|
|
message.error('未找到可导出的总结内容');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const canvas = await html2canvas(summaryPdfRef.current, {
|
|
|
|
|
|
scale: 2,
|
|
|
|
|
|
useCORS: true,
|
|
|
|
|
|
backgroundColor: '#ffffff',
|
|
|
|
|
|
});
|
|
|
|
|
|
const imgData = canvas.toDataURL('image/png');
|
|
|
|
|
|
const pdf = new jsPDF('p', 'mm', 'a4');
|
|
|
|
|
|
const pageWidth = pdf.internal.pageSize.getWidth();
|
|
|
|
|
|
const pageHeight = pdf.internal.pageSize.getHeight();
|
|
|
|
|
|
const imgWidth = pageWidth;
|
|
|
|
|
|
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
|
|
|
|
|
|
|
|
|
|
|
let heightLeft = imgHeight;
|
|
|
|
|
|
let position = 0;
|
|
|
|
|
|
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
|
|
|
|
|
heightLeft -= pageHeight;
|
|
|
|
|
|
|
|
|
|
|
|
while (heightLeft > 0) {
|
|
|
|
|
|
position = heightLeft - imgHeight;
|
|
|
|
|
|
pdf.addPage();
|
|
|
|
|
|
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
|
|
|
|
|
heightLeft -= pageHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fileName = `${(meeting.title || 'meeting').replace(/[\\\\/:*?\"<>|\\r\\n]/g, '_')}-AI-summary.pdf`;
|
|
|
|
|
|
pdf.save(fileName);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err);
|
|
|
|
|
|
message.error('PDF导出失败');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setDownloadLoading(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
setDownloadLoading('word');
|
|
|
|
|
|
const res = await downloadMeetingSummary(meeting.id, format);
|
|
|
|
|
|
const contentType: string =
|
|
|
|
|
|
res.headers['content-type'] ||
|
|
|
|
|
|
(format === 'pdf'
|
|
|
|
|
|
? 'application/pdf'
|
|
|
|
|
|
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
|
|
|
|
|
|
|
|
|
|
// 后端若返回业务错误,可能是 JSON Blob,不能当文件保存
|
|
|
|
|
|
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 a = document.createElement('a');
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
a.download = getFileNameFromDisposition(
|
|
|
|
|
|
res.headers['content-disposition'],
|
|
|
|
|
|
`meeting-summary.${format === 'pdf' ? 'pdf' : 'docx'}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
a.remove();
|
|
|
|
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err);
|
|
|
|
|
|
message.error('下载失败');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setDownloadLoading(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-02 11:59:47 +00:00
|
|
|
|
if (loading) return <div style={{ padding: '24px' }}><Skeleton active /></div>;
|
|
|
|
|
|
if (!meeting) return <div style={{ padding: '24px' }}><Empty description="会议不存在" /></div>;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: '24px', height: 'calc(100vh - 64px)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
|
|
|
|
|
<Breadcrumb style={{ marginBottom: '16px' }}>
|
|
|
|
|
|
<Breadcrumb.Item><a onClick={() => navigate('/meetings')}>会议中心</a></Breadcrumb.Item>
|
|
|
|
|
|
<Breadcrumb.Item>会议详情</Breadcrumb.Item>
|
|
|
|
|
|
</Breadcrumb>
|
|
|
|
|
|
|
|
|
|
|
|
<Card style={{ marginBottom: '16px', flexShrink: 0 }} bodyStyle={{ padding: '16px 24px' }}>
|
|
|
|
|
|
<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 && (
|
|
|
|
|
|
<EditOutlined
|
|
|
|
|
|
style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff', marginLeft: 8 }}
|
|
|
|
|
|
onClick={handleEditMeeting}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Title>
|
|
|
|
|
|
<Space split={<Divider type="vertical" />}>
|
|
|
|
|
|
<Text type="secondary"><ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}</Text>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Space>{meeting.tags?.split(',').filter(Boolean).map((t) => <Tag key={t} color="blue">{t}</Tag>)}</Space>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
<Text type="secondary"><UserOutlined /> {meeting.participants || '未指定'}</Text>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col>
|
|
|
|
|
|
<Space>
|
2026-03-05 01:36:41 +00:00
|
|
|
|
{isOwner && meeting.status === 3 && (
|
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'}>
|
|
|
|
|
|
下载PDF
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('word')} loading={downloadLoading === 'word'}>
|
|
|
|
|
|
下载Word
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>返回列表</Button>
|
|
|
|
|
|
</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%' }}>
|
|
|
|
|
|
<Col span={12} style={{ height: '100%' }}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Card
|
|
|
|
|
|
title={<span><AudioOutlined /> 语音转录</span>}
|
|
|
|
|
|
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
|
|
|
|
|
bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }}
|
|
|
|
|
|
extra={meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} controls style={{ height: '32px' }} />}
|
|
|
|
|
|
>
|
|
|
|
|
|
<List
|
|
|
|
|
|
dataSource={transcripts}
|
|
|
|
|
|
renderItem={(item) => (
|
|
|
|
|
|
<List.Item
|
|
|
|
|
|
style={{ borderBottom: '1px solid #f0f0f0', padding: '12px 0', cursor: 'pointer' }}
|
|
|
|
|
|
onClick={() => seekTo(item.startTime)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<List.Item.Meta
|
|
|
|
|
|
avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />}
|
|
|
|
|
|
title={
|
|
|
|
|
|
<Space>
|
|
|
|
|
|
{isOwner ? (
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
content={
|
|
|
|
|
|
<SpeakerEditor
|
|
|
|
|
|
meetingId={meeting.id}
|
|
|
|
|
|
speakerId={item.speakerId}
|
|
|
|
|
|
initialName={item.speakerName}
|
|
|
|
|
|
initialLabel={item.speakerLabel}
|
|
|
|
|
|
onSuccess={() => fetchData(meeting.id)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
title="编辑发言人"
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span style={{ color: '#1890ff', cursor: 'pointer' }} onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: '12px' }} />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Text strong>{item.speakerName || item.speakerId || '发言人'}</Text>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{item.speakerLabel && (
|
|
|
|
|
|
<Tag color="blue">
|
|
|
|
|
|
{speakerLabels.find((l) => l.itemValue === item.speakerLabel)?.itemLabel || item.speakerLabel}
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: '12px' }}>{formatTime(item.startTime)}</Text>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
}
|
|
|
|
|
|
description={<Text style={{ color: '#333' }}>{item.content}</Text>}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</List.Item>
|
|
|
|
|
|
)}
|
|
|
|
|
|
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
|
|
|
|
|
/>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={12} style={{ height: '100%' }}>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Card
|
|
|
|
|
|
title={<span><RobotOutlined /> AI 总结</span>}
|
|
|
|
|
|
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
|
|
|
|
|
bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div ref={summaryPdfRef}>
|
|
|
|
|
|
{meeting.summaryContent ? (
|
|
|
|
|
|
<div className="markdown-body"><ReactMarkdown>{meeting.summaryContent}</ReactMarkdown></div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ textAlign: 'center', marginTop: '100px' }}>
|
|
|
|
|
|
{meeting.status === 2 ? (
|
|
|
|
|
|
<Space direction="vertical">
|
|
|
|
|
|
<LoadingOutlined style={{ fontSize: 24 }} spin />
|
|
|
|
|
|
<Text type="secondary">正在重新总结...</Text>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Empty description="暂无总结" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-04 09:19:41 +00:00
|
|
|
|
</Card>
|
|
|
|
|
|
</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>{`
|
|
|
|
|
|
.markdown-body { font-size: 14px; line-height: 1.8; color: #333; }
|
|
|
|
|
|
.markdown-body p { margin-bottom: 16px; }
|
|
|
|
|
|
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; }
|
|
|
|
|
|
`}</style>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
{isOwner && (
|
2026-03-05 09:52:08 +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 }}>
|
|
|
|
|
|
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}><Input /></Form.Item>
|
|
|
|
|
|
<Form.Item name="tags" label="业务标签"><Select mode="tags" placeholder="输入标签按回车" /></Form.Item>
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<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-05 09:52:08 +00:00
|
|
|
|
{llmModels.map((m) => (
|
|
|
|
|
|
<Option key={m.id} value={m.id}>
|
|
|
|
|
|
{m.modelName} {m.isDefault === 1 && <Tag color="gold" style={{ marginLeft: 4 }}>默认</Tag>}
|
|
|
|
|
|
</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="选择模板">
|
|
|
|
|
|
{prompts.map((p) => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Select>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Divider />
|
2026-03-05 09:52:08 +00:00
|
|
|
|
<Text type="secondary">提示:重新总结将基于当前语音转录全文重新生成纪要,原有总结内容会被覆盖。</Text>
|
2026-03-02 11:59:47 +00:00
|
|
|
|
</Form>
|
|
|
|
|
|
</Drawer>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default MeetingDetail;
|