411 lines
18 KiB
TypeScript
411 lines
18 KiB
TypeScript
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;
|