feat: 添加声纹库管理功能和相关API
- 在 `Speaker` 实体中添加 `creatorId` 和 `externalSpeakerId` 字段 - 更新数据库表 `biz_speakers`,添加 `creator_id` 和 `external_speaker_id` 字段,并创建相应索引 - 在 `SpeakerService` 中添加 `listByCreator` 和 `deleteSpeaker` 方法 - 更新前端API和组件,支持声纹注册、删除和列表查询 - 优化声纹注册逻辑,支持第三方声纹服务的调用和状态更新dev_na
parent
f0d63c97a3
commit
578359a0d3
|
|
@ -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. 业务模块 - 热词管理
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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">支持上传 mp3、wav、m4a 等常见音频格式</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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue