feat: 添加声纹库管理功能和相关API

- 在 `Speaker` 实体中添加 `creatorId` 和 `externalSpeakerId` 字段
- 更新数据库表 `biz_speakers`,添加 `creator_id` 和 `external_speaker_id` 字段,并创建相应索引
- 在 `SpeakerService` 中添加 `listByCreator` 和 `deleteSpeaker` 方法
- 更新前端API和组件,支持声纹注册、删除和列表查询
- 优化声纹注册逻辑,支持第三方声纹服务的调用和状态更新
dev_na
chenhao 2026-04-01 14:31:12 +08:00
parent f0d63c97a3
commit 578359a0d3
9 changed files with 515 additions and 229 deletions

View File

@ -235,7 +235,9 @@ VALUES (1, 'iMeeting 智能会议系统', '© 2026 iMeeting Team. All rights res
DROP TABLE IF EXISTS biz_speakers CASCADE; DROP TABLE IF EXISTS biz_speakers CASCADE;
CREATE TABLE biz_speakers ( CREATE TABLE biz_speakers (
id BIGSERIAL PRIMARY KEY, 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, -- 发言人姓名 name VARCHAR(100) NOT NULL, -- 发言人姓名
voice_path VARCHAR(512), -- 原始声纹文件存储路径 voice_path VARCHAR(512), -- 原始声纹文件存储路径
voice_ext VARCHAR(10), -- 文件后缀 voice_ext VARCHAR(10), -- 文件后缀
@ -248,9 +250,11 @@ CREATE TABLE biz_speakers (
is_deleted SMALLINT NOT NULL DEFAULT 0 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_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. 业务模块 - 热词管理 -- 7. 业务模块 - 热词管理

View File

@ -1,11 +1,7 @@
package com.imeeting.controller.biz; package com.imeeting.controller.biz;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.dto.biz.SpeakerRegisterDTO; import com.imeeting.dto.biz.SpeakerRegisterDTO;
import com.imeeting.dto.biz.SpeakerVO; import com.imeeting.dto.biz.SpeakerVO;
import com.imeeting.entity.biz.Speaker;
import com.imeeting.service.biz.SpeakerService; import com.imeeting.service.biz.SpeakerService;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
@ -14,7 +10,6 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/biz/speaker") @RequestMapping("/api/biz/speaker")
@ -29,51 +24,40 @@ public class SpeakerController {
@PostMapping("/register") @PostMapping("/register")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<SpeakerVO> register(@ModelAttribute SpeakerRegisterDTO registerDTO) { public ApiResponse<SpeakerVO> register(@ModelAttribute SpeakerRegisterDTO registerDTO) {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); LoginUser loginUser = getLoginUser();
if (!(principal instanceof LoginUser)) { if (loginUser == null || loginUser.getUserId() == null) {
return ApiResponse.error("未获取到用户信息"); return ApiResponse.error("未获取到用户信息");
} }
LoginUser loginUser = (LoginUser) principal; registerDTO.setCreatorId(loginUser.getUserId());
registerDTO.setUserId(loginUser.getUserId());
// 自动取当前登录人姓名,如果没有,可以用登录名兜底
registerDTO.setName(loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername());
return ApiResponse.ok(speakerService.register(registerDTO)); return ApiResponse.ok(speakerService.register(registerDTO));
} }
@GetMapping("/list") @GetMapping("/list")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<List<SpeakerVO>> list() { public ApiResponse<List<SpeakerVO>> list() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); LoginUser loginUser = getLoginUser();
if (!(principal instanceof LoginUser)) { if (loginUser == null || loginUser.getUserId() == null) {
return ApiResponse.error("未获取到用户信息"); return ApiResponse.error("未获取到用户信息");
} }
LoginUser loginUser = (LoginUser) principal; return ApiResponse.ok(speakerService.listByCreator(loginUser.getUserId()));
if (loginUser.getUserId() == null) {
return ApiResponse.error("无效的用户ID");
} }
List<Speaker> list = speakerService.list(new LambdaQueryWrapper<Speaker>() @DeleteMapping("/{id}")
.eq(Speaker::getUserId, loginUser.getUserId()) @PreAuthorize("isAuthenticated()")
.orderByDesc(Speaker::getUpdatedAt)); public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = getLoginUser();
List<SpeakerVO> vos = list.stream().map(this::toVO).collect(Collectors.toList()); if (loginUser == null || loginUser.getUserId() == null) {
return ApiResponse.ok(vos); return ApiResponse.error("未获取到用户信息");
}
speakerService.deleteSpeaker(id, loginUser.getUserId());
return ApiResponse.ok(Boolean.TRUE);
} }
private SpeakerVO toVO(Speaker speaker) { private LoginUser getLoginUser() {
SpeakerVO vo = new SpeakerVO(); Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
vo.setId(speaker.getId()); if (principal instanceof LoginUser loginUser) {
vo.setName(speaker.getName()); return loginUser;
vo.setUserId(speaker.getUserId()); }
vo.setVoicePath(speaker.getVoicePath()); return null;
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;
} }
} }

View File

@ -6,6 +6,7 @@ import org.springframework.web.multipart.MultipartFile;
@Data @Data
public class SpeakerRegisterDTO { public class SpeakerRegisterDTO {
private String name; private String name;
private Long creatorId;
private Long userId; private Long userId;
private String remark; private String remark;
private MultipartFile file; private MultipartFile file;

View File

@ -8,7 +8,9 @@ import java.time.LocalDateTime;
public class SpeakerVO { public class SpeakerVO {
private Long id; private Long id;
private String name; private String name;
private Long creatorId;
private Long userId; private Long userId;
private String externalSpeakerId;
private String voicePath; private String voicePath;
private String voiceExt; private String voiceExt;
private Long voiceSize; private Long voiceSize;

View File

@ -15,8 +15,12 @@ public class Speaker extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO) @TableId(value = "id", type = IdType.AUTO)
private Long id; private Long id;
private Long creatorId;
private Long userId; private Long userId;
private String externalSpeakerId;
private String name; private String name;
private String voicePath; private String voicePath;

View File

@ -5,6 +5,12 @@ import com.imeeting.dto.biz.SpeakerRegisterDTO;
import com.imeeting.dto.biz.SpeakerVO; import com.imeeting.dto.biz.SpeakerVO;
import com.imeeting.entity.biz.Speaker; import com.imeeting.entity.biz.Speaker;
import java.util.List;
public interface SpeakerService extends IService<Speaker> { public interface SpeakerService extends IService<Speaker> {
SpeakerVO register(SpeakerRegisterDTO registerDTO); SpeakerVO register(SpeakerRegisterDTO registerDTO);
List<SpeakerVO> listByCreator(Long creatorId);
void deleteSpeaker(Long id, Long creatorId);
} }

View File

@ -28,7 +28,9 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@ -59,20 +61,22 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public SpeakerVO register(SpeakerRegisterDTO registerDTO) { 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(); MultipartFile file = registerDTO.getFile();
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw new RuntimeException("Voice file is required"); throw new RuntimeException("声纹文件不能为空");
} }
// 1. 检查是否已存在该用户的声纹记录 Speaker speaker = new Speaker();
Speaker speaker = this.getOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Speaker>() speaker.setCreatorId(registerDTO.getCreatorId());
.eq(Speaker::getUserId, registerDTO.getUserId()));
boolean isNew = (speaker == null);
if (isNew) {
speaker = new Speaker();
speaker.setUserId(registerDTO.getUserId()); speaker.setUserId(registerDTO.getUserId());
} speaker.setName(registerDTO.getName().trim());
speaker.setRemark(registerDTO.getRemark());
String originalFilename = file.getOriginalFilename(); String originalFilename = file.getOriginalFilename();
String extension = ""; String extension = "";
@ -103,21 +107,49 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
} }
// 3. 更新实体信息 // 3. 更新实体信息
speaker.setName(registerDTO.getName());
speaker.setVoicePath("voiceprints/" + fileName); speaker.setVoicePath("voiceprints/" + fileName);
speaker.setVoiceExt(extension.replace(".", "")); speaker.setVoiceExt(extension.replace(".", ""));
speaker.setVoiceSize(file.getSize()); speaker.setVoiceSize(file.getSize());
speaker.setStatus(1); // 已保存 speaker.setStatus(1); // 已保存
speaker.setCreatedAt(LocalDateTime.now());
speaker.setUpdatedAt(LocalDateTime.now()); speaker.setUpdatedAt(LocalDateTime.now());
this.saveOrUpdate(speaker); this.save(speaker);
// 4. 调用外部声纹注册接口 // 4. 调用外部声纹注册接口
callExternalVoiceprintReg(speaker, isNew); callExternalVoiceprintReg(speaker);
return toVO(speaker); return toVO(speaker);
} }
private void callExternalVoiceprintReg(Speaker speaker, boolean isNew) { @Override
public List<SpeakerVO> listByCreator(Long creatorId) {
List<Speaker> list = this.lambdaQuery()
.eq(Speaker::getCreatorId, creatorId)
.orderByDesc(Speaker::getUpdatedAt)
.list();
List<SpeakerVO> 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 { try {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long tenantId = loginUser.getTenantId(); Long tenantId = loginUser.getTenantId();
@ -129,16 +161,13 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
} }
String baseUrl = asrModel.getBaseUrl(); String baseUrl = asrModel.getBaseUrl();
String url = baseUrl.endsWith("/") ? baseUrl + "api/speakers" : baseUrl + "/api/speakers"; String url = baseUrl.endsWith("/") ? baseUrl + "api/v1/speakers" : baseUrl + "/api/v1/speakers";
// 如果是更新,使用 PUT /api/speakers/{name}
if (!isNew) {
url += "/" + speaker.getUserId();
}
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("name", String.valueOf(speaker.getUserId())); body.put("name", speaker.getName());
body.put("user_id", speaker.getUserId()); if (speaker.getUserId() != null) {
body.put("user_id", String.valueOf(speaker.getUserId()));
}
// 拼接完整下载路径: serverBaseUrl + resourcePrefix + voicePath // 拼接完整下载路径: serverBaseUrl + resourcePrefix + voicePath
String fullPath = serverBaseUrl; String fullPath = serverBaseUrl;
@ -151,19 +180,14 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
} }
fullPath += speaker.getVoicePath(); fullPath += speaker.getVoicePath();
body.put("file_path", fullPath); body.put("file_url", fullPath);
String jsonBody = objectMapper.writeValueAsString(body); String jsonBody = objectMapper.writeValueAsString(body);
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url)) .uri(URI.create(url))
.header("Content-Type", "application/json"); .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
if (isNew) {
requestBuilder.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
requestBuilder.PUT(HttpRequest.BodyPublishers.ofString(jsonBody));
}
if (asrModel.getApiKey() != null && !asrModel.getApiKey().isEmpty()) { if (asrModel.getApiKey() != null && !asrModel.getApiKey().isEmpty()) {
requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey()); requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey());
@ -175,11 +199,11 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
log.error("External voiceprint registration failed: status={}, body={}", response.statusCode(), response.body()); log.error("External voiceprint registration failed: status={}, body={}", response.statusCode(), response.body());
// 这里可以根据业务决定是否抛出异常导致事务回滚 speaker.setStatus(4);
// 目前选择仅记录日志 this.updateById(speaker);
} else { } 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); // 已注册 (根据之前的定义) speaker.setStatus(3); // 已注册 (根据之前的定义)
this.updateById(speaker); this.updateById(speaker);
} }
@ -189,11 +213,81 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> 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<String> 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<String, Object> 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<String, Object> 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<String, Object>) dataMap).get("speaker_id");
if (nestedSpeakerId == null) {
nestedSpeakerId = ((Map<String, Object>) dataMap).get("id");
}
if (nestedSpeakerId != null) {
return String.valueOf(nestedSpeakerId);
}
}
return null;
}
private SpeakerVO toVO(Speaker speaker) { private SpeakerVO toVO(Speaker speaker) {
SpeakerVO vo = new SpeakerVO(); SpeakerVO vo = new SpeakerVO();
vo.setId(speaker.getId()); vo.setId(speaker.getId());
vo.setName(speaker.getName()); vo.setName(speaker.getName());
vo.setCreatorId(speaker.getCreatorId());
vo.setUserId(speaker.getUserId()); vo.setUserId(speaker.getUserId());
vo.setExternalSpeakerId(speaker.getExternalSpeakerId());
vo.setVoicePath(speaker.getVoicePath()); vo.setVoicePath(speaker.getVoicePath());
vo.setVoiceExt(speaker.getVoiceExt()); vo.setVoiceExt(speaker.getVoiceExt());
vo.setVoiceSize(speaker.getVoiceSize()); vo.setVoiceSize(speaker.getVoiceSize());

View File

@ -2,14 +2,17 @@ import http from "../http";
export interface SpeakerVO { export interface SpeakerVO {
id: number; id: number;
creatorId: number;
name: string; name: string;
userId?: number; userId?: number;
externalSpeakerId?: string;
voicePath: string; voicePath: string;
voiceExt: string; voiceExt: string;
voiceSize: number; voiceSize: number;
status: number; status: number;
remark?: string; remark?: string;
createdAt: string; createdAt: string;
updatedAt: string;
} }
export interface SpeakerRegisterParams { export interface SpeakerRegisterParams {
@ -42,3 +45,9 @@ export const getSpeakerList = () => {
"/api/biz/speaker/list" "/api/biz/speaker/list"
); );
}; };
export const deleteSpeaker = (id: number) => {
return http.delete<any, { code: string; data: boolean; msg: string }>(
`/api/biz/speaker/${id}`
);
};

View File

@ -1,37 +1,62 @@
import React, { useState, useRef, useEffect, useMemo } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Card, Button, Space, message, Typography, Divider, Tag, Progress, Row, Col, Empty, Badge, Upload, Tabs } from 'antd';
import { import {
AudioOutlined, StopOutlined, CloudUploadOutlined, AudioOutlined,
DeleteOutlined, CheckCircleOutlined, InfoCircleOutlined, CheckCircleOutlined,
FormOutlined, UploadOutlined CloudUploadOutlined,
DeleteOutlined,
FormOutlined,
InfoCircleOutlined,
StopOutlined,
UploadOutlined
} from '@ant-design/icons'; } 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 type { UploadProps } from 'antd';
import dayjs from 'dayjs'; 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 { Title, Text, Paragraph } = Typography;
const REG_CONTENT = "iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。"; const REG_CONTENT =
const DEFAULT_DURATION = 10; // 默认录音时长 10 秒 'iMeeting 智能会议系统,助力高效办公,让每一场讨论都有据可查。我正在进行声纹注册,以确保会议识别的准确性。';
const DEFAULT_DURATION = 10;
const SpeakerReg: React.FC = () => { const SpeakerReg: React.FC = () => {
const [form] = Form.useForm();
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null); const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null); const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [existingSpeaker, setExistingSpeaker] = useState<SpeakerVO | null>(null); const [speakers, setSpeakers] = useState<SpeakerVO[]>([]);
const [listLoading, setListLoading] = useState(false); const [listLoading, setListLoading] = useState(false);
const [userOptions, setUserOptions] = useState<SysUser[]>([]);
// 计时状态
const [seconds, setSeconds] = useState(0); const [seconds, setSeconds] = useState(0);
const timerRef = useRef<any>(null); const timerRef = useRef<any>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]); const audioChunksRef = useRef<Blob[]>([]);
// 获取资源前缀
const resourcePrefix = useMemo(() => { const resourcePrefix = useMemo(() => {
const configStr = sessionStorage.getItem("platformConfig"); const configStr = sessionStorage.getItem('platformConfig');
if (configStr) { if (configStr) {
const config = JSON.parse(configStr); const config = JSON.parse(configStr);
return config.resourcePrefix || '/api/static/'; return config.resourcePrefix || '/api/static/';
@ -40,26 +65,48 @@ const SpeakerReg: React.FC = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchSpeakers(); void fetchSpeakers();
return () => stopTimer(); void fetchUsers();
return () => {
stopTimer();
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
};
}, []); }, []);
const fetchSpeakers = async () => { const fetchSpeakers = async () => {
setListLoading(true); setListLoading(true);
try { try {
const res = await getSpeakerList(); const res = await getSpeakerList();
if (res.data && res.data.data && res.data.data.length > 0) { setSpeakers(res.data?.data || []);
setExistingSpeaker(res.data.data[0]);
} else {
setExistingSpeaker(null);
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
message.error('加载声纹库失败');
} finally { } finally {
setListLoading(false); 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 = () => { const startTimer = () => {
setSeconds(0); setSeconds(0);
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
@ -87,12 +134,15 @@ const SpeakerReg: React.FC = () => {
mediaRecorderRef.current = mediaRecorder; mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = []; audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => { 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 = () => { mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: 'audio/wav' }); const blob = new Blob(audioChunksRef.current, { type: 'audio/wav' });
resetAudioState();
setAudioBlob(blob); setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob)); setAudioUrl(URL.createObjectURL(blob));
stopTimer(); stopTimer();
@ -100,10 +150,10 @@ const SpeakerReg: React.FC = () => {
mediaRecorder.start(); mediaRecorder.start();
setRecording(true); setRecording(true);
setAudioBlob(null); resetAudioState();
setAudioUrl(null);
startTimer(); startTimer();
} catch (err) { } catch (err) {
console.error(err);
message.error('无法访问麦克风,请检查权限设置'); message.error('无法访问麦克风,请检查权限设置');
} }
}; };
@ -118,17 +168,18 @@ const SpeakerReg: React.FC = () => {
}; };
const uploadProps: UploadProps = { const uploadProps: UploadProps = {
beforeUpload: (file) => { beforeUpload: file => {
const isAudio = file.type.startsWith('audio/'); const isAudio = file.type.startsWith('audio/');
if (!isAudio) { if (!isAudio) {
message.error('只能上传音频文件!'); message.error('只能上传音频文件');
return Upload.LIST_IGNORE; return Upload.LIST_IGNORE;
} }
resetAudioState();
setAudioBlob(file); setAudioBlob(file);
setAudioUrl(URL.createObjectURL(file)); setAudioUrl(URL.createObjectURL(file));
return false; // Prevent auto upload return false;
}, },
showUploadList: false, showUploadList: false
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@ -136,48 +187,110 @@ const SpeakerReg: React.FC = () => {
message.warning('请先录制或上传声纹文件'); message.warning('请先录制或上传声纹文件');
return; return;
} }
setLoading(true);
try { try {
const values = await form.validateFields();
setLoading(true);
await registerSpeaker({ await registerSpeaker({
name: '', name: values.name.trim(),
userId: values.userId ? Number(values.userId) : undefined,
remark: values.remark?.trim(),
file: audioBlob file: audioBlob
}); });
message.success(existingSpeaker ? '声纹已更新' : '声纹注册成功'); message.success('声纹录入成功');
setAudioBlob(null); form.resetFields(['name', 'userId', 'remark']);
setAudioUrl(null); resetAudioState();
fetchSpeakers(); void fetchSpeakers();
} catch (err) { } catch (err) {
if ((err as { errorFields?: unknown }).errorFields) {
return;
}
console.error(err); console.error(err);
message.error('声纹录入失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleDelete = async (speaker: SpeakerVO) => {
try {
await deleteSpeaker(speaker.id);
message.success('声纹已删除');
void fetchSpeakers();
} catch (err) {
console.error(err);
message.error('删除声纹失败');
}
};
return ( return (
<div style={{ padding: '32px 24px', height: '100%', overflowY: 'auto', background: 'var(--app-bg-page)' }}> <div style={{ padding: '32px 24px', height: '100%', overflowY: 'auto', background: 'var(--app-bg-page)' }}>
<div style={{ maxWidth: 800, margin: '0 auto' }}> <div style={{ maxWidth: 1100, margin: '0 auto' }}>
<Title level={3}></Title> <Title level={3}></Title>
<Text type="secondary"> AI </Text> <Text type="secondary"></Text>
<Row gutter={24} style={{ marginTop: 24 }}> <Row gutter={24} style={{ marginTop: 24 }}>
{/* 左侧:采集与录音 */} <Col xs={24} xl={14}>
<Col span={15}> <Space direction="vertical" size={24} style={{ width: '100%' }}>
<Card bordered={false} style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}> <Card
bordered={false}
style={{
borderRadius: 16,
boxShadow: 'var(--app-shadow)',
background: 'var(--app-bg-card)',
border: '1px solid var(--app-border-color)',
backdropFilter: 'blur(16px)'
}}
>
<Form form={form} layout="vertical" initialValues={{ name: '', userId: undefined, remark: '' }}>
<Row gutter={16}>
<Col span={14}>
<Form.Item
label="声纹名称"
name="name"
rules={[
{ required: true, message: '请输入声纹名称' },
{ max: 100, message: '名称不能超过100个字符' }
]}
>
<Input prefix={<FormOutlined />} placeholder="例如:张三 / 财务总监 / 访客A" maxLength={100} />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item label="绑定用户" name="userId">
<Select
allowClear
showSearch
placeholder="可选,选择系统用户"
optionFilterProp="label"
options={userOptions.map(user => ({
label: `${user.displayName || user.username} (${user.username})`,
value: user.userId
}))}
/>
</Form.Item>
</Col>
</Row>
<Form.Item label="备注" name="remark">
<Input.TextArea placeholder="可选,记录来源、场景或备注信息" autoSize={{ minRows: 2, maxRows: 4 }} maxLength={500} />
</Form.Item>
</Form>
<Tabs defaultActiveKey="record"> <Tabs defaultActiveKey="record">
<Tabs.TabPane tab="在线录制" key="record"> <Tabs.TabPane tab="在线录制" key="record">
<div style={{ <div
style={{
padding: '24px', padding: '24px',
background: 'var(--app-bg-surface-soft)', background: 'var(--app-bg-surface-soft)',
borderRadius: 12, borderRadius: 12,
border: '1px solid var(--app-border-color)', border: '1px solid var(--app-border-color)',
marginBottom: 24, marginBottom: 24,
textAlign: 'center' textAlign: 'center'
}}> }}
>
<Paragraph style={{ fontSize: 18, color: recording ? 'var(--app-primary-color)' : 'var(--app-text-main)', fontWeight: 500, lineHeight: 1.8 }}> <Paragraph style={{ fontSize: 18, color: recording ? 'var(--app-primary-color)' : 'var(--app-text-main)', fontWeight: 500, lineHeight: 1.8 }}>
{REG_CONTENT} {REG_CONTENT}
</Paragraph> </Paragraph>
<Text type="secondary" size="small"></Text> <Text type="secondary"></Text>
</div> </div>
<div style={{ textAlign: 'center', margin: '32px 0' }}> <div style={{ textAlign: 'center', margin: '32px 0' }}>
@ -209,8 +322,8 @@ const SpeakerReg: React.FC = () => {
</div> </div>
<div> <div>
<Text strong type={recording ? "danger" : "secondary"}> <Text strong type={recording ? 'danger' : 'secondary'}>
{recording ? `录制中... ${seconds}/${DEFAULT_DURATION}s` : "点击红色图标开始录音"} {recording ? `录制中... ${seconds}/${DEFAULT_DURATION}s` : '点击红色图标开始录音'}
</Text> </Text>
</div> </div>
{recording && ( {recording && (
@ -220,23 +333,45 @@ const SpeakerReg: React.FC = () => {
)} )}
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab="本地上传" key="upload"> <Tabs.TabPane tab="本地上传" key="upload">
<div style={{ textAlign: 'center', padding: '40px 0', border: '1px dashed var(--app-border-color)', borderRadius: '8px', marginBottom: 24, background: 'var(--app-bg-surface-soft)' }}> <div
style={{
textAlign: 'center',
padding: '40px 0',
border: '1px dashed var(--app-border-color)',
borderRadius: '8px',
marginBottom: 24,
background: 'var(--app-bg-surface-soft)'
}}
>
<Upload {...uploadProps} accept="audio/*"> <Upload {...uploadProps} accept="audio/*">
<Button icon={<UploadOutlined />} size="large"></Button> <Button icon={<UploadOutlined />} size="large">
</Button>
</Upload> </Upload>
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Text type="secondary"> mp3, wav, m4a </Text> <Text type="secondary"> mp3wavm4a </Text>
</div> </div>
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
{audioUrl && ( {audioUrl && (
<div style={{ background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', padding: '16px', borderRadius: 12, marginBottom: 24, border: '1px solid color-mix(in srgb, var(--app-primary-color) 32%, var(--app-border-color))' }}> <div
style={{
background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))',
padding: '16px',
borderRadius: 12,
marginBottom: 24,
border: '1px solid color-mix(in srgb, var(--app-primary-color) 32%, var(--app-border-color))'
}}
>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}> <div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
<Text strong></Text> <Text strong></Text>
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={() => { setAudioBlob(null); setAudioUrl(null); }}></Button> <Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={resetAudioState}>
</Button>
</div> </div>
<audio src={audioUrl} controls style={{ width: '100%', height: 32 }} /> <audio src={audioUrl} controls style={{ width: '100%', height: 32 }} />
</div> </div>
@ -252,48 +387,95 @@ const SpeakerReg: React.FC = () => {
disabled={recording || !audioBlob} disabled={recording || !audioBlob}
style={{ height: 50, borderRadius: 8, fontSize: 16, fontWeight: 600 }} style={{ height: 50, borderRadius: 8, fontSize: 16, fontWeight: 600 }}
> >
{existingSpeaker ? '覆盖原有声纹' : '提交保存声纹'}
</Button> </Button>
</Card> </Card>
</Col>
{/* 右侧:当前状态 */}
<Col span={9}>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Card title={<span><CheckCircleOutlined /> </span>} bordered={false} style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }}>
{existingSpeaker ? (
<div style={{ textAlign: 'center' }}>
<Tag color="success" style={{ marginBottom: 16, padding: '4px 12px' }}></Tag>
<div style={{ marginBottom: 16 }}>
<Text type="secondary" size="small" style={{ display: 'block' }}></Text>
<Text strong>{dayjs(existingSpeaker.updatedAt).format('YYYY-MM-DD HH:mm')}</Text>
</div>
<Divider style={{ margin: '12px 0' }} />
<audio
src={`${resourcePrefix}${existingSpeaker.voicePath}`}
controls
style={{ width: '100%', height: 32 }}
/>
</div>
) : (
<Empty description="尚未录入声纹" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
<Card size="small" bordered={false} style={{ borderRadius: 16, backgroundColor: '#fffbe6', border: '1px solid #ffe58f' }}> <Card size="small" bordered={false} style={{ borderRadius: 16, backgroundColor: '#fffbe6', border: '1px solid #ffe58f' }}>
<Space align="start"> <Space align="start">
<InfoCircleOutlined style={{ color: '#faad14', marginTop: 4 }} /> <InfoCircleOutlined style={{ color: '#faad14', marginTop: 4 }} />
<div style={{ fontSize: 12, color: '#856404' }}> <div style={{ fontSize: 12, color: '#856404' }}>
<b></b><br/> <b></b>
1. <br/> <br />
2. 使<br/> 1.
3. 线 <br />
2.
<br />
3.
</div> </div>
</Space> </Space>
</Card> </Card>
</Space> </Space>
</Col> </Col>
<Col xs={24} xl={10}>
<Card
title={
<span>
<CheckCircleOutlined />
</span>
}
bordered={false}
style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)', minHeight: 640 }}
loading={listLoading}
>
{speakers.length === 0 ? (
<Empty description="尚未录入声纹" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
dataSource={speakers}
itemLayout="vertical"
renderItem={speaker => (
<List.Item
key={speaker.id}
actions={[
<Popconfirm
key="delete"
title="确认删除这条声纹吗?"
description="会同步尝试删除第三方声纹记录。"
onConfirm={() => handleDelete(speaker)}
okText="删除"
cancelText="取消"
>
<Button danger type="link" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
]}
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<Space wrap>
<Text strong style={{ fontSize: 16 }}>
{speaker.name}
</Text>
<Tag color={speaker.userId ? 'blue' : 'default'}>
{speaker.userId ? `绑定用户 ${speaker.userId}` : '未绑定用户'}
</Tag>
<Tag color={speaker.status === 3 ? 'success' : speaker.status === 4 ? 'error' : 'processing'}>
{speaker.status === 3 ? '已同步' : speaker.status === 4 ? '同步失败' : '处理中'}
</Tag>
</Space>
<Text type="secondary">{dayjs(speaker.updatedAt).format('MM-DD HH:mm')}</Text>
</div>
{speaker.remark && <Text type="secondary">{speaker.remark}</Text>}
<Space wrap size={[8, 8]}>
<Tag>ID {speaker.id}</Tag>
{speaker.externalSpeakerId && <Tag color="purple">ID {speaker.externalSpeakerId}</Tag>}
<Tag>{speaker.voiceExt || 'unknown'}</Tag>
<Tag>{((speaker.voiceSize || 0) / 1024).toFixed(1)} KB</Tag>
</Space>
<audio src={`${resourcePrefix}${speaker.voicePath}`} controls style={{ width: '100%', height: 32 }} />
</Space>
</List.Item>
)}
/>
)}
</Card>
</Col>
</Row> </Row>
</div> </div>
</div> </div>