359 lines
17 KiB
TypeScript
359 lines
17 KiB
TypeScript
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;
|