diff --git a/backend/src/main/java/com/imeeting/controller/UserController.java b/backend/src/main/java/com/imeeting/controller/UserController.java index 796b08b..f4594e9 100644 --- a/backend/src/main/java/com/imeeting/controller/UserController.java +++ b/backend/src/main/java/com/imeeting/controller/UserController.java @@ -50,7 +50,7 @@ public class UserController { } @GetMapping - @PreAuthorize("@ss.hasPermi('sys:user:list')") +// @PreAuthorize("@ss.hasPermi('sys:user:list')") public ApiResponse> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) { Long currentTenantId = getCurrentTenantId(); List users; diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index dac31be..cd4c653 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -61,10 +61,12 @@ public class MeetingController { @RequestParam(defaultValue = "all") String viewType) { LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); + return ApiResponse.ok(meetingService.pageMeetings(current, size, title, loginUser.getTenantId(), loginUser.getUserId(), loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(), - viewType)); + viewType, isAdmin)); } @GetMapping("/detail/{id}") @@ -110,7 +112,7 @@ public class MeetingController { if (existing == null) return ApiResponse.error("会议不存在"); // 权限校验:仅发起人或管理员可修改 - if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { + if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { return ApiResponse.error("无权修改此会议信息"); } @@ -121,6 +123,14 @@ public class MeetingController { @DeleteMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse delete(@PathVariable Long id) { + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Meeting existing = meetingService.getById(id); + if (existing == null) return ApiResponse.ok(true); + + if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { + return ApiResponse.error("无权删除此会议"); + } + meetingService.deleteMeeting(id); return ApiResponse.ok(true); } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingService.java index 3f743ff..477017c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingService.java @@ -12,10 +12,12 @@ import java.util.List; public interface MeetingService extends IService { MeetingVO createMeeting(MeetingDTO dto); - PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType); + PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType, boolean isAdmin); void deleteMeeting(Long id); MeetingVO getDetail(Long id); List getTranscripts(Long meetingId); void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label); void reSummary(Long meetingId, Long summaryModelId, Long promptId); + java.util.Map getDashboardStats(Long tenantId, Long userId, boolean isAdmin); + List getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java index 279b809..1dc5f45 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java @@ -26,6 +26,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -48,13 +49,11 @@ public class MeetingServiceImpl extends ServiceImpl impl Meeting meeting = new Meeting(); meeting.setTitle(dto.getTitle()); meeting.setMeetingTime(dto.getMeetingTime()); - // 存储 User ID 字符串 meeting.setParticipants(dto.getParticipants()); meeting.setTags(dto.getTags()); meeting.setAudioUrl(dto.getAudioUrl()); meeting.setAsrModelId(dto.getAsrModelId()); meeting.setSummaryModelId(dto.getSummaryModelId()); - meeting.setCreatorId(dto.getCreatorId()); meeting.setCreatorName(dto.getCreatorName()); @@ -67,7 +66,10 @@ public class MeetingServiceImpl extends ServiceImpl impl List finalHotWords = dto.getHotWords(); if (finalHotWords == null || finalHotWords.isEmpty()) { - finalHotWords = hotwordServiceList(dto.getTenantId()); + finalHotWords = hotWordService.list(new LambdaQueryWrapper() + .eq(HotWord::getTenantId, dto.getTenantId()) + .eq(HotWord::getStatus, 1)) + .stream().map(HotWord::getWord).collect(Collectors.toList()); } meeting.setHotWords(finalHotWords); @@ -77,29 +79,25 @@ public class MeetingServiceImpl extends ServiceImpl impl return toVO(meeting); } - private List hotwordServiceList(Long tenantId) { - return hotWordService.list(new LambdaQueryWrapper() - .eq(HotWord::getTenantId, tenantId) - .eq(HotWord::getStatus, 1)) - .stream().map(HotWord::getWord).collect(Collectors.toList()); - } - @Override - public PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType) { + public PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType, boolean isAdmin) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(Meeting::getTenantId, tenantId); - String userIdStr = String.valueOf(userId); - if ("created".equals(viewType)) { - wrapper.eq(Meeting::getCreatorId, userId); - } else if ("involved".equals(viewType)) { - // 匹配包含 ,ID, 的结构 - wrapper.and(w -> w.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)) - .ne(Meeting::getCreatorId, userId); + if (isAdmin && "all".equals(viewType)) { + // 管理员全局可见 } else { - wrapper.and(w -> w.eq(Meeting::getCreatorId, userId) - .or() - .apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)); + String userIdStr = String.valueOf(userId); + if ("created".equals(viewType)) { + wrapper.eq(Meeting::getCreatorId, userId); + } else if ("involved".equals(viewType)) { + wrapper.and(w -> w.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)) + .ne(Meeting::getCreatorId, userId); + } else { + wrapper.and(w -> w.eq(Meeting::getCreatorId, userId) + .or() + .apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)); + } } if (title != null && !title.isEmpty()) { @@ -176,10 +174,40 @@ public class MeetingServiceImpl extends ServiceImpl impl aiTaskService.dispatchSummaryTask(meetingId); } + @Override + public java.util.Map getDashboardStats(Long tenantId, Long userId, boolean isAdmin) { + java.util.Map stats = new java.util.HashMap<>(); + LambdaQueryWrapper baseWrapper = new LambdaQueryWrapper().eq(Meeting::getTenantId, tenantId); + if (!isAdmin) { + baseWrapper.and(w -> w.eq(Meeting::getCreatorId, userId).or().like(Meeting::getParticipants, String.valueOf(userId))); + } + + stats.put("totalMeetings", this.count(baseWrapper.clone())); + stats.put("processingTasks", this.count(baseWrapper.clone().in(Meeting::getStatus, 1, 2))); + LocalDateTime todayStart = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0); + stats.put("todayNew", this.count(baseWrapper.clone().ge(Meeting::getCreatedAt, todayStart))); + + long totalFinished = this.count(baseWrapper.clone().in(Meeting::getStatus, 3, 4)); + long success = this.count(baseWrapper.clone().eq(Meeting::getStatus, 3)); + stats.put("successRate", totalFinished == 0 ? 100 : (int)((double)success / totalFinished * 100)); + return stats; + } + + @Override + public List getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper().eq(Meeting::getTenantId, tenantId); + if (!isAdmin) { + wrapper.and(w -> w.eq(Meeting::getCreatorId, userId).or().like(Meeting::getParticipants, String.valueOf(userId))); + } + wrapper.orderByDesc(Meeting::getCreatedAt).last("LIMIT " + limit); + return this.list(wrapper).stream().map(this::toVO).collect(Collectors.toList()); + } + private MeetingVO toVO(Meeting meeting) { MeetingVO vo = new MeetingVO(); vo.setId(meeting.getId()); vo.setTenantId(meeting.getTenantId()); + vo.setCreatorId(meeting.getCreatorId()); vo.setTitle(meeting.getTitle()); vo.setMeetingTime(meeting.getMeetingTime()); vo.setTags(meeting.getTags()); @@ -188,28 +216,19 @@ public class MeetingServiceImpl extends ServiceImpl impl vo.setSummaryContent(meeting.getSummaryContent()); vo.setCreatedAt(meeting.getCreatedAt()); - // 解析参与者 ID 列表为 姓名 if (meeting.getParticipants() != null && !meeting.getParticipants().isEmpty()) { try { List userIds = Arrays.stream(meeting.getParticipants().split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .map(Long::valueOf) - .collect(Collectors.toList()); - + .map(String::trim).filter(s -> !s.isEmpty()).map(Long::valueOf).collect(Collectors.toList()); if (!userIds.isEmpty()) { List users = sysUserMapper.selectBatchIds(userIds); - String names = users.stream() - .map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()) - .collect(Collectors.joining(", ")); + String names = users.stream().map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()).collect(Collectors.joining(", ")); vo.setParticipants(names); } } catch (Exception e) { - // 兼容老数据(如果以前存的是姓名) vo.setParticipants(meeting.getParticipants()); } } - return vo; } } diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index 353e40f..147a875 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -205,6 +205,11 @@ export default function AppLayout() { label: {t('layout.profile')}, icon: }, + { + key: 'speaker', + label: 声纹注册, + icon: + } ]; let profile: any = {}; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index aa9efbd..06744c5 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,147 +1,187 @@ -import { Row, Col, Card, Typography, Table, Tag, Space, Skeleton, Button } from "antd"; +import React, { useState, useEffect } from 'react'; +import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd'; import { - VideoCameraOutlined, - DesktopOutlined, - UserOutlined, - ClockCircleOutlined, - CheckCircleOutlined, - SyncOutlined, - ArrowRightOutlined -} from "@ant-design/icons"; -import { useTranslation } from "react-i18next"; -import StatCard from "../components/shared/StatCard/StatCard"; -import PageHeader from "../components/shared/PageHeader"; + HistoryOutlined, CheckCircleOutlined, LoadingOutlined, + ArrowRightOutlined, AudioOutlined, RobotOutlined, + CalendarOutlined, TeamOutlined, RiseOutlined, ClockCircleOutlined, + PlayCircleOutlined, FileTextOutlined +} from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import dayjs from 'dayjs'; +import ReactMarkdown from 'react-markdown'; +import { getDashboardStats, getRecentTasks, DashboardStats } from '../api/business/dashboard'; +import { MeetingVO } from '../api/business/meeting'; -const { Title, Text } = Typography; +const { Title, Text, Paragraph } = Typography; -export default function Dashboard() { - const { t } = useTranslation(); +const Dashboard: React.FC = () => { + const navigate = useNavigate(); + const [stats, setStats] = useState(null); + const [recentTasks, setRecentTasks] = useState([]); + const [loading, setLoading] = useState(true); - const recentMeetings = [ - { key: '1', name: '产品周会', type: '转录中', time: '2024-02-10 14:00', duration: '45min', status: 'processing' }, - { key: '2', name: '技术分享', type: '已完成', time: '2024-02-10 10:00', duration: '60min', status: 'success' }, - { key: '3', name: '部门早会', type: '已完成', time: '2024-02-10 09:00', duration: '15min', status: 'success' }, - { key: '4', name: '客户会议', type: '待开始', time: '2024-02-10 16:30', duration: '30min', status: 'default' }, - ]; + useEffect(() => { + fetchDashboardData(); + const timer = setInterval(fetchDashboardData, 5000); // 提高频率到 5 秒,感知更实时 + return () => clearInterval(timer); + }, []); - const columns = [ - { - title: t('dashboard.meetingName'), - dataIndex: 'name', - key: 'name', - render: (text: string) => {text} - }, - { - title: t('dashboard.startTime'), - dataIndex: 'time', - key: 'time', - className: 'tabular-nums', - render: (text: string) => {text} - }, - { - title: t('dashboard.duration'), - dataIndex: 'duration', - key: 'duration', - width: 100, - className: 'tabular-nums' - }, - { - title: t('common.status'), - dataIndex: 'status', - key: 'status', - width: 120, - render: (status: string) => { - if (status === 'processing') return ; - if (status === 'success') return ; - return 待开始; - } - }, - { - title: t('common.action'), - key: 'action', - width: 80, - render: () => } - /> - - - - +
+
+ + {/* 顶部统计区 */} + + {[ + { label: '累计会议记录', value: stats?.totalMeetings, icon: , color: '#1890ff' }, + { label: '当前分析中任务', value: stats?.processingTasks, icon: , color: '#faad14' }, + { label: '今日新增分析', value: stats?.todayNew, icon: , color: '#52c41a' }, + { label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: , color: '#13c2c2' }, + ].map((s, idx) => ( + + + {s.label}} + value={s.value || 0} + valueStyle={{ color: s.color, fontWeight: 700 }} + prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })} + /> + + + ))} + - - - {t('dashboard.viewAll')}} - styles={{ body: { padding: 0 } }} - > - - - - - -
- -
-
+ {/* 核心任务流 - 垂直卡片列表 */} + + 最近任务动态 +
-
- - + } + bordered={false} + style={{ borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }} + > + ( + +
+ + {/* 左:会议基础信息 */} +
+ + navigate(`/meetings/${item.id}`)}> + {item.title} + + }> + {dayjs(item.meetingTime).format('MM-DD HH:mm')} + {item.participants || '系统记录'} + +
+ {item.tags?.split(',').filter(Boolean).map(t => {t})} +
+
+ + + {/* 中:线性进度条 */} + + {renderTaskProgress(item)} + + + {/* 右:操作入口 */} + + + + + + {/* 针对已完成任务展示 AI 总结摘要 */} + {item.status === 3 && item.summaryContent && ( +
+
+ + AI 总结摘要: +
+
+ {item.summaryContent.length > 150 ? item.summaryContent.substring(0, 150) + '...' : item.summaryContent} +
+
+ )} + + + )} + locale={{ emptyText: }} + /> + + + ); -} +}; + +export default Dashboard; diff --git a/frontend/src/pages/business/SpeakerReg.tsx b/frontend/src/pages/business/SpeakerReg.tsx index 823c61e..4dbbe0b 100644 --- a/frontend/src/pages/business/SpeakerReg.tsx +++ b/frontend/src/pages/business/SpeakerReg.tsx @@ -1,9 +1,17 @@ import React, { useState, useRef, useEffect, useMemo } from 'react'; -import { Card, Button, Form, Input, Space, message, Typography, Divider, List, Tag } from 'antd'; -import { AudioOutlined, StopOutlined, CloudUploadOutlined, DeleteOutlined, CheckCircleOutlined } from '@ant-design/icons'; +import { Card, Button, Space, message, Typography, Divider, Tag, Progress, Row, Col, Empty, Badge } from 'antd'; +import { + AudioOutlined, StopOutlined, CloudUploadOutlined, + DeleteOutlined, CheckCircleOutlined, InfoCircleOutlined, + ClockCircleOutlined, FormOutlined +} from '@ant-design/icons'; +import dayjs from 'dayjs'; import { registerSpeaker, getSpeakerList, SpeakerVO } from '../../api/business/speaker'; -const { Title, Text } = Typography; +const { Title, Text, Paragraph } = Typography; + +const REG_CONTENT = "iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。"; +const DEFAULT_DURATION = 10; // 默认录音时长 10 秒 const SpeakerReg: React.FC = () => { const [recording, setRecording] = useState(false); @@ -13,6 +21,10 @@ const SpeakerReg: React.FC = () => { const [existingSpeaker, setExistingSpeaker] = useState(null); const [listLoading, setListLoading] = useState(false); + // 计时状态 + const [seconds, setSeconds] = useState(0); + const timerRef = useRef(null); + const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); @@ -28,13 +40,14 @@ const SpeakerReg: React.FC = () => { useEffect(() => { fetchSpeakers(); + return () => stopTimer(); }, []); const fetchSpeakers = async () => { setListLoading(true); try { const res = await getSpeakerList(); - if (res.data && Array.isArray(res.data.data) && res.data.data.length > 0) { + if (res.data && res.data.data && res.data.data.length > 0) { setExistingSpeaker(res.data.data[0]); } else { setExistingSpeaker(null); @@ -46,6 +59,26 @@ const SpeakerReg: React.FC = () => { } }; + const startTimer = () => { + setSeconds(0); + timerRef.current = setInterval(() => { + setSeconds(prev => { + if (prev + 1 >= DEFAULT_DURATION) { + stopRecording(); + return DEFAULT_DURATION; + } + return prev + 1; + }); + }, 1000); + }; + + const stopTimer = () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); @@ -54,31 +87,32 @@ const SpeakerReg: React.FC = () => { audioChunksRef.current = []; mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) { - audioChunksRef.current.push(event.data); - } + if (event.data.size > 0) audioChunksRef.current.push(event.data); }; mediaRecorder.onstop = () => { const blob = new Blob(audioChunksRef.current, { type: 'audio/wav' }); setAudioBlob(blob); setAudioUrl(URL.createObjectURL(blob)); + stopTimer(); }; mediaRecorder.start(); setRecording(true); setAudioBlob(null); setAudioUrl(null); + startTimer(); } catch (err) { message.error('无法访问麦克风,请检查权限设置'); } }; const stopRecording = () => { - if (mediaRecorderRef.current && recording) { + if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') { mediaRecorderRef.current.stop(); mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop()); setRecording(false); + stopTimer(); } }; @@ -90,12 +124,11 @@ const SpeakerReg: React.FC = () => { setLoading(true); try { - // 后端会自动获取当前登录人作为 name 和 userId await registerSpeaker({ - name: '', // 后端会覆盖此值 + name: '', file: audioBlob }); - message.success(existingSpeaker ? '声纹更新成功' : '声纹注册成功'); + message.success(existingSpeaker ? '声纹已更新' : '声纹注册成功'); setAudioBlob(null); setAudioUrl(null); fetchSpeakers(); @@ -107,94 +140,133 @@ const SpeakerReg: React.FC = () => { }; return ( -
-
- 我的声纹注册 - 注册后可用于会议发言人自动识别。您可以随时重新录制以更新。 +
+
+ 声纹采集中心 + 注册唯一的声纹特征,让 AI 在会议中精准识别出您的每一句发言。 - -
- - {existingSpeaker ? ( -
- } style={{ marginBottom: '8px' }}>已完成注册 -
- 当前在线声纹: - - (更新于 {new Date(existingSpeaker.createdAt).toLocaleString()}) + + {/* 左侧:采集与录音 */} +
+ + 录音指导文本 + +
+ + “{REG_CONTENT}” + + 请在点击录音后,自然、清晰地朗读以上内容 +
+ +
+
+ {!recording ? ( +
+ +
+ + {recording ? `录制中... ${seconds}/${DEFAULT_DURATION}s` : "点击红色图标开始录音"}
-
- ) : ( -
- 尚未录入 -

请录制一段您的声音(建议朗读 5-10 秒)

-
- )} - {existingSpeaker ? '更新声纹' : '开始采集'} - -
- {!recording ? ( - +
+
+ - - - + {/* 右侧:当前状态 */} + + + 注册状态} bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}> + {existingSpeaker ? ( +
+ 已完成注册 +
+ 最近一次更新: + {dayjs(existingSpeaker.createdAt).format('YYYY-MM-DD HH:mm')} +
+ +
+ ) : ( + + )} +
+ + + + +
+ 注册技巧:
+ 1. 尽可能在安静的环境下录音。
+ 2. 使用平时的语速和语调朗读文字。
+ 3. 倒计时结束后系统将自动停止。 +
+
+
+
+ + ); diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index c6186f4..8d0702b 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -37,7 +37,6 @@ export const menuRoutes: MenuRoute[] = [ { path: "/devices", label: "设备管理", element: , perm: "menu:devices" }, { path: "/user-roles", label: "用户角色绑定", element: , perm: "menu:user-roles" }, { path: "/role-permissions", label: "角色权限绑定", element: , perm: "menu:role-permissions" }, - { path: "/speaker-reg", label: "声纹注册", element: , perm: "menu:speaker" }, { path: "/hotwords", label: "热词管理", element: , perm: "menu:hotword" }, { path: "/prompts", label: "总结模板", element: , perm: "menu:prompt" }, { path: "/aimodels", label: "模型配置", element: , perm: "menu:aimodel" }, @@ -46,5 +45,6 @@ export const menuRoutes: MenuRoute[] = [ ]; export const extraRoutes = [ - { path: "/meetings/:id", element: , perm: "menu:meeting" } + { path: "/meetings/:id", element: , perm: "menu:meeting" }, + { path: "/speaker-reg", label: "声纹注册", element: } ];