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

411 lines
18 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, SettingOutlined } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import dayjs from 'dayjs';
import { getMeetingDetail, getTranscripts, updateSpeakerInfo, reSummary, updateMeeting, MeetingVO, MeetingTranscriptVO, getMeetingProgress, MeetingProgress } 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 && res.data.data) {
setProgress(res.data.data);
if (res.data.data.percent === 100) {
onComplete();
}
}
} catch (err) {}
};
fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meetingId]);
const percent = progress?.percent || 0;
const isError = percent < 0;
// 格式化剩余时间 (ETA)
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" size="small"></Text>
<Title level={4} style={{ margin: 0 }}>{isError ? 'ERROR' : `${percent}%`}</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary" size="small"></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" size="small"></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" size="small"></Text>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} />
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary" size="small"></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 [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [userList, setUserList] = useState<SysUser[]>([]);
const { items: speakerLabels } = useDict('biz_speaker_label');
const audioRef = useRef<HTMLAudioElement>(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) {}
};
const loadUsers = async () => {
try {
const users = await listUsers();
setUserList(users || []);
} catch (err) {}
};
const handleEditMeeting = () => {
if (!meeting || !isOwner) return;
// 由于后端存储的是姓名字符串,而我们现在需要 ID 匹配,
// 这里简单处理:让发起人依然可以修改基础元数据。
// 如果需要修改参会人 ID需要前端存储 ID 列表快照。
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();
}
};
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' }} 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>
)}
<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" size="small" 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 }}>
{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>}
</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>
{/* 修改基础信息弹窗 - 仅限 Owner */}
{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" size="small"> ID </Text>
</Form>
</Modal>
)}
{/* 重新总结抽屉 - 仅限 Owner */}
{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" size="small"></Text>
</Form>
</Drawer>
)}
</div>
);
};
export default MeetingDetail;