codex/dev
mula.liu 2026-04-07 18:48:35 +08:00
parent af735bd93d
commit f3d9429b28
8 changed files with 460 additions and 45 deletions

View File

@ -614,6 +614,13 @@ class SystemConfigService:
@classmethod @classmethod
def get_branding_config(cls) -> Dict[str, str]: def get_branding_config(cls) -> Dict[str, str]:
max_audio_size_mb = cls.get_max_audio_size(100)
max_image_size_mb = cls.get_config("max_image_size", "10")
try:
max_image_size_mb = int(max_image_size_mb)
except (ValueError, TypeError):
max_image_size_mb = 10
return { return {
"app_name": str(cls.get_config(cls.BRANDING_APP_NAME, "智听云平台") or "智听云平台"), "app_name": str(cls.get_config(cls.BRANDING_APP_NAME, "智听云平台") or "智听云平台"),
"home_headline": str(cls.get_config(cls.BRANDING_HOME_HEADLINE, "智听云平台") or "智听云平台"), "home_headline": str(cls.get_config(cls.BRANDING_HOME_HEADLINE, "智听云平台") or "智听云平台"),
@ -622,6 +629,10 @@ class SystemConfigService:
"preview_title": str(cls.get_config(cls.BRANDING_PREVIEW_TITLE, "会议预览") or "会议预览"), "preview_title": str(cls.get_config(cls.BRANDING_PREVIEW_TITLE, "会议预览") or "会议预览"),
"login_welcome": str(cls.get_config(cls.BRANDING_LOGIN_WELCOME, "欢迎回来,请输入您的登录凭证。") or "欢迎回来,请输入您的登录凭证。"), "login_welcome": str(cls.get_config(cls.BRANDING_LOGIN_WELCOME, "欢迎回来,请输入您的登录凭证。") or "欢迎回来,请输入您的登录凭证。"),
"footer_text": str(cls.get_config(cls.BRANDING_FOOTER_TEXT, "©2026 智听云平台") or "©2026 智听云平台"), "footer_text": str(cls.get_config(cls.BRANDING_FOOTER_TEXT, "©2026 智听云平台") or "©2026 智听云平台"),
"max_audio_size": str(max_audio_size_mb),
"MAX_FILE_SIZE": max_audio_size_mb * 1024 * 1024,
"max_image_size": str(max_image_size_mb),
"MAX_IMAGE_SIZE": max_image_size_mb * 1024 * 1024,
} }
# LLM模型配置获取方法直接使用通用方法 # LLM模型配置获取方法直接使用通用方法

View File

@ -22,6 +22,7 @@ const AudioPlayerBar = ({
moreMenuItems = [], moreMenuItems = [],
emptyText = '暂无音频', emptyText = '暂无音频',
showMoreButton = true, showMoreButton = true,
moreButtonDisabled = false,
rateOptions = DEFAULT_RATE_OPTIONS, rateOptions = DEFAULT_RATE_OPTIONS,
}) => { }) => {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@ -124,6 +125,18 @@ const AudioPlayerBar = ({
return ( return (
<div className="audio-player-bar is-empty"> <div className="audio-player-bar is-empty">
<span className="audio-player-bar-empty-text">{emptyText}</span> <span className="audio-player-bar-empty-text">{emptyText}</span>
{showMoreButton ? (
<>
<span className="audio-player-bar-divider" />
{!moreButtonDisabled && moreMenuItems.length > 0 ? (
<Dropdown menu={{ items: moreMenuItems }} trigger={['click']}>
<Button type="text" className="audio-player-bar-control audio-player-bar-more" icon={<MoreOutlined />} />
</Dropdown>
) : (
<Button type="text" disabled className="audio-player-bar-control audio-player-bar-more" icon={<MoreOutlined />} />
)}
</>
) : null}
</div> </div>
); );
} }
@ -169,7 +182,7 @@ const AudioPlayerBar = ({
{showMoreButton ? ( {showMoreButton ? (
<> <>
<span className="audio-player-bar-divider" /> <span className="audio-player-bar-divider" />
{moreMenuItems.length > 0 ? ( {!moreButtonDisabled && moreMenuItems.length > 0 ? (
<Dropdown menu={{ items: moreMenuItems }} trigger={['click']}> <Dropdown menu={{ items: moreMenuItems }} trigger={['click']}>
<Button type="text" className="audio-player-bar-control audio-player-bar-more" icon={<MoreOutlined />} /> <Button type="text" className="audio-player-bar-control audio-player-bar-more" icon={<MoreOutlined />} />
</Dropdown> </Dropdown>

View File

@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Drawer, Form, Input, Button, DatePicker, Select, Space, App } from 'antd'; import { Drawer, Form, Input, Button, DatePicker, Select, Space, App, Upload, Card, Progress, Typography } from 'antd';
import { SaveOutlined } from '@ant-design/icons'; import { SaveOutlined, UploadOutlined, DeleteOutlined, AudioOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import apiClient from '../utils/apiClient'; import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import configService from '../utils/configService';
import { AUDIO_UPLOAD_ACCEPT, uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService';
const { TextArea } = Input; const { Text } = Typography;
const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => { const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
const { message } = App.useApp(); const { message } = App.useApp();
@ -14,17 +16,27 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [prompts, setPrompts] = useState([]); const [prompts, setPrompts] = useState([]);
const [selectedAudioFile, setSelectedAudioFile] = useState(null);
const [audioUploading, setAudioUploading] = useState(false);
const [audioUploadProgress, setAudioUploadProgress] = useState(0);
const [audioUploadMessage, setAudioUploadMessage] = useState('');
const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024);
const isEdit = Boolean(meetingId); const isEdit = Boolean(meetingId);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
fetchOptions(); fetchOptions();
loadAudioUploadConfig();
if (isEdit) { if (isEdit) {
fetchMeeting(); fetchMeeting();
} else { } else {
form.resetFields(); form.resetFields();
form.setFieldsValue({ meeting_time: dayjs() }); form.setFieldsValue({ meeting_time: dayjs() });
setSelectedAudioFile(null);
setAudioUploading(false);
setAudioUploadProgress(0);
setAudioUploadMessage('');
} }
}, [open, meetingId]); }, [open, meetingId]);
@ -39,6 +51,15 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
} catch {} } catch {}
}; };
const loadAudioUploadConfig = async () => {
try {
const nextMaxAudioSize = await configService.getMaxAudioSize();
setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024);
} catch (error) {
setMaxAudioSize(100 * 1024 * 1024);
}
};
const fetchMeeting = async () => { const fetchMeeting = async () => {
setFetching(true); setFetching(true);
try { try {
@ -50,7 +71,6 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
attendee_ids: meeting.attendee_ids || meeting.attendees?.map((a) => a.user_id).filter(Boolean) || [], attendee_ids: meeting.attendee_ids || meeting.attendees?.map((a) => a.user_id).filter(Boolean) || [],
prompt_id: meeting.prompt_id, prompt_id: meeting.prompt_id,
tags: meeting.tags?.map((t) => t.name) || [], tags: meeting.tags?.map((t) => t.name) || [],
description: meeting.description,
}); });
} catch { } catch {
message.error('加载会议数据失败'); message.error('加载会议数据失败');
@ -59,6 +79,23 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
} }
}; };
const handleAudioBeforeUpload = (file) => {
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
if (validationMessage) {
message.warning(validationMessage);
return Upload.LIST_IGNORE;
}
setSelectedAudioFile(file);
return false;
};
const clearSelectedAudio = () => {
setSelectedAudioFile(null);
setAudioUploadProgress(0);
setAudioUploadMessage('');
};
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
@ -76,7 +113,35 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
} else { } else {
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload); const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
if (res.code === '200') { if (res.code === '200') {
const newMeetingId = res.data.meeting_id;
if (selectedAudioFile) {
setAudioUploading(true);
setAudioUploadProgress(0);
setAudioUploadMessage('正在上传音频文件...');
try {
await uploadMeetingAudio({
meetingId: newMeetingId,
file: selectedAudioFile,
promptId: values.prompt_id,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
setAudioUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total)));
}
setAudioUploadMessage('正在上传音频文件...');
},
});
setAudioUploadProgress(100);
setAudioUploadMessage('上传完成,正在启动转录任务...');
message.success('会议创建成功,音频已开始上传处理');
} catch (uploadError) {
message.warning(uploadError?.response?.data?.message || uploadError?.response?.data?.detail || '会议已创建,但音频上传失败,请在详情页重试');
} finally {
setAudioUploading(false);
}
} else {
message.success('会议创建成功'); message.success('会议创建成功');
}
onSuccess?.(res.data.meeting_id); onSuccess?.(res.data.meeting_id);
onClose(); onClose();
return; return;
@ -103,7 +168,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
destroyOnClose destroyOnClose
extra={ extra={
<Space> <Space>
<Button type="primary" icon={<SaveOutlined />} loading={loading} onClick={handleSubmit}> <Button type="primary" icon={<SaveOutlined />} loading={loading || audioUploading} onClick={handleSubmit}>
{isEdit ? '保存修改' : '创建会议'} {isEdit ? '保存修改' : '创建会议'}
</Button> </Button>
</Space> </Space>
@ -138,9 +203,105 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
<Select mode="tags" placeholder="输入标签按回车" /> <Select mode="tags" placeholder="输入标签按回车" />
</Form.Item> </Form.Item>
<Form.Item label="会议备注" name="description"> {!isEdit ? (
<TextArea rows={3} placeholder="添加会议背景或说明..." /> <Form.Item label="会议音频">
<Card
variant="borderless"
style={{
borderRadius: 14,
border: '1px solid #d9e2f2',
background: selectedAudioFile ? 'linear-gradient(135deg, #f8fbff 0%, #ffffff 100%)' : '#fbfdff',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.86)',
}}
styles={{ body: { padding: 18 } }}
>
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start' }}>
<div>
<Text type="secondary">
支持 {AUDIO_UPLOAD_ACCEPT.replace(/\./g, '').toUpperCase()}
<br/>音频文件最大 {configService.formatFileSize(maxAudioSize)}
</Text>
</div>
<Upload accept={AUDIO_UPLOAD_ACCEPT} showUploadList={false} beforeUpload={handleAudioBeforeUpload}>
<Button icon={<UploadOutlined />} disabled={audioUploading}>选择音频</Button>
</Upload>
</div>
{selectedAudioFile ? (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 16,
padding: '12px 14px',
borderRadius: 12,
background: '#ffffff',
border: '1px solid #dbe7f5',
}}
>
<Space size={12}>
<div
style={{
width: 36,
height: 36,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: '#edf4ff',
color: '#1d4ed8',
}}
>
<AudioOutlined />
</div>
<div>
<Text strong style={{ display: 'block' }}>{selectedAudioFile.name}</Text>
<Text type="secondary">{configService.formatFileSize(selectedAudioFile.size)}</Text>
</div>
</Space>
<Button type="text" icon={<DeleteOutlined />} onClick={clearSelectedAudio} disabled={audioUploading}>
移除
</Button>
</div>
) : (
<div
style={{
borderRadius: 12,
border: '1px dashed #c7d5ea',
padding: '18px 16px',
background: '#ffffff',
color: 'rgba(17, 43, 78, 0.66)',
textAlign: 'center',
}}
>
可在会议详情中补传
</div>
)}
{audioUploading ? (
<div
style={{
padding: '12px 14px',
borderRadius: 12,
background: '#f0f7ff',
border: '1px solid #bfdbfe',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text strong>音频上传中</Text>
<Text strong style={{ color: '#1677ff' }}>{audioUploadProgress}%</Text>
</div>
<Text type="secondary" style={{ display: 'block', marginBottom: 10 }}>
{audioUploadMessage || '正在上传音频文件...'}
</Text>
<Progress percent={audioUploadProgress} status="active" strokeColor={{ from: '#69b1ff', to: '#1677ff' }} />
</div>
) : null}
</Space>
</Card>
</Form.Item> </Form.Item>
) : null}
</Form> </Form>
</Drawer> </Drawer>
); );

View File

@ -1,21 +1,20 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Card, Form, Input, Button, DatePicker, Select, Space, Card, Form, Input, Button, DatePicker, Select,
Typography, App, Divider, Row, Col, Tag Typography, App, Divider, Row, Col, Upload, Space, Progress
} from 'antd'; } from 'antd';
import { import {
ArrowLeftOutlined, UserOutlined, CalendarOutlined, ArrowLeftOutlined, UploadOutlined, SaveOutlined, DeleteOutlined,
TeamOutlined, FileTextOutlined, PlusOutlined, VideoCameraAddOutlined, AudioOutlined
UploadOutlined, SaveOutlined,
VideoCameraAddOutlined, TagOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import apiClient from '../utils/apiClient'; import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import configService from '../utils/configService';
import { AUDIO_UPLOAD_ACCEPT, uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { TextArea } = Input;
const CreateMeeting = () => { const CreateMeeting = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -24,10 +23,16 @@ const CreateMeeting = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [prompts, setPrompts] = useState([]); const [prompts, setPrompts] = useState([]);
const [selectedAudioFile, setSelectedAudioFile] = useState(null);
const [audioUploading, setAudioUploading] = useState(false);
const [audioUploadProgress, setAudioUploadProgress] = useState(0);
const [audioUploadMessage, setAudioUploadMessage] = useState('');
const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024);
useEffect(() => { useEffect(() => {
fetchUsers(); fetchUsers();
fetchPrompts(); fetchPrompts();
loadAudioUploadConfig();
}, []); }, []);
const fetchUsers = async () => { const fetchUsers = async () => {
@ -44,18 +49,74 @@ const CreateMeeting = () => {
} catch (e) {} } catch (e) {}
}; };
const loadAudioUploadConfig = async () => {
try {
const nextMaxAudioSize = await configService.getMaxAudioSize();
setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024);
} catch (error) {
setMaxAudioSize(100 * 1024 * 1024);
}
};
const handleAudioBeforeUpload = (file) => {
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
if (validationMessage) {
message.warning(validationMessage);
return Upload.LIST_IGNORE;
}
setSelectedAudioFile(file);
return false;
};
const clearSelectedAudio = () => {
setSelectedAudioFile(null);
setAudioUploadProgress(0);
setAudioUploadMessage('');
};
const onFinish = async (values) => { const onFinish = async (values) => {
setLoading(true); setLoading(true);
try { try {
const payload = { const payload = {
...values, ...values,
meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'), meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'),
attendee_ids: values.attendee_ids attendee_ids: values.attendee_ids,
tags: values.tags?.join(',') || ''
}; };
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload); const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
if (res.code === '200') { if (res.code === '200') {
const meetingId = res.data.meeting_id;
if (selectedAudioFile) {
setAudioUploading(true);
setAudioUploadProgress(0);
setAudioUploadMessage('正在上传音频文件...');
try {
await uploadMeetingAudio({
meetingId,
file: selectedAudioFile,
promptId: values.prompt_id,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
setAudioUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total)));
}
setAudioUploadMessage('正在上传音频文件...');
},
});
setAudioUploadProgress(100);
setAudioUploadMessage('上传完成,正在启动转录任务...');
message.success('会议创建成功,音频已开始上传处理');
} catch (uploadError) {
message.warning(uploadError.response?.data?.message || uploadError.response?.data?.detail || '会议已创建,但音频上传失败,请在详情页重试');
} finally {
setAudioUploading(false);
}
} else {
message.success('会议创建成功'); message.success('会议创建成功');
navigate(`/meetings/${res.data.meeting_id}`); }
navigate(`/meetings/${meetingId}`);
} }
} catch (error) { } catch (error) {
message.error(error.response?.data?.message || error.response?.data?.detail || '创建失败'); message.error(error.response?.data?.message || error.response?.data?.detail || '创建失败');
@ -102,15 +163,115 @@ const CreateMeeting = () => {
<Select mode="tags" size="large" placeholder="输入标签按回车" /> <Select mode="tags" size="large" placeholder="输入标签按回车" />
</Form.Item> </Form.Item>
<Form.Item label="会议备注" name="description"> <Form.Item label="会议音频">
<TextArea rows={4} placeholder="添加会议背景或说明..." /> <Card
variant="borderless"
style={{
borderRadius: 14,
border: '1px solid #d9e2f2',
background: selectedAudioFile ? 'linear-gradient(135deg, #f8fbff 0%, #ffffff 100%)' : '#fbfdff',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.86)',
}}
styles={{ body: { padding: 18 } }}
>
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start' }}>
<div>
<Text strong style={{ display: 'block', marginBottom: 4 }}>上传会议音频</Text>
<Text type="secondary">
创建后会自动沿用会议详情页同一套上传与转录逻辑支持 {AUDIO_UPLOAD_ACCEPT.replace(/\./g, '').toUpperCase()}
最大 {configService.formatFileSize(maxAudioSize)}
</Text>
</div>
<Upload accept={AUDIO_UPLOAD_ACCEPT} showUploadList={false} beforeUpload={handleAudioBeforeUpload}>
<Button icon={<UploadOutlined />} disabled={audioUploading}>选择音频</Button>
</Upload>
</div>
{selectedAudioFile ? (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 16,
padding: '12px 14px',
borderRadius: 12,
background: '#ffffff',
border: '1px solid #dbe7f5',
}}
>
<Space size={12}>
<div
style={{
width: 36,
height: 36,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: '#edf4ff',
color: '#1d4ed8',
}}
>
<AudioOutlined />
</div>
<div>
<Text strong style={{ display: 'block' }}>{selectedAudioFile.name}</Text>
<Text type="secondary">{configService.formatFileSize(selectedAudioFile.size)}</Text>
</div>
</Space>
<Button
type="text"
icon={<DeleteOutlined />}
onClick={clearSelectedAudio}
disabled={audioUploading}
>
移除
</Button>
</div>
) : (
<div
style={{
borderRadius: 12,
border: '1px dashed #c7d5ea',
padding: '18px 16px',
background: '#ffffff',
color: 'rgba(17, 43, 78, 0.66)',
textAlign: 'center',
}}
>
可选若现在不上传也可以创建后在会议详情中补传
</div>
)}
{audioUploading ? (
<div
style={{
padding: '12px 14px',
borderRadius: 12,
background: '#f0f7ff',
border: '1px solid #bfdbfe',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text strong>音频上传中</Text>
<Text strong style={{ color: '#1677ff' }}>{audioUploadProgress}%</Text>
</div>
<Text type="secondary" style={{ display: 'block', marginBottom: 10 }}>
{audioUploadMessage || '正在上传音频文件...'}
</Text>
<Progress percent={audioUploadProgress} status="active" strokeColor={{ from: '#69b1ff', to: '#1677ff' }} />
</div>
) : null}
</Space>
</Card>
</Form.Item> </Form.Item>
<Divider /> <Divider />
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" size="large" icon={<SaveOutlined />} loading={loading} block style={{ height: 50, fontSize: 16, borderRadius: 8 }}> <Button type="primary" htmlType="submit" size="large" icon={<SaveOutlined />} loading={loading || audioUploading} block style={{ height: 50, fontSize: 16, borderRadius: 8 }}>
创建并进入详情 {selectedAudioFile ? '创建并上传音频' : '创建并进入详情'}
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>

View File

@ -1,11 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Card, Form, Input, Button, DatePicker, Select, Space, Card, Form, Input, Button, DatePicker, Select,
Typography, App, Divider, Row, Col Typography, App, Divider, Row, Col
} from 'antd'; } from 'antd';
import { import {
ArrowLeftOutlined, TeamOutlined, SaveOutlined, ArrowLeftOutlined, SaveOutlined, VideoCameraAddOutlined
VideoCameraAddOutlined, TagOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -13,7 +12,6 @@ import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../config/api';
const { Title } = Typography; const { Title } = Typography;
const { TextArea } = Input;
const EditMeeting = () => { const EditMeeting = () => {
const { meeting_id } = useParams(); const { meeting_id } = useParams();
@ -111,10 +109,6 @@ const EditMeeting = () => {
<Select mode="tags" size="large" placeholder="输入标签按回车" /> <Select mode="tags" size="large" placeholder="输入标签按回车" />
</Form.Item> </Form.Item>
<Form.Item label="会议备注" name="description">
<TextArea rows={4} placeholder="添加会议背景或说明..." />
</Form.Item>
<Divider /> <Divider />
<Form.Item> <Form.Item>

View File

@ -27,6 +27,8 @@ import tools from '../utils/tools';
import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import QRCodeModal from '../components/QRCodeModal'; import QRCodeModal from '../components/QRCodeModal';
import MeetingFormDrawer from '../components/MeetingFormDrawer'; import MeetingFormDrawer from '../components/MeetingFormDrawer';
import configService from '../utils/configService';
import { AUDIO_UPLOAD_ACCEPT, uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@ -115,6 +117,7 @@ const MeetingDetails = ({ user }) => {
const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false); const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
const [accessPasswordDraft, setAccessPasswordDraft] = useState(''); const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
const [savingAccessPassword, setSavingAccessPassword] = useState(false); const [savingAccessPassword, setSavingAccessPassword] = useState(false);
const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024);
// Drawer // Drawer
const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false); const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false);
@ -159,6 +162,7 @@ const MeetingDetails = ({ user }) => {
useEffect(() => { useEffect(() => {
fetchMeetingDetails(); fetchMeetingDetails();
fetchTranscript(); fetchTranscript();
loadAudioUploadConfig();
return () => { return () => {
if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current); if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current);
if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current); if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current);
@ -166,6 +170,15 @@ const MeetingDetails = ({ user }) => {
}; };
}, [meeting_id]); }, [meeting_id]);
const loadAudioUploadConfig = async () => {
try {
const nextMaxAudioSize = await configService.getMaxAudioSize();
setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024);
} catch (error) {
setMaxAudioSize(100 * 1024 * 1024);
}
};
useEffect(() => { useEffect(() => {
if (!showSummaryDrawer) { if (!showSummaryDrawer) {
return; return;
@ -490,27 +503,27 @@ const MeetingDetails = ({ user }) => {
}; };
const handleUploadAudio = async (file) => { const handleUploadAudio = async (file) => {
const formData = new FormData(); const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
formData.append('audio_file', file); if (validationMessage) {
formData.append('meeting_id', meeting_id); message.warning(validationMessage);
formData.append('force_replace', 'true'); throw new Error(validationMessage);
if (meeting?.prompt_id) {
formData.append('prompt_id', String(meeting.prompt_id));
}
if (selectedModelCode) {
formData.append('model_code', selectedModelCode);
} }
setIsUploading(true); setIsUploading(true);
setUploadProgress(0); setUploadProgress(0);
setUploadStatusMessage('正在上传音频文件...'); setUploadStatusMessage('正在上传音频文件...');
try { try {
await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData, { await uploadMeetingAudio({
meetingId: meeting_id,
file,
promptId: meeting?.prompt_id,
modelCode: selectedModelCode,
onUploadProgress: (progressEvent) => { onUploadProgress: (progressEvent) => {
if (progressEvent.total) { if (progressEvent.total) {
setUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total))); setUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total)));
} }
setUploadStatusMessage('正在上传音频文件...'); setUploadStatusMessage('正在上传音频文件...');
} },
}); });
setUploadProgress(100); setUploadProgress(100);
setUploadStatusMessage('上传完成,正在启动转录任务...'); setUploadStatusMessage('上传完成,正在启动转录任务...');
@ -944,6 +957,15 @@ const MeetingDetails = ({ user }) => {
<Upload <Upload
id="audio-upload-input" id="audio-upload-input"
showUploadList={false} showUploadList={false}
accept={AUDIO_UPLOAD_ACCEPT}
beforeUpload={(file) => {
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
if (validationMessage) {
message.warning(validationMessage);
return Upload.LIST_IGNORE;
}
return true;
}}
customRequest={async ({ file, onSuccess, onError }) => { customRequest={async ({ file, onSuccess, onError }) => {
try { try {
await handleUploadAudio(file); await handleUploadAudio(file);
@ -989,7 +1011,8 @@ const MeetingDetails = ({ user }) => {
} }
}} }}
moreMenuItems={audioMoreMenuItems} moreMenuItems={audioMoreMenuItems}
emptyText="暂无音频,可通过右侧更多操作上传音频" moreButtonDisabled={!isMeetingOwner}
emptyText="暂无音频,可通过'更多'上传音频"
/> />
</div> </div>

View File

@ -0,0 +1,48 @@
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
export const AUDIO_UPLOAD_ACCEPT = '.mp3,.wav,.m4a,.mpeg,.mp4';
const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.m4a', '.mpeg', '.mp4'];
export const validateMeetingAudioFile = (file, maxAudioSize, formatFileSize) => {
if (!file) {
return '请选择音频文件';
}
const fileName = String(file.name || '').toLowerCase();
const isAllowed = AUDIO_EXTENSIONS.some((ext) => fileName.endsWith(ext));
if (!isAllowed) {
return `仅支持 ${AUDIO_EXTENSIONS.join(' / ').toUpperCase().replace(/\./g, '')} 格式音频`;
}
if (maxAudioSize && file.size > maxAudioSize) {
const readableSize = formatFileSize ? formatFileSize(maxAudioSize) : `${Math.round(maxAudioSize / 1024 / 1024)} MB`;
return `音频大小不能超过 ${readableSize}`;
}
return null;
};
export const uploadMeetingAudio = async ({
meetingId,
file,
promptId,
modelCode,
onUploadProgress,
}) => {
const formData = new FormData();
formData.append('audio_file', file);
formData.append('meeting_id', meetingId);
if (promptId) {
formData.append('prompt_id', String(promptId));
}
if (modelCode) {
formData.append('model_code', modelCode);
}
return apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData, {
onUploadProgress,
});
};

View File

@ -36,7 +36,7 @@ class ConfigService {
async loadConfigsFromServer() { async loadConfigsFromServer() {
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_CONFIG)); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PUBLIC.SYSTEM_CONFIG));
return response.data; return response.data;
} catch (error) { } catch (error) {
console.warn('Failed to load system configs, using defaults:', error); console.warn('Failed to load system configs, using defaults:', error);
@ -53,6 +53,10 @@ class ConfigService {
return configs.MAX_FILE_SIZE || 100 * 1024 * 1024; return configs.MAX_FILE_SIZE || 100 * 1024 * 1024;
} }
async getMaxAudioSize() {
return this.getMaxFileSize();
}
async getMaxImageSize() { async getMaxImageSize() {
const configs = await this.getConfigs(); const configs = await this.getConfigs();
return configs.MAX_IMAGE_SIZE || 10 * 1024 * 1024; return configs.MAX_IMAGE_SIZE || 10 * 1024 * 1024;