修复重建会议弹窗内容样式

alan-pg
tanlianwang 2026-02-27 16:57:20 +08:00
parent 3738e14716
commit e85dfe5f77
2 changed files with 532 additions and 75 deletions

View File

@ -1,23 +1,164 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Form, Input, Button, Upload, message, Progress, Select, Row, Col } from 'antd'; import {
import { InboxOutlined, AudioOutlined, UserAddOutlined } from '@ant-design/icons'; Card, Form, Input, Button, Upload, message, Progress, Select, Modal,
Typography, Space, DatePicker, Row, Col, Avatar, Radio
} from 'antd';
import {
AudioOutlined,
CloudUploadOutlined,
VideoCameraOutlined,
CalendarOutlined,
TagOutlined,
FileTextOutlined,
CheckOutlined,
UserOutlined,
RightOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api'; import { api } from '../api';
import PageHeader from '../components/PageHeader/PageHeader'; import PageHeader from '../components/PageHeader/PageHeader';
const { Dragger } = Upload; const { Dragger } = Upload;
const { Option } = Select; const { Option } = Select;
const { Text, Title } = Typography;
const PromptSelector = ({ value, onChange, prompts }: { value?: number, onChange?: (val: number) => void, prompts: any[] }) => {
if (!prompts || prompts.length === 0) {
return (
<div style={{ textAlign: 'center', padding: '12px', color: '#94a3b8', background: '#f8fafc', borderRadius: 8 }}>
</div>
);
}
return (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 8,
maxHeight: 220,
overflowY: 'auto',
padding: 2
}}>
{prompts.map(prompt => {
const isSelected = value === prompt.id;
return (
<div
key={prompt.id}
onClick={() => onChange?.(prompt.id)}
style={{
padding: '8px 12px',
borderRadius: 6,
border: isSelected ? '1px solid #2563eb' : '1px solid #e2e8f0',
backgroundColor: isSelected ? '#eff6ff' : '#fff',
cursor: 'pointer',
transition: 'all 0.2s',
position: 'relative',
display: 'flex',
flexDirection: 'column',
height: 64,
justifyContent: 'center'
}}
>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 2 }}>
<FileTextOutlined style={{ color: isSelected ? '#2563eb' : '#64748b', fontSize: 14, marginRight: 6 }} />
<div style={{
fontWeight: 600,
color: isSelected ? '#1e293b' : '#334155',
fontSize: 13,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1
}}>
{prompt.name}
</div>
<div style={{
fontSize: 10,
padding: '1px 4px',
borderRadius: 3,
backgroundColor: prompt.is_system ? '#e0f2fe' : '#fff7ed',
color: prompt.is_system ? '#0284c7' : '#ea580c',
marginLeft: 6,
flexShrink: 0,
lineHeight: '1.2'
}}>
{prompt.is_system ? '系统' : '个人'}
</div>
</div>
<div style={{
fontSize: 11,
color: isSelected ? '#60a5fa' : '#94a3b8',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
paddingLeft: 20
}}>
{prompt.description || '暂无描述'}
</div>
{isSelected && (
<div style={{ position: 'absolute', bottom: 4, right: 4 }}>
<CheckOutlined style={{ color: '#2563eb', fontSize: 10 }} />
</div>
)}
</div>
);
})}
</div>
);
};
const MeetingLive: React.FC = () => { const MeetingLive: React.FC = () => {
const [form] = Form.useForm(); const [liveForm] = Form.useForm();
const [uploadForm] = Form.useForm();
const [users, setUsers] = useState<any[]>([]);
const [prompts, setPrompts] = useState<any[]>([]);
const [isLiveModalVisible, setIsLiveModalVisible] = useState(false);
const [isUploadModalVisible, setIsUploadModalVisible] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const handleCreate = async (values: any) => { useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try { try {
const res = await api.createMeeting(values); const [usersData, promptsData] = await Promise.all([
api.listUsers(),
api.listPrompts({ scope: 'personal' })
]);
setUsers(usersData);
setPrompts(promptsData.filter((p: any) => p.is_active === true || p.is_active === 1));
} catch (error) {
console.error('Failed to fetch data:', error);
message.error('加载基础数据失败');
}
};
const handleCreateLive = async (values: any) => {
try {
const payload = {
title: values.title,
participants: values.participants,
prompt_id: values.promptTemplateId,
tags: values.tags,
meeting_time: values.time ? values.time.valueOf() : dayjs().valueOf(),
type: 'live'
};
const res = await api.createMeeting(payload);
message.success('会议创建成功'); message.success('会议创建成功');
setIsLiveModalVisible(false);
navigate(`/meeting/history/${res.meeting_id}`); navigate(`/meeting/history/${res.meeting_id}`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -25,13 +166,40 @@ const MeetingLive: React.FC = () => {
} }
}; };
const handleUpload = async (file: File) => { const handleCreateUpload = async (values: any) => {
if (!selectedFile) {
message.error('请先上传录音文件');
return;
}
setUploading(true); setUploading(true);
setProgress(0); setProgress(0);
try { try {
// Mock progress since fetch doesn't support it easily without XHR const formData = new FormData();
formData.append('file', selectedFile);
formData.append('title', values.title);
if (values.participants) {
// Backend might expect list or comma separated string depending on implementation
// Assuming api.ts handles FormData correctly, we append each participant or join them
// If backend uses FastAPI List[str] = Form(...), we append multiple times
// If backend uses str = Form(...), we join them.
// Safer to try JSON string if complex, but standard form data usually repeats keys
// For now, let's append as JSON string or multiple keys?
// Let's assume the backend parses JSON for complex fields if passed as string
// OR we append each one.
// Given existing code elsewhere, usually standard form submission.
// Let's iterate.
values.participants.forEach((p: any) => formData.append('participants', String(p)));
}
if (values.promptTemplateId) formData.append('prompt_id', String(values.promptTemplateId));
if (values.tags) values.tags.forEach((t: string) => formData.append('tags', t));
if (values.time) formData.append('meeting_time', String(values.time.valueOf()));
formData.append('type', 'upload');
// Simulated progress for better UX since fetch doesn't support upload progress natively easily
const timer = setInterval(() => { const timer = setInterval(() => {
setProgress((prev) => { setProgress(prev => {
if (prev >= 90) { if (prev >= 90) {
clearInterval(timer); clearInterval(timer);
return 90; return 90;
@ -40,80 +208,372 @@ const MeetingLive: React.FC = () => {
}); });
}, 500); }, 500);
const res = await api.uploadMeetingAudio(file); const res = await api.createMeeting(formData);
clearInterval(timer); clearInterval(timer);
setProgress(100); setProgress(100);
message.success('上传成功,开始转译'); message.success('上传并创建成功');
// Assuming upload returns meeting_id or we navigate to list setIsUploadModalVisible(false);
if (res && res.meeting_id) { navigate(`/meeting/history/${res.meeting_id}`);
navigate(`/meeting/history/${res.meeting_id}`);
} else {
navigate('/meeting/history');
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
message.error('上传失败'); message.error('上传失败: ' + (error as any).message);
} finally {
setUploading(false); setUploading(false);
} }
return false; // Prevent default upload behavior
}; };
return ( const CardButton = ({
<div style={{ padding: 24 }}> icon,
<PageHeader title,
title="新建会议" description,
description="创建实时会议或上传音频文件进行转写" color,
/> onClick
}: {
icon: React.ReactNode,
title: string,
description: string,
color: string,
onClick: () => void
}) => (
<Card
hoverable
style={{
height: '100%',
borderRadius: 16,
border: '1px solid #f0f0f0',
transition: 'all 0.3s ease',
cursor: 'pointer'
}}
bodyStyle={{ padding: 32, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center' }}
onClick={onClick}
>
<div style={{
width: 80,
height: 80,
borderRadius: '50%',
background: `${color}15`, // 15% opacity
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
color: color,
fontSize: 36
}}>
{icon}
</div>
<Title level={4} style={{ marginBottom: 12 }}>{title}</Title>
<Text type="secondary" style={{ fontSize: 14 }}>{description}</Text>
<div style={{ marginTop: 24, color: color, display: 'flex', alignItems: 'center', fontWeight: 500 }}>
<RightOutlined style={{ marginLeft: 8, fontSize: 12 }} />
</div>
</Card>
);
<Row gutter={[24, 24]}> const renderFormFields = (isUpload: boolean) => (
<Col span={12}> <Row gutter={32}>
<Card title="创建实时会议" bordered={false} hoverable> <Col span={12}>
<Form form={form} layout="vertical" onFinish={handleCreate}> <div style={{ marginBottom: 24 }}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}> <Text strong style={{ fontSize: 12, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
<Input placeholder="请输入会议标题" prefix={<AudioOutlined />} />
</Form.Item> </Text>
<Form.Item name="participants" label="参会人"> </div>
<Select mode="tags" placeholder="请输入参会人姓名" open={false}>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<UserAddOutlined />} block>
</Button>
</Form.Item>
</Form>
</Card>
</Col>
<Col span={12}> <Form.Item
<Card title="上传录音文件" variant="borderless" hoverable> name="title"
rules={[{ required: true, message: '请输入会议标题' }]}
>
<Input
size="large"
placeholder="会议标题"
prefix={<AudioOutlined style={{ color: '#bfbfbf' }} />}
style={{ borderRadius: 12, padding: '10px 16px' }}
/>
</Form.Item>
<Form.Item
name="time"
initialValue={dayjs()}
>
<DatePicker
showTime
size="large"
style={{ width: '100%', borderRadius: 12, padding: '10px 16px' }}
format="YYYY-MM-DD HH:mm"
placeholder="会议时间"
suffixIcon={<CalendarOutlined style={{ color: '#bfbfbf' }} />}
/>
</Form.Item>
<Form.Item name="tags">
<Select
mode="tags"
size="large"
placeholder="业务标签 (输入回车添加)"
style={{ width: '100%', borderRadius: 12 }}
tokenSeparators={[',']}
suffixIcon={<TagOutlined style={{ color: '#bfbfbf' }} />}
tagRender={(props) => (
<span style={{
backgroundColor: '#eff6ff',
color: '#2563eb',
border: '1px solid #dbeafe',
borderRadius: 6,
padding: '2px 8px',
marginRight: 4,
fontSize: 12,
fontWeight: 600
}}>
{props.label}
<span onClick={props.onClose} style={{ marginLeft: 4, cursor: 'pointer' }}>×</span>
</span>
)}
/>
</Form.Item>
{isUpload && (
<div style={{ marginTop: 24 }}>
<div style={{ marginBottom: 12 }}>
<Text strong style={{ fontSize: 12, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
()
</Text>
</div>
<Dragger <Dragger
name="file" name="file"
multiple={false} multiple={false}
beforeUpload={handleUpload} beforeUpload={(file) => {
setSelectedFile(file);
const currentTitle = uploadForm.getFieldValue('title');
if (!currentTitle) {
uploadForm.setFieldsValue({ title: file.name.replace(/\.[^/.]+$/, "") });
}
return false;
}}
showUploadList={false} showUploadList={false}
accept="audio/*,video/*" accept="audio/*,video/*"
disabled={uploading} disabled={uploading}
style={{
padding: 24,
background: selectedFile ? '#eff6ff' : '#f8fafc',
border: selectedFile ? '2px solid #2563eb' : '2px dashed #cbd5e1',
borderRadius: 16
}}
> >
<p className="ant-upload-drag-icon"> {selectedFile ? (
<InboxOutlined /> <div style={{ padding: '12px 0' }}>
</p> <FileTextOutlined style={{ fontSize: 32, color: '#2563eb', marginBottom: 12 }} />
<p className="ant-upload-text"></p> <div style={{ fontSize: 14, fontWeight: 600, color: '#1e293b' }}>{selectedFile.name}</div>
<p className="ant-upload-hint"> <div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>
MP3, WAV, M4A {(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p> </div>
<Button
type="text"
danger
size="small"
onClick={(e) => {
e.stopPropagation();
setSelectedFile(null);
}}
style={{ marginTop: 8 }}
>
</Button>
</div>
) : (
<>
<p className="ant-upload-drag-icon">
<CloudUploadOutlined style={{ color: '#94a3b8' }} />
</p>
<p className="ant-upload-text" style={{ fontSize: 14, color: '#64748b' }}></p>
<p className="ant-upload-hint" style={{ fontSize: 12, color: '#94a3b8' }}>
MP3, WAV, M4A
</p>
</>
)}
</Dragger> </Dragger>
{uploading && ( {uploading && (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Progress percent={progress} status={progress === 100 ? 'success' : 'active'} /> <Progress percent={progress} status="active" strokeColor="#fa8c16" showInfo={false} size="small" />
<div style={{ textAlign: 'center', marginTop: 8 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
{progress === 100 ? '上传完成,正在处理...' : '上传中...'} <Text style={{ fontSize: 12, color: '#fa8c16' }}>...</Text>
<Text style={{ fontSize: 12, color: '#fa8c16' }}>{progress}%</Text>
</div> </div>
</div> </div>
)} )}
</Card> </div>
)}
</Col>
<Col span={12}>
<div style={{ marginBottom: 24 }}>
<Text strong style={{ fontSize: 12, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
</Text>
</div>
<Form.Item name="participants">
<Select
mode="multiple"
size="large"
placeholder="选择参会人员"
style={{ width: '100%' }}
optionLabelProp="label"
dropdownStyle={{ borderRadius: 12, padding: 8 }}
>
{users.map(user => (
<Option key={user.user_id} value={user.user_id} label={user.display_name || user.username}>
<div style={{ display: 'flex', alignItems: 'center', padding: '4px 0' }}>
<Avatar size="small" style={{ backgroundColor: '#bfbfbf', marginRight: 8 }}>
{(user.display_name || user.username)[0]}
</Avatar>
<div style={{ fontWeight: 500 }}>{user.display_name || user.username}</div>
</div>
</Option>
))}
</Select>
</Form.Item>
<div style={{ marginTop: 32, marginBottom: 24 }}>
<Text strong style={{ fontSize: 12, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
</Text>
</div>
<Form.Item name="promptTemplateId">
<PromptSelector prompts={prompts} />
</Form.Item>
</Col>
</Row>
);
return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
<PageHeader
title="新建会议"
description="选择会议类型以开始记录,系统将自动为您生成智能会议纪要"
/>
<div style={{ marginBottom: 40 }} />
<Row gutter={40} justify="center">
<Col span={10}>
<CardButton
icon={<VideoCameraOutlined />}
title="实时会议"
description="开始即时会议录音,实时转写并生成会议纪要"
color="#1890ff"
onClick={() => setIsLiveModalVisible(true)}
/>
</Col>
<Col span={10}>
<CardButton
icon={<CloudUploadOutlined />}
title="上传录音"
description="上传已有的音频/视频文件AI 自动整理会议内容"
color="#fa8c16"
onClick={() => setIsUploadModalVisible(true)}
/>
</Col> </Col>
</Row> </Row>
{/* Live Meeting Modal */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingBottom: 12 }}>
<div style={{
background: '#e6f7ff',
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#1890ff',
fontSize: 20
}}>
<VideoCameraOutlined />
</div>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}></div>
<div style={{ fontSize: 12, color: '#94a3b8', fontWeight: 400 }}></div>
</div>
</div>
}
open={isLiveModalVisible}
onCancel={() => setIsLiveModalVisible(false)}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, paddingTop: 12 }}>
<Button size="large" onClick={() => setIsLiveModalVisible(false)} style={{ borderRadius: 12 }}></Button>
<Button
type="primary"
size="large"
onClick={() => liveForm.submit()}
icon={<VideoCameraOutlined />}
style={{ borderRadius: 12, backgroundColor: '#1890ff' }}
>
</Button>
</div>
}
width={800}
destroyOnClose
centered
styles={{ body: { padding: '24px 0 0 0' } }}
>
<Form form={liveForm} layout="vertical" onFinish={handleCreateLive}>
{renderFormFields(false)}
</Form>
</Modal>
{/* Upload Modal */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingBottom: 12 }}>
<div style={{
background: '#fff7e6',
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fa8c16',
fontSize: 20
}}>
<CloudUploadOutlined />
</div>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}></div>
<div style={{ fontSize: 12, color: '#94a3b8', fontWeight: 400 }}> MP3, WAV, M4A </div>
</div>
</div>
}
open={isUploadModalVisible}
onCancel={() => {
if (!uploading) setIsUploadModalVisible(false);
}}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, paddingTop: 12 }}>
<Button size="large" onClick={() => setIsUploadModalVisible(false)} disabled={uploading} style={{ borderRadius: 12 }}></Button>
<Button
type="primary"
size="large"
onClick={() => uploadForm.submit()}
loading={uploading}
icon={<CloudUploadOutlined />}
style={{ borderRadius: 12, backgroundColor: '#fa8c16', borderColor: '#fa8c16' }}
>
{uploading ? '上传处理中...' : '开始上传'}
</Button>
</div>
}
width={800}
destroyOnClose
centered
maskClosable={!uploading}
styles={{ body: { padding: '24px 0 0 0' } }}
>
<Form form={uploadForm} layout="vertical" onFinish={handleCreateUpload}>
{renderFormFields(true)}
</Form>
</Modal>
</div> </div>
); );
}; };

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Button, Card, Tag, Space, Form, Input, InputNumber, Switch, message, Select, Modal, Tooltip, Divider } from 'antd'; import { App, Button, Card, Tag, Space, Form, Input, InputNumber, Switch, Select, Modal, Tooltip, Divider } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
EditOutlined, EditOutlined,
@ -18,6 +18,7 @@ import ListTable from '../components/ListTable/ListTable';
const { Option } = Select; const { Option } = Select;
const PromptManage: React.FC = () => { const PromptManage: React.FC = () => {
const { message, modal } = App.useApp();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]); const [categories, setCategories] = useState<any[]>([]);
@ -63,7 +64,7 @@ const PromptManage: React.FC = () => {
}; };
const handleDelete = (id: number) => { const handleDelete = (id: number) => {
Modal.confirm({ modal.confirm({
title: '确认删除', title: '确认删除',
content: '确定要删除此个人模板吗?', content: '确定要删除此个人模板吗?',
onOk: async () => { onOk: async () => {
@ -74,10 +75,9 @@ const PromptManage: React.FC = () => {
}); });
}; };
const handleTogglePersonalActive = async (e: React.MouseEvent, record: any) => { const handleTogglePersonalActive = async (checked: boolean, record: any) => {
e.stopPropagation(); await api.updatePromptConfig(record.id, { is_active: checked });
await api.updatePromptConfig(record.id, { is_active: !record.is_active }); message.success(checked ? '已启用' : '已停用');
message.success(record.is_active ? '已隐藏' : '已显示');
fetchPrompts(searchKeyword); fetchPrompts(searchKeyword);
}; };
@ -128,20 +128,17 @@ const PromptManage: React.FC = () => {
} }
}, },
{ {
title: '总结显示', title: '是否启用',
dataIndex: 'is_active', dataIndex: 'is_active',
key: 'is_active', key: 'is_active',
width: 100, width: 100,
render: (active: boolean, record: any) => ( render: (active: boolean, record: any) => (
<Button <Switch
type="text" checked={!!active}
size="small" size="small"
icon={active ? <EyeOutlined /> : <EyeInvisibleOutlined />} onChange={(checked) => handleTogglePersonalActive(checked, record)}
onClick={(e) => handleTogglePersonalActive(e, record)} onClick={(_, e) => e.stopPropagation()}
style={{ color: active ? '#1890ff' : '#ccc' }} />
>
{active ? '显示' : '隐藏'}
</Button>
), ),
}, },
{ {