diff --git a/frontend/package.json b/frontend/package.json index 865e249..0fdb20b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "vite build", "preview": "vite preview" }, diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index ad858dc..9d5fd9b 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -245,11 +245,6 @@ export default function Login() { -
- {/**/} - {/* {t('login.demoAccount')}:admin / {t('login.password')}:123456*/} - {/**/} -
diff --git a/frontend/src/pages/business/HotWords.tsx b/frontend/src/pages/business/HotWords.tsx index 0cf7bbf..decce9c 100644 --- a/frontend/src/pages/business/HotWords.tsx +++ b/frontend/src/pages/business/HotWords.tsx @@ -82,13 +82,12 @@ const HotWords: React.FC = () => { } setEditingId(record.id); form.setFieldsValue({ - ...record, - pinyinList: record.pinyinList.join(', ') + ...record }); } else { setEditingId(null); form.resetFields(); - form.setFieldsValue({ weight: 10, status: 1, isPublic: 0 }); + form.setFieldsValue({ weight: 2, status: 1, isPublic: 0 }); } setModalVisible(true); }; @@ -197,10 +196,16 @@ const HotWords: React.FC = () => { {canEdit ? ( <> - deleteHotWord(record.id).then(fetchData)}> + handleDelete(record.id)} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + > + ) : ( 无权操作 )} @@ -274,10 +279,6 @@ const HotWords: React.FC = () => { - - - - @@ -287,8 +288,8 @@ const HotWords: React.FC = () => { - - + + diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx index c69a5d1..fd9a58c 100644 --- a/frontend/src/pages/business/Meetings.tsx +++ b/frontend/src/pages/business/Meetings.tsx @@ -1,34 +1,53 @@ import React, { useState, useEffect } from 'react'; -import { Card, Button, Input, Space, Tag, message, Popconfirm, Typography, Row, Col, List, Badge, Empty, Skeleton, Tooltip, Radio, Pagination } from 'antd'; +import { Card, Button, Input, Space, Tag, message, Popconfirm, Typography, Row, Col, List, Badge, Empty, Skeleton, Tooltip, Radio, Pagination, Progress, Drawer, Form, DatePicker, Upload, Avatar, Divider, Switch, Select } from 'antd'; import { PlusOutlined, DeleteOutlined, SearchOutlined, CheckCircleOutlined, LoadingOutlined, UserOutlined, CalendarOutlined, PlayCircleOutlined, TeamOutlined, ClockCircleOutlined, EditOutlined, RightOutlined, - SyncOutlined, InfoCircleOutlined + SyncOutlined, InfoCircleOutlined, CloudUploadOutlined, SettingOutlined, + QuestionCircleOutlined, FileTextOutlined, CheckOutlined, RocketOutlined, + AudioOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; -import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress } from '../../api/business/meeting'; +import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, 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'; import dayjs from 'dayjs'; +import { useTranslation } from 'react-i18next'; const { Text, Title } = Typography; +const { Dragger } = Upload; +const { Option } = Select; -// 状态标签组件:集成进度背景 -const IntegratedStatusTag: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => { +// --- 进度感知 Hook --- +const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => { const [progress, setProgress] = useState(null); - useEffect(() => { if (meeting.status !== 1 && meeting.status !== 2) return; const fetchProgress = async () => { try { const res = await getMeetingProgress(meeting.id); - if (res.data && res.data.data) setProgress(res.data.data); + if (res.data && res.data.data) { + setProgress(res.data.data); + // 当达到 100% 时触发完成回调 + if (res.data.data.percent === 100 && onComplete) { + onComplete(); + } + } } catch (err) {} }; fetchProgress(); const timer = setInterval(fetchProgress, 3000); return () => clearInterval(timer); }, [meeting.id, meeting.status]); + return progress; +}; +// --- 状态标签组件 --- +const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgress | null }> = ({ meeting, progress }) => { const statusConfig: Record = { 0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' }, 1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' }, @@ -36,42 +55,15 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => { 3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' }, 4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' } }; - const config = statusConfig[meeting.status] || statusConfig[0]; const percent = progress?.percent || 0; const isProcessing = meeting.status === 1 || meeting.status === 2; - return ( -
+
{/* 进度填充背景 */} {isProcessing && percent > 0 && ( -
+
)} - {isProcessing ? : null} {config.text} @@ -81,91 +73,219 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => { ); }; -// 新增:提取进度信息 Hook 供卡片内部使用 -const useMeetingProgress = (meeting: MeetingVO) => { - const [progress, setProgress] = useState(null); +// --- 发起会议表单组件 (左侧高度占满版) --- +const MeetingCreateForm: React.FC<{ + form: any, + audioUrl: string, + setAudioUrl: (url: string) => void, + uploadProgress: number, + setUploadProgress: (p: number) => void, + fileList: any[], + setFileList: (list: any[]) => void +}> = ({ form, audioUrl, setAudioUrl, uploadProgress, setUploadProgress, fileList, setFileList }) => { + const [asrModels, setAsrModels] = useState([]); + const [llmModels, setLlmModels] = useState([]); + const [prompts, setPrompts] = useState([]); + const [hotwordList, setHotwordList] = useState([]); + const [userList, setUserList] = useState([]); + const watchedPromptId = Form.useWatch('promptId', form); useEffect(() => { - if (meeting.status !== 1 && meeting.status !== 2) return; - const fetchProgress = async () => { - try { - const res = await getMeetingProgress(meeting.id); - if (res.data && res.data.data) setProgress(res.data.data); - } catch (err) {} - }; - fetchProgress(); - const timer = setInterval(fetchProgress, 3000); - return () => clearInterval(timer); - }, [meeting.id, meeting.status]); + loadInitialData(); + }, []); - return progress; + 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: 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('文件上传失败'); + } + }; + + return ( +
+ + + {/* 基础信息卡片 - 固定高度 */} + 基础信息} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)', marginBottom: 20 }}> + + + + + {userList.map(u => ())} + + + + + {/* 录音上传卡片 - 占满剩余高度 */} + 录音上传} + bordered={false} + style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)', background: '#f9fbff', flex: 1, display: 'flex', flexDirection: 'column' }} + bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px' }} + > + setFileList(info.fileList.slice(-1))} + maxCount={1} + style={{ borderRadius: 8, flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }} + > +
+

+

点击或拖拽录音文件到此处

+

支持高质量 .mp3, .wav, .m4a 格式音频

+ {uploadProgress > 0 && uploadProgress < 100 && ( +
+ +
文件传输中,请稍候...
+
+ )} + {audioUrl && ( + + + 就绪: {audioUrl.split('/').pop()} + + + )} +
+
+
+ + + + AI 分析配置} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)', marginBottom: 20 }}> + + + + + + + + + {prompts.length > 15 ? ( + + ) : ( +
+ + {prompts.map(p => { + const isSelected = watchedPromptId === p.id; + return ( + +
form.setFieldsValue({ promptId: p.id })} style={{ padding: '6px', borderRadius: 6, border: `1.5px solid ${isSelected ? '#1890ff' : '#f0f0f0'}`, backgroundColor: isSelected ? '#f0f7ff' : '#fff', cursor: 'pointer', textAlign: 'center', position: 'relative' }}> +
{p.templateName}
+ {isSelected &&
} +
+ + ); + })} +
+
+ )} +
+ + 声纹识别 } valuePropName="checked" getValueProps={(v) => ({ checked: v === 1 })} normalize={(v) => (v ? 1 : 0)} style={{ marginBottom: 20 }}> + + +
+ +
+ 智能分析链路:转录固化 + AI 总结 + 说话人分离。 +
+ +
+
+ ); }; -const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void }> = ({ item, config, fetchData }) => { +// --- 卡片项组件 --- +const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any }> = ({ item, config, fetchData, t }) => { const navigate = useNavigate(); - const progress = useMeetingProgress(item); + // 注入自动刷新回调 + const progress = useMeetingProgress(item, () => fetchData()); const isProcessing = item.status === 1 || item.status === 2; return ( - navigate(`/meetings/${item.id}`)} - className="meeting-card" - style={{ - borderRadius: 16, - border: 'none', - height: '220px', - position: 'relative', - boxShadow: '0 6px 16px rgba(0,0,0,0.04)', - transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' - }} - bodyStyle={{ padding: 0, display: 'flex', height: '100%' }} - > - {/* 左侧状态装饰条 - 增加分析中的呼吸灯效果 */} -
- + navigate(`/meetings/${item.id}`)} className="meeting-card" style={{ borderRadius: 16, border: 'none', height: '220px', position: 'relative', boxShadow: '0 6px 16px rgba(0,0,0,0.04)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} bodyStyle={{ padding: 0, display: 'flex', height: '100%' }}> +
- - {/* 右上角醒目图标 */}
e.stopPropagation()}> - -
navigate(`/meetings/${item.id}`)}> - -
-
- deleteMeeting(item.id).then(fetchData)}> - -
- -
-
+
navigate(`/meetings/${item.id}`)}>
+ deleteMeeting(item.id).then(fetchData)} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + > +
- {/* 内容排版 */}
- +
- -
- - {item.title} - -
- +
{item.title}
-
- - {dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')} -
- +
{dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')}
{isProcessing ? (
display: 'flex', alignItems: 'center', background: item.status === 1 ? '#e6f7ff' : '#fff7e6', - padding: '4px 8px', - borderRadius: 4, - marginTop: 2 + padding: '6px 12px', // 增加内边距,更聚拢 + borderRadius: 6, + marginTop: 4, + width: 'calc(100% - 12px)', // 留出右侧与卡片边缘的距离 + overflow: 'hidden', + boxSizing: 'border-box' }}> - - + + {progress?.message || '等待引擎调度...'}
) : ( -
- - {item.participants || '无参与人员'} -
+ +
{item.participants || '无参与人员'}
)}
- - {/* 底部详情提示 */}
-
- {item.tags?.split(',').slice(0, 2).map(t => ( - {t} - ))} -
+
{item.tags?.split(',').slice(0, 2).map(t => ( + {t} + ))}
@@ -206,32 +332,55 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => ); }; +// --- 主组件 --- const Meetings: React.FC = () => { + const { t } = useTranslation(); const navigate = useNavigate(); + const [form] = Form.useForm(); const [loading, setLoading] = useState(false); + const [submitLoading, setSubmitLoading] = useState(false); const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [current, setCurrent] = useState(1); const [size, setSize] = useState(8); const [searchTitle, setSearchTitle] = useState(''); const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all'); + const [createDrawerVisible, setCreateDrawerVisible] = useState(false); + + const [audioUrl, setAudioUrl] = useState(''); + const [uploadProgress, setUploadProgress] = useState(0); + const [fileList, setFileList] = useState([]); - useEffect(() => { - fetchData(); - }, [current, size, searchTitle, viewType]); + useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]); const fetchData = async () => { setLoading(true); try { const res = await getMeetingPage({ current, size, title: searchTitle, viewType }); - if (res.data && res.data.data) { - setData(res.data.data.records); - setTotal(res.data.data.total); - } - } catch (err) { - console.error(err); - } finally { - setLoading(false); + if (res.data && res.data.data) { setData(res.data.data.records); setTotal(res.data.data.total); } + } catch (err) {} finally { setLoading(false); } + }; + + const handleCreateSubmit = async () => { + if (!audioUrl) { + message.error('请先上传录音文件'); + return; + } + const values = await form.validateFields(); + 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('会议发起成功'); + setCreateDrawerVisible(false); + fetchData(); + } catch (err) {} finally { + setSubmitLoading(false); } }; @@ -244,124 +393,84 @@ const Meetings: React.FC = () => { }; return ( -
+
- - {/* 固定头部 - 极简白卡 */} - - -
- 会议中心 -
- +
会议中心
{ setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid"> - 全部 - 我发起 - 我参与 + 全部我发起我参与 - } - allowClear - onPressEnter={(e) => { setSearchTitle((e.target as any).value); setCurrent(1); }} - style={{ width: 220, borderRadius: 8 }} - /> - + } allowClear onPressEnter={(e) => { setSearchTitle((e.target as any).value); setCurrent(1); }} style={{ width: 220, borderRadius: 8 }} /> +
- {/* 列表区 */}
- { - const config = statusConfig[item.status] || statusConfig[0]; - return ; - }} - locale={{ emptyText: }} - /> + { + const config = statusConfig[item.status] || statusConfig[0]; + return ; + }} locale={{ emptyText: }} />
- {/* 分页 */} {total > 0 && (
- { setCurrent(p); setSize(s); }} - showTotal={(total) => 为您找到 {total} 场会议} - size="small" - /> + { setCurrent(p); setSize(s); }} showTotal={(total) => 为您找到 {total} 场会议} size="small" />
)}
+ +
发起新会议分析
} + width="clamp(800px, 85vw, 1100px)" + onClose={() => setCreateDrawerVisible(false)} + open={createDrawerVisible} + destroyOnClose + styles={{ body: { backgroundColor: '#f4f7f9', padding: '24px 32px' } }} + footer={ +
+ + + + +
+ } + > + +
+
); diff --git a/frontend/src/pages/business/PromptTemplates.tsx b/frontend/src/pages/business/PromptTemplates.tsx index 64a7876..88a80a8 100644 --- a/frontend/src/pages/business/PromptTemplates.tsx +++ b/frontend/src/pages/business/PromptTemplates.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Card, Button, Input, Space, Drawer, Form, Select, Tag, message, Popconfirm, Typography, Divider, Tooltip, Row, Col, List, Empty, Skeleton, Switch, Modal } from 'antd'; +import { Card, Button, Input, Space, Drawer, Form, Select, Tag, message, Popconfirm, Typography, Divider, Tooltip, Row, Col, List, Empty, Skeleton, Switch, Modal, Pagination } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, SaveOutlined, StarFilled } from '@ant-design/icons'; import ReactMarkdown from 'react-markdown'; import { useDict } from '../../hooks/useDict'; @@ -13,17 +13,24 @@ import { PromptTemplateDTO } from '../../api/business/prompt'; +import { useTranslation } from 'react-i18next'; + const { Option } = Select; const { Text, Title } = Typography; const PromptTemplates: React.FC = () => { + const { t } = useTranslation(); const [form] = Form.useForm(); const [searchForm] = Form.useForm(); const { items: categories, loading: dictLoading } = useDict('biz_prompt_category'); const { items: dictTags } = useDict('biz_prompt_tag'); const { items: promptLevels } = useDict('biz_prompt_level'); + const [loading, setLoading] = useState(false); const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [current, setCurrent] = useState(1); + const [pageSize, setPageSize] = useState(12); const [drawerVisible, setDrawerVisible] = useState(false); const [editingId, setEditingId] = useState(null); @@ -42,20 +49,21 @@ const PromptTemplates: React.FC = () => { useEffect(() => { fetchData(); - }, []); + }, [current, pageSize]); const fetchData = async () => { const values = searchForm.getFieldsValue(); setLoading(true); try { const res = await getPromptPage({ - current: 1, - size: 1000, + current, + size: pageSize, name: values.name, category: values.category }); if (res.data && res.data.data) { setData(res.data.data.records); + setTotal(res.data.data.total); } } catch (err) { console.error(err); @@ -175,16 +183,17 @@ const PromptTemplates: React.FC = () => { const renderCard = (item: PromptTemplateVO) => { const isSystem = item.isSystem === 1; - const isPlatformLevel = Number(item.tenantId) === 0; + const isPlatformLevel = Number(item.tenantId) === 0 && isSystem; const isTenantLevel = Number(item.tenantId) > 0 && isSystem; + const isPersonalLevel = !isSystem; // 权限判定逻辑 (使用 Number 强制转换防止类型不匹配) let canEdit = false; const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1; if (isPlatformAdmin) { - // 平台管理员管理平台级 (tenantId = 0) - canEdit = isPlatformLevel; + // 平台管理员管理平台下的所有 (tenantId = 0) + canEdit = Number(item.tenantId) === 0; } else if (isTenantAdmin) { // 租户管理员管理本租户所有模板 canEdit = Number(item.tenantId) === activeTenantId; @@ -213,10 +222,11 @@ const PromptTemplates: React.FC = () => {
- +
{levelTag}
@@ -253,7 +263,12 @@ const PromptTemplates: React.FC = () => { handleOpenDrawer(item, true)} /> {canEdit && ( - deletePromptTemplate(item.id).then(fetchData)}> + deletePromptTemplate(item.id).then(fetchData)} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + > @@ -296,17 +311,34 @@ const PromptTemplates: React.FC = () => { {Object.keys(groupedData).length === 0 ? ( ) : ( - Object.keys(groupedData).map(catKey => { - const catLabel = categories.find(c => c.itemValue === catKey)?.itemLabel || catKey; - return ( -
- {catLabel} -
- {groupedData[catKey].map(renderCard)} + <> + {Object.keys(groupedData).map(catKey => { + const catLabel = categories.find(c => c.itemValue === catKey)?.itemLabel || catKey; + return ( +
+ {catLabel} +
+ {groupedData[catKey].map(renderCard)} +
-
- ); - }) + ); + })} + +
+ { + setCurrent(page); + setPageSize(size); + }} + showTotal={(total) => `共 ${total} 条模板`} + /> +
+ )}