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

611 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

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

import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
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';
import ReactMarkdown from 'react-markdown';
import dayjs from 'dayjs';
import {
getMeetingDetail,
getTranscripts,
updateSpeakerInfo,
reSummary,
updateMeeting,
MeetingVO,
MeetingTranscriptVO,
getMeetingProgress,
MeetingProgress,
downloadMeetingSummary,
} from '../../api/business/meeting';
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;
const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void }> = ({ meetingId, onComplete }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
useEffect(() => {
const fetchProgress = async () => {
try {
const res = await getMeetingProgress(meetingId);
if (res.data?.data) {
setProgress(res.data.data);
if (res.data.data.percent === 100) {
onComplete();
}
}
} catch (err) {
// ignore polling errors
}
};
fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meetingId, onComplete]);
const percent = progress?.percent || 0;
const isError = percent < 0;
const formatETA = (seconds?: number) => {
if (!seconds || seconds <= 0) return '正在分析中';
if (seconds < 60) return `${seconds}`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return s > 0 ? `${m}${s}` : `${m}分钟`;
};
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
background: '#fff',
borderRadius: 16,
padding: 40,
}}
>
<div style={{ width: '100%', maxWidth: 600, textAlign: 'center' }}>
<Title level={3} style={{ marginBottom: 24 }}>AI </Title>
<Progress
type="circle"
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }}
width={180}
strokeWidth={8}
/>
<div style={{ marginTop: 32 }}>
<Text
strong
style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#1890ff', display: 'block', marginBottom: 8 }}
>
{progress?.message || '正在准备计算资源...'}
</Text>
<Text type="secondary"></Text>
</div>
<Divider style={{ margin: '32px 0' }} />
<Row gutter={24}>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>{isError ? 'ERROR' : `${percent}%`}</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>{isError ? '--' : formatETA(progress?.eta)}</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>
{isError ? '已中断' : '正常'}
</Title>
</Space>
</Col>
</Row>
</div>
</div>
);
};
const SpeakerEditor: React.FC<{
meetingId: number;
speakerId: string;
initialName: string;
initialLabel: string;
onSuccess: () => void;
}> = ({ meetingId, speakerId, initialName, initialLabel, onSuccess }) => {
const [name, setName] = useState(initialName || speakerId);
const [label, setLabel] = useState(initialLabel);
const [loading, setLoading] = useState(false);
const { items: speakerLabels } = useDict('biz_speaker_label');
const handleSave = async (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 (
<div style={{ width: 250, padding: '8px 4px' }} onClick={(e) => e.stopPropagation()}>
<div style={{ marginBottom: 12 }}>
<Text type="secondary"></Text>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="输入姓名"
size="small"
style={{ marginTop: 4 }}
/>
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary"></Text>
<Select
value={label}
onChange={setLabel}
placeholder="选择角色"
style={{ width: '100%', marginTop: 4 }}
size="small"
allowClear
>
{speakerLabels.map((item) => (
<Select.Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Select.Option>
))}
</Select>
</div>
<Button type="primary" size="small" block onClick={handleSave} loading={loading}>
</Button>
</div>
);
};
const MeetingDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [form] = Form.useForm();
const [summaryForm] = Form.useForm();
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
const [loading, setLoading] = useState(true);
const [editVisible, setEditVisible] = useState(false);
const [summaryVisible, setSummaryVisible] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | null>(null);
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [, setUserList] = useState<SysUser[]>([]);
const { items: speakerLabels } = useDict('biz_speaker_label');
const audioRef = useRef<HTMLAudioElement>(null);
const summaryPdfRef = useRef<HTMLDivElement>(null);
const isOwner = React.useMemo(() => {
if (!meeting) return false;
const profileStr = sessionStorage.getItem('userProfile');
if (profileStr) {
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true || profile.userId === meeting.creatorId;
}
return false;
}, [meeting]);
useEffect(() => {
if (id) {
fetchData(Number(id));
loadAiConfigs();
loadUsers();
}
}, [id]);
const fetchData = async (meetingId: number) => {
try {
const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
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 }),
getAiModelDefault('LLM'),
]);
setLlmModels(mRes.data.data.records.filter((m) => m.status === 1));
setPrompts(pRes.data.data.records.filter((p) => p.status === 1));
summaryForm.setFieldsValue({ summaryModelId: dRes.data.data?.id });
} catch (e) {
// ignore
}
};
const loadUsers = async () => {
try {
const users = await listUsers();
setUserList(users || []);
} catch (err) {
// ignore
}
};
const handleEditMeeting = () => {
if (!meeting || !isOwner) return;
form.setFieldsValue({
...meeting,
tags: meeting.tags?.split(',').filter(Boolean),
});
setEditVisible(true);
};
const handleUpdateBasic = async () => {
const vals = await form.validateFields();
setActionLoading(true);
try {
await updateMeeting({
...vals,
id: meeting?.id,
tags: vals.tags?.join(','),
});
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,
promptId: vals.promptId,
});
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();
}
};
const getFileNameFromDisposition = (disposition?: string, fallback?: string) => {
if (!disposition) return fallback || 'summary';
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
const normalMatch = disposition.match(/filename=\"?([^\";]+)\"?/i);
return normalMatch?.[1] || fallback || 'summary';
};
const handleDownloadSummary = async (format: 'pdf' | 'word') => {
if (!meeting) return;
if (!meeting.summaryContent) {
message.warning('当前暂无可下载的AI总结');
return;
}
try {
setDownloadLoading(format);
const res = await downloadMeetingSummary(meeting.id, format);
const contentType: 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.title || 'meeting').replace(/[\\\\/:*?\"<>|\\r\\n]/g, '_')}-AI纪要.${format === 'pdf' ? 'pdf' : 'docx'}`,
);
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error(err);
message.error(`${format.toUpperCase()}下载失败`);
} finally {
setDownloadLoading(null);
}
};
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 }}>
{meeting.title}
{isOwner && (
<EditOutlined
style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff', marginLeft: 8 }}
onClick={handleEditMeeting}
/>
)}
</Title>
<Space split={<Divider type="vertical" />}>
<Text type="secondary"><ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}</Text>
<Space>{meeting.tags?.split(',').filter(Boolean).map((t) => <Tag key={t} color="blue">{t}</Tag>)}</Space>
<Text type="secondary"><UserOutlined /> {meeting.participants || '未指定'}</Text>
</Space>
</Space>
</Col>
<Col>
<Space>
{isOwner && meeting.status === 3 && (
<Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>
</Button>
)}
{isOwner && meeting.status === 2 && (
<Button icon={<LoadingOutlined />} type="primary" ghost disabled loading>
</Button>
)}
{meeting.status === 3 && !!meeting.summaryContent && (
<>
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('pdf')} loading={downloadLoading === 'pdf'}>
PDF
</Button>
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('word')} loading={downloadLoading === 'word'}>
Word
</Button>
</>
)}
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}></Button>
</Space>
</Col>
</Row>
</Card>
<div style={{ flex: 1, minHeight: 0 }}>
{meeting.status === 1 || meeting.status === 2 ? (
<MeetingProgressDisplay meetingId={meeting.id} onComplete={() => fetchData(meeting.id)} />
) : (
<Row gutter={24} style={{ height: '100%' }}>
<Col span={12} style={{ height: '100%' }}>
<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 ? '识别任务进行中...' : '暂无数据' }}
/>
</Card>
</Col>
<Col span={12} style={{ height: '100%' }}>
<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>
</Card>
</Col>
</Row>
)}
</div>
<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>
{isOwner && (
<Modal
title="编辑会议信息"
open={editVisible}
onOk={handleUpdateBasic}
onCancel={() => setEditVisible(false)}
confirmLoading={actionLoading}
width={600}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="tags" label="业务标签"><Select mode="tags" placeholder="输入标签按回车" /></Form.Item>
<Text type="warning"> ID </Text>
</Form>
</Modal>
)}
{isOwner && (
<Drawer
title="重新生成 AI 总结"
width={400}
onClose={() => setSummaryVisible(false)}
open={summaryVisible}
extra={<Button type="primary" onClick={handleReSummary} loading={actionLoading}></Button>}
>
<Form form={summaryForm} layout="vertical">
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
<Select placeholder="选择 LLM 模型">
{llmModels.map((m) => (
<Option key={m.id} value={m.id}>
{m.modelName} {m.isDefault === 1 && <Tag color="gold" style={{ marginLeft: 4 }}></Tag>}
</Option>
))}
</Select>
</Form.Item>
<Form.Item name="promptId" label="提示词模板" rules={[{ required: true }]}>
<Select placeholder="选择模板">
{prompts.map((p) => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
</Select>
</Form.Item>
<Divider />
<Text type="secondary"></Text>
</Form>
</Drawer>
)}
</div>
);
};
export default MeetingDetail;