imeeting/frontend/src/components/business/MeetingCreateDrawer.tsx

488 lines
22 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, useMemo } from 'react';
import { Drawer, Form, Input, Select, DatePicker, Switch, Upload, Progress, Space, Avatar, Row, Col, Radio, Typography, Tooltip, App, Tag, Button, Collapse } from 'antd';
import { UserOutlined, CloudUploadOutlined, AudioOutlined, QuestionCircleOutlined, CheckOutlined, LinkOutlined, SettingOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
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 { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting';
import { SysUser } from '../../types';
const { Option } = Select;
const { Dragger } = Upload;
const { Text, Title } = Typography;
export type MeetingCreateType = 'upload' | 'realtime';
interface MeetingCreateDrawerProps {
open: boolean;
initialType?: MeetingCreateType;
onCancel: () => void;
onSuccess: () => void;
}
type RealtimeMeetingSessionDraft = {
meetingId: number;
meetingTitle: string;
asrModelName: string;
summaryModelName: string;
asrModelId: number;
mode: string;
language: string;
useSpkId: number;
enablePunctuation: boolean;
enableItn: boolean;
enableTextRefine: boolean;
saveAudio: boolean;
hotwords: Array<{ hotword: string; weight: number }>;
};
function resolveWsUrl(model?: AiModelVO | null) {
if (model?.wsUrl) return model.wsUrl;
if (model?.baseUrl) return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
return "";
}
function buildRealtimeProxyPreviewUrl() {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
return `${protocol}://${window.location.host}/ws/meeting/realtime`;
}
function getSessionKey(meetingId: number) {
return `realtimeMeetingSession:${meetingId}`;
}
export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open, initialType = 'upload', onCancel, onSuccess }) => {
const { message } = App.useApp();
const navigate = useNavigate();
const [form] = Form.useForm();
const [type, setType] = useState<MeetingCreateType>(initialType);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = 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 [audioUrl, setAudioUrl] = useState('');
const [uploadProgress, setUploadProgress] = useState(0);
const [fileList, setFileList] = useState<any[]>([]);
const watchedAsrModelId = Form.useWatch("asrModelId", form);
const watchedPromptId = Form.useWatch("promptId", form);
const watchedSummaryModelId = Form.useWatch("summaryModelId", form);
const selectedAsrModel = useMemo(() => asrModels.find((item) => item.id === watchedAsrModelId) || null, [asrModels, watchedAsrModelId]);
const selectedSummaryModel = useMemo(() => llmModels.find((item) => item.id === watchedSummaryModelId) || null, [llmModels, watchedSummaryModelId]);
useEffect(() => {
if (open) {
setType(initialType);
loadInitialData();
setAudioUrl('');
setUploadProgress(0);
setFileList([]);
}
}, [open, initialType]);
const loadInitialData = async () => {
setLoading(true);
try {
const [asrRes, llmRes, promptRes, hotwordRes, users, defaultAsr, defaultLlm] = 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(),
getAiModelDefault("ASR"),
getAiModelDefault("LLM")
]);
const activeAsrModels = asrRes.data.data.records.filter((m: AiModelVO) => m.status === 1);
const activeLlmModels = llmRes.data.data.records.filter((m: AiModelVO) => m.status === 1);
const activePrompts = promptRes.data.data.records.filter((p: PromptTemplateVO) => p.status === 1);
const activeHotwords = hotwordRes.data.data.records.filter((h: HotWordVO) => h.status === 1);
setAsrModels(activeAsrModels);
setLlmModels(activeLlmModels);
setPrompts(activePrompts);
setHotwordList(activeHotwords);
setUserList(users || []);
form.setFieldsValue({
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
meetingTime: dayjs(),
asrModelId: defaultAsr.data.data?.id,
summaryModelId: defaultLlm.data.data?.id,
promptId: activePrompts.length > 0 ? activePrompts[0].id : undefined,
useSpkId: 1,
enableTextRefine: false,
mode: "2pass",
language: "auto",
enablePunctuation: true,
enableItn: true,
saveAudio: false,
});
} catch (err) {
message.error("加载配置失败");
} finally {
setLoading(false);
}
};
// Sync title when type changes
useEffect(() => {
if (!open) return;
const currentTitle = form.getFieldValue('title');
if (currentTitle && (currentTitle.startsWith('文件会议') || currentTitle.startsWith('实时会议'))) {
form.setFieldsValue({
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
useSpkId: 1
});
}
}, [type, form, open]);
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('文件上传失败');
}
};
const handleOk = async () => {
if (type === 'upload' && !audioUrl) {
message.error('请先上传录音文件');
return;
}
const values = await form.validateFields();
if (type === 'realtime') {
const wsUrl = resolveWsUrl(selectedAsrModel);
if (!wsUrl) {
message.error("当前 ASR 模型没有配置 WebSocket 地址");
return;
}
}
setSubmitting(true);
try {
const { hostUserId, ...meetingValues } = values;
if (type === 'upload') {
await createMeeting({
...meetingValues,
...(hostUserId != null ? { hostUserId } : {}),
meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
audioUrl,
participants: meetingValues.participants?.join(','),
tags: meetingValues.tags?.join(',')
});
message.success('会议发起成功');
onSuccess();
onCancel();
} else {
const selectedHotwords = hotwordList.map((item) => ({
hotword: item.word,
weight: Number(item.weight || 2) / 10,
}));
const payload: CreateRealtimeMeetingCommand = {
...meetingValues,
...(hostUserId != null ? { hostUserId } : {}),
meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
participants: meetingValues.participants?.join(",") || "",
tags: meetingValues.tags?.join(",") || "",
mode: meetingValues.mode || "2pass",
language: meetingValues.language || "auto",
useSpkId: meetingValues.useSpkId ? 1 : 0,
enablePunctuation: meetingValues.enablePunctuation !== false,
enableItn: meetingValues.enableItn !== false,
enableTextRefine: !!meetingValues.enableTextRefine,
saveAudio: !!meetingValues.saveAudio,
};
const res = await createRealtimeMeeting(payload);
const createdMeeting = res.data.data;
const sessionDraft: RealtimeMeetingSessionDraft = {
meetingId: createdMeeting.id,
meetingTitle: createdMeeting.title,
asrModelName: selectedAsrModel?.modelName || "ASR",
summaryModelName: selectedSummaryModel?.modelName || "LLM",
asrModelId: selectedAsrModel?.id || values.asrModelId,
mode: values.mode || "2pass",
language: values.language || "auto",
useSpkId: values.useSpkId ? 1 : 0,
enablePunctuation: values.enablePunctuation !== false,
enableItn: values.enableItn !== false,
enableTextRefine: !!values.enableTextRefine,
saveAudio: !!values.saveAudio,
hotwords: selectedHotwords,
};
sessionStorage.setItem(getSessionKey(createdMeeting.id), JSON.stringify(sessionDraft));
message.success("会议已创建,即将进入实时识别");
onSuccess();
onCancel();
navigate(`/meeting-live-session/${createdMeeting.id}`);
}
} catch (err) {
message.error(type === 'upload' ? '创建会议失败' : '创建实时会议失败');
} finally {
setSubmitting(false);
}
};
return (
<Drawer
title={null}
open={open}
onClose={onCancel}
width={960}
forceRender
destroyOnClose={false}
placement="right"
closable={false}
footer={
<div style={{ textAlign: 'right', padding: '16px 32px' }}>
<Space size={16}>
<Button onClick={onCancel} size="large" style={{ borderRadius: 8, minWidth: 120 }}></Button>
<Button type="primary" onClick={handleOk} loading={submitting} size="large" style={{ borderRadius: 8, minWidth: 140, fontWeight: 500 }}>
{type === 'upload' ? (audioUrl ? '开始分析' : '创建并上传') : '创建并进入识别'}
</Button>
</Space>
</div>
}
styles={{
header: { display: 'none' },
body: { padding: 0, display: 'flex', flexDirection: 'column', background: 'var(--app-bg-layout)' },
footer: { padding: 0, borderTop: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface)' }
}}
>
<div style={{ background: 'var(--app-bg-surface)', padding: '24px 32px', borderBottom: '1px solid var(--app-border-color)' }}>
<Row justify="space-between" align="middle">
<Col>
<Space size={16}>
<div style={{ width: 48, height: 48, borderRadius: 12, background: 'var(--app-bg-surface-strong)', color: 'var(--app-text-main)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, border: '1px solid var(--app-border-color)' }}>
{type === 'upload' ? <CloudUploadOutlined /> : <AudioOutlined />}
</div>
<div>
<Title level={4} style={{ margin: 0, fontWeight: 600 }}>{type === 'upload' ? '上传录音分析' : '创建实时会议'}</Title>
<Text type="secondary" style={{ fontSize: 13 }}>{type === 'upload' ? '上传已有录音文件进行转写和总结' : '创建会议实时进行语音转写和内容分析'}</Text>
</div>
</Space>
</Col>
<Col>
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" size="large">
<Radio.Button value="upload" style={{ padding: '0 24px' }}><CloudUploadOutlined style={{ marginRight: 6 }} /> </Radio.Button>
<Radio.Button value="realtime" style={{ padding: '0 24px' }}><AudioOutlined style={{ marginRight: 6 }} /> </Radio.Button>
</Radio.Group>
</Col>
</Row>
</div>
<div style={{ padding: '32px 40px', flex: 1, overflowY: 'auto', background: 'var(--app-bg-layout)' }}>
<Form form={form} layout="vertical" disabled={loading}>
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
<Title level={5} style={{ margin: 0 }}></Title>
</div>
<Row gutter={32}>
<Col span={12}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
<Input placeholder="输入会议标题" size="large" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]}>
<DatePicker showTime style={{ width: '100%' }} size="large" />
</Form.Item>
</Col>
</Row>
<Row gutter={32}>
<Col span={12}>
<Form.Item name="participants" label="参会人员">
<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>
</Col>
<Col span={12}>
<Form.Item name="hostUserId" label="会议主持人">
<Select allowClear 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>
</Col>
</Row>
<Row gutter={32}>
<Col span={12}>
<Form.Item name="tags" label="会议标签">
<Select mode="tags" placeholder="输入标签" size="large" />
</Form.Item>
</Col>
</Row>
<div style={{ margin: '32px 0', borderTop: '1px solid var(--app-border-color)' }} />
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
<Title level={5} style={{ margin: 0 }}>AI </Title>
</div>
<Row gutter={32}>
<Col span={12}>
<Form.Item name="asrModelId" label="语音识别 (ASR)" rules={[{ required: true }]}>
<Select placeholder="选择 ASR 模型" size="large">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="summaryModelId" label="内容总结 (LLM)" rules={[{ required: true }]}>
<Select placeholder="选择总结模型" size="large">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]}>
{prompts.length > 15 ? (
<Select placeholder="请选择模板" showSearch optionFilterProp="children" size="large">
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
</Select>
) : (
<div style={{ padding: '2px' }}>
<Row gutter={[12, 12]}>
{prompts.map(p => {
const isSelected = watchedPromptId === p.id;
return (
<Col span={8} key={p.id}>
<div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: '12px 16px', borderRadius: 8, border: `1px solid ${isSelected ? '#1890ff' : 'var(--app-border-color)'}`, background: isSelected ? '#e6f7ff' : 'var(--app-bg-surface)', cursor: 'pointer', position: 'relative', transition: 'all 0.2s', display: 'flex', alignItems: 'center', height: '100%' }}>
<div style={{ fontSize: '14px', color: isSelected ? '#1890ff' : 'var(--app-text-main)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSelected ? 500 : 400 }}>{p.templateName}</div>
{isSelected && <div style={{ position: 'absolute', top: -1, right: -1, width: 20, height: 20, background: '#1890ff', borderRadius: '0 8px 0 8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CheckOutlined style={{ color: '#fff', fontSize: 12 }} /></div>}
</div>
</Col>
);
})}
</Row>
</div>
)}
</Form.Item>
<Collapse
ghost
expandIconPosition="end"
style={{ marginBottom: 24, background: 'var(--app-bg-surface)', border: '1px solid var(--app-border-color)', borderRadius: 8, overflow: 'hidden' }}
items={[
{
key: 'advanced',
label: (
<div style={{ display: 'flex', alignItems: 'center', width: '100%', height: '32px' }}>
<div style={{ display: 'flex', alignItems: 'center', fontWeight: 600, color: 'var(--app-text-main)', fontSize: 15 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: '#f0f5ff', color: '#1677ff', display: 'flex', alignItems: 'center', justifyContent: 'center', marginRight: 12 }}>
<SettingOutlined style={{ fontSize: 16 }} />
</div>
</div>
</div>
),
children: (
<div style={{ paddingTop: 8, borderTop: '1px dashed var(--app-border-color)' }}>
<Row gutter={32}>
<Col span={8}>
<Form.Item name="useSpkId" label={<span> <Tooltip title="开启后将尝试区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}>
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="enableTextRefine" label={<span> <Tooltip title="开启后将尝试对识别文本进行修正"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked">
<Switch />
</Form.Item>
</Col>
{type === 'realtime' && (
<Col span={8}>
<Form.Item name="mode" label="识别模式">
<Select size="large">
<Option value="2pass">2pass (+线)</Option>
<Option value="online">online ()</Option>
</Select>
</Form.Item>
</Col>
)}
</Row>
</div>
),
}
]}
/>
{type === 'realtime' && (
<>
<Form.Item name="language" hidden><Input /></Form.Item>
<Form.Item name="enablePunctuation" hidden valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="enableItn" hidden valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="saveAudio" hidden valuePropName="checked"><Switch /></Form.Item>
</>
)}
{type === 'upload' && (
<>
<div style={{ margin: '32px 0', borderTop: '1px solid var(--app-border-color)' }} />
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
<Title level={5} style={{ margin: 0 }}></Title>
</div>
<Dragger
accept=".mp3,.wav,.m4a"
fileList={fileList}
customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))}
maxCount={1}
style={{ borderRadius: 12, padding: '32px 0', background: 'var(--app-bg-surface)', border: '1px dashed var(--app-border-color)' }}
>
<div>
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: '#1890ff' }} /></p>
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: 'var(--app-text-main)' }}></p>
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: 'var(--app-text-secondary)' }}> .mp3, .wav, .m4a </p>
{uploadProgress > 0 && uploadProgress < 100 && (
<div style={{ width: '60%', margin: '32px auto 0' }}>
<Progress percent={uploadProgress} size="small" />
<div style={{ fontSize: 13, color: '#1890ff', marginTop: 8 }}>...</div>
</div>
)}
{audioUrl && (
<Tag color="processing" style={{ marginTop: 24, padding: '6px 16px', fontSize: 14, borderRadius: 6, maxWidth: '90%', display: 'inline-flex', alignItems: 'center' }}>
<span style={{ flexShrink: 0 }}>:</span>
<span style={{ marginLeft: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{audioUrl.split('/').pop()}
</span>
</Tag>
)}
</div>
</Dragger>
</>
)}
</Form>
</div>
</Drawer>
);
};