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

566 lines
29 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 } from 'react';
import { Card, Button, Input, Space, Tag, message, Popconfirm, Typography, Row, Col, List, Badge, Empty, Skeleton, Tooltip, Radio, Pagination, Progress, Drawer, Form, DatePicker, Upload, Avatar, Divider, Switch, Select, Modal } from 'antd';
import {
PlusOutlined, DeleteOutlined, SearchOutlined, CheckCircleOutlined,
LoadingOutlined, UserOutlined, CalendarOutlined, PlayCircleOutlined,
TeamOutlined, ClockCircleOutlined, EditOutlined, RightOutlined,
SyncOutlined, InfoCircleOutlined, CloudUploadOutlined, SettingOutlined,
QuestionCircleOutlined, FileTextOutlined, CheckOutlined, RocketOutlined,
AudioOutlined
} from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { usePermission } from '../../hooks/usePermission';
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants } from '../../api/business/meeting';
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
import { listUsers } from '../../api';
import { SysUser } from '../../types';
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
const { Text, Title } = Typography;
const { Dragger } = Upload;
const { Option } = Select;
// --- 进度感知 Hook ---
const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
useEffect(() => {
if (meeting.status !== 1 && meeting.status !== 2) return;
const fetchProgress = async () => {
try {
const res = await getMeetingProgress(meeting.id);
if (res.data && res.data.data) {
setProgress(res.data.data);
// 当达到 100% 时触发完成回调
if (res.data.data.percent === 100 && onComplete) {
onComplete();
}
}
} catch (err) {}
};
fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meeting.id, meeting.status]);
return progress;
};
// --- 状态标签组件 ---
const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgress | null }> = ({ meeting, progress }) => {
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' }
};
const config = statusConfig[meeting.status] || statusConfig[0];
const percent = progress?.percent || 0;
const isProcessing = meeting.status === 1 || meeting.status === 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: meeting.status === 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 MeetingCreateForm: React.FC<{
form: any,
audioUrl: string,
setAudioUrl: (url: string) => void,
uploadProgress: number,
setUploadProgress: (p: number) => void,
fileList: any[],
setFileList: (list: any[]) => void
}> = ({ form, audioUrl, setAudioUrl, uploadProgress, setUploadProgress, fileList, setFileList }) => {
const [asrModels, setAsrModels] = useState<AiModelVO[]>([]);
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
const [userList, setUserList] = useState<SysUser[]>([]);
const watchedPromptId = Form.useWatch('promptId', form);
useEffect(() => {
loadInitialData();
}, []);
const loadInitialData = async () => {
try {
const [asrRes, llmRes, promptRes, hotwordRes, users] = await Promise.all([
getAiModelPage({ current: 1, size: 100, type: 'ASR' }),
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
getPromptPage({ current: 1, size: 100 }),
getHotWordPage({ current: 1, size: 1000 }),
listUsers()
]);
setAsrModels(asrRes.data.data.records.filter(m => m.status === 1));
setLlmModels(llmRes.data.data.records.filter(m => m.status === 1));
const activePrompts = promptRes.data.data.records.filter(p => p.status === 1);
setPrompts(activePrompts);
setHotwordList(hotwordRes.data.data.records.filter(h => h.status === 1));
setUserList(users || []);
const defaultAsr = await getAiModelDefault('ASR');
const defaultLlm = await getAiModelDefault('LLM');
form.setFieldsValue({
asrModelId: defaultAsr.data.data?.id,
summaryModelId: defaultLlm.data.data?.id,
promptId: activePrompts.length > 0 ? activePrompts[0].id : undefined,
meetingTime: dayjs(),
useSpkId: 1
});
} catch (err) {}
};
const customUpload = async (options: any) => {
const { file, onSuccess: uploadSuccess, onError } = options;
setUploadProgress(0);
try {
const interval = setInterval(() => setUploadProgress(prev => (prev < 95 ? prev + 5 : prev)), 300);
const res = await uploadAudio(file);
clearInterval(interval);
setUploadProgress(100);
setAudioUrl(res.data.data);
uploadSuccess(res.data.data);
message.success('录音上传成功');
} catch (err) {
onError(err);
message.error('文件上传失败');
}
};
return (
<Form form={form} layout="vertical" style={{ height: 'calc(100vh - 220px)', display: 'flex', flexDirection: 'column' }}>
<Row gutter={24} style={{ flex: 1, minHeight: 0 }}>
<Col span={16} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 基础信息卡片 - 固定高度 */}
<Card size="small" title={<Space><InfoCircleOutlined /> </Space>} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)', marginBottom: 20 }}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]} style={{ marginBottom: 12 }}><Input placeholder="输入会议标题" size="large" /></Form.Item>
<Row gutter={16}>
<Col span={12}><Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]} style={{ marginBottom: 12 }}><DatePicker showTime style={{ width: '100%' }} size="large" /></Form.Item></Col>
<Col span={12}><Form.Item name="tags" label="会议标签" style={{ marginBottom: 12 }}><Select mode="tags" placeholder="输入标签" size="large" /></Form.Item></Col>
</Row>
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children" size="large">
{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>
</Card>
{/* 录音上传卡片 - 占满剩余高度 */}
<Card
size="small"
title={<Space><AudioOutlined /> </Space>}
bordered={false}
style={{ borderRadius: 12, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-surface-soft)', border: '1px solid var(--app-border-color)', flex: 1, display: 'flex', flexDirection: 'column', backdropFilter: 'blur(16px)' }}
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px' }}
>
<Dragger
accept=".mp3,.wav,.m4a"
fileList={fileList}
customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))}
maxCount={1}
style={{ borderRadius: 8, flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
>
<div>
<p className="ant-upload-drag-icon" style={{ marginBottom: 12 }}><CloudUploadOutlined style={{ fontSize: 48 }} /></p>
<p className="ant-upload-text" style={{ fontSize: 16, fontWeight: 500 }}></p>
<p className="ant-upload-hint" style={{ fontSize: 13, marginTop: 8 }}> .mp3, .wav, .m4a </p>
{uploadProgress > 0 && uploadProgress < 100 && (
<div style={{ width: '80%', margin: '24px auto 0' }}>
<Progress percent={uploadProgress} size="small" />
<div style={{ fontSize: 12, color: '#1890ff', marginTop: 4 }}>...</div>
</div>
)}
{audioUrl && (
<Tooltip title={audioUrl.split('/').pop()}>
<Tag
color="success"
style={{
marginTop: 20,
padding: '4px 12px',
fontSize: 13,
maxWidth: '500px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
// size="large"
>
: {audioUrl.split('/').pop()}
</Tag>
</Tooltip>
)}
</div>
</Dragger>
</Card>
</Col>
<Col span={8} style={{ height: '100%', overflowY: 'auto', paddingRight: 4 }}>
<Card size="small" title={<Space><SettingOutlined /> AI </Space>} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)', marginBottom: 20 }}>
<Form.Item name="asrModelId" label="语音识别 (ASR)" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
<Select placeholder="选择 ASR 模型" size="small">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
</Form.Item>
<Form.Item name="summaryModelId" label="内容总结 (LLM)" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
<Select placeholder="选择总结模型" size="small">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
</Form.Item>
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
{prompts.length > 15 ? (
<Select placeholder="请选择模板" showSearch optionFilterProp="children" size="small">
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
</Select>
) : (
<div style={{ padding: '2px' }}>
<Row gutter={[6, 6]}>
{prompts.map(p => {
const isSelected = watchedPromptId === p.id;
return (
<Col span={12} key={p.id}>
<div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: '6px', borderRadius: 6, border: `1.5px solid ${isSelected ? 'var(--app-primary-color)' : 'var(--app-border-color)'}`, background: isSelected ? 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))' : 'var(--app-bg-surface-strong)', cursor: 'pointer', textAlign: 'center', position: 'relative' }}>
<div style={{ fontSize: '11px', color: isSelected ? '#1890ff' : '#434343', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.templateName}</div>
{isSelected && <div style={{ position: 'absolute', top: 0, right: 0, width: 12, height: 12, background: '#1890ff', borderRadius: '0 4px 0 4px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CheckOutlined style={{ color: '#fff', fontSize: 8 }} /></div>}
</div>
</Col>
);
})}
</Row>
</div>
)}
</Form.Item>
<Form.Item name="useSpkId" label={<span> <Tooltip title="开启后将区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: v === 1 })} normalize={(v) => (v ? 1 : 0)} style={{ marginBottom: 20 }}>
<Switch size="small" />
</Form.Item>
</Card>
<div style={{ backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', padding: '12px 16px', borderRadius: 12 }}>
<Text type="secondary" style={{ fontSize: '12px' }}><CheckCircleOutlined style={{ color: '#52c41a', marginRight: 8 }} /> + AI + </Text>
</div>
</Col>
</Row>
</Form>
);
};
// --- 卡片项组件 ---
const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants }) => {
const navigate = useNavigate();
// 注入自动刷新回调
const progress = useMeetingProgress(item, () => fetchData());
const isProcessing = item.status === 1 || item.status === 2;
return (
<List.Item style={{ marginBottom: 24 }}>
<Card hoverable onClick={() => navigate(`/meetings/${item.id}`)} 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)' }} bodyStyle={{ padding: 0, display: 'flex', height: '100%' }}>
<div className={isProcessing ? 'status-bar-active' : ''} style={{ width: 6, backgroundColor: config.color, borderRadius: '16px 0 0 16px' }}></div>
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
<Space size={8}>
<Tooltip title="编辑参会人"><div className="icon-btn edit" onClick={() => onEditParticipants(item)}><EditOutlined /></div></Tooltip>
<Popconfirm
title="确定删除?"
onConfirm={() => deleteMeeting(item.id).then(fetchData)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Tooltip title="删除"><div className="icon-btn delete"><DeleteOutlined /></div></Tooltip>
</Popconfirm>
</Space>
</div>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 12 }}>
<IntegratedStatusTag meeting={item} progress={progress} />
</div>
<div style={{ marginBottom: 16, paddingRight: 40, height: '44px', overflow: 'hidden' }}><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%' }}>
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}><CalendarOutlined style={{ marginRight: 10 }} />{dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')}</div>
{isProcessing ? (
<div style={{
fontSize: '12px',
color: item.status === 1 ? '#1890ff' : '#faad14',
display: 'flex',
alignItems: 'center',
background: item.status === 1 ? '#e6f7ff' : '#fff7e6',
padding: '6px 10px',
borderRadius: 6,
marginTop: 4,
width: '100%', // 占满 Space 容器
overflow: 'hidden',
boxSizing: 'border-box',
minWidth: 0 ,// 关键:允许 flex 子项收缩
maxWidth: 250
}}>
<InfoCircleOutlined style={{ marginRight: 6, flexShrink: 0 }} />
<Text
ellipsis={{ tooltip: progress?.message || '分析中...' }}
style={{
color: 'inherit',
fontSize: '12px',
flex: 1,
minWidth: 0, // 关键:触发文本截断
maxWidth: 250, // 关键:触发文本截断
fontWeight: 500,
whiteSpace: 'nowrap'
}}
>
{progress?.message || '等待引擎调度...'}
</Text>
</div>
) : (
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}><TeamOutlined style={{ marginRight: 10 }} /><Text type="secondary" ellipsis style={{ maxWidth: '85%' }}>{item.participants || '无参与人员'}</Text></div>
)}
</Space>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
<div style={{ display: 'flex', gap: 4 }}>{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 }}>{t}</Tag>
))}</div>
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12 }} />
</div>
</div>
</Card>
</List.Item>
);
};
// --- 主组件 ---
const Meetings: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { can } = usePermission();
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [data, setData] = useState<MeetingVO[]>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [size, setSize] = useState(8);
const [searchTitle, setSearchTitle] = useState('');
const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all');
const [createDrawerVisible, setCreateDrawerVisible] = useState(false);
useEffect(() => {
if (searchParams.get('create') === 'true') {
setCreateDrawerVisible(true);
const newParams = new URLSearchParams(searchParams);
newParams.delete('create');
setSearchParams(newParams, { replace: true });
}
}, [searchParams]);
const [audioUrl, setAudioUrl] = useState('');
const [uploadProgress, setUploadProgress] = useState(0);
const [fileList, setFileList] = useState<any[]>([]);
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 => item.status === 0 || item.status === 1 || item.status === 2);
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) { setData(res.data.data.records); setTotal(res.data.data.total); }
} catch (err) {} finally { if (!silent) setLoading(false); }
};
const handleCreateSubmit = async () => {
if (!audioUrl) {
message.error('请先上传录音文件');
return;
}
const values = await form.validateFields();
setSubmitLoading(true);
try {
await createMeeting({
...values,
meetingTime: values.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
audioUrl,
participants: values.participants?.join(','),
tags: values.tags?.join(',')
});
message.success('会议发起成功');
setCreateDrawerVisible(false);
fetchData();
} catch (err) {} finally {
setSubmitLoading(false);
}
};
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' }
};
return (
<div style={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column', background: 'var(--app-bg-page)', padding: '24px', overflow: 'hidden' }}>
<div style={{ maxWidth: 1600, margin: '0 auto', width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
<Card bordered={false} style={{ marginBottom: 20, borderRadius: 16, flexShrink: 0, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }} bodyStyle={{ padding: '16px 28px' }}>
<Row justify="space-between" align="middle">
<Col><Space size={12}><div style={{ width: 8, height: 24, background: '#1890ff', borderRadius: 4 }}></div><Title level={4} style={{ margin: 0 }}></Title></Space></Col>
<Col>
<Space size={16}>
<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={() => {
form.resetFields();
setAudioUrl('');
setUploadProgress(0);
setFileList([]);
setCreateDrawerVisible(true);
}} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}></Button>
{can("meeting:create:realtime") && <Button icon={<AudioOutlined />} onClick={() => navigate('/meeting-live-create')} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}></Button>}
</Space>
</Col>
</Row>
</Card>
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '4px 8px 4px 4px' }}>
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
<List grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} dataSource={data} renderItem={(item) => {
const config = statusConfig[item.status] || statusConfig[0];
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} />;
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
</Skeleton>
</div>
{total > 0 && (
<div style={{ flexShrink: 0, display: 'flex', justifyContent: 'center', padding: '16px 0 8px 0' }}>
<Pagination current={current} pageSize={size} total={total} onChange={(p, s) => { setCurrent(p); setSize(s); }} showTotal={(total) => <Text type="secondary" size="small"> {total} </Text>} size="small" />
</div>
)}
</div>
<Drawer
title={<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}><div style={{ width: 4, height: 18, background: '#1890ff', borderRadius: 2 }} /></div>}
width="clamp(800px, 85vw, 1100px)"
onClose={() => setCreateDrawerVisible(false)}
open={createDrawerVisible}
destroyOnClose
styles={{ body: { background: 'var(--app-bg-page)', padding: '24px 32px' } }}
footer={
<div style={{ textAlign: 'right', padding: '12px 24px' }}>
<Space size={12}>
<Button onClick={() => setCreateDrawerVisible(false)} size="large" style={{ borderRadius: 8, minWidth: 100 }}></Button>
<Button type="primary" size="large" icon={<RocketOutlined />} loading={submitLoading} onClick={handleCreateSubmit} style={{ borderRadius: 8, minWidth: 160, fontWeight: 600, boxShadow: '0 4px 12px rgba(24, 144, 255, 0.3)' }}>
</Button>
</Space>
</div>
}
>
<MeetingCreateForm
form={form}
audioUrl={audioUrl}
setAudioUrl={setAudioUrl}
uploadProgress={uploadProgress}
setUploadProgress={setUploadProgress}
fileList={fileList}
setFileList={setFileList}
/>
</Drawer>
<Modal
title="编辑参会人"
open={participantsEditVisible}
onCancel={() => setParticipantsEditVisible(false)}
onOk={handleUpdateParticipants}
confirmLoading={participantsEditLoading}
destroyOnClose
>
<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;