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

359 lines
17 KiB
TypeScript
Raw Normal View History

import React, { useState, useEffect } from 'react';
import { Card, Button, Form, Input, Space, Select, Tag, message, Typography, Divider, Row, Col, DatePicker, Upload, Progress, Tooltip, Avatar, Switch } from 'antd';
import {
AudioOutlined, CheckCircleOutlined, UserOutlined, CloudUploadOutlined,
LeftOutlined, SettingOutlined, QuestionCircleOutlined, InfoCircleOutlined,
CalendarOutlined, TeamOutlined, RobotOutlined, RocketOutlined,
FileTextOutlined, CheckOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { createMeeting, uploadAudio } 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';
const { Title, Text } = Typography;
const { Dragger } = Upload;
const { Option } = Select;
const MeetingCreate: React.FC = () => {
const navigate = useNavigate();
const [form] = Form.useForm();
const [submitLoading, setSubmitLoading] = useState(false);
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);
const [fileList, setFileList] = useState<any[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [audioUrl, setAudioUrl] = useState('');
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, 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);
onSuccess(res.data.data);
message.success('录音上传成功');
} catch (err) {
onError(err);
message.error('文件上传失败');
}
};
const onFinish = async (values: any) => {
if (!audioUrl) {
message.error('请先上传录音文件');
return;
}
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('会议发起成功');
navigate('/meetings');
} catch (err) {
console.error(err);
} finally {
setSubmitLoading(false);
}
};
return (
<div style={{
height: 'calc(100vh - 64px)',
backgroundColor: '#f4f7f9',
padding: '20px 24px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<div style={{ maxWidth: 1300, margin: '0 auto', width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 头部导航 - 紧凑化 */}
<div style={{ marginBottom: 16, flexShrink: 0 }}>
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')} type="link" style={{ padding: 0, fontSize: '13px' }}></Button>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 4 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary" size="small" style={{ marginLeft: 12 }}> AI </Text>
</div>
</div>
<Form form={form} layout="vertical" onFinish={onFinish} style={{ flex: 1, minHeight: 0 }}>
<Row gutter={24} style={{ height: '100%' }}>
{/* 左侧:文件与基础信息 */}
<Col span={14} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Space direction="vertical" size={16} style={{ width: '100%', flex: 1, overflowY: 'auto', paddingRight: 8 }}>
<Card size="small" title={<Space><AudioOutlined /> </Space>} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)' }}>
<Dragger
accept=".mp3,.wav,.m4a"
fileList={fileList}
customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))}
maxCount={1}
style={{ borderRadius: 8, padding: '16px 0' }}
>
<p className="ant-upload-drag-icon" style={{ marginBottom: 4 }}><CloudUploadOutlined style={{ fontSize: 32 }} /></p>
<p className="ant-upload-text" style={{ fontSize: 14 }}></p>
{uploadProgress > 0 && uploadProgress < 100 && <Progress percent={uploadProgress} size="small" style={{ width: '60%', margin: '0 auto' }} />}
2026-03-06 05:45:56 +00:00
{audioUrl && (
<div style={{ marginTop: 8 }}>
<Tag
color="success"
style={{
maxWidth: '90%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'inline-block',
verticalAlign: 'middle'
}}
>
: {audioUrl.split('/').pop()}
</Tag>
</div>
)}
</Dragger>
</Card>
<Card size="small" title={<Space><InfoCircleOutlined /> </Space>} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)' }}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]} style={{ marginBottom: 12 }}>
<Input placeholder="输入标题" />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]} style={{ marginBottom: 12 }}>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="tags" label="会议标签" style={{ marginBottom: 12 }}>
<Select mode="tags" placeholder="输入标签" />
</Form.Item>
</Col>
</Row>
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children">
{userList.map(u => (
<Option key={u.userId} value={u.displayName || u.username}>
<Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space>
</Option>
))}
</Select>
</Form.Item>
</Card>
</Space>
</Col>
{/* 右侧AI 配置 - 固定且不滚动 */}
<Col span={10} style={{ height: '100%' }}>
<Card
size="small"
title={<Space><SettingOutlined /> AI </Space>}
bordered={false}
style={{ borderRadius: 12, height: '100%', display: 'flex', flexDirection: 'column', boxShadow: '0 2px 8px rgba(0,0,0,0.03)' }}
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px' }}
>
<div style={{ flex: 1 }}>
<Form.Item name="asrModelId" label="语音识别 (ASR)" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
<Select placeholder="选择 ASR 模型">
{asrModels.map(m => (
<Option key={m.id} value={m.id}>{m.modelName} {m.isDefault === 1 && <Tag color="gold" size="small"></Tag>}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="summaryModelId" label="内容总结 (LLM)" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
<Select placeholder="选择总结模型">
{llmModels.map(m => (
<Option key={m.id} value={m.id}>{m.modelName} {m.isDefault === 1 && <Tag color="gold" size="small"></Tag>}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]} style={{ marginBottom: 12 }}>
<div style={{ maxHeight: 180, overflowY: 'auto', overflowX: 'hidden', padding: '2px 4px' }}>
<Row gutter={[8, 8]} style={{ margin: 0 }}>
{prompts.map(p => {
const isSelected = watchedPromptId === p.id;
return (
<Col span={8} key={p.id}>
<div
onClick={() => form.setFieldsValue({ promptId: p.id })}
style={{
padding: '8px 6px',
borderRadius: 8,
border: `1.5px solid ${isSelected ? '#1890ff' : '#f0f0f0'}`,
backgroundColor: isSelected ? '#f0f7ff' : '#fff',
cursor: 'pointer',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
boxShadow: isSelected ? '0 2px 6px rgba(24, 144, 255, 0.12)' : 'none'
}}
>
<div style={{
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: isSelected ? '#1890ff' : '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 4
}}>
<FileTextOutlined style={{ color: isSelected ? '#fff' : '#999', fontSize: 12 }} />
</div>
<div style={{
fontWeight: 500,
fontSize: '12px',
color: isSelected ? '#1890ff' : '#434343',
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: 1.2
}} title={p.templateName}>
{p.templateName}
</div>
{isSelected && (
<div style={{
position: 'absolute',
top: 0,
right: 0,
width: 14,
height: 14,
backgroundColor: '#1890ff',
borderRadius: '0 6px 0 6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<CheckOutlined style={{ color: '#fff', fontSize: 8 }} />
</div>
)}
</div>
</Col>
);
})}
</Row>
</div>
</Form.Item>
<Row gutter={16} align="middle" style={{ marginBottom: 16 }}>
<Col flex="auto">
<Form.Item
name="hotWords"
label={<span> <Tooltip title="不选默认应用全部启用热词"><QuestionCircleOutlined /></Tooltip></span>}
style={{ marginBottom: 0 }}
>
<Select mode="multiple" placeholder="可选热词" allowClear maxTagCount="responsive">
{hotwordList.map(h => <Option key={h.word} value={h.word}>{h.word}</Option>)}
</Select>
</Form.Item>
</Col>
<Col>
<Form.Item
name="useSpkId"
label={<span> <Tooltip title="开启后将区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>}
valuePropName="checked"
getValueProps={(value) => ({ checked: value === 1 })}
normalize={(value) => (value ? 1 : 0)}
style={{ marginBottom: 0 }}
>
<Switch />
</Form.Item>
</Col>
</Row>
</div>
<div style={{ flexShrink: 0 }}>
<div style={{ backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', padding: '10px 12px', borderRadius: 8, marginBottom: 16 }}>
<Text type="secondary" style={{ fontSize: '12px' }}>
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 6 }} />
+
</Text>
</div>
<Button
type="primary"
size="large"
block
icon={<RocketOutlined />}
htmlType="submit"
loading={submitLoading}
style={{ height: 48, borderRadius: 8, fontSize: 16, fontWeight: 600, boxShadow: '0 4px 12px rgba(24, 144, 255, 0.3)' }}
>
</Button>
</div>
</Card>
</Col>
</Row>
</Form>
</div>
</div>
);
};
export default MeetingCreate;