diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index 833d00b..cee870a 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -235,7 +235,9 @@ VALUES (1, 'iMeeting 智能会议系统', '© 2026 iMeeting Team. All rights res DROP TABLE IF EXISTS biz_speakers CASCADE; CREATE TABLE biz_speakers ( id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, -- 关联系统用户ID + creator_id BIGINT NOT NULL, -- 创建人ID,用于声纹库管理归属 + user_id BIGINT, -- 关联系统用户ID,可为空 + external_speaker_id VARCHAR(100), -- 第三方声纹库中的人员ID name VARCHAR(100) NOT NULL, -- 发言人姓名 voice_path VARCHAR(512), -- 原始声纹文件存储路径 voice_ext VARCHAR(10), -- 文件后缀 @@ -248,9 +250,11 @@ CREATE TABLE biz_speakers ( is_deleted SMALLINT NOT NULL DEFAULT 0 ); +CREATE INDEX idx_speaker_creator ON biz_speakers (creator_id) WHERE is_deleted = 0; CREATE INDEX idx_speaker_user ON biz_speakers (user_id) WHERE is_deleted = 0; +CREATE INDEX idx_speaker_external ON biz_speakers (external_speaker_id) WHERE is_deleted = 0; -COMMENT ON TABLE biz_speakers IS '声纹发言人基础信息表 (用户全局资源)'; +COMMENT ON TABLE biz_speakers IS '声纹发言人基础信息表 (声纹库资源)'; -- ---------------------------- -- 7. 业务模块 - 热词管理 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 26b9389..25e2e08 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java @@ -1,11 +1,7 @@ package com.imeeting.controller.biz; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; - import com.imeeting.dto.biz.SpeakerRegisterDTO; import com.imeeting.dto.biz.SpeakerVO; -import com.imeeting.entity.biz.Speaker; - import com.imeeting.service.biz.SpeakerService; import com.unisbase.common.ApiResponse; import com.unisbase.security.LoginUser; @@ -14,7 +10,6 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.stream.Collectors; @RestController @RequestMapping("/api/biz/speaker") @@ -29,51 +24,40 @@ public class SpeakerController { @PostMapping("/register") @PreAuthorize("isAuthenticated()") public ApiResponse register(@ModelAttribute SpeakerRegisterDTO registerDTO) { - Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - if (!(principal instanceof LoginUser)) { + LoginUser loginUser = getLoginUser(); + if (loginUser == null || loginUser.getUserId() == null) { return ApiResponse.error("未获取到用户信息"); } - LoginUser loginUser = (LoginUser) principal; - registerDTO.setUserId(loginUser.getUserId()); - - // 自动取当前登录人姓名,如果没有,可以用登录名兜底 - registerDTO.setName(loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername()); - + registerDTO.setCreatorId(loginUser.getUserId()); return ApiResponse.ok(speakerService.register(registerDTO)); } @GetMapping("/list") @PreAuthorize("isAuthenticated()") public ApiResponse> list() { - Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - if (!(principal instanceof LoginUser)) { + LoginUser loginUser = getLoginUser(); + if (loginUser == null || loginUser.getUserId() == null) { return ApiResponse.error("未获取到用户信息"); } - LoginUser loginUser = (LoginUser) principal; - if (loginUser.getUserId() == null) { - return ApiResponse.error("无效的用户ID"); - } - - List list = speakerService.list(new LambdaQueryWrapper() - .eq(Speaker::getUserId, loginUser.getUserId()) - .orderByDesc(Speaker::getUpdatedAt)); - - List vos = list.stream().map(this::toVO).collect(Collectors.toList()); - return ApiResponse.ok(vos); + return ApiResponse.ok(speakerService.listByCreator(loginUser.getUserId())); } - private SpeakerVO toVO(Speaker speaker) { - SpeakerVO vo = new SpeakerVO(); - vo.setId(speaker.getId()); - vo.setName(speaker.getName()); - vo.setUserId(speaker.getUserId()); - vo.setVoicePath(speaker.getVoicePath()); - vo.setVoiceExt(speaker.getVoiceExt()); - vo.setVoiceSize(speaker.getVoiceSize()); - vo.setStatus(speaker.getStatus()); - vo.setRemark(speaker.getRemark()); - vo.setCreatedAt(speaker.getCreatedAt()); - vo.setUpdatedAt(speaker.getUpdatedAt()); - return vo; + @DeleteMapping("/{id}") + @PreAuthorize("isAuthenticated()") + public ApiResponse delete(@PathVariable Long id) { + LoginUser loginUser = getLoginUser(); + if (loginUser == null || loginUser.getUserId() == null) { + return ApiResponse.error("未获取到用户信息"); + } + speakerService.deleteSpeaker(id, loginUser.getUserId()); + return ApiResponse.ok(Boolean.TRUE); + } + + private LoginUser getLoginUser() { + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (principal instanceof LoginUser loginUser) { + return loginUser; + } + return null; } } diff --git a/backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java b/backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java index 4d0340f..0534624 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java @@ -6,6 +6,7 @@ import org.springframework.web.multipart.MultipartFile; @Data public class SpeakerRegisterDTO { private String name; + private Long creatorId; private Long userId; private String remark; private MultipartFile file; diff --git a/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java b/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java index 8d8c438..339bc09 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java @@ -8,7 +8,9 @@ import java.time.LocalDateTime; public class SpeakerVO { private Long id; private String name; + private Long creatorId; private Long userId; + private String externalSpeakerId; private String voicePath; private String voiceExt; private Long voiceSize; diff --git a/backend/src/main/java/com/imeeting/entity/biz/Speaker.java b/backend/src/main/java/com/imeeting/entity/biz/Speaker.java index cee54af..b6154e4 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Speaker.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Speaker.java @@ -15,8 +15,12 @@ public class Speaker extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) private Long id; + private Long creatorId; + private Long userId; + private String externalSpeakerId; + private String name; private String voicePath; 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 96b4866..668c4e3 100644 --- a/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java +++ b/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java @@ -5,6 +5,12 @@ import com.imeeting.dto.biz.SpeakerRegisterDTO; import com.imeeting.dto.biz.SpeakerVO; import com.imeeting.entity.biz.Speaker; +import java.util.List; + public interface SpeakerService extends IService { SpeakerVO register(SpeakerRegisterDTO registerDTO); + + List listByCreator(Long creatorId); + + void deleteSpeaker(Long id, Long creatorId); } 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 73f9316..bd0a6aa 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 @@ -28,7 +28,9 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -59,20 +61,22 @@ public class SpeakerServiceImpl extends ServiceImpl impl @Override @Transactional(rollbackFor = Exception.class) public SpeakerVO register(SpeakerRegisterDTO registerDTO) { + if (registerDTO.getCreatorId() == null) { + throw new RuntimeException("创建人不能为空"); + } + if (registerDTO.getName() == null || registerDTO.getName().isBlank()) { + throw new RuntimeException("声纹名称不能为空"); + } MultipartFile file = registerDTO.getFile(); if (file == null || file.isEmpty()) { - throw new RuntimeException("Voice file is required"); + throw new RuntimeException("声纹文件不能为空"); } - // 1. 检查是否已存在该用户的声纹记录 - Speaker speaker = this.getOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() - .eq(Speaker::getUserId, registerDTO.getUserId())); - - boolean isNew = (speaker == null); - if (isNew) { - speaker = new Speaker(); - speaker.setUserId(registerDTO.getUserId()); - } + Speaker speaker = new Speaker(); + speaker.setCreatorId(registerDTO.getCreatorId()); + speaker.setUserId(registerDTO.getUserId()); + speaker.setName(registerDTO.getName().trim()); + speaker.setRemark(registerDTO.getRemark()); String originalFilename = file.getOriginalFilename(); String extension = ""; @@ -103,21 +107,49 @@ public class SpeakerServiceImpl extends ServiceImpl impl } // 3. 更新实体信息 - speaker.setName(registerDTO.getName()); speaker.setVoicePath("voiceprints/" + fileName); speaker.setVoiceExt(extension.replace(".", "")); speaker.setVoiceSize(file.getSize()); speaker.setStatus(1); // 已保存 + speaker.setCreatedAt(LocalDateTime.now()); speaker.setUpdatedAt(LocalDateTime.now()); - this.saveOrUpdate(speaker); + this.save(speaker); // 4. 调用外部声纹注册接口 - callExternalVoiceprintReg(speaker, isNew); + callExternalVoiceprintReg(speaker); return toVO(speaker); } - private void callExternalVoiceprintReg(Speaker speaker, boolean isNew) { + @Override + public List listByCreator(Long creatorId) { + List list = this.lambdaQuery() + .eq(Speaker::getCreatorId, creatorId) + .orderByDesc(Speaker::getUpdatedAt) + .list(); + List vos = new ArrayList<>(list.size()); + for (Speaker speaker : list) { + vos.add(toVO(speaker)); + } + return vos; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSpeaker(Long id, Long creatorId) { + Speaker speaker = this.lambdaQuery() + .eq(Speaker::getId, id) + .eq(Speaker::getCreatorId, creatorId) + .one(); + if (speaker == null) { + throw new RuntimeException("声纹记录不存在"); + } + + deleteExternalVoiceprint(speaker); + this.removeById(id); + } + + private void callExternalVoiceprintReg(Speaker speaker) { try { LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); Long tenantId = loginUser.getTenantId(); @@ -129,17 +161,14 @@ public class SpeakerServiceImpl extends ServiceImpl impl } String baseUrl = asrModel.getBaseUrl(); - String url = baseUrl.endsWith("/") ? baseUrl + "api/speakers" : baseUrl + "/api/speakers"; - - // 如果是更新,使用 PUT /api/speakers/{name} - if (!isNew) { - url += "/" + speaker.getUserId(); - } + String url = baseUrl.endsWith("/") ? baseUrl + "api/v1/speakers" : baseUrl + "/api/v1/speakers"; Map body = new HashMap<>(); - body.put("name", String.valueOf(speaker.getUserId())); - body.put("user_id", speaker.getUserId()); - + body.put("name", speaker.getName()); + if (speaker.getUserId() != null) { + body.put("user_id", String.valueOf(speaker.getUserId())); + } + // 拼接完整下载路径: serverBaseUrl + resourcePrefix + voicePath String fullPath = serverBaseUrl; if (!fullPath.endsWith("/") && !resourcePrefix.startsWith("/")) { @@ -150,20 +179,15 @@ public class SpeakerServiceImpl extends ServiceImpl impl fullPath += "/"; } fullPath += speaker.getVoicePath(); - - body.put("file_path", fullPath); + + body.put("file_url", fullPath); String jsonBody = objectMapper.writeValueAsString(body); - + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() .uri(URI.create(url)) - .header("Content-Type", "application/json"); - - if (isNew) { - requestBuilder.POST(HttpRequest.BodyPublishers.ofString(jsonBody)); - } else { - requestBuilder.PUT(HttpRequest.BodyPublishers.ofString(jsonBody)); - } + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)); if (asrModel.getApiKey() != null && !asrModel.getApiKey().isEmpty()) { requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey()); @@ -175,11 +199,11 @@ public class SpeakerServiceImpl extends ServiceImpl impl if (response.statusCode() != 200) { log.error("External voiceprint registration failed: status={}, body={}", response.statusCode(), response.body()); - // 这里可以根据业务决定是否抛出异常导致事务回滚 - // 目前选择仅记录日志 + speaker.setStatus(4); + this.updateById(speaker); } else { - log.info("External voiceprint registration success for userId: {}", speaker.getUserId()); - // 如果需要,可以解析返回结果更新状态 + log.info("External voiceprint registration success for speakerId: {}", speaker.getId()); + fillExternalSpeakerId(speaker, response.body()); speaker.setStatus(3); // 已注册 (根据之前的定义) this.updateById(speaker); } @@ -189,11 +213,81 @@ public class SpeakerServiceImpl extends ServiceImpl impl } } + private void deleteExternalVoiceprint(Speaker speaker) { + if (speaker.getExternalSpeakerId() == null || speaker.getExternalSpeakerId().isBlank()) { + return; + } + try { + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long tenantId = loginUser.getTenantId(); + AiModelVO asrModel = aiModelService.getDefaultModel("ASR", tenantId); + if (asrModel == null || asrModel.getBaseUrl() == null) { + return; + } + + String baseUrl = asrModel.getBaseUrl(); + String url = baseUrl.endsWith("/") + ? baseUrl + "api/v1/speakers/" + speaker.getExternalSpeakerId() + : baseUrl + "/api/v1/speakers/" + speaker.getExternalSpeakerId(); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .DELETE(); + if (asrModel.getApiKey() != null && !asrModel.getApiKey().isEmpty()) { + requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey()); + } + + HttpResponse response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + log.warn("External voiceprint delete failed: status={}, body={}", response.statusCode(), response.body()); + } + } catch (Exception e) { + log.warn("Call external voiceprint delete error", e); + } + } + + @SuppressWarnings("unchecked") + private void fillExternalSpeakerId(Speaker speaker, String responseBody) { + try { + Map body = objectMapper.readValue(responseBody, Map.class); + String externalSpeakerId = readSpeakerId(body); + if (externalSpeakerId != null && !externalSpeakerId.isBlank()) { + speaker.setExternalSpeakerId(externalSpeakerId); + } + } catch (Exception e) { + log.warn("Parse external speaker id failed, body={}", responseBody, e); + } + } + + @SuppressWarnings("unchecked") + private String readSpeakerId(Map body) { + Object speakerId = body.get("speaker_id"); + if (speakerId == null) { + speakerId = body.get("id"); + } + if (speakerId != null) { + return String.valueOf(speakerId); + } + Object data = body.get("data"); + if (data instanceof Map dataMap) { + Object nestedSpeakerId = ((Map) dataMap).get("speaker_id"); + if (nestedSpeakerId == null) { + nestedSpeakerId = ((Map) dataMap).get("id"); + } + if (nestedSpeakerId != null) { + return String.valueOf(nestedSpeakerId); + } + } + return null; + } + private SpeakerVO toVO(Speaker speaker) { SpeakerVO vo = new SpeakerVO(); vo.setId(speaker.getId()); vo.setName(speaker.getName()); + vo.setCreatorId(speaker.getCreatorId()); vo.setUserId(speaker.getUserId()); + vo.setExternalSpeakerId(speaker.getExternalSpeakerId()); vo.setVoicePath(speaker.getVoicePath()); vo.setVoiceExt(speaker.getVoiceExt()); vo.setVoiceSize(speaker.getVoiceSize()); diff --git a/frontend/src/api/business/speaker.ts b/frontend/src/api/business/speaker.ts index de301ec..4d110e4 100644 --- a/frontend/src/api/business/speaker.ts +++ b/frontend/src/api/business/speaker.ts @@ -2,14 +2,17 @@ import http from "../http"; export interface SpeakerVO { id: number; + creatorId: number; name: string; userId?: number; + externalSpeakerId?: string; voicePath: string; voiceExt: string; voiceSize: number; status: number; remark?: string; createdAt: string; + updatedAt: string; } export interface SpeakerRegisterParams { @@ -42,3 +45,9 @@ export const getSpeakerList = () => { "/api/biz/speaker/list" ); }; + +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 1398481..54df315 100644 --- a/frontend/src/pages/business/SpeakerReg.tsx +++ b/frontend/src/pages/business/SpeakerReg.tsx @@ -1,37 +1,62 @@ -import React, { useState, useRef, useEffect, useMemo } from 'react'; -import { Card, Button, Space, message, Typography, Divider, Tag, Progress, Row, Col, Empty, Badge, Upload, Tabs } from 'antd'; -import { - AudioOutlined, StopOutlined, CloudUploadOutlined, - DeleteOutlined, CheckCircleOutlined, InfoCircleOutlined, - FormOutlined, UploadOutlined +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + AudioOutlined, + CheckCircleOutlined, + CloudUploadOutlined, + DeleteOutlined, + FormOutlined, + InfoCircleOutlined, + StopOutlined, + UploadOutlined } from '@ant-design/icons'; +import { + Badge, + Button, + Card, + Col, + Empty, + Form, + Input, + List, + message, + Popconfirm, + Progress, + Row, + Select, + Space, + Tabs, + Tag, + Typography, + Upload +} from 'antd'; import type { UploadProps } from 'antd'; import dayjs from 'dayjs'; -import { registerSpeaker, getSpeakerList, SpeakerVO } from '../../api/business/speaker'; +import { listUsers } from '../../api'; +import { deleteSpeaker, getSpeakerList, registerSpeaker, SpeakerVO } from '../../api/business/speaker'; +import type { SysUser } from '../../types'; const { Title, Text, Paragraph } = Typography; -const REG_CONTENT = "iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。"; -const DEFAULT_DURATION = 10; // 默认录音时长 10 秒 +const REG_CONTENT = + 'iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。'; +const DEFAULT_DURATION = 10; const SpeakerReg: React.FC = () => { + const [form] = Form.useForm(); const [recording, setRecording] = useState(false); const [audioBlob, setAudioBlob] = useState(null); const [audioUrl, setAudioUrl] = useState(null); const [loading, setLoading] = useState(false); - const [existingSpeaker, setExistingSpeaker] = useState(null); + const [speakers, setSpeakers] = useState([]); const [listLoading, setListLoading] = useState(false); - - // 计时状态 + const [userOptions, setUserOptions] = useState([]); const [seconds, setSeconds] = useState(0); const timerRef = useRef(null); - const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); - // 获取资源前缀 const resourcePrefix = useMemo(() => { - const configStr = sessionStorage.getItem("platformConfig"); + const configStr = sessionStorage.getItem('platformConfig'); if (configStr) { const config = JSON.parse(configStr); return config.resourcePrefix || '/api/static/'; @@ -40,26 +65,48 @@ const SpeakerReg: React.FC = () => { }, []); useEffect(() => { - fetchSpeakers(); - return () => stopTimer(); + void fetchSpeakers(); + void fetchUsers(); + return () => { + stopTimer(); + if (audioUrl) { + URL.revokeObjectURL(audioUrl); + } + }; }, []); const fetchSpeakers = async () => { setListLoading(true); try { const res = await getSpeakerList(); - if (res.data && res.data.data && res.data.data.length > 0) { - setExistingSpeaker(res.data.data[0]); - } else { - setExistingSpeaker(null); - } + setSpeakers(res.data?.data || []); } catch (err) { console.error(err); + message.error('加载声纹库失败'); } finally { setListLoading(false); } }; + const fetchUsers = async () => { + try { + const users = await listUsers(); + setUserOptions(users || []); + } catch (err) { + console.error(err); + setUserOptions([]); + message.error('加载用户列表失败'); + } + }; + + const resetAudioState = () => { + setAudioBlob(null); + if (audioUrl) { + URL.revokeObjectURL(audioUrl); + } + setAudioUrl(null); + }; + const startTimer = () => { setSeconds(0); timerRef.current = setInterval(() => { @@ -87,12 +134,15 @@ const SpeakerReg: React.FC = () => { mediaRecorderRef.current = mediaRecorder; audioChunksRef.current = []; - mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) audioChunksRef.current.push(event.data); + mediaRecorder.ondataavailable = event => { + if (event.data.size > 0) { + audioChunksRef.current.push(event.data); + } }; mediaRecorder.onstop = () => { const blob = new Blob(audioChunksRef.current, { type: 'audio/wav' }); + resetAudioState(); setAudioBlob(blob); setAudioUrl(URL.createObjectURL(blob)); stopTimer(); @@ -100,10 +150,10 @@ const SpeakerReg: React.FC = () => { mediaRecorder.start(); setRecording(true); - setAudioBlob(null); - setAudioUrl(null); + resetAudioState(); startTimer(); } catch (err) { + console.error(err); message.error('无法访问麦克风,请检查权限设置'); } }; @@ -118,17 +168,18 @@ const SpeakerReg: React.FC = () => { }; const uploadProps: UploadProps = { - beforeUpload: (file) => { + beforeUpload: file => { const isAudio = file.type.startsWith('audio/'); if (!isAudio) { - message.error('只能上传音频文件!'); + message.error('只能上传音频文件'); return Upload.LIST_IGNORE; } + resetAudioState(); setAudioBlob(file); setAudioUrl(URL.createObjectURL(file)); - return false; // Prevent auto upload + return false; }, - showUploadList: false, + showUploadList: false }; const handleSubmit = async () => { @@ -136,164 +187,295 @@ const SpeakerReg: React.FC = () => { message.warning('请先录制或上传声纹文件'); return; } - - setLoading(true); try { + const values = await form.validateFields(); + setLoading(true); await registerSpeaker({ - name: '', + name: values.name.trim(), + userId: values.userId ? Number(values.userId) : undefined, + remark: values.remark?.trim(), file: audioBlob }); - message.success(existingSpeaker ? '声纹已更新' : '声纹注册成功'); - setAudioBlob(null); - setAudioUrl(null); - fetchSpeakers(); + message.success('声纹录入成功'); + form.resetFields(['name', 'userId', 'remark']); + resetAudioState(); + void fetchSpeakers(); } catch (err) { + if ((err as { errorFields?: unknown }).errorFields) { + return; + } console.error(err); + message.error('声纹录入失败'); } finally { setLoading(false); } }; + const handleDelete = async (speaker: SpeakerVO) => { + try { + await deleteSpeaker(speaker.id); + message.success('声纹已删除'); + void fetchSpeakers(); + } catch (err) { + console.error(err); + message.error('删除声纹失败'); + } + }; + return (
-
- 声纹采集中心 - 注册唯一的声纹特征,让 AI 在会议中精准识别出您的每一句发言。 - - - {/* 左侧:采集与录音 */} - - - - -
- - “{REG_CONTENT}” - - 请在点击录音后,自然、清晰地朗读以上内容 -
+
+ 声纹库管理 + 支持录入仅有名称的声纹样本,也支持绑定系统用户,提交后同步到第三方声纹管理服务。 -
-
- {!recording ? ( -