From 4f42fb50ad7464cdcca22ad683b95a3e93a8f890 Mon Sep 17 00:00:00 2001 From: chenhao Date: Thu, 2 Apr 2026 15:52:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=92=8C=E5=89=8D=E7=AB=AF=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `SpeakerController` 中添加分页查询接口 - 在 `SpeakerServiceImpl` 中实现分页查询逻辑 - 更新前端API和组件,支持分页查询和按名称搜索声纹记录 --- .../controller/biz/SpeakerController.java | 14 + .../imeeting/service/biz/SpeakerService.java | 3 + .../service/biz/impl/SpeakerServiceImpl.java | 26 + frontend/src/api/business/speaker.ts | 13 + frontend/src/pages/business/SpeakerReg.tsx | 755 +++++++++++------- 5 files changed, 539 insertions(+), 272 deletions(-) diff --git a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java index 127eee9..7450d65 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java @@ -4,6 +4,7 @@ import com.imeeting.dto.biz.SpeakerRegisterDTO; import com.imeeting.dto.biz.SpeakerVO; import com.imeeting.service.biz.SpeakerService; import com.unisbase.common.ApiResponse; +import com.unisbase.dto.PageResult; import com.unisbase.security.LoginUser; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; @@ -32,6 +33,19 @@ public class SpeakerController { return ApiResponse.ok(speakerService.register(registerDTO, loginUser)); } + @GetMapping("/page") + @PreAuthorize("isAuthenticated()") + public ApiResponse>> page( + @RequestParam(defaultValue = "1") Integer current, + @RequestParam(defaultValue = "8") Integer size, + @RequestParam(required = false) String name) { + LoginUser loginUser = getLoginUser(); + if (loginUser == null || loginUser.getUserId() == null) { + return ApiResponse.error("未获取到用户信息"); + } + return ApiResponse.ok(speakerService.pageVisible(current, size, name, loginUser)); + } + @GetMapping("/list") @PreAuthorize("isAuthenticated()") public ApiResponse> list() { diff --git a/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java b/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java index 2a8938c..80e092b 100644 --- a/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java +++ b/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.imeeting.dto.biz.SpeakerRegisterDTO; import com.imeeting.dto.biz.SpeakerVO; import com.imeeting.entity.biz.Speaker; +import com.unisbase.dto.PageResult; import com.unisbase.security.LoginUser; import java.util.List; @@ -11,6 +12,8 @@ import java.util.List; public interface SpeakerService extends IService { SpeakerVO register(SpeakerRegisterDTO registerDTO, LoginUser loginUser); + PageResult> pageVisible(Integer current, Integer size, String name, LoginUser loginUser); + List listVisible(LoginUser loginUser); void deleteSpeaker(Long id, LoginUser loginUser); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java index d2db6fa..8ba16fd 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java @@ -1,5 +1,6 @@ package com.imeeting.service.biz.impl; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.dto.biz.AiModelVO; @@ -9,6 +10,8 @@ import com.imeeting.entity.biz.Speaker; import com.imeeting.mapper.biz.SpeakerMapper; import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.SpeakerService; +import com.unisbase.annotation.DataScope; +import com.unisbase.dto.PageResult; import com.unisbase.security.LoginUser; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -103,6 +106,29 @@ public class SpeakerServiceImpl extends ServiceImpl impl return toVO(speaker); } + @Override + public PageResult> pageVisible(Integer current, Integer size, String name, LoginUser loginUser) { + boolean admin = isAdmin(loginUser); + String normalizedName = name == null ? null : name.trim(); + + Page page = this.lambdaQuery() + .eq(Speaker::getTenantId, loginUser.getTenantId()) + .eq(!admin, Speaker::getUserId, loginUser.getUserId()) + .like(normalizedName != null && !normalizedName.isEmpty(), Speaker::getName, normalizedName) + .orderByDesc(Speaker::getUpdatedAt) + .page(new Page<>(current, size)); + + List records = new ArrayList<>(page.getRecords().size()); + for (Speaker speaker : page.getRecords()) { + records.add(toVO(speaker)); + } + + PageResult> result = new PageResult<>(); + result.setTotal(page.getTotal()); + result.setRecords(records); + return result; + } + @Override public List listVisible(LoginUser loginUser) { boolean admin = isAdmin(loginUser); diff --git a/frontend/src/api/business/speaker.ts b/frontend/src/api/business/speaker.ts index 27f4fc3..7f4a2af 100644 --- a/frontend/src/api/business/speaker.ts +++ b/frontend/src/api/business/speaker.ts @@ -23,6 +23,12 @@ export interface SpeakerRegisterParams { file?: File | Blob; } +export interface SpeakerPageParams { + current: number; + size: number; + name?: string; +} + export const registerSpeaker = (params: SpeakerRegisterParams) => { const formData = new FormData(); if (params.id) formData.append("id", params.id.toString()); @@ -50,6 +56,13 @@ export const getSpeakerList = () => { ); }; +export const getSpeakerPage = (params: SpeakerPageParams) => { + return http.get( + "/api/biz/speaker/page", + { params } + ); +}; + export const deleteSpeaker = (id: number) => { return http.delete( `/api/biz/speaker/${id}` diff --git a/frontend/src/pages/business/SpeakerReg.tsx b/frontend/src/pages/business/SpeakerReg.tsx index 00496b9..cb0de6b 100644 --- a/frontend/src/pages/business/SpeakerReg.tsx +++ b/frontend/src/pages/business/SpeakerReg.tsx @@ -6,41 +6,51 @@ import { DeleteOutlined, FormOutlined, InfoCircleOutlined, + SearchOutlined, StopOutlined, - UploadOutlined + UploadOutlined, + UserOutlined, + ClockCircleOutlined, + SoundOutlined, + SafetyCertificateOutlined } from '@ant-design/icons'; import { Badge, Button, - Card, Col, Empty, Form, Input, List, message, + Pagination, Popconfirm, Progress, Row, Select, + Spin, Space, Tabs, Tag, Typography, - Upload + Upload, + Tooltip, + Divider } from 'antd'; import type { UploadProps } from 'antd'; import dayjs from 'dayjs'; import { listUsers } from '../../api'; -import { deleteSpeaker, getSpeakerList, registerSpeaker, SpeakerVO } from '../../api/business/speaker'; +import { deleteSpeaker, getSpeakerPage, registerSpeaker, SpeakerVO } from '../../api/business/speaker'; import { useAuth } from '../../hooks/useAuth'; import type { SysUser } from '../../types'; const { Title, Text, Paragraph } = Typography; +const { Search } = Input; const REG_CONTENT = 'iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。'; const DEFAULT_DURATION = 10; +const DEFAULT_PAGE_SIZE = 8; const SpeakerReg: React.FC = () => { const [form] = Form.useForm(); @@ -49,6 +59,11 @@ const SpeakerReg: React.FC = () => { const [audioUrl, setAudioUrl] = useState(null); const [loading, setLoading] = useState(false); const [speakers, setSpeakers] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [queryName, setQueryName] = useState(''); + const [current, setCurrent] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [total, setTotal] = useState(0); const [listLoading, setListLoading] = useState(false); const [userOptions, setUserOptions] = useState([]); const [editingSpeaker, setEditingSpeaker] = useState(null); @@ -69,16 +84,25 @@ const SpeakerReg: React.FC = () => { }, []); useEffect(() => { - void fetchSpeakers(); void fetchUsers(); return () => { stopTimer(); - if (audioUrl) { - URL.revokeObjectURL(audioUrl); - } }; }, []); + useEffect(() => { + if (!audioUrl) { + return; + } + return () => { + URL.revokeObjectURL(audioUrl); + }; + }, [audioUrl]); + + useEffect(() => { + void fetchSpeakers(current, pageSize, queryName); + }, [current, pageSize, queryName]); + useEffect(() => { if (!profile?.userId || isAdmin) { return; @@ -86,11 +110,24 @@ const SpeakerReg: React.FC = () => { form.setFieldValue('userId', profile.userId); }, [form, isAdmin, profile?.userId]); - const fetchSpeakers = async () => { + const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => { setListLoading(true); try { - const res = await getSpeakerList(); - setSpeakers(res.data?.data || []); + const res = await getSpeakerPage({ + current: page, + size, + name: name || undefined + }); + const records = res.data?.data?.records || []; + const nextTotal = res.data?.data?.total || 0; + + if (page > 1 && records.length === 0 && nextTotal > 0) { + setCurrent(page - 1); + return; + } + + setSpeakers(records); + setTotal(nextTotal); } catch (err) { console.error(err); message.error('加载声纹库失败'); @@ -219,7 +256,11 @@ const SpeakerReg: React.FC = () => { }); message.success(editingSpeaker ? '声纹更新成功' : '声纹录入成功'); resetFormState(); - void fetchSpeakers(); + if (current !== 1) { + setCurrent(1); + } else { + void fetchSpeakers(1, pageSize, queryName); + } } catch (err) { if ((err as { errorFields?: unknown }).errorFields) { return; @@ -235,7 +276,11 @@ const SpeakerReg: React.FC = () => { try { await deleteSpeaker(speaker.id); message.success('声纹已删除'); - void fetchSpeakers(); + if (speakers.length === 1 && current > 1) { + setCurrent(current - 1); + } else { + void fetchSpeakers(current, pageSize, queryName); + } } catch (err) { console.error(err); message.error('删除声纹失败'); @@ -253,280 +298,446 @@ const SpeakerReg: React.FC = () => { resetAudioState(); }; + const handleUserChange = (userId: number) => { + const selectedUser = userOptions.find(u => u.userId === userId); + if (selectedUser) { + form.setFieldValue('name', selectedUser.displayName || selectedUser.username); + } + }; + + const handleSearch = (value?: string) => { + const keyword = (value ?? searchKeyword).trim(); + setCurrent(1); + setQueryName(keyword); + }; + return ( -
-
- 声纹库管理 - 支持录入仅有名称的声纹样本,也支持绑定系统用户,提交后同步到第三方声纹管理服务。 +
+ + +
+
+

声纹采集工作台

+ 注册唯一的声纹特征,让系统识别您的专属声音 +
+
+ 声纹引擎就绪} /> +
+
+ +
+
+
+
+
+ +
+

{editingSpeaker ? '更新声纹档案' : '新建声纹档案'}

- - + +
+ + + 声纹名称} + rules={[{ required: true, message: '必填' }]} + > + + + + + 绑定用户}> + + + + + + 实时录制采集} key="1"> +
+
+ 录音文本内容: +
{REG_CONTENT}
+
+ +
+ + +
+
+ {recording ? '正在采集声音...' : '等待录制'} + {seconds}s / {DEFAULT_DURATION}s +
+ +
+
+
+
+ 离线文件上传} key="2"> + +
+ +
点击此处或将音频文件拖入
+ 支持 MP3 / WAV / M4A,建议时长 5-15秒 +
+
+
+
+ + {audioUrl && ( +
+
+ 采样文件已就绪 + +
+
+ )} + +
+ + {editingSpeaker && ( + + )} + +
+ +
+ 数据将加密存储,仅用于会议期间的发言人识别与角色分离。同租户内声纹名称需保持唯一。 +
+
+
+
+
+ +
+
+
+

+ + 已注册声纹库 +

+ + 按名称快速筛选当前声纹记录 + +
+ { + const nextValue = e.target.value; + setSearchKeyword(nextValue); + if (!nextValue.trim() && queryName) { + setCurrent(1); + setQueryName(''); + } + }} + onSearch={handleSearch} + placeholder="按名称搜索" + style={{ width: 220, borderRadius: 8 }} + /> + +
+ +
+ + {speakers.length === 0 ? ( + {queryName ? '未找到匹配的声纹记录' : '暂无声纹记录'}} + style={{ marginTop: 40 }} + /> + ) : ( + speakers.map((s) => ( +
+
+
+ {s.name} +
+ + {s.status === 3 ? '已就绪' : '处理中'} + + {s.userId && ID:{s.userId}} +
+
+ + +
+ + {s.remark && ( +
{s.remark}
+ )} + +
+ )) + )} +
+
+
+ { + setCurrent(page); + setPageSize(size); + }} + showTotal={count => `共 ${count} 条`} + /> +
+
);