feat: 添加声纹库管理功能和相关API
- 在 `Speaker` 实体中添加 `creatorId` 和 `externalSpeakerId` 字段 - 更新数据库表 `biz_speakers`,添加 `creator_id` 和 `external_speaker_id` 字段,并创建相应索引 - 在 `SpeakerService` 中添加 `listByCreator` 和 `deleteSpeaker` 方法 - 更新前端API和组件,支持声纹注册、删除和列表查询 - 优化声纹注册逻辑,支持第三方声纹服务的调用和状态更新dev_na
parent
578359a0d3
commit
5da9a97d55
|
|
@ -235,6 +235,7 @@ 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,
|
||||||
|
tenant_id BIGINT NOT NULL, -- 租户ID
|
||||||
creator_id BIGINT NOT NULL, -- 创建人ID,用于声纹库管理归属
|
creator_id BIGINT NOT NULL, -- 创建人ID,用于声纹库管理归属
|
||||||
user_id BIGINT, -- 关联系统用户ID,可为空
|
user_id BIGINT, -- 关联系统用户ID,可为空
|
||||||
external_speaker_id VARCHAR(100), -- 第三方声纹库中的人员ID
|
external_speaker_id VARCHAR(100), -- 第三方声纹库中的人员ID
|
||||||
|
|
@ -250,9 +251,11 @@ CREATE TABLE biz_speakers (
|
||||||
is_deleted SMALLINT NOT NULL DEFAULT 0
|
is_deleted SMALLINT NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_speaker_tenant ON biz_speakers (tenant_id) WHERE is_deleted = 0;
|
||||||
CREATE INDEX idx_speaker_creator ON biz_speakers (creator_id) WHERE is_deleted = 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;
|
CREATE INDEX idx_speaker_external ON biz_speakers (external_speaker_id) WHERE is_deleted = 0;
|
||||||
|
CREATE UNIQUE INDEX uk_speaker_tenant_name ON biz_speakers (tenant_id, name) WHERE is_deleted = 0;
|
||||||
|
|
||||||
COMMENT ON TABLE biz_speakers IS '声纹发言人基础信息表 (声纹库资源)';
|
COMMENT ON TABLE biz_speakers IS '声纹发言人基础信息表 (声纹库资源)';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ public class SpeakerController {
|
||||||
return ApiResponse.error("未获取到用户信息");
|
return ApiResponse.error("未获取到用户信息");
|
||||||
}
|
}
|
||||||
registerDTO.setCreatorId(loginUser.getUserId());
|
registerDTO.setCreatorId(loginUser.getUserId());
|
||||||
return ApiResponse.ok(speakerService.register(registerDTO));
|
return ApiResponse.ok(speakerService.register(registerDTO, loginUser));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
|
|
@ -39,7 +39,7 @@ public class SpeakerController {
|
||||||
if (loginUser == null || loginUser.getUserId() == null) {
|
if (loginUser == null || loginUser.getUserId() == null) {
|
||||||
return ApiResponse.error("未获取到用户信息");
|
return ApiResponse.error("未获取到用户信息");
|
||||||
}
|
}
|
||||||
return ApiResponse.ok(speakerService.listByCreator(loginUser.getUserId()));
|
return ApiResponse.ok(speakerService.listVisible(loginUser));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
|
|
@ -49,7 +49,7 @@ public class SpeakerController {
|
||||||
if (loginUser == null || loginUser.getUserId() == null) {
|
if (loginUser == null || loginUser.getUserId() == null) {
|
||||||
return ApiResponse.error("未获取到用户信息");
|
return ApiResponse.error("未获取到用户信息");
|
||||||
}
|
}
|
||||||
speakerService.deleteSpeaker(id, loginUser.getUserId());
|
speakerService.deleteSpeaker(id, loginUser);
|
||||||
return ApiResponse.ok(Boolean.TRUE);
|
return ApiResponse.ok(Boolean.TRUE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class SpeakerRegisterDTO {
|
public class SpeakerRegisterDTO {
|
||||||
|
private Long id;
|
||||||
private String name;
|
private String name;
|
||||||
private Long creatorId;
|
private Long creatorId;
|
||||||
private Long userId;
|
private Long userId;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import java.time.LocalDateTime;
|
||||||
@Data
|
@Data
|
||||||
public class SpeakerVO {
|
public class SpeakerVO {
|
||||||
private Long id;
|
private Long id;
|
||||||
|
private Long tenantId;
|
||||||
private String name;
|
private String name;
|
||||||
private Long creatorId;
|
private Long creatorId;
|
||||||
private Long userId;
|
private Long userId;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.imeeting.entity.biz;
|
package com.imeeting.entity.biz;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import com.unisbase.entity.BaseEntity;
|
import com.unisbase.entity.BaseEntity;
|
||||||
|
|
@ -15,6 +14,8 @@ 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 tenantId;
|
||||||
|
|
||||||
private Long creatorId;
|
private Long creatorId;
|
||||||
|
|
||||||
private Long userId;
|
private Long userId;
|
||||||
|
|
@ -31,9 +32,6 @@ public class Speaker extends BaseEntity {
|
||||||
|
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
@TableField(exist = false)
|
|
||||||
private Long tenantId;
|
|
||||||
|
|
||||||
// Note: status, createdAt, updatedAt, isDeleted are in BaseEntity
|
// Note: status, createdAt, updatedAt, isDeleted are in BaseEntity
|
||||||
// embedding is reserved for future pgvector use
|
// embedding is reserved for future pgvector use
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
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.entity.biz.Speaker;
|
||||||
|
import com.unisbase.security.LoginUser;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface SpeakerService extends IService<Speaker> {
|
public interface SpeakerService extends IService<Speaker> {
|
||||||
SpeakerVO register(SpeakerRegisterDTO registerDTO);
|
SpeakerVO register(SpeakerRegisterDTO registerDTO, LoginUser loginUser);
|
||||||
|
|
||||||
List<SpeakerVO> listByCreator(Long creatorId);
|
List<SpeakerVO> listVisible(LoginUser loginUser);
|
||||||
|
|
||||||
void deleteSpeaker(Long id, Long creatorId);
|
void deleteSpeaker(Long id, LoginUser loginUser);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,11 @@ 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 com.imeeting.mapper.biz.SpeakerMapper;
|
import com.imeeting.mapper.biz.SpeakerMapper;
|
||||||
|
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.SpeakerService;
|
import com.imeeting.service.biz.SpeakerService;
|
||||||
import com.unisbase.security.LoginUser;
|
import com.unisbase.security.LoginUser;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
@ -60,71 +58,57 @@ 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, LoginUser loginUser) {
|
||||||
if (registerDTO.getCreatorId() == null) {
|
if (loginUser == null || loginUser.getUserId() == null || loginUser.getTenantId() == null) {
|
||||||
throw new RuntimeException("创建人不能为空");
|
throw new RuntimeException("未获取到有效登录信息");
|
||||||
}
|
}
|
||||||
if (registerDTO.getName() == null || registerDTO.getName().isBlank()) {
|
if (registerDTO.getName() == null || registerDTO.getName().isBlank()) {
|
||||||
throw new RuntimeException("声纹名称不能为空");
|
throw new RuntimeException("声纹名称不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean admin = isAdmin(loginUser);
|
||||||
|
Speaker speaker = prepareSpeaker(registerDTO, loginUser, admin);
|
||||||
|
String normalizedName = registerDTO.getName().trim();
|
||||||
|
validateDuplicateName(loginUser.getTenantId(), normalizedName, speaker.getId());
|
||||||
|
|
||||||
MultipartFile file = registerDTO.getFile();
|
MultipartFile file = registerDTO.getFile();
|
||||||
if (file == null || file.isEmpty()) {
|
if ((speaker.getId() == null || speaker.getVoicePath() == null || speaker.getVoicePath().isBlank())
|
||||||
|
&& (file == null || file.isEmpty())) {
|
||||||
throw new RuntimeException("声纹文件不能为空");
|
throw new RuntimeException("声纹文件不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
Speaker speaker = new Speaker();
|
speaker.setTenantId(loginUser.getTenantId());
|
||||||
speaker.setCreatorId(registerDTO.getCreatorId());
|
speaker.setName(normalizedName);
|
||||||
speaker.setUserId(registerDTO.getUserId());
|
|
||||||
speaker.setName(registerDTO.getName().trim());
|
|
||||||
speaker.setRemark(registerDTO.getRemark());
|
speaker.setRemark(registerDTO.getRemark());
|
||||||
|
if (speaker.getId() == null) {
|
||||||
String originalFilename = file.getOriginalFilename();
|
speaker.setCreatorId(loginUser.getUserId());
|
||||||
String extension = "";
|
speaker.setCreatedAt(LocalDateTime.now());
|
||||||
if (originalFilename != null && originalFilename.contains(".")) {
|
|
||||||
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure directory exists
|
if (!admin) {
|
||||||
Path voiceprintDir = Paths.get(uploadPath, "voiceprints");
|
speaker.setUserId(loginUser.getUserId());
|
||||||
try {
|
} else {
|
||||||
if (!Files.exists(voiceprintDir)) {
|
speaker.setUserId(registerDTO.getUserId());
|
||||||
Files.createDirectories(voiceprintDir);
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("Create voiceprints directory error", e);
|
|
||||||
throw new RuntimeException("Failed to initialize storage");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 生成文件名
|
if (file != null && !file.isEmpty()) {
|
||||||
String fileName = UUID.randomUUID().toString() + extension;
|
saveVoiceFile(speaker, file);
|
||||||
Path filePath = voiceprintDir.resolve(fileName);
|
|
||||||
|
|
||||||
try {
|
|
||||||
Files.copy(file.getInputStream(), filePath);
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("Save voice file error", e);
|
|
||||||
throw new RuntimeException("Failed to save voice file");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 更新实体信息
|
speaker.setStatus(1);
|
||||||
speaker.setVoicePath("voiceprints/" + fileName);
|
|
||||||
speaker.setVoiceExt(extension.replace(".", ""));
|
|
||||||
speaker.setVoiceSize(file.getSize());
|
|
||||||
speaker.setStatus(1); // 已保存
|
|
||||||
speaker.setCreatedAt(LocalDateTime.now());
|
|
||||||
speaker.setUpdatedAt(LocalDateTime.now());
|
speaker.setUpdatedAt(LocalDateTime.now());
|
||||||
this.save(speaker);
|
this.saveOrUpdate(speaker);
|
||||||
|
|
||||||
// 4. 调用外部声纹注册接口
|
|
||||||
callExternalVoiceprintReg(speaker);
|
|
||||||
|
|
||||||
|
syncExternalVoiceprint(speaker, loginUser);
|
||||||
return toVO(speaker);
|
return toVO(speaker);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<SpeakerVO> listByCreator(Long creatorId) {
|
public List<SpeakerVO> listVisible(LoginUser loginUser) {
|
||||||
|
boolean admin = isAdmin(loginUser);
|
||||||
List<Speaker> list = this.lambdaQuery()
|
List<Speaker> list = this.lambdaQuery()
|
||||||
.eq(Speaker::getCreatorId, creatorId)
|
.eq(Speaker::getTenantId, loginUser.getTenantId())
|
||||||
|
.eq(!admin, Speaker::getUserId, loginUser.getUserId())
|
||||||
.orderByDesc(Speaker::getUpdatedAt)
|
.orderByDesc(Speaker::getUpdatedAt)
|
||||||
.list();
|
.list();
|
||||||
List<SpeakerVO> vos = new ArrayList<>(list.size());
|
List<SpeakerVO> vos = new ArrayList<>(list.size());
|
||||||
|
|
@ -136,109 +120,154 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void deleteSpeaker(Long id, Long creatorId) {
|
public void deleteSpeaker(Long id, LoginUser loginUser) {
|
||||||
Speaker speaker = this.lambdaQuery()
|
Speaker speaker = getSpeakerForWrite(id, loginUser);
|
||||||
.eq(Speaker::getId, id)
|
deleteExternalVoiceprint(speaker, loginUser);
|
||||||
.eq(Speaker::getCreatorId, creatorId)
|
|
||||||
.one();
|
|
||||||
if (speaker == null) {
|
|
||||||
throw new RuntimeException("声纹记录不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteExternalVoiceprint(speaker);
|
|
||||||
this.removeById(id);
|
this.removeById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void callExternalVoiceprintReg(Speaker speaker) {
|
private Speaker prepareSpeaker(SpeakerRegisterDTO registerDTO, LoginUser loginUser, boolean admin) {
|
||||||
try {
|
if (registerDTO.getId() != null) {
|
||||||
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
return getSpeakerForWrite(registerDTO.getId(), loginUser);
|
||||||
Long tenantId = loginUser.getTenantId();
|
}
|
||||||
|
if (!admin) {
|
||||||
|
Speaker existing = this.lambdaQuery()
|
||||||
|
.eq(Speaker::getTenantId, loginUser.getTenantId())
|
||||||
|
.eq(Speaker::getUserId, loginUser.getUserId())
|
||||||
|
.one();
|
||||||
|
if (existing != null) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Speaker speaker = new Speaker();
|
||||||
|
speaker.setTenantId(loginUser.getTenantId());
|
||||||
|
speaker.setCreatorId(loginUser.getUserId());
|
||||||
|
return speaker;
|
||||||
|
}
|
||||||
|
|
||||||
AiModelVO asrModel = aiModelService.getDefaultModel("ASR", tenantId);
|
private Speaker getSpeakerForWrite(Long id, LoginUser loginUser) {
|
||||||
|
boolean admin = isAdmin(loginUser);
|
||||||
|
Speaker speaker = this.lambdaQuery()
|
||||||
|
.eq(Speaker::getId, id)
|
||||||
|
.eq(Speaker::getTenantId, loginUser.getTenantId())
|
||||||
|
.eq(!admin, Speaker::getUserId, loginUser.getUserId())
|
||||||
|
.one();
|
||||||
|
if (speaker == null) {
|
||||||
|
throw new RuntimeException("声纹记录不存在或无权操作");
|
||||||
|
}
|
||||||
|
return speaker;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateDuplicateName(Long tenantId, String name, Long excludeId) {
|
||||||
|
boolean exists = this.lambdaQuery()
|
||||||
|
.eq(Speaker::getTenantId, tenantId)
|
||||||
|
.eq(Speaker::getName, name)
|
||||||
|
.ne(excludeId != null, Speaker::getId, excludeId)
|
||||||
|
.exists();
|
||||||
|
if (exists) {
|
||||||
|
throw new RuntimeException("当前租户下声纹名称已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveVoiceFile(Speaker speaker, MultipartFile file) {
|
||||||
|
String originalFilename = file.getOriginalFilename();
|
||||||
|
String extension = "";
|
||||||
|
if (originalFilename != null && originalFilename.contains(".")) {
|
||||||
|
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
|
||||||
|
}
|
||||||
|
|
||||||
|
Path voiceprintDir = Paths.get(uploadPath, "voiceprints");
|
||||||
|
try {
|
||||||
|
if (!Files.exists(voiceprintDir)) {
|
||||||
|
Files.createDirectories(voiceprintDir);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Create voiceprints directory error", e);
|
||||||
|
throw new RuntimeException("Failed to initialize storage");
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileName = UUID.randomUUID().toString() + extension;
|
||||||
|
Path filePath = voiceprintDir.resolve(fileName);
|
||||||
|
try {
|
||||||
|
Files.copy(file.getInputStream(), filePath);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Save voice file error", e);
|
||||||
|
throw new RuntimeException("Failed to save voice file");
|
||||||
|
}
|
||||||
|
|
||||||
|
speaker.setVoicePath("voiceprints/" + fileName);
|
||||||
|
speaker.setVoiceExt(extension.replace(".", ""));
|
||||||
|
speaker.setVoiceSize(file.getSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void syncExternalVoiceprint(Speaker speaker, LoginUser loginUser) {
|
||||||
|
deleteExternalVoiceprint(speaker, loginUser);
|
||||||
|
callExternalVoiceprintReg(speaker, loginUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void callExternalVoiceprintReg(Speaker speaker, LoginUser loginUser) {
|
||||||
|
try {
|
||||||
|
AiModelVO asrModel = aiModelService.getDefaultModel("ASR", loginUser.getTenantId());
|
||||||
if (asrModel == null || asrModel.getBaseUrl() == null) {
|
if (asrModel == null || asrModel.getBaseUrl() == null) {
|
||||||
log.warn("Default ASR model not configured, skipping external voiceprint registration");
|
log.warn("Default ASR model not configured, skipping external voiceprint registration");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String baseUrl = asrModel.getBaseUrl();
|
String url = appendPath(asrModel.getBaseUrl(), "api/v1/speakers");
|
||||||
String url = baseUrl.endsWith("/") ? baseUrl + "api/v1/speakers" : baseUrl + "/api/v1/speakers";
|
|
||||||
|
|
||||||
Map<String, Object> body = new HashMap<>();
|
Map<String, Object> body = new HashMap<>();
|
||||||
body.put("name", speaker.getName());
|
body.put("name", speaker.getName());
|
||||||
if (speaker.getUserId() != null) {
|
if (speaker.getUserId() != null) {
|
||||||
body.put("user_id", String.valueOf(speaker.getUserId()));
|
body.put("user_id", String.valueOf(speaker.getUserId()));
|
||||||
}
|
}
|
||||||
|
body.put("file_url", buildFileUrl(speaker.getVoicePath()));
|
||||||
// 拼接完整下载路径: serverBaseUrl + resourcePrefix + voicePath
|
|
||||||
String fullPath = serverBaseUrl;
|
|
||||||
if (!fullPath.endsWith("/") && !resourcePrefix.startsWith("/")) {
|
|
||||||
fullPath += "/";
|
|
||||||
}
|
|
||||||
fullPath += resourcePrefix;
|
|
||||||
if (!fullPath.endsWith("/") && !speaker.getVoicePath().startsWith("/")) {
|
|
||||||
fullPath += "/";
|
|
||||||
}
|
|
||||||
fullPath += speaker.getVoicePath();
|
|
||||||
|
|
||||||
body.put("file_url", fullPath);
|
|
||||||
|
|
||||||
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));
|
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
|
||||||
|
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Calling external voiceprint registration: {} with body: {}", url, jsonBody);
|
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
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);
|
speaker.setStatus(4);
|
||||||
this.updateById(speaker);
|
this.updateById(speaker);
|
||||||
} else {
|
return;
|
||||||
log.info("External voiceprint registration success for speakerId: {}", speaker.getId());
|
|
||||||
fillExternalSpeakerId(speaker, response.body());
|
|
||||||
speaker.setStatus(3); // 已注册 (根据之前的定义)
|
|
||||||
this.updateById(speaker);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fillExternalSpeakerId(speaker, response.body());
|
||||||
|
speaker.setStatus(3);
|
||||||
|
this.updateById(speaker);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Call external voiceprint registration error", e);
|
log.error("Call external voiceprint registration error", e);
|
||||||
|
speaker.setStatus(4);
|
||||||
|
this.updateById(speaker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void deleteExternalVoiceprint(Speaker speaker) {
|
private void deleteExternalVoiceprint(Speaker speaker, LoginUser loginUser) {
|
||||||
if (speaker.getExternalSpeakerId() == null || speaker.getExternalSpeakerId().isBlank()) {
|
if (speaker.getExternalSpeakerId() == null || speaker.getExternalSpeakerId().isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
AiModelVO asrModel = aiModelService.getDefaultModel("ASR", loginUser.getTenantId());
|
||||||
Long tenantId = loginUser.getTenantId();
|
|
||||||
AiModelVO asrModel = aiModelService.getDefaultModel("ASR", tenantId);
|
|
||||||
if (asrModel == null || asrModel.getBaseUrl() == null) {
|
if (asrModel == null || asrModel.getBaseUrl() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String baseUrl = asrModel.getBaseUrl();
|
String url = appendPath(asrModel.getBaseUrl(), "api/v1/speakers/" + speaker.getExternalSpeakerId());
|
||||||
String url = baseUrl.endsWith("/")
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(url)).DELETE();
|
||||||
? 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()) {
|
if (asrModel.getApiKey() != null && !asrModel.getApiKey().isEmpty()) {
|
||||||
requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey());
|
requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() == 200) {
|
||||||
|
speaker.setExternalSpeakerId(null);
|
||||||
|
} else {
|
||||||
log.warn("External voiceprint delete failed: status={}, body={}", response.statusCode(), response.body());
|
log.warn("External voiceprint delete failed: status={}, body={}", response.statusCode(), response.body());
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
@ -246,6 +275,23 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String buildFileUrl(String voicePath) {
|
||||||
|
String fullPath = serverBaseUrl;
|
||||||
|
if (!fullPath.endsWith("/") && !resourcePrefix.startsWith("/")) {
|
||||||
|
fullPath += "/";
|
||||||
|
}
|
||||||
|
fullPath += resourcePrefix;
|
||||||
|
if (!fullPath.endsWith("/") && !voicePath.startsWith("/")) {
|
||||||
|
fullPath += "/";
|
||||||
|
}
|
||||||
|
fullPath += voicePath;
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String appendPath(String baseUrl, String path) {
|
||||||
|
return baseUrl.endsWith("/") ? baseUrl + path : baseUrl + "/" + path;
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private void fillExternalSpeakerId(Speaker speaker, String responseBody) {
|
private void fillExternalSpeakerId(Speaker speaker, String responseBody) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -281,9 +327,14 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isAdmin(LoginUser loginUser) {
|
||||||
|
return Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
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.setTenantId(speaker.getTenantId());
|
||||||
vo.setName(speaker.getName());
|
vo.setName(speaker.getName());
|
||||||
vo.setCreatorId(speaker.getCreatorId());
|
vo.setCreatorId(speaker.getCreatorId());
|
||||||
vo.setUserId(speaker.getUserId());
|
vo.setUserId(speaker.getUserId());
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,22 @@ export interface SpeakerVO {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeakerRegisterParams {
|
export interface SpeakerRegisterParams {
|
||||||
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
file: File | Blob;
|
file?: File | Blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const registerSpeaker = (params: SpeakerRegisterParams) => {
|
export const registerSpeaker = (params: SpeakerRegisterParams) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
if (params.id) formData.append("id", params.id.toString());
|
||||||
formData.append("name", params.name);
|
formData.append("name", params.name);
|
||||||
if (params.userId) formData.append("userId", params.userId.toString());
|
if (params.userId !== undefined) formData.append("userId", params.userId.toString());
|
||||||
if (params.remark) formData.append("remark", params.remark);
|
if (params.remark) formData.append("remark", params.remark);
|
||||||
formData.append("file", params.file, "voice.wav");
|
if (params.file) {
|
||||||
|
formData.append("file", params.file, "voice.wav");
|
||||||
|
}
|
||||||
|
|
||||||
return http.post<any, { code: string; data: SpeakerVO; msg: string }>(
|
return http.post<any, { code: string; data: SpeakerVO; msg: string }>(
|
||||||
"/api/biz/speaker/register",
|
"/api/biz/speaker/register",
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ export default function Roles() {
|
||||||
const profileStr = sessionStorage.getItem("userProfile");
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
if (!profileStr) return false;
|
if (!profileStr) return false;
|
||||||
const profile = JSON.parse(profileStr);
|
const profile = JSON.parse(profileStr);
|
||||||
return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0";
|
return !!profile.isPlatformAdmin;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const activeTenantId = useMemo(() => normalizeNumber(localStorage.getItem("activeTenantId")) ?? 0, []);
|
const activeTenantId = useMemo(() => normalizeNumber(localStorage.getItem("activeTenantId")) ?? 0, []);
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ export default function Users() {
|
||||||
const profileStr = sessionStorage.getItem("userProfile");
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
if (!profileStr) return false;
|
if (!profileStr) return false;
|
||||||
const profile = JSON.parse(profileStr);
|
const profile = JSON.parse(profileStr);
|
||||||
return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0";
|
return !!profile.isPlatformAdmin;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
|
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
|
||||||
|
|
@ -248,7 +248,7 @@ export default function Users() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.password) {
|
if (values.password) {
|
||||||
userPayload.passwordHash = values.password;
|
userPayload.password = values.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
let userId = editing?.userId;
|
let userId = editing?.userId;
|
||||||
|
|
@ -294,24 +294,24 @@ export default function Users() {
|
||||||
},
|
},
|
||||||
...(isPlatformMode
|
...(isPlatformMode
|
||||||
? [{
|
? [{
|
||||||
title: t("users.tenant"),
|
title: t("users.tenant"),
|
||||||
key: "tenant",
|
key: "tenant",
|
||||||
render: (_: any, record: SysUser) => {
|
render: (_: any, record: SysUser) => {
|
||||||
if (record.memberships && record.memberships.length > 0) {
|
if (record.memberships && record.memberships.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{record.memberships.slice(0, 2).map((membership: any) => (
|
{record.memberships.slice(0, 2).map((membership: any) => (
|
||||||
<Tag key={membership.tenantId} color="blue" style={{ margin: 0, padding: "0 4px", fontSize: 11 }}>
|
<Tag key={membership.tenantId} color="blue" style={{ margin: 0, padding: "0 4px", fontSize: 11 }}>
|
||||||
{tenantMap[membership.tenantId] || `Tenant ${membership.tenantId}`}
|
{tenantMap[membership.tenantId] || `Tenant ${membership.tenantId}`}
|
||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
{record.memberships.length > 2 && <Text type="secondary" style={{ fontSize: 11 }}>+{record.memberships.length - 2} more</Text>}
|
{record.memberships.length > 2 && <Text type="secondary" style={{ fontSize: 11 }}>+{record.memberships.length - 2} more</Text>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return <Text type="secondary">{t("usersExt.noTenant")}</Text>;
|
|
||||||
}
|
}
|
||||||
}]
|
return <Text type="secondary">{t("usersExt.noTenant")}</Text>;
|
||||||
|
}
|
||||||
|
}]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
title: t("users.orgNode"),
|
title: t("users.orgNode"),
|
||||||
|
|
@ -375,10 +375,10 @@ export default function Users() {
|
||||||
<div className="users-table-toolbar">
|
<div className="users-table-toolbar">
|
||||||
<Space size="middle" wrap className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
|
<Space size="middle" wrap className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
|
||||||
<Space size="middle" wrap className="app-page__toolbar">
|
<Space size="middle" wrap className="app-page__toolbar">
|
||||||
{isPlatformMode && <Select placeholder={t("users.tenantFilter")} style={{ width: 200 }} allowClear value={filterTenantId} onChange={setFilterTenantId} options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} suffixIcon={<ShopOutlined aria-hidden="true" />} />}
|
{isPlatformMode && <Select placeholder={t("users.tenantFilter")} style={{ width: 200 }} allowClear value={filterTenantId} onChange={setFilterTenantId} options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} suffixIcon={<ShopOutlined aria-hidden="true" />} />}
|
||||||
<Input placeholder={t("users.searchPlaceholder")} prefix={<SearchOutlined aria-hidden="true" />} className="users-search-input" style={{ width: 300 }} value={searchText} onChange={(event) => { setSearchText(event.target.value); setCurrent(1); }} allowClear aria-label={t("common.search")} />
|
<Input placeholder={t("users.searchPlaceholder")} prefix={<SearchOutlined aria-hidden="true" />} className="users-search-input" style={{ width: 300 }} value={searchText} onChange={(event) => { setSearchText(event.target.value); setCurrent(1); }} allowClear aria-label={t("common.search")} />
|
||||||
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
|
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
|
||||||
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
|
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
|
||||||
</Space>
|
</Space>
|
||||||
{can("sys:user:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>{t("common.create")}</Button>}
|
{can("sys:user:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>{t("common.create")}</Button>}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
@ -439,4 +439,4 @@ export default function Users() {
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import type { UploadProps } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { listUsers } from '../../api';
|
import { listUsers } from '../../api';
|
||||||
import { deleteSpeaker, getSpeakerList, registerSpeaker, SpeakerVO } from '../../api/business/speaker';
|
import { deleteSpeaker, getSpeakerList, registerSpeaker, SpeakerVO } from '../../api/business/speaker';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import type { SysUser } from '../../types';
|
import type { SysUser } from '../../types';
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
@ -50,10 +51,13 @@ const SpeakerReg: React.FC = () => {
|
||||||
const [speakers, setSpeakers] = useState<SpeakerVO[]>([]);
|
const [speakers, setSpeakers] = useState<SpeakerVO[]>([]);
|
||||||
const [listLoading, setListLoading] = useState(false);
|
const [listLoading, setListLoading] = useState(false);
|
||||||
const [userOptions, setUserOptions] = useState<SysUser[]>([]);
|
const [userOptions, setUserOptions] = useState<SysUser[]>([]);
|
||||||
|
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
||||||
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 { profile } = useAuth();
|
||||||
|
const isAdmin = !!(profile?.isAdmin || profile?.isPlatformAdmin);
|
||||||
|
|
||||||
const resourcePrefix = useMemo(() => {
|
const resourcePrefix = useMemo(() => {
|
||||||
const configStr = sessionStorage.getItem('platformConfig');
|
const configStr = sessionStorage.getItem('platformConfig');
|
||||||
|
|
@ -75,6 +79,13 @@ const SpeakerReg: React.FC = () => {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile?.userId || isAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.setFieldValue('userId', profile.userId);
|
||||||
|
}, [form, isAdmin, profile?.userId]);
|
||||||
|
|
||||||
const fetchSpeakers = async () => {
|
const fetchSpeakers = async () => {
|
||||||
setListLoading(true);
|
setListLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -107,6 +118,15 @@ const SpeakerReg: React.FC = () => {
|
||||||
setAudioUrl(null);
|
setAudioUrl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetFormState = () => {
|
||||||
|
setEditingSpeaker(null);
|
||||||
|
form.resetFields(['id', 'name', 'userId', 'remark']);
|
||||||
|
if (!isAdmin && profile?.userId) {
|
||||||
|
form.setFieldValue('userId', profile.userId);
|
||||||
|
}
|
||||||
|
resetAudioState();
|
||||||
|
};
|
||||||
|
|
||||||
const startTimer = () => {
|
const startTimer = () => {
|
||||||
setSeconds(0);
|
setSeconds(0);
|
||||||
timerRef.current = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
|
|
@ -183,7 +203,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!audioBlob) {
|
if (!audioBlob && !editingSpeaker) {
|
||||||
message.warning('请先录制或上传声纹文件');
|
message.warning('请先录制或上传声纹文件');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -191,14 +211,14 @@ const SpeakerReg: React.FC = () => {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await registerSpeaker({
|
await registerSpeaker({
|
||||||
|
id: editingSpeaker?.id,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
userId: values.userId ? Number(values.userId) : undefined,
|
userId: values.userId ? Number(values.userId) : undefined,
|
||||||
remark: values.remark?.trim(),
|
remark: values.remark?.trim(),
|
||||||
file: audioBlob
|
file: audioBlob || undefined
|
||||||
});
|
});
|
||||||
message.success('声纹录入成功');
|
message.success(editingSpeaker ? '声纹更新成功' : '声纹录入成功');
|
||||||
form.resetFields(['name', 'userId', 'remark']);
|
resetFormState();
|
||||||
resetAudioState();
|
|
||||||
void fetchSpeakers();
|
void fetchSpeakers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as { errorFields?: unknown }).errorFields) {
|
if ((err as { errorFields?: unknown }).errorFields) {
|
||||||
|
|
@ -222,15 +242,26 @@ const SpeakerReg: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (speaker: SpeakerVO) => {
|
||||||
|
setEditingSpeaker(speaker);
|
||||||
|
form.setFieldsValue({
|
||||||
|
id: speaker.id,
|
||||||
|
name: speaker.name,
|
||||||
|
userId: speaker.userId,
|
||||||
|
remark: speaker.remark
|
||||||
|
});
|
||||||
|
resetAudioState();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '32px 24px', height: '100%', overflowY: 'auto', background: 'var(--app-bg-page)' }}>
|
<div style={{ padding: '24px', height: '100%', boxSizing: 'border-box', overflowX: 'hidden', overflowY: 'auto', background: 'var(--app-bg-page)' }}>
|
||||||
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
|
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
|
||||||
<Title level={3}>声纹库管理</Title>
|
<Title level={3}>声纹库管理</Title>
|
||||||
<Text type="secondary">支持录入仅有名称的声纹样本,也支持绑定系统用户,提交后同步到第三方声纹管理服务。</Text>
|
<Text type="secondary">支持录入仅有名称的声纹样本,也支持绑定系统用户,提交后同步到第三方声纹管理服务。</Text>
|
||||||
|
|
||||||
<Row gutter={24} style={{ marginTop: 24 }}>
|
<Row gutter={24} style={{ marginTop: 24 }}>
|
||||||
<Col xs={24} xl={14}>
|
<Col xs={24} xl={14}>
|
||||||
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
<Card
|
<Card
|
||||||
bordered={false}
|
bordered={false}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -241,9 +272,9 @@ const SpeakerReg: React.FC = () => {
|
||||||
backdropFilter: 'blur(16px)'
|
backdropFilter: 'blur(16px)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" initialValues={{ name: '', userId: undefined, remark: '' }}>
|
<Form form={form} layout="vertical" initialValues={{ id: undefined, name: '', userId: undefined, remark: '' }}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={14}>
|
<Col xs={24} sm={14}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="声纹名称"
|
label="声纹名称"
|
||||||
name="name"
|
name="name"
|
||||||
|
|
@ -255,12 +286,13 @@ const SpeakerReg: React.FC = () => {
|
||||||
<Input prefix={<FormOutlined />} placeholder="例如:张三 / 财务总监 / 访客A" maxLength={100} />
|
<Input prefix={<FormOutlined />} placeholder="例如:张三 / 财务总监 / 访客A" maxLength={100} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={10}>
|
<Col xs={24} sm={10}>
|
||||||
<Form.Item label="绑定用户" name="userId">
|
<Form.Item label="绑定用户" name="userId">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear={isAdmin}
|
||||||
showSearch
|
disabled={!isAdmin}
|
||||||
placeholder="可选,选择系统用户"
|
showSearch={isAdmin}
|
||||||
|
placeholder={isAdmin ? '可选,选择系统用户' : '默认绑定当前用户'}
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
options={userOptions.map(user => ({
|
options={userOptions.map(user => ({
|
||||||
label: `${user.displayName || user.username} (${user.username})`,
|
label: `${user.displayName || user.username} (${user.username})`,
|
||||||
|
|
@ -293,7 +325,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
<Text type="secondary">请在点击录音后,自然、清晰地朗读以上内容</Text>
|
<Text type="secondary">请在点击录音后,自然、清晰地朗读以上内容</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ textAlign: 'center', margin: '32px 0' }}>
|
<div style={{ textAlign: 'center', margin: '24px 0' }}>
|
||||||
<div style={{ position: 'relative', display: 'inline-block', marginBottom: 16 }}>
|
<div style={{ position: 'relative', display: 'inline-block', marginBottom: 16 }}>
|
||||||
{!recording ? (
|
{!recording ? (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -338,7 +370,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: '40px 0',
|
padding: '24px 0',
|
||||||
border: '1px dashed var(--app-border-color)',
|
border: '1px dashed var(--app-border-color)',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
|
|
@ -368,7 +400,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<Text strong>采集完成,请试听:</Text>
|
<Text strong>{editingSpeaker ? '已选择新的声纹文件,请试听:' : '采集完成,请试听:'}</Text>
|
||||||
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={resetAudioState}>
|
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={resetAudioState}>
|
||||||
清除文件
|
清除文件
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -377,18 +409,25 @@ const SpeakerReg: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Space style={{ width: '100%' }}>
|
||||||
type="primary"
|
<Button
|
||||||
size="large"
|
type="primary"
|
||||||
block
|
size="large"
|
||||||
icon={<CloudUploadOutlined />}
|
block
|
||||||
onClick={handleSubmit}
|
icon={<CloudUploadOutlined />}
|
||||||
loading={loading}
|
onClick={handleSubmit}
|
||||||
disabled={recording || !audioBlob}
|
loading={loading}
|
||||||
style={{ height: 50, borderRadius: 8, fontSize: 16, fontWeight: 600 }}
|
disabled={recording || (!audioBlob && !editingSpeaker)}
|
||||||
>
|
style={{ height: 50, borderRadius: 8, fontSize: 16, fontWeight: 600 }}
|
||||||
提交到声纹库
|
>
|
||||||
</Button>
|
{editingSpeaker ? '保存修改' : '提交到声纹库'}
|
||||||
|
</Button>
|
||||||
|
{editingSpeaker && (
|
||||||
|
<Button size="large" onClick={resetFormState} style={{ height: 50, borderRadius: 8 }}>
|
||||||
|
取消编辑
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</Card>
|
</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' }}>
|
||||||
|
|
@ -397,11 +436,11 @@ const SpeakerReg: React.FC = () => {
|
||||||
<div style={{ fontSize: 12, color: '#856404' }}>
|
<div style={{ fontSize: 12, color: '#856404' }}>
|
||||||
<b>录入说明:</b>
|
<b>录入说明:</b>
|
||||||
<br />
|
<br />
|
||||||
1. 名称必填,绑定用户可为空。
|
1. 普通用户默认绑定自己且不可修改,管理员可为本租户其他用户录入。
|
||||||
<br />
|
<br />
|
||||||
2. 提交后系统会先保存本地文件,再调用第三方声纹管理接口注册。
|
2. 同一租户下声纹名称不可重复。
|
||||||
<br />
|
<br />
|
||||||
3. 删除本地记录时,会优先尝试删除第三方声纹库中的对应记录。
|
3. 提交后系统会先保存本地文件,再调用第三方声纹管理接口注册。
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -409,16 +448,19 @@ const SpeakerReg: React.FC = () => {
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} xl={10}>
|
<Col xs={24} xl={10}>
|
||||||
<Card
|
<div style={{ position: 'sticky', top: 24 }}>
|
||||||
title={
|
<Card
|
||||||
<span>
|
title={
|
||||||
<CheckCircleOutlined /> 我的声纹库
|
<span>
|
||||||
</span>
|
<CheckCircleOutlined /> 我的声纹库
|
||||||
}
|
</span>
|
||||||
bordered={false}
|
}
|
||||||
style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)', minHeight: 640 }}
|
bordered={false}
|
||||||
loading={listLoading}
|
style={{ borderRadius: 16, boxShadow: '0 4px 12px rgba(0,0,0,0.03)', display: 'flex', flexDirection: 'column', maxHeight: 'calc(100vh - 48px)' }}
|
||||||
>
|
styles={{ body: { overflowY: 'auto', flex: 1, minHeight: 0 } }}
|
||||||
|
bodyStyle={{ overflowY: 'auto', flex: 1, minHeight: 0 }}
|
||||||
|
loading={listLoading}
|
||||||
|
>
|
||||||
{speakers.length === 0 ? (
|
{speakers.length === 0 ? (
|
||||||
<Empty description="尚未录入声纹" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
<Empty description="尚未录入声纹" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -429,6 +471,13 @@ const SpeakerReg: React.FC = () => {
|
||||||
<List.Item
|
<List.Item
|
||||||
key={speaker.id}
|
key={speaker.id}
|
||||||
actions={[
|
actions={[
|
||||||
|
...(isAdmin
|
||||||
|
? [
|
||||||
|
<Button key="edit" type="link" onClick={() => handleEdit(speaker)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
]
|
||||||
|
: []),
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
key="delete"
|
key="delete"
|
||||||
title="确认删除这条声纹吗?"
|
title="确认删除这条声纹吗?"
|
||||||
|
|
@ -474,7 +523,8 @@ const SpeakerReg: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export default function Orgs() {
|
||||||
const profileStr = sessionStorage.getItem("userProfile");
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
if (!profileStr) return false;
|
if (!profileStr) return false;
|
||||||
const profile = JSON.parse(profileStr);
|
const profile = JSON.parse(profileStr);
|
||||||
return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0";
|
return !!profile.isPlatformAdmin;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
|
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
|
||||||
|
|
@ -215,4 +215,4 @@ export default function Orgs() {
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,237 +9,240 @@ import type { BotCredential, UserProfile } from "@/types";
|
||||||
const { Paragraph, Title, Text } = Typography;
|
const { Paragraph, Title, Text } = Typography;
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [credentialLoading, setCredentialLoading] = useState(false);
|
const [credentialLoading, setCredentialLoading] = useState(false);
|
||||||
const [credentialSaving, setCredentialSaving] = useState(false);
|
const [credentialSaving, setCredentialSaving] = useState(false);
|
||||||
const [user, setUser] = useState<UserProfile | null>(null);
|
const [user, setUser] = useState<UserProfile | null>(null);
|
||||||
const [credential, setCredential] = useState<BotCredential | null>(null);
|
const [credential, setCredential] = useState<BotCredential | null>(null);
|
||||||
const [profileForm] = Form.useForm();
|
const [profileForm] = Form.useForm();
|
||||||
const [pwdForm] = Form.useForm();
|
const [pwdForm] = Form.useForm();
|
||||||
|
|
||||||
const loadUser = async () => {
|
const loadUser = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getCurrentUser();
|
const data = await getCurrentUser();
|
||||||
setUser(data);
|
setUser(data);
|
||||||
profileForm.setFieldsValue(data);
|
profileForm.setFieldsValue(data);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadCredential = async () => {
|
const loadCredential = async () => {
|
||||||
setCredentialLoading(true);
|
setCredentialLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getMyBotCredential();
|
const data = await getMyBotCredential();
|
||||||
setCredential(data);
|
setCredential(data);
|
||||||
} finally {
|
} finally {
|
||||||
setCredentialLoading(false);
|
setCredentialLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUser();
|
loadUser();
|
||||||
loadCredential();
|
loadCredential();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleUpdateProfile = async () => {
|
const handleUpdateProfile = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await profileForm.validateFields();
|
const values = await profileForm.validateFields();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await updateMyProfile(values);
|
await updateMyProfile(values);
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
loadUser();
|
loadUser();
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdatePassword = async () => {
|
const handleUpdatePassword = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await pwdForm.validateFields();
|
const values = await pwdForm.validateFields();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await updateMyPassword(values);
|
await updateMyPassword(values);
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
pwdForm.resetFields();
|
pwdForm.resetFields();
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateCredential = async () => {
|
const handleGenerateCredential = async () => {
|
||||||
try {
|
try {
|
||||||
setCredentialSaving(true);
|
setCredentialSaving(true);
|
||||||
const data = await generateMyBotCredential();
|
const data = await generateMyBotCredential();
|
||||||
setCredential(data);
|
setCredential(data);
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
} finally {
|
} finally {
|
||||||
setCredentialSaving(false);
|
setCredentialSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderValue = (value?: string) => value || "-";
|
const renderValue = (value?: string) => value || "-";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}>
|
<div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}>
|
||||||
<PageHeader title={t("profile.title")} subtitle={t("profile.subtitle")} />
|
<PageHeader title={t("profile.title")} subtitle={t("profile.subtitle")} />
|
||||||
|
|
||||||
<Row gutter={24}>
|
<Row gutter={24}>
|
||||||
<Col xs={24} lg={8}>
|
<Col xs={24} lg={8}>
|
||||||
<Card className="app-page__content-card text-center" loading={loading}>
|
<Card className="app-page__content-card text-center" loading={loading}>
|
||||||
<Avatar size={80} icon={<UserOutlined />} style={{ backgroundColor: "#1677ff", marginBottom: 16 }} />
|
<Avatar size={80} icon={<UserOutlined />} style={{ backgroundColor: "#1677ff", marginBottom: 16 }} />
|
||||||
<Title level={5} style={{ margin: 0 }}>{user?.displayName}</Title>
|
<Title level={5} style={{ margin: 0 }}>{user?.displayName}</Title>
|
||||||
<Text type="secondary">@{user?.username}</Text>
|
<Text type="secondary">@{user?.username}</Text>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{user?.isPlatformAdmin ? <Tag color="gold">{t("users.platformAdmin")}</Tag> : <Tag color="blue">{t("profile.standardUser")}</Tag>}
|
{user?.isPlatformAdmin ? <Tag color="gold">{t("users.platformAdmin")}</Tag> : null}
|
||||||
</div>
|
{user?.isTenantAdmin ? <Tag color="blue">租户管理员</Tag> : null}
|
||||||
</Card>
|
{!user?.isPlatformAdmin && !user?.isTenantAdmin ? <Tag color="blue">{t("profile.standardUser")}</Tag> : null}
|
||||||
</Col>
|
{!user?.isPlatformAdmin && user?.hasPlatformAdminPrivilege ? <Tag color="purple">可切换平台管理员</Tag> : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} lg={16}>
|
<Col xs={24} lg={16}>
|
||||||
<Card className="app-page__content-card">
|
<Card className="app-page__content-card">
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultActiveKey="basic"
|
defaultActiveKey="basic"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: "basic",
|
key: "basic",
|
||||||
label: <span><SolutionOutlined /> {t("profile.basicInfo")}</span>,
|
label: <span><SolutionOutlined /> {t("profile.basicInfo")}</span>,
|
||||||
children: (
|
children: (
|
||||||
<Form form={profileForm} layout="vertical" onFinish={handleUpdateProfile} style={{ marginTop: 16 }}>
|
<Form form={profileForm} layout="vertical" onFinish={handleUpdateProfile} style={{ marginTop: 16 }}>
|
||||||
<Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true }]}>
|
<Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true }]}>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("users.email")} name="email">
|
<Form.Item label={t("users.email")} name="email">
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("users.phone")} name="phone">
|
<Form.Item label={t("users.phone")} name="phone">
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
|
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
|
||||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => profileForm.submit()}>
|
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => profileForm.submit()}>
|
||||||
{t("profile.saveChanges")}
|
{t("profile.saveChanges")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "password",
|
key: "password",
|
||||||
label: <span><LockOutlined /> {t("profile.security")}</span>,
|
label: <span><LockOutlined /> {t("profile.security")}</span>,
|
||||||
children: (
|
children: (
|
||||||
<Form form={pwdForm} layout="vertical" onFinish={handleUpdatePassword} style={{ marginTop: 16 }}>
|
<Form form={pwdForm} layout="vertical" onFinish={handleUpdatePassword} style={{ marginTop: 16 }}>
|
||||||
<Form.Item label={t("profile.currentPassword")} name="oldPassword" rules={[{ required: true }]}>
|
<Form.Item label={t("profile.currentPassword")} name="oldPassword" rules={[{ required: true }]}>
|
||||||
<Input.Password />
|
<Input.Password />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("profile.newPassword")} name="newPassword" rules={[{ required: true, min: 6 }]}>
|
<Form.Item label={t("profile.newPassword")} name="newPassword" rules={[{ required: true, min: 6 }]}>
|
||||||
<Input.Password />
|
<Input.Password />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("profile.confirmNewPassword")}
|
label={t("profile.confirmNewPassword")}
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
dependencies={["newPassword"]}
|
dependencies={["newPassword"]}
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true },
|
{ required: true },
|
||||||
({ getFieldValue }) => ({
|
({ getFieldValue }) => ({
|
||||||
validator(_, value) {
|
validator(_, value) {
|
||||||
if (!value || getFieldValue("newPassword") === value) {
|
if (!value || getFieldValue("newPassword") === value) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
return Promise.reject(new Error(t("profile.passwordsDoNotMatch")));
|
return Promise.reject(new Error(t("profile.passwordsDoNotMatch")));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input.Password />
|
<Input.Password />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
|
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
|
||||||
<Button type="primary" danger loading={saving} onClick={() => pwdForm.submit()}>
|
<Button type="primary" danger loading={saving} onClick={() => pwdForm.submit()}>
|
||||||
{t("profile.updatePassword")}
|
{t("profile.updatePassword")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "bot-credential",
|
key: "bot-credential",
|
||||||
label: <span><KeyOutlined /> {t("profile.botCredentialTab")}</span>,
|
label: <span><KeyOutlined /> {t("profile.botCredentialTab")}</span>,
|
||||||
children: (
|
children: (
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message={t("profile.botCredentialHint")}
|
message={t("profile.botCredentialHint")}
|
||||||
description={t("profile.botCredentialHintDesc")}
|
description={t("profile.botCredentialHintDesc")}
|
||||||
/>
|
|
||||||
|
|
||||||
<Descriptions
|
|
||||||
bordered
|
|
||||||
size="middle"
|
|
||||||
column={1} items={[
|
|
||||||
{
|
|
||||||
key: "bind-status",
|
|
||||||
label: t("profile.botBindStatus"),
|
|
||||||
children: credential?.bound
|
|
||||||
? <Tag color="success">{t("profile.botBound")}</Tag>
|
|
||||||
: <Tag>{t("profile.botUnbound")}</Tag>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "bot-id",
|
|
||||||
label: "X-Bot-Id",
|
|
||||||
children: credential?.botId ? (
|
|
||||||
<Paragraph copyable={{ text: credential.botId }} style={{ marginBottom: 0 }}>
|
|
||||||
{credential.botId}
|
|
||||||
</Paragraph>
|
|
||||||
) : "-"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "bot-secret",
|
|
||||||
label: "X-Bot-Secret",
|
|
||||||
children: credential?.botSecret ? (
|
|
||||||
<Paragraph copyable={{ text: credential.botSecret }} style={{ marginBottom: 0 }}>
|
|
||||||
{credential.botSecret}
|
|
||||||
</Paragraph>
|
|
||||||
) : t("profile.botSecretHidden")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "last-access-time",
|
|
||||||
label: t("profile.botLastAccessTime"),
|
|
||||||
children: renderValue(credential?.lastAccessTime)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "last-access-ip",
|
|
||||||
label: t("profile.botLastAccessIp"),
|
|
||||||
children: renderValue(credential?.lastAccessIp)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={credential?.bound ? <ReloadOutlined /> : <KeyOutlined />}
|
|
||||||
loading={credentialSaving}
|
|
||||||
onClick={handleGenerateCredential}
|
|
||||||
>
|
|
||||||
{credential?.bound
|
|
||||||
? t("profile.regenerateBotCredential")
|
|
||||||
: t("profile.generateBotCredential")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
|
||||||
</Col>
|
<Descriptions
|
||||||
</Row>
|
bordered
|
||||||
</div>
|
size="middle"
|
||||||
);
|
column={1} items={[
|
||||||
|
{
|
||||||
|
key: "bind-status",
|
||||||
|
label: t("profile.botBindStatus"),
|
||||||
|
children: credential?.bound
|
||||||
|
? <Tag color="success">{t("profile.botBound")}</Tag>
|
||||||
|
: <Tag>{t("profile.botUnbound")}</Tag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "bot-id",
|
||||||
|
label: "X-Bot-Id",
|
||||||
|
children: credential?.botId ? (
|
||||||
|
<Paragraph copyable={{ text: credential.botId }} style={{ marginBottom: 0 }}>
|
||||||
|
{credential.botId}
|
||||||
|
</Paragraph>
|
||||||
|
) : "-"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "bot-secret",
|
||||||
|
label: "X-Bot-Secret",
|
||||||
|
children: credential?.botSecret ? (
|
||||||
|
<Paragraph copyable={{ text: credential.botSecret }} style={{ marginBottom: 0 }}>
|
||||||
|
{credential.botSecret}
|
||||||
|
</Paragraph>
|
||||||
|
) : t("profile.botSecretHidden")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "last-access-time",
|
||||||
|
label: t("profile.botLastAccessTime"),
|
||||||
|
children: renderValue(credential?.lastAccessTime)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "last-access-ip",
|
||||||
|
label: t("profile.botLastAccessIp"),
|
||||||
|
children: renderValue(credential?.lastAccessIp)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={credential?.bound ? <ReloadOutlined /> : <KeyOutlined />}
|
||||||
|
loading={credentialSaving}
|
||||||
|
onClick={handleGenerateCredential}
|
||||||
|
>
|
||||||
|
{credential?.bound
|
||||||
|
? t("profile.regenerateBotCredential")
|
||||||
|
: t("profile.generateBotCredential")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export default function Logs() {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isPlatformAdmin = Boolean(userProfile?.isPlatformAdmin && userProfile?.tenantId === 0);
|
const isPlatformAdmin = Boolean(userProfile?.isPlatformAdmin);
|
||||||
|
|
||||||
const loadData = async (currentParams = params) => {
|
const loadData = async (currentParams = params) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -190,16 +190,16 @@ export default function Logs() {
|
||||||
width: 180,
|
width: 180,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (method: string) => (
|
render: (method: string) => (
|
||||||
<Tag
|
<Tag
|
||||||
color="blue"
|
color="blue"
|
||||||
style={{
|
style={{
|
||||||
fontSize: "11px",
|
fontSize: "11px",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
verticalAlign: "middle"
|
verticalAlign: "middle"
|
||||||
}}
|
}}
|
||||||
title={method}
|
title={method}
|
||||||
>
|
>
|
||||||
{method}
|
{method}
|
||||||
|
|
@ -300,4 +300,4 @@ export default function Logs() {
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ export interface UserProfile {
|
||||||
status?: number;
|
status?: number;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isPlatformAdmin?: boolean;
|
isPlatformAdmin?: boolean;
|
||||||
|
isTenantAdmin?: boolean;
|
||||||
|
hasPlatformAdminPrivilege?: boolean;
|
||||||
pwdResetRequired?: number;
|
pwdResetRequired?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue