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

359 lines
17 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, 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' }} />}
{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;