From 21c38355c322aefddb725d06ce9ed984c45c2ff6 Mon Sep 17 00:00:00 2001 From: alanpaine Date: Wed, 8 Apr 2026 14:34:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E6=B7=BB=E5=8A=A0Antd=20App?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=92=8C=E9=85=8D=E7=BD=AE=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将全局message替换为App.useApp以支持Antd 5.x静态方法 - 更新vite代理配置指向新后端地址 - 添加baseUrl到tsconfig.json支持路径别名 - 统一Card组件使用variant="borderless"替代bordered={false} - 移除AppLayout中的菜单loading属性 - 优化热词表格显示,添加文本省略 - 更新Drawer组件的destroyOnClose为destroyOnHidden - 添加前端.gitignore文件 - 更新分页组件size配置为default - 修复会议详情页面总结更新参数传递问题 - 移除实时识别创建页面路由,整合到会议列表 - 添加生产环境配置文件到target目录 - 更新字体文件到资源目录 更新首页样式 --- .gitignore | 2 + frontend/.gitignore | 24 + frontend/src/App.tsx | 8 +- frontend/src/api/business/aimodel.ts | 16 +- frontend/src/api/business/dashboard.ts | 4 +- frontend/src/api/business/hotword.ts | 12 +- frontend/src/api/business/meeting.ts | 41 +- frontend/src/api/business/prompt.ts | 10 +- frontend/src/api/business/speaker.ts | 8 +- frontend/src/components/AppLayout.tsx | 1 - .../business/MeetingCreateDrawer.tsx | 497 ++++++++++++++++ .../ButtonWithHoverCard.jsx | 2 +- .../components/shared/MainLayout/AppSider.jsx | 3 +- .../components/shared/PDFViewer/PDFViewer.jsx | 3 +- .../shared/PDFViewer/VirtualPDFViewer.jsx | 3 +- .../src/components/shared/Toast/Toast.tsx | 2 +- frontend/src/index.css | 48 +- frontend/src/layouts/AppLayout.tsx | 7 +- .../src/pages/access/permissions/index.tsx | 5 +- frontend/src/pages/access/roles/index.tsx | 208 ++++--- frontend/src/pages/access/users/index.tsx | 5 +- frontend/src/pages/auth/login/index.tsx | 3 +- .../src/pages/auth/reset-password/index.tsx | 3 +- .../pages/bindings/role-permission/index.tsx | 16 +- .../src/pages/bindings/user-role/index.tsx | 16 +- frontend/src/pages/business/AiModels.tsx | 24 +- frontend/src/pages/business/HotWords.tsx | 31 +- frontend/src/pages/business/MeetingDetail.tsx | 42 +- frontend/src/pages/business/Meetings.tsx | 336 ++--------- .../src/pages/business/PromptTemplates.tsx | 21 +- frontend/src/pages/business/RealtimeAsr.tsx | 556 ------------------ .../src/pages/business/RealtimeAsrSession.tsx | 31 +- frontend/src/pages/business/SpeakerReg.tsx | 122 ++-- frontend/src/pages/dashboard/index.tsx | 4 +- frontend/src/pages/devices/index.tsx | 5 +- frontend/src/pages/home/index.less | 288 ++++++--- frontend/src/pages/home/index.tsx | 105 +++- .../src/pages/organization/orgs/index.tsx | 5 +- .../src/pages/organization/tenants/index.tsx | 5 +- frontend/src/pages/profile/index.tsx | 3 +- .../src/pages/system/dictionaries/index.tsx | 7 +- frontend/src/pages/system/logs/index.tsx | 22 +- .../pages/system/platform-settings/index.tsx | 3 +- .../src/pages/system/sys-params/index.tsx | 5 +- frontend/src/routes/routes.tsx | 3 - frontend/src/utils/pagination.ts | 2 +- frontend/tsconfig.json | 1 + frontend/vite.config.ts | 8 +- 48 files changed, 1249 insertions(+), 1327 deletions(-) create mode 100644 .gitignore create mode 100644 frontend/.gitignore create mode 100644 frontend/src/components/business/MeetingCreateDrawer.tsx delete mode 100644 frontend/src/pages/business/RealtimeAsr.tsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48abcaa --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +backend/target/ +node_modules/ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 01fdc9a..32ac777 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from "react"; -import { ConfigProvider, theme } from "antd"; +import { useEffect, useState } from "react"; +import { ConfigProvider, theme, App as AntdApp } from "antd"; import AppRoutes from "./routes"; import { getOpenPlatformConfig } from "./api"; import {useThemeStore} from "./store/themeStore"; @@ -51,7 +51,9 @@ export default function App() { } }} > - + + + ); } diff --git a/frontend/src/api/business/aimodel.ts b/frontend/src/api/business/aimodel.ts index 462d335..aa7a50a 100644 --- a/frontend/src/api/business/aimodel.ts +++ b/frontend/src/api/business/aimodel.ts @@ -53,55 +53,55 @@ export const getAiModelPage = (params: { name?: string; type?: string; }) => { - return http.get( + return http.get<{ code: string; data: { records: AiModelVO[]; total: number }; msg: string }>( "/api/biz/aimodel/page", { params } ); }; export const saveAiModel = (data: AiModelDTO) => { - return http.post( + return http.post<{ code: string; data: AiModelVO; msg: string }>( "/api/biz/aimodel", data ); }; export const updateAiModel = (data: AiModelDTO) => { - return http.put( + return http.put<{ code: string; data: AiModelVO; msg: string }>( "/api/biz/aimodel", data ); }; export const deleteAiModel = (id: number) => { - return http.delete( + return http.delete<{ code: string; data: boolean; msg: string }>( `/api/biz/aimodel/${id}` ); }; export const deleteAiModelByType = (id: number, type: 'ASR' | 'LLM') => { - return http.delete( + return http.delete<{ code: string; data: boolean; msg: string }>( `/api/biz/aimodel/${id}`, { params: { type } } ); }; export const getRemoteModelList = (params: { provider: string; baseUrl: string; apiKey?: string }) => { - return http.get( + return http.get<{ code: string; data: string[]; msg: string }>( "/api/biz/aimodel/remote-list", { params } ); }; export const testLocalModelConnectivity = (data: { baseUrl: string; apiKey: string }) => { - return http.post( + return http.post<{ code: string; data: AiLocalProfileVO; msg: string }>( "/api/biz/aimodel/local-connectivity-test", data ); }; export const getAiModelDefault = (type: 'ASR' | 'LLM') => { - return http.get( + return http.get<{ code: string; data: AiModelVO; msg: string }>( "/api/biz/aimodel/default", { params: { type } } ); diff --git a/frontend/src/api/business/dashboard.ts b/frontend/src/api/business/dashboard.ts index 12edf8e..ccf7d2f 100644 --- a/frontend/src/api/business/dashboard.ts +++ b/frontend/src/api/business/dashboard.ts @@ -9,13 +9,13 @@ export interface DashboardStats { } export const getDashboardStats = () => { - return http.get( + return http.get<{ code: string; data: DashboardStats; msg: string }>( "/api/biz/dashboard/stats" ); }; export const getRecentTasks = () => { - return http.get( + return http.get<{ code: string; data: MeetingVO[]; msg: string }>( "/api/biz/dashboard/recent" ); }; diff --git a/frontend/src/api/business/hotword.ts b/frontend/src/api/business/hotword.ts index 662bf2a..b301924 100644 --- a/frontend/src/api/business/hotword.ts +++ b/frontend/src/api/business/hotword.ts @@ -35,40 +35,40 @@ export const getHotWordPage = (params: { category?: string; isPublic?: number; }) => { - return http.get( + return http.get<{ code: string; data: { records: HotWordVO[]; total: number }; msg: string }>( "/api/biz/hotword/page", { params } ); }; export const syncHotWord = (id: number) => { - return http.post( + return http.post<{ code: string; data: boolean; msg: string }>( `/api/biz/hotword/${id}/sync` ); }; export const saveHotWord = (data: HotWordDTO) => { - return http.post( + return http.post<{ code: string; data: HotWordVO; msg: string }>( "/api/biz/hotword", data ); }; export const updateHotWord = (data: HotWordDTO) => { - return http.put( + return http.put<{ code: string; data: HotWordVO; msg: string }>( "/api/biz/hotword", data ); }; export const deleteHotWord = (id: number) => { - return http.delete( + return http.delete<{ code: string; data: boolean; msg: string }>( `/api/biz/hotword/${id}` ); }; export const getPinyinSuggestion = (word: string) => { - return http.get( + return http.get<{ code: string; data: string[]; msg: string }>( "/api/biz/hotword/pinyin", { params: { word } } ); diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index 28d9e86..36d8cce 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -92,14 +92,14 @@ export const getMeetingPage = (params: { title?: string; viewType?: "all" | "created" | "involved"; }) => { - return http.get( + return http.get<{ code: string; data: { records: MeetingVO[]; total: number }; msg: string }>( "/api/biz/meeting/page", { params } ); }; export const createMeeting = (data: CreateMeetingCommand) => { - return http.post( + return http.post<{ code: string; data: MeetingVO; msg: string }>( "/api/biz/meeting", data ); @@ -144,34 +144,34 @@ export interface RealtimeMeetingSessionStatus { } export const createRealtimeMeeting = (data: CreateRealtimeMeetingCommand) => { - return http.post( + return http.post<{ code: string; data: MeetingVO; msg: string }>( "/api/biz/meeting/realtime/start", data ); }; export const appendRealtimeTranscripts = (meetingId: number, data: RealtimeTranscriptItemDTO[]) => { - return http.post( + return http.post<{ code: string; data: boolean; msg: string }>( `/api/biz/meeting/${meetingId}/realtime/transcripts`, data ); }; export const getRealtimeMeetingSessionStatus = (meetingId: number) => { - return http.get( + return http.get<{ code: string; data: RealtimeMeetingSessionStatus; msg: string }>( `/api/biz/meeting/${meetingId}/realtime/session-status` ); }; export const getRealtimeMeetingSessionStatuses = (meetingIds: number[]) => { - return http.post; msg: string }>( + return http.post<{ code: string; data: Record; msg: string }>( "/api/biz/meeting/realtime/session-status/batch", meetingIds ); }; export const pauseRealtimeMeeting = (meetingId: number) => { - return http.post( + return http.post<{ code: string; data: RealtimeMeetingSessionStatus; msg: string }>( `/api/biz/meeting/${meetingId}/realtime/pause`, {} ); @@ -181,21 +181,21 @@ export const openRealtimeMeetingSocketSession = ( meetingId: number, data: RealtimeSocketSessionRequest, ) => { - return http.post( + return http.post<{ code: string; data: RealtimeSocketSessionVO; msg: string }>( `/api/biz/meeting/${meetingId}/realtime/socket-session`, data ); }; export const completeRealtimeMeeting = (meetingId: number, data?: { audioUrl?: string; overwriteAudio?: boolean }) => { - return http.post( + return http.post<{ code: string; data: boolean; msg: string }>( `/api/biz/meeting/${meetingId}/realtime/complete`, data || {} ); }; export const deleteMeeting = (id: number) => { - return http.delete( + return http.delete<{ code: string; data: boolean; msg: string }>( `/api/biz/meeting/${id}` ); }; @@ -211,13 +211,13 @@ export interface MeetingTranscriptVO { } export const getMeetingDetail = (id: number) => { - return http.get( + return http.get<{ code: string; data: MeetingVO; msg: string }>( `/api/biz/meeting/${id}` ); }; export const getTranscripts = (id: number) => { - return http.get( + return http.get<{ code: string; data: MeetingTranscriptVO[]; msg: string }>( `/api/biz/meeting/${id}/transcripts` ); }; @@ -236,14 +236,14 @@ export interface MeetingTranscriptUpdateDTO { } export const updateSpeakerInfo = (params: MeetingSpeakerUpdateDTO) => { - return http.put( + return http.put<{ code: string; data: boolean; msg: string }>( "/api/biz/meeting/speaker", params ); }; export const updateMeetingTranscript = (params: MeetingTranscriptUpdateDTO) => { - return http.put( + return http.put<{ code: string; data: boolean; msg: string }>( `/api/biz/meeting/${params.meetingId}/transcripts/${params.transcriptId}`, params ); @@ -256,21 +256,21 @@ export interface MeetingResummaryDTO { } export const reSummary = (params: MeetingResummaryDTO) => { - return http.post( + return http.post<{ code: string; data: boolean; msg: string }>( `/api/biz/meeting/${params.meetingId}/summary/regenerate`, params ); }; export const updateMeetingBasic = (data: UpdateMeetingBasicCommand) => { - return http.put( + return http.put<{ code: string; data: boolean; msg: string }>( `/api/biz/meeting/${data.meetingId}/basic`, data ); }; export const updateMeetingSummary = (data: UpdateMeetingSummaryCommand) => { - return http.put( + return http.put<{ code: string; data: boolean; msg: string }>( `/api/biz/meeting/${data.meetingId}/summary`, data ); @@ -284,7 +284,7 @@ export interface UpdateMeetingParticipantsCommand { export type MeetingParticipantsUpdateDTO = UpdateMeetingParticipantsCommand; export const updateMeetingParticipants = (params: UpdateMeetingParticipantsCommand) => { - return http.put( + return http.put<{ code: string; data: boolean; msg: string }>( `/api/biz/meeting/${params.meetingId}/participants`, params ); @@ -293,7 +293,7 @@ export const updateMeetingParticipants = (params: UpdateMeetingParticipantsComma export const uploadAudio = (file: File) => { const formData = new FormData(); formData.append("file", file); - return http.post( + return http.post<{ code: string; data: string; msg: string }>( "/api/biz/meeting/upload", formData, { headers: { "Content-Type": "multipart/form-data" } } @@ -304,10 +304,11 @@ export interface MeetingProgress { percent: number; message: string; updateAt: number; + eta?: number; } export const getMeetingProgress = (id: number) => { - return http.get( + return http.get<{ code: string; data: MeetingProgress; msg: string }>( `/api/biz/meeting/${id}/progress` ); }; diff --git a/frontend/src/api/business/prompt.ts b/frontend/src/api/business/prompt.ts index 2630f9b..c69a821 100644 --- a/frontend/src/api/business/prompt.ts +++ b/frontend/src/api/business/prompt.ts @@ -33,34 +33,34 @@ export const getPromptPage = (params: { name?: string; category?: string; }) => { - return http.get( + return http.get<{ code: string; data: { records: PromptTemplateVO[]; total: number }; msg: string }>( "/api/biz/prompt/page", { params } ); }; export const savePromptTemplate = (data: PromptTemplateDTO) => { - return http.post( + return http.post<{ code: string; data: PromptTemplateVO; msg: string }>( "/api/biz/prompt", data ); }; export const updatePromptTemplate = (data: PromptTemplateDTO) => { - return http.put( + return http.put<{ code: string; data: PromptTemplateVO; msg: string }>( "/api/biz/prompt", data ); }; export const deletePromptTemplate = (id: number) => { - return http.delete( + return http.delete<{ code: string; data: boolean; msg: string }>( `/api/biz/prompt/${id}` ); }; export const updatePromptStatus = (id: number, status: number) => { - return http.put( + return http.put<{ code: string; data: boolean; msg: string }>( `/api/biz/prompt/${id}/status`, null, { params: { status } } diff --git a/frontend/src/api/business/speaker.ts b/frontend/src/api/business/speaker.ts index 7f4a2af..16f4c3e 100644 --- a/frontend/src/api/business/speaker.ts +++ b/frontend/src/api/business/speaker.ts @@ -39,7 +39,7 @@ export const registerSpeaker = (params: SpeakerRegisterParams) => { formData.append("file", params.file, "voice.wav"); } - return http.post( + return http.post<{ code: string; data: SpeakerVO; msg: string }>( "/api/biz/speaker/register", formData, { @@ -51,20 +51,20 @@ export const registerSpeaker = (params: SpeakerRegisterParams) => { }; export const getSpeakerList = () => { - return http.get( + return http.get<{ code: string; data: SpeakerVO[]; msg: string }>( "/api/biz/speaker/list" ); }; export const getSpeakerPage = (params: SpeakerPageParams) => { - return http.get( + return http.get<{ code: string; data: { records: SpeakerVO[]; total: number }; msg: string }>( "/api/biz/speaker/page", { params } ); }; export const deleteSpeaker = (id: number) => { - return http.delete( + return http.delete<{ code: string; data: boolean; msg: string }>( `/api/biz/speaker/${id}` ); }; diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index da0ee91..c055848 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -92,7 +92,6 @@ export default function AppLayout() { mode="inline" selectedKeys={[location.pathname]} items={menuItems} - loading={loading} style={{ height: 'calc(100% - 64px)', display: 'flex', flexDirection: 'column' }} /> diff --git a/frontend/src/components/business/MeetingCreateDrawer.tsx b/frontend/src/components/business/MeetingCreateDrawer.tsx new file mode 100644 index 0000000..6b72b2a --- /dev/null +++ b/frontend/src/components/business/MeetingCreateDrawer.tsx @@ -0,0 +1,497 @@ +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 = ({ open, initialType = 'upload', onCancel, onSuccess }) => { + const { message } = App.useApp(); + const navigate = useNavigate(); + const [form] = Form.useForm(); + + const [type, setType] = useState(initialType); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const [asrModels, setAsrModels] = useState([]); + const [llmModels, setLlmModels] = useState([]); + const [prompts, setPrompts] = useState([]); + const [hotwordList, setHotwordList] = useState([]); + const [userList, setUserList] = useState([]); + + const [audioUrl, setAudioUrl] = useState(''); + const [uploadProgress, setUploadProgress] = useState(0); + const [fileList, setFileList] = useState([]); + + 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")}` + }); + } + }, [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 = (values.hotWords?.length + ? hotwordList.filter((item) => values.hotWords.includes(item.word)) + : 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, + hotWords: meetingValues.hotWords, + }; + + 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 ( + + + + + + + } + 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)' } + }} + > +
+ + + +
+ {type === 'upload' ? : } +
+
+ {type === 'upload' ? '上传录音分析' : '创建实时会议'} + {type === 'upload' ? '上传已有录音文件进行转写和总结' : '创建会议实时进行语音转写和内容分析'} +
+
+ + + setType(e.target.value)} optionType="button" buttonStyle="solid" size="large"> + 上传录音 + 实时识别 + + +
+
+ +
+
+
+
+ 基础信息 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {asrModels.map(m => ())} + + + + + + + + + + + {prompts.length > 15 ? ( + + ) : ( +
+ + {prompts.map(p => { + const isSelected = watchedPromptId === p.id; + return ( + +
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%' }}> +
{p.templateName}
+ {isSelected &&
} +
+ + ); + })} +
+
+ )} +
+ + + + 热词增强 }> + + + + + + + + 高级设置 + + ), + children: ( +
+ + + 声纹区分 } valuePropName="checked" getValueProps={(v) => ({ checked: v === 1 || v === true })} normalize={(v) => (v ? 1 : 0)}> + + + + + 文本修正 } valuePropName="checked"> + + + + {type === 'realtime' && ( + + + + + + )} + +
+ ), + } + ]} + /> + + {type === 'realtime' && ( + <> + + + + + + )} + + {type === 'upload' && ( + <> +
+
+
+ 上传录音文件 +
+ 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)' }} + > +
+

+

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

+

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

+ {uploadProgress > 0 && uploadProgress < 100 && ( +
+ +
文件传输中,请稍候...
+
+ )} + {audioUrl && ( + + 就绪: + + {audioUrl.split('/').pop()} + + + )} +
+
+ + )} + +
+ + ); +}; diff --git a/frontend/src/components/shared/ButtonWithHoverCard/ButtonWithHoverCard.jsx b/frontend/src/components/shared/ButtonWithHoverCard/ButtonWithHoverCard.jsx index 9800e64..434cc27 100644 --- a/frontend/src/components/shared/ButtonWithHoverCard/ButtonWithHoverCard.jsx +++ b/frontend/src/components/shared/ButtonWithHoverCard/ButtonWithHoverCard.jsx @@ -67,7 +67,7 @@ function ButtonWithHoverCard({ > {/* 标题区 */} diff --git a/frontend/src/components/shared/MainLayout/AppSider.jsx b/frontend/src/components/shared/MainLayout/AppSider.jsx index 4b9df79..29e945a 100644 --- a/frontend/src/components/shared/MainLayout/AppSider.jsx +++ b/frontend/src/components/shared/MainLayout/AppSider.jsx @@ -18,7 +18,7 @@ import { ReadOutlined, BookOutlined, } from '@ant-design/icons' -import { message } from 'antd' +import { App } from 'antd' import { getUserMenus } from '@/api/menu' import useUserStore from '@/stores/userStore' import ModernSidebar from '../ModernSidebar/ModernSidebar' @@ -43,6 +43,7 @@ const iconMap = { } function AppSider({ collapsed, onToggle }) { + const { message } = App.useApp(); const navigate = useNavigate() const location = useLocation() const { user, logout } = useUserStore() diff --git a/frontend/src/components/shared/PDFViewer/PDFViewer.jsx b/frontend/src/components/shared/PDFViewer/PDFViewer.jsx index e33faaf..1fc59b6 100644 --- a/frontend/src/components/shared/PDFViewer/PDFViewer.jsx +++ b/frontend/src/components/shared/PDFViewer/PDFViewer.jsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react' import { Document, Page, pdfjs } from 'react-pdf' -import { Button, Space, InputNumber, message, Spin } from 'antd' +import { Button, Space, InputNumber, Spin, App } from 'antd' import { LeftOutlined, RightOutlined, @@ -15,6 +15,7 @@ import './PDFViewer.css' pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs' function PDFViewer({ url, filename }) { + const { message } = App.useApp(); const [numPages, setNumPages] = useState(null) const [pageNumber, setPageNumber] = useState(1) const [scale, setScale] = useState(1.0) diff --git a/frontend/src/components/shared/PDFViewer/VirtualPDFViewer.jsx b/frontend/src/components/shared/PDFViewer/VirtualPDFViewer.jsx index 4978844..830e9cd 100644 --- a/frontend/src/components/shared/PDFViewer/VirtualPDFViewer.jsx +++ b/frontend/src/components/shared/PDFViewer/VirtualPDFViewer.jsx @@ -1,6 +1,6 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react' import { Document, Page, pdfjs } from 'react-pdf' -import { Button, Space, InputNumber, message, Spin } from 'antd' +import { Button, Space, InputNumber, App, Spin } from 'antd' import { ZoomInOutlined, ZoomOutOutlined, @@ -16,6 +16,7 @@ import './VirtualPDFViewer.css' pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs' function VirtualPDFViewer({ url, filename }) { + const { message } = App.useApp(); const [numPages, setNumPages] = useState(null) const [scale, setScale] = useState(1.0) const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // 默认 A4 diff --git a/frontend/src/components/shared/Toast/Toast.tsx b/frontend/src/components/shared/Toast/Toast.tsx index e42d996..5049247 100644 --- a/frontend/src/components/shared/Toast/Toast.tsx +++ b/frontend/src/components/shared/Toast/Toast.tsx @@ -1,4 +1,4 @@ -import { notification } from "antd"; +import { notification } from 'antd'; import { CheckCircleOutlined, CloseCircleOutlined, diff --git a/frontend/src/index.css b/frontend/src/index.css index ac2d990..754080c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,4 @@ -:root { +:root { --app-primary-color: #1677ff; --app-primary-rgb: 22, 119, 255; --app-bg-main: @@ -728,6 +728,51 @@ body::after { box-shadow: 0 0 0 6px rgba(var(--home-tech-rgb), 0.14) !important; } +:root[data-theme="default"] .home-recent-card, +:root[data-theme="tech"] .home-recent-card { + border: none !important; + background: linear-gradient(180deg, #f9f8fe 0%, #f3f2fa 100%) !important; + box-shadow: 0 8px 28px rgba(113, 107, 151, 0.08) !important; +} + +:root[data-theme="default"] .home-recent-card:hover, +:root[data-theme="tech"] .home-recent-card:hover { + border: none !important; + background: linear-gradient(180deg, #f9f8fe 0%, #f3f2fa 100%) !important; + box-shadow: 0 14px 34px rgba(113, 107, 151, 0.12) !important; +} + +:root[data-theme="default"] .home-recent-card .home-recent-card-title, +:root[data-theme="tech"] .home-recent-card .home-recent-card-title { + color: #2d2c59 !important; +} + +:root[data-theme="default"] .home-recent-card .home-recent-card-icon, +:root[data-theme="tech"] .home-recent-card .home-recent-card-icon { + background: #efedf8 !important; + color: #8a80ff !important; + box-shadow: none !important; +} + +:root[data-theme="default"] .home-recent-card .home-recent-card-tag, +:root[data-theme="tech"] .home-recent-card .home-recent-card-tag { + background: #eceaf7 !important; + color: #6f66f0 !important; +} + +:root[data-theme="default"] .home-recent-card .home-recent-card-duration, +:root[data-theme="default"] .home-recent-card .home-recent-card-time, +:root[data-theme="tech"] .home-recent-card .home-recent-card-duration, +:root[data-theme="tech"] .home-recent-card .home-recent-card-time { + color: #7d7d9e !important; +} + +:root[data-theme="default"] .home-recent-card .home-recent-card-dot, +:root[data-theme="tech"] .home-recent-card .home-recent-card-dot { + background: linear-gradient(180deg, #ff8f8f 0%, #f56f6f 100%) !important; + box-shadow: 0 0 0 4px rgba(249, 248, 254, 0.96) !important; +} + @media (max-width: 768px) { body::after { inset: 10px; @@ -751,4 +796,3 @@ body::after { } - diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index 3026400..b800705 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -1,4 +1,4 @@ -import * as AntIcons from "@ant-design/icons"; +import * as AntIcons from "@ant-design/icons"; import { BellOutlined, ApartmentOutlined, @@ -17,7 +17,7 @@ import { UserOutlined, VideoCameraOutlined } from "@ant-design/icons"; -import { Avatar, Button, Dropdown, Layout, Menu, Space, message, type MenuProps } from "antd"; +import { Avatar, Button, Dropdown, Layout, Menu, Space, type MenuProps, App } from 'antd'; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -57,6 +57,7 @@ type PermissionMenuNode = SysPermission & { }; export default function AppLayout() { + const { message } = App.useApp(); const { t, i18n } = useTranslation(); const [collapsed, setCollapsed] = useState(false); const [menus, setMenus] = useState([]); @@ -422,7 +423,7 @@ export default function AppLayout() { record.permType !== "button" && !!record.children?.length }} /> -