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

544 lines
23 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 {
CalendarOutlined,
DeleteOutlined,
EditOutlined,
InfoCircleOutlined,
PauseCircleOutlined,
PlusOutlined,
RightOutlined,
SearchOutlined,
SyncOutlined,
TeamOutlined,
UserOutlined,
AppstoreOutlined,
UnorderedListOutlined
} from '@ant-design/icons';
import {
App,
Avatar,
Button,
Card,
Empty,
Form,
Input,
List,
Modal,
Popconfirm,
Radio,
Select,
Skeleton,
Space,
Tag,
Tooltip,
Typography,
Table
} from 'antd';
import dayjs from 'dayjs';
import React, {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {useNavigate, useSearchParams} from 'react-router-dom';
import {listUsers} from '../../api';
import {
deleteMeeting,
getMeetingPage,
getMeetingProgress,
getRealtimeMeetingSessionStatus,
getRealtimeMeetingSessionStatuses,
MeetingProgress,
MeetingVO,
RealtimeMeetingSessionStatus,
updateMeetingParticipants
} from '../../api/business/meeting';
import {MeetingCreateDrawer, MeetingCreateType} from '../../components/business/MeetingCreateDrawer';
import AppPagination from '../../components/shared/AppPagination';
import {usePermission} from '../../hooks/usePermission';
import {SysUser} from '../../types';
const { Text, Title } = Typography;
const { Option } = Select;
const PAUSED_DISPLAY_STATUS = 5;
const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => {
if (!sessionStatus) {
return item;
}
if (sessionStatus.status === 'PAUSED_EMPTY' || sessionStatus.status === 'PAUSED_RESUMABLE') {
return {
...item,
displayStatus: PAUSED_DISPLAY_STATUS,
realtimeSessionStatus: sessionStatus.status
};
}
if (sessionStatus.status === 'ACTIVE') {
return {
...item,
displayStatus: 1,
realtimeSessionStatus: sessionStatus.status
};
}
if (sessionStatus.status === 'IDLE' && !item.audioUrl) {
return {
...item,
displayStatus: 0,
realtimeSessionStatus: sessionStatus.status
};
}
return {
...item,
realtimeSessionStatus: sessionStatus.status
};
};
// --- 进度感知 Hook ---
const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
useEffect(() => {
const effectiveStatus = meeting.displayStatus ?? meeting.status;
if (effectiveStatus !== 1 && effectiveStatus !== 2) return;
const fetchProgress = async () => {
try {
const res = await getMeetingProgress(meeting.id);
if (res.data && res.data.data) {
setProgress(res.data.data);
if (res.data.data.percent === 100 && onComplete) {
onComplete();
}
}
} catch (err) {}
};
fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meeting.id, meeting.status, meeting.displayStatus]);
return progress;
};
// --- 状态标签组件 ---
const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgress | null }> = ({ meeting, progress }) => {
const effectiveStatus = meeting.displayStatus ?? meeting.status;
const statusConfig: Record<number, { text: string; color: string; bgColor: string }> = {
0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' },
1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' },
2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' },
3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' },
4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' },
5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' }
};
const config = statusConfig[effectiveStatus] || statusConfig[0];
const percent = progress?.percent || 0;
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2;
return (
<div style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 10px', borderRadius: 6, fontSize: 11, fontWeight: 600, color: config.color, background: config.bgColor, position: 'relative', overflow: 'hidden', border: `1px solid ${isProcessing ? 'transparent' : '#eee'}`, minWidth: 80, justifyContent: 'center' }}>
{isProcessing && percent > 0 && (
<div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${percent}%`, background: effectiveStatus === 1 ? 'rgba(24, 144, 255, 0.2)' : 'rgba(250, 173, 20, 0.2)', transition: 'width 0.5s cubic-bezier(0.4, 0, 0.2, 1)', zIndex: 0 }} />
)}
<span style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', gap: 4 }}>
{isProcessing ? <SyncOutlined spin style={{ fontSize: 10 }} /> : null}
{config.text}
{isProcessing && <span style={{ fontFamily: 'monospace', marginLeft: 4 }}>{percent}%</span>}
</span>
</div>
);
};
// --- 表格状态单元格 ---
const TableStatusCell: React.FC<{ meeting: MeetingVO, fetchData: () => void }> = ({ meeting, fetchData }) => {
const progress = useMeetingProgress(meeting, () => fetchData());
return <IntegratedStatusTag meeting={meeting} progress={progress} />;
};
// --- 卡片项组件 ---
const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void, onOpenMeeting: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants, onOpenMeeting }) => {
const progress = useMeetingProgress(item, () => fetchData());
const effectiveStatus = item.displayStatus ?? item.status;
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2;
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
return (
<List.Item>
<Card hoverable onClick={() => onOpenMeeting(item)} className="meeting-card" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', backdropFilter: 'blur(16px)', height: '220px', position: 'relative', boxShadow: 'var(--app-shadow)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} styles={{ body: { padding: 0, display: 'flex', flexDirection: 'row', height: '100%' } }}>
<div className={isProcessing ? 'status-bar-active' : ''} style={{ width: 6, height: '100%', backgroundColor: config.color, borderRadius: '16px 0 0 16px', flexShrink: 0 }}></div>
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10, background: 'var(--app-bg-card)', borderRadius: 8, padding: '4px' }} onClick={e => e.stopPropagation()}>
<Space size={8}>
<Tooltip title="编辑参会人"><div className="icon-btn edit"><EditOutlined onClick={() => onEditParticipants(item)} /></div></Tooltip>
<Popconfirm
title="确定删除?"
onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(item.id).then(fetchData); }}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onCancel={(e) => e?.stopPropagation()}
>
<Tooltip title="删除"><div className="icon-btn delete" onClick={e => e.stopPropagation()}><DeleteOutlined /></div></Tooltip>
</Popconfirm>
</Space>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ marginBottom: 12, paddingRight: 80 }}>
<IntegratedStatusTag meeting={item} progress={progress} />
</div>
<div style={{ marginBottom: 16, paddingRight: 80, height: '44px', overflow: 'hidden', flexShrink: 0 }}>
<Text strong style={{ fontSize: 16, color: '#262626', lineHeight: '22px' }} ellipsis={{ tooltip: item.title }}>{item.title}</Text>
</div>
<Space direction="vertical" size={10} style={{ width: '100%', minWidth: 0 }}>
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}><CalendarOutlined style={{ marginRight: 10, flexShrink: 0 }} /><span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')}</span></div>
{isProcessing ? (
<div style={{
fontSize: '12px',
color: effectiveStatus === 1 ? '#1890ff' : '#faad14',
display: 'flex',
alignItems: 'center',
background: effectiveStatus === 1 ? '#e6f7ff' : '#fff7e6',
padding: '6px 10px',
borderRadius: 6,
marginTop: 4,
width: '100%',
overflow: 'hidden',
boxSizing: 'border-box'
}}>
<InfoCircleOutlined style={{ marginRight: 6, flexShrink: 0 }} />
<Text
ellipsis={{ tooltip: progress?.message || '分析中...' }}
style={{
color: 'inherit',
fontSize: '12px',
flex: 1,
minWidth: 0,
fontWeight: 500,
whiteSpace: 'nowrap'
}}
>
{progress?.message || '等待引擎调度...'}
</Text>
</div>
) : isPaused ? (
<div style={{
fontSize: '12px',
color: '#d48806',
display: 'flex',
alignItems: 'center',
background: '#fff7e6',
padding: '6px 10px',
borderRadius: 6,
marginTop: 4,
width: '100%',
boxSizing: 'border-box',
overflow: 'hidden'
}}>
<PauseCircleOutlined style={{ marginRight: 6, flexShrink: 0 }} />
<Text ellipsis style={{ color: 'inherit', fontSize: '12px', fontWeight: 500, flex: 1, minWidth: 0 }}>
</Text>
</div>
) : (
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}><TeamOutlined style={{ marginRight: 10, flexShrink: 0 }} /><Text type="secondary" ellipsis style={{ flex: 1, minWidth: 0 }}>{item.participants || '无参与人员'}</Text></div>
)}
</Space>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, flexShrink: 0 }}>
<div style={{ display: 'flex', gap: 4, overflow: 'hidden' }}>{item.tags?.split(',').slice(0, 2).map(t => (
<Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-strong)', color: 'var(--app-text-main)', fontSize: 10, margin: 0, borderRadius: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100px' }}>{t}</Tag>
))}</div>
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12, flexShrink: 0 }} />
</div>
</div>
</Card>
</List.Item>
);
};
// --- 主组件 ---
const Meetings: React.FC = () => {
const { message } = App.useApp();
const { t } = useTranslation();
const navigate = useNavigate();
const { can } = usePermission();
const [searchParams, setSearchParams] = useSearchParams();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<MeetingVO[]>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [displayMode, setDisplayMode] = useState<'card' | 'list'>('card');
const [size, setSize] = useState(8);
const [searchTitle, setSearchTitle] = useState('');
const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all');
const [createDrawerVisible, setCreateDrawerVisible] = useState(false);
const [createDrawerType, setCreateDrawerType] = useState<MeetingCreateType>('upload');
const [userList, setUserList] = useState<SysUser[]>([]);
const [participantsEditVisible, setParticipantsEditVisible] = useState(false);
const [editingMeeting, setEditingMeeting] = useState<MeetingVO | null>(null);
const [participantsEditLoading, setParticipantsEditLoading] = useState(false);
const [participantsEditForm] = Form.useForm();
const hasRunningTasks = data.some(item => {
const effectiveStatus = item.displayStatus ?? item.status;
return effectiveStatus === 0 || effectiveStatus === 1 || effectiveStatus === 2;
});
const handleDisplayModeChange = (mode: 'card' | 'list') => {
setDisplayMode(mode);
setSize(mode === 'card' ? 8 : 10);
setCurrent(1);
};
useEffect(() => {
const action = searchParams.get('action');
const type = searchParams.get('type') as MeetingCreateType;
if (action === 'create' && (type === 'realtime' || type === 'upload')) {
setCreateDrawerType(type);
setCreateDrawerVisible(true);
setSearchParams({});
}
}, [searchParams, setSearchParams]);
useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]);
useEffect(() => {
if (!hasRunningTasks) return;
const timer = setInterval(() => fetchData(true), 5000);
return () => clearInterval(timer);
}, [hasRunningTasks, current, size, searchTitle, viewType]);
useEffect(() => {
listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([]));
}, []);
const fetchData = async (silent = false) => {
if (!silent) setLoading(true);
try {
const res = await getMeetingPage({ current, size, title: searchTitle, viewType });
if (res.data && res.data.data) {
const records = res.data.data.records || [];
let statusMap: Record<number, RealtimeMeetingSessionStatus> = {};
try {
const sessionRes = await getRealtimeMeetingSessionStatuses(records.map((item) => item.id));
statusMap = sessionRes.data?.data || {};
} catch {}
const withDisplayStatus = records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id]));
setData(withDisplayStatus);
setTotal(res.data.data.total);
}
} catch (err) {} finally { if (!silent) setLoading(false); }
};
const handleOpenMeeting = async (meeting: MeetingVO) => {
try {
const res = await getRealtimeMeetingSessionStatus(meeting.id);
const sessionStatus = res.data?.data;
if (sessionStatus && !meeting.audioUrl && (
sessionStatus.status === 'PAUSED_EMPTY'
|| sessionStatus.status === 'PAUSED_RESUMABLE'
|| sessionStatus.status === 'ACTIVE'
|| sessionStatus.status === 'IDLE'
)) {
navigate(`/meeting-live-session/${meeting.id}`);
return;
}
} catch (error) {}
navigate(`/meetings/${meeting.id}`);
};
const openEditParticipants = (meeting: MeetingVO) => {
setEditingMeeting(meeting);
participantsEditForm.setFieldsValue({
participantIds: meeting.participantIds || []
});
setParticipantsEditVisible(true);
};
const handleUpdateParticipants = async () => {
if (!editingMeeting) {
return;
}
const values = await participantsEditForm.validateFields();
const participantIds: number[] = values.participantIds || [];
setParticipantsEditLoading(true);
try {
await updateMeetingParticipants({
meetingId: editingMeeting.id,
participants: participantIds.join(",")
});
message.success("参会人已更新");
setParticipantsEditVisible(false);
fetchData();
} finally {
setParticipantsEditLoading(false);
}
};
const statusConfig: Record<number, { text: string; color: string; bgColor: string }> = {
0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' },
1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' },
2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' },
3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' },
4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' },
5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' }
};
const tableColumns = [
{
title: '会议标题',
dataIndex: 'title',
key: 'title',
render: (text: string, record: MeetingVO) => (
<a style={{ fontWeight: 500 }} onClick={() => handleOpenMeeting(record)}>{text}</a>
)
},
{
title: '状态',
key: 'status',
width: 150,
render: (_: any, record: MeetingVO) => (
<TableStatusCell meeting={record} fetchData={fetchData} />
)
},
{
title: '会议时间',
dataIndex: 'meetingTime',
key: 'meetingTime',
width: 180,
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm')
},
{
title: '参会人',
dataIndex: 'participants',
key: 'participants',
render: (text: string) => (
<Text type="secondary" ellipsis style={{ maxWidth: 200 }}>{text || '无参与人员'}</Text>
)
},
{
title: '操作',
key: 'action',
width: 160,
render: (_: any, record: MeetingVO) => (
<Space size="middle">
<Button type="link" size="small" onClick={(e) => { e.stopPropagation(); openEditParticipants(record); }}></Button>
<Popconfirm
title="确定删除?"
onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(record.id).then(fetchData); }}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onCancel={(e) => e?.stopPropagation()}
>
<Button type="link" danger size="small" onClick={(e) => e.stopPropagation()}></Button>
</Popconfirm>
</Space>
)
}
];
return (
<div className="app-page">
<Card
className="app-page__content-card shadow-sm"
style={{ flex: 1, minHeight: 0 }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
title={
<Space size={12}>
<div style={{ width: 8, height: 24, background: '#1890ff', borderRadius: 4 }}></div>
<Title level={4} style={{ margin: 0 }}></Title>
</Space>
}
extra={
<Space size={16} wrap>
<Radio.Group value={displayMode} onChange={e => handleDisplayModeChange(e.target.value)} buttonStyle="solid">
<Radio.Button value="card"><AppstoreOutlined /></Radio.Button>
<Radio.Button value="list"><UnorderedListOutlined /></Radio.Button>
</Radio.Group>
<Radio.Group value={viewType} onChange={e => { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid">
<Radio.Button value="all"></Radio.Button><Radio.Button value="created"></Radio.Button><Radio.Button value="involved"></Radio.Button>
</Radio.Group>
<Input placeholder="搜索会议标题" prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />} allowClear onPressEnter={(e) => { setSearchTitle((e.target as any).value); setCurrent(1); }} style={{ width: 220, borderRadius: 8 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreateDrawerType('upload');
setCreateDrawerVisible(true);
}} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}></Button>
</Space>
}
>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflowX: "hidden", overflowY: "auto", padding: "24px" }}>
{displayMode === 'card' ? (
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
<List grid={{ gutter: [24, 24], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} dataSource={data} renderItem={(item) => {
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0];
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} onOpenMeeting={handleOpenMeeting} />;
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
</Skeleton>
) : (
<Table
columns={tableColumns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
onRow={(record) => ({
onClick: () => handleOpenMeeting(record),
style: { cursor: 'pointer' }
})}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
/>
)}
</div>
<AppPagination current={current} pageSize={size} total={total} onChange={(p, s) => { setCurrent(p); setSize(s); }} />
</Card>
<MeetingCreateDrawer
open={createDrawerVisible}
initialType={createDrawerType}
onCancel={() => setCreateDrawerVisible(false)}
onSuccess={() => {
setCreateDrawerVisible(false);
fetchData();
}}
/>
<Modal
title="编辑参会人"
open={participantsEditVisible}
onCancel={() => {
setParticipantsEditVisible(false);
setEditingMeeting(null);
}}
onOk={handleUpdateParticipants}
confirmLoading={participantsEditLoading}
destroyOnHidden
forceRender
width={500}
>
<Form form={participantsEditForm} layout="vertical">
<Form.Item
name="participantIds"
label="参会人员"
>
<Select mode="multiple" placeholder="请选择参会人" showSearch optionFilterProp="children">
{userList.map(u => (
<Option key={u.userId} value={u.userId}>
<Space>
<Avatar size="small" icon={<UserOutlined />} />
{u.displayName || u.username}
</Space>
</Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
<style>{`
.meeting-card:hover { transform: translateY(-4px); box-shadow: 0 18px 36px rgba(0,0,0,0.16) !important; }
.status-bar-active { animation: statusBreathing 2s infinite ease-in-out; }
@keyframes statusBreathing { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
.icon-btn { width: 32px; height: 32px; border-radius: 50%; background: var(--app-bg-surface-strong); border: 1px solid var(--app-border-color); display: flex; justify-content: center; align-items: center; box-shadow: 0 2px 6px rgba(0,0,0,0.1); transition: all 0.2s; color: var(--app-text-muted); }
.icon-btn:hover { transform: scale(1.1); }
.icon-btn.edit:hover { color: #1890ff; background: #e6f7ff; }
.icon-btn.delete:hover { color: #ff4d4f; background: #fff1f0; }
.card-actions { opacity: 0.6; transition: opacity 0.3s; }
.meeting-card:hover .card-actions { opacity: 1; }
`}</style>
</div>
);
};
export default Meetings;