From 5da9a97d55d5ab99a35af8bf4d69172228187158 Mon Sep 17 00:00:00 2001 From: chenhao Date: Wed, 1 Apr 2026 17:20:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A3=B0=E7=BA=B9?= =?UTF-8?q?=E5=BA=93=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E5=92=8C=E7=9B=B8?= =?UTF-8?q?=E5=85=B3API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `Speaker` 实体中添加 `creatorId` 和 `externalSpeakerId` 字段 - 更新数据库表 `biz_speakers`,添加 `creator_id` 和 `external_speaker_id` 字段,并创建相应索引 - 在 `SpeakerService` 中添加 `listByCreator` 和 `deleteSpeaker` 方法 - 更新前端API和组件,支持声纹注册、删除和列表查询 - 优化声纹注册逻辑,支持第三方声纹服务的调用和状态更新 --- backend/design/db_schema_pgsql.sql | 3 + .../controller/biz/SpeakerController.java | 6 +- .../imeeting/dto/biz/SpeakerRegisterDTO.java | 1 + .../java/com/imeeting/dto/biz/SpeakerVO.java | 1 + .../java/com/imeeting/entity/biz/Speaker.java | 6 +- .../imeeting/service/biz/SpeakerService.java | 7 +- .../service/biz/impl/SpeakerServiceImpl.java | 251 ++++++---- frontend/src/api/business/speaker.ts | 10 +- frontend/src/pages/access/roles/index.tsx | 2 +- frontend/src/pages/access/users/index.tsx | 48 +- frontend/src/pages/business/SpeakerReg.tsx | 134 ++++-- .../src/pages/organization/orgs/index.tsx | 4 +- frontend/src/pages/profile/index.tsx | 443 +++++++++--------- frontend/src/pages/system/logs/index.tsx | 20 +- frontend/src/types/index.ts | 2 + 15 files changed, 526 insertions(+), 412 deletions(-) diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index cee870a..b3f6877 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -235,6 +235,7 @@ VALUES (1, 'iMeeting 智能会议系统', '© 2026 iMeeting Team. All rights res DROP TABLE IF EXISTS biz_speakers CASCADE; CREATE TABLE biz_speakers ( id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, -- 租户ID creator_id BIGINT NOT NULL, -- 创建人ID,用于声纹库管理归属 user_id BIGINT, -- 关联系统用户ID,可为空 external_speaker_id VARCHAR(100), -- 第三方声纹库中的人员ID @@ -250,9 +251,11 @@ CREATE TABLE biz_speakers ( 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_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 UNIQUE INDEX uk_speaker_tenant_name ON biz_speakers (tenant_id, name) WHERE is_deleted = 0; COMMENT ON TABLE biz_speakers IS '声纹发言人基础信息表 (声纹库资源)'; diff --git a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java index 25e2e08..127eee9 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java @@ -29,7 +29,7 @@ public class SpeakerController { return ApiResponse.error("未获取到用户信息"); } registerDTO.setCreatorId(loginUser.getUserId()); - return ApiResponse.ok(speakerService.register(registerDTO)); + return ApiResponse.ok(speakerService.register(registerDTO, loginUser)); } @GetMapping("/list") @@ -39,7 +39,7 @@ public class SpeakerController { if (loginUser == null || loginUser.getUserId() == null) { return ApiResponse.error("未获取到用户信息"); } - return ApiResponse.ok(speakerService.listByCreator(loginUser.getUserId())); + return ApiResponse.ok(speakerService.listVisible(loginUser)); } @DeleteMapping("/{id}") @@ -49,7 +49,7 @@ public class SpeakerController { if (loginUser == null || loginUser.getUserId() == null) { return ApiResponse.error("未获取到用户信息"); } - speakerService.deleteSpeaker(id, loginUser.getUserId()); + speakerService.deleteSpeaker(id, loginUser); return ApiResponse.ok(Boolean.TRUE); } diff --git a/backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java b/backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java index 0534624..92913cc 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java @@ -5,6 +5,7 @@ import org.springframework.web.multipart.MultipartFile; @Data public class SpeakerRegisterDTO { + private Long id; private String name; private Long creatorId; private Long userId; diff --git a/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java b/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java index 339bc09..d2afba0 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java @@ -7,6 +7,7 @@ import java.time.LocalDateTime; @Data public class SpeakerVO { private Long id; + private Long tenantId; private String name; private Long creatorId; private Long userId; diff --git a/backend/src/main/java/com/imeeting/entity/biz/Speaker.java b/backend/src/main/java/com/imeeting/entity/biz/Speaker.java index b6154e4..779550f 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Speaker.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Speaker.java @@ -1,7 +1,6 @@ package com.imeeting.entity.biz; import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.unisbase.entity.BaseEntity; @@ -15,6 +14,8 @@ public class Speaker extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) private Long id; + private Long tenantId; + private Long creatorId; private Long userId; @@ -31,9 +32,6 @@ public class Speaker extends BaseEntity { private String remark; - @TableField(exist = false) - private Long tenantId; - // Note: status, createdAt, updatedAt, isDeleted are in BaseEntity // embedding is reserved for future pgvector use } diff --git a/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java b/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java index 668c4e3..2a8938c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java +++ b/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java @@ -4,13 +4,14 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.imeeting.dto.biz.SpeakerRegisterDTO; import com.imeeting.dto.biz.SpeakerVO; import com.imeeting.entity.biz.Speaker; +import com.unisbase.security.LoginUser; import java.util.List; public interface SpeakerService extends IService { - SpeakerVO register(SpeakerRegisterDTO registerDTO); + SpeakerVO register(SpeakerRegisterDTO registerDTO, LoginUser loginUser); - List listByCreator(Long creatorId); + List listVisible(LoginUser loginUser); - void deleteSpeaker(Long id, Long creatorId); + void deleteSpeaker(Long id, LoginUser loginUser); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java index bd0a6aa..d2db6fa 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java @@ -7,13 +7,11 @@ import com.imeeting.dto.biz.SpeakerRegisterDTO; import com.imeeting.dto.biz.SpeakerVO; import com.imeeting.entity.biz.Speaker; import com.imeeting.mapper.biz.SpeakerMapper; - import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.SpeakerService; import com.unisbase.security.LoginUser; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -60,71 +58,57 @@ public class SpeakerServiceImpl extends ServiceImpl impl @Override @Transactional(rollbackFor = Exception.class) - public SpeakerVO register(SpeakerRegisterDTO registerDTO) { - if (registerDTO.getCreatorId() == null) { - throw new RuntimeException("创建人不能为空"); + public SpeakerVO register(SpeakerRegisterDTO registerDTO, LoginUser loginUser) { + if (loginUser == null || loginUser.getUserId() == null || loginUser.getTenantId() == null) { + throw new RuntimeException("未获取到有效登录信息"); } if (registerDTO.getName() == null || registerDTO.getName().isBlank()) { 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(); - if (file == null || file.isEmpty()) { + if ((speaker.getId() == null || speaker.getVoicePath() == null || speaker.getVoicePath().isBlank()) + && (file == null || file.isEmpty())) { throw new RuntimeException("声纹文件不能为空"); } - Speaker speaker = new Speaker(); - speaker.setCreatorId(registerDTO.getCreatorId()); - speaker.setUserId(registerDTO.getUserId()); - speaker.setName(registerDTO.getName().trim()); + speaker.setTenantId(loginUser.getTenantId()); + speaker.setName(normalizedName); speaker.setRemark(registerDTO.getRemark()); - - String originalFilename = file.getOriginalFilename(); - String extension = ""; - if (originalFilename != null && originalFilename.contains(".")) { - extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + if (speaker.getId() == null) { + speaker.setCreatorId(loginUser.getUserId()); + speaker.setCreatedAt(LocalDateTime.now()); } - // Ensure directory exists - 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"); + if (!admin) { + speaker.setUserId(loginUser.getUserId()); + } else { + speaker.setUserId(registerDTO.getUserId()); } - // 2. 生成文件名 - 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"); + if (file != null && !file.isEmpty()) { + saveVoiceFile(speaker, file); } - // 3. 更新实体信息 - speaker.setVoicePath("voiceprints/" + fileName); - speaker.setVoiceExt(extension.replace(".", "")); - speaker.setVoiceSize(file.getSize()); - speaker.setStatus(1); // 已保存 - speaker.setCreatedAt(LocalDateTime.now()); + speaker.setStatus(1); speaker.setUpdatedAt(LocalDateTime.now()); - this.save(speaker); - - // 4. 调用外部声纹注册接口 - callExternalVoiceprintReg(speaker); + this.saveOrUpdate(speaker); + syncExternalVoiceprint(speaker, loginUser); return toVO(speaker); } @Override - public List listByCreator(Long creatorId) { + public List listVisible(LoginUser loginUser) { + boolean admin = isAdmin(loginUser); List list = this.lambdaQuery() - .eq(Speaker::getCreatorId, creatorId) + .eq(Speaker::getTenantId, loginUser.getTenantId()) + .eq(!admin, Speaker::getUserId, loginUser.getUserId()) .orderByDesc(Speaker::getUpdatedAt) .list(); List vos = new ArrayList<>(list.size()); @@ -136,109 +120,154 @@ public class SpeakerServiceImpl extends ServiceImpl impl @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); + public void deleteSpeaker(Long id, LoginUser loginUser) { + Speaker speaker = getSpeakerForWrite(id, loginUser); + deleteExternalVoiceprint(speaker, loginUser); this.removeById(id); } - private void callExternalVoiceprintReg(Speaker speaker) { - try { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Long tenantId = loginUser.getTenantId(); + private Speaker prepareSpeaker(SpeakerRegisterDTO registerDTO, LoginUser loginUser, boolean admin) { + if (registerDTO.getId() != null) { + return getSpeakerForWrite(registerDTO.getId(), loginUser); + } + 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) { log.warn("Default ASR model not configured, skipping external voiceprint registration"); return; } - String baseUrl = asrModel.getBaseUrl(); - String url = baseUrl.endsWith("/") ? baseUrl + "api/v1/speakers" : baseUrl + "/api/v1/speakers"; - + String url = appendPath(asrModel.getBaseUrl(), "api/v1/speakers"); Map body = new HashMap<>(); body.put("name", speaker.getName()); if (speaker.getUserId() != null) { body.put("user_id", String.valueOf(speaker.getUserId())); } - - // 拼接完整下载路径: serverBaseUrl + resourcePrefix + voicePath - String fullPath = serverBaseUrl; - if (!fullPath.endsWith("/") && !resourcePrefix.startsWith("/")) { - fullPath += "/"; - } - fullPath += resourcePrefix; - if (!fullPath.endsWith("/") && !speaker.getVoicePath().startsWith("/")) { - fullPath += "/"; - } - fullPath += speaker.getVoicePath(); - - body.put("file_url", fullPath); - - String jsonBody = objectMapper.writeValueAsString(body); + body.put("file_url", buildFileUrl(speaker.getVoicePath())); HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() .uri(URI.create(url)) .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(jsonBody)); + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); if (asrModel.getApiKey() != null && !asrModel.getApiKey().isEmpty()) { requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey()); } - log.info("Calling external voiceprint registration: {} with body: {}", url, jsonBody); - HttpResponse response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { log.error("External voiceprint registration failed: status={}, body={}", response.statusCode(), response.body()); speaker.setStatus(4); this.updateById(speaker); - } else { - log.info("External voiceprint registration success for speakerId: {}", speaker.getId()); - fillExternalSpeakerId(speaker, response.body()); - speaker.setStatus(3); // 已注册 (根据之前的定义) - this.updateById(speaker); + return; } + fillExternalSpeakerId(speaker, response.body()); + speaker.setStatus(3); + this.updateById(speaker); } catch (Exception 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()) { return; } try { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Long tenantId = loginUser.getTenantId(); - AiModelVO asrModel = aiModelService.getDefaultModel("ASR", tenantId); + AiModelVO asrModel = aiModelService.getDefaultModel("ASR", loginUser.getTenantId()); 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(); + String url = appendPath(asrModel.getBaseUrl(), "api/v1/speakers/" + speaker.getExternalSpeakerId()); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(url)).DELETE(); if (asrModel.getApiKey() != null && !asrModel.getApiKey().isEmpty()) { requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey()); } HttpResponse response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (response.statusCode() == 200) { + speaker.setExternalSpeakerId(null); + } else { log.warn("External voiceprint delete failed: status={}, body={}", response.statusCode(), response.body()); } } catch (Exception e) { @@ -246,6 +275,23 @@ public class SpeakerServiceImpl extends ServiceImpl 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") private void fillExternalSpeakerId(Speaker speaker, String responseBody) { try { @@ -281,9 +327,14 @@ public class SpeakerServiceImpl extends ServiceImpl impl return null; } + private boolean isAdmin(LoginUser loginUser) { + return Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); + } + private SpeakerVO toVO(Speaker speaker) { SpeakerVO vo = new SpeakerVO(); vo.setId(speaker.getId()); + vo.setTenantId(speaker.getTenantId()); vo.setName(speaker.getName()); vo.setCreatorId(speaker.getCreatorId()); vo.setUserId(speaker.getUserId()); diff --git a/frontend/src/api/business/speaker.ts b/frontend/src/api/business/speaker.ts index 4d110e4..27f4fc3 100644 --- a/frontend/src/api/business/speaker.ts +++ b/frontend/src/api/business/speaker.ts @@ -16,18 +16,22 @@ export interface SpeakerVO { } export interface SpeakerRegisterParams { + id?: number; name: string; userId?: number; remark?: string; - file: File | Blob; + file?: File | Blob; } export const registerSpeaker = (params: SpeakerRegisterParams) => { const formData = new FormData(); + if (params.id) formData.append("id", params.id.toString()); 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); - formData.append("file", params.file, "voice.wav"); + if (params.file) { + formData.append("file", params.file, "voice.wav"); + } return http.post( "/api/biz/speaker/register", diff --git a/frontend/src/pages/access/roles/index.tsx b/frontend/src/pages/access/roles/index.tsx index a282df2..720e165 100644 --- a/frontend/src/pages/access/roles/index.tsx +++ b/frontend/src/pages/access/roles/index.tsx @@ -196,7 +196,7 @@ export default function Roles() { const profileStr = sessionStorage.getItem("userProfile"); if (!profileStr) return false; const profile = JSON.parse(profileStr); - return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0"; + return !!profile.isPlatformAdmin; }, []); const activeTenantId = useMemo(() => normalizeNumber(localStorage.getItem("activeTenantId")) ?? 0, []); diff --git a/frontend/src/pages/access/users/index.tsx b/frontend/src/pages/access/users/index.tsx index 3019bb6..a2da460 100644 --- a/frontend/src/pages/access/users/index.tsx +++ b/frontend/src/pages/access/users/index.tsx @@ -93,7 +93,7 @@ export default function Users() { const profileStr = sessionStorage.getItem("userProfile"); if (!profileStr) return false; const profile = JSON.parse(profileStr); - return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0"; + return !!profile.isPlatformAdmin; }, []); const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []); @@ -248,7 +248,7 @@ export default function Users() { } if (values.password) { - userPayload.passwordHash = values.password; + userPayload.password = values.password; } let userId = editing?.userId; @@ -294,24 +294,24 @@ export default function Users() { }, ...(isPlatformMode ? [{ - title: t("users.tenant"), - key: "tenant", - render: (_: any, record: SysUser) => { - if (record.memberships && record.memberships.length > 0) { - return ( -
- {record.memberships.slice(0, 2).map((membership: any) => ( - - {tenantMap[membership.tenantId] || `Tenant ${membership.tenantId}`} - - ))} - {record.memberships.length > 2 && +{record.memberships.length - 2} more} -
- ); - } - return {t("usersExt.noTenant")}; + title: t("users.tenant"), + key: "tenant", + render: (_: any, record: SysUser) => { + if (record.memberships && record.memberships.length > 0) { + return ( +
+ {record.memberships.slice(0, 2).map((membership: any) => ( + + {tenantMap[membership.tenantId] || `Tenant ${membership.tenantId}`} + + ))} + {record.memberships.length > 2 && +{record.memberships.length - 2} more} +
+ ); } - }] + return {t("usersExt.noTenant")}; + } + }] : []), { title: t("users.orgNode"), @@ -375,10 +375,10 @@ export default function Users() {
- {isPlatformMode && } className="users-search-input" style={{ width: 300 }} value={searchText} onChange={(event) => { setSearchText(event.target.value); setCurrent(1); }} allowClear aria-label={t("common.search")} /> - - + {isPlatformMode && } className="users-search-input" style={{ width: 300 }} value={searchText} onChange={(event) => { setSearchText(event.target.value); setCurrent(1); }} allowClear aria-label={t("common.search")} /> + + {can("sys:user:create") && } @@ -439,4 +439,4 @@ export default function Users() {
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/business/SpeakerReg.tsx b/frontend/src/pages/business/SpeakerReg.tsx index 54df315..00496b9 100644 --- a/frontend/src/pages/business/SpeakerReg.tsx +++ b/frontend/src/pages/business/SpeakerReg.tsx @@ -33,6 +33,7 @@ import type { UploadProps } from 'antd'; import dayjs from 'dayjs'; import { listUsers } from '../../api'; import { deleteSpeaker, getSpeakerList, registerSpeaker, SpeakerVO } from '../../api/business/speaker'; +import { useAuth } from '../../hooks/useAuth'; import type { SysUser } from '../../types'; const { Title, Text, Paragraph } = Typography; @@ -50,10 +51,13 @@ const SpeakerReg: React.FC = () => { const [speakers, setSpeakers] = useState([]); const [listLoading, setListLoading] = useState(false); const [userOptions, setUserOptions] = useState([]); + const [editingSpeaker, setEditingSpeaker] = useState(null); const [seconds, setSeconds] = useState(0); const timerRef = useRef(null); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); + const { profile } = useAuth(); + const isAdmin = !!(profile?.isAdmin || profile?.isPlatformAdmin); const resourcePrefix = useMemo(() => { 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 () => { setListLoading(true); try { @@ -107,6 +118,15 @@ const SpeakerReg: React.FC = () => { setAudioUrl(null); }; + const resetFormState = () => { + setEditingSpeaker(null); + form.resetFields(['id', 'name', 'userId', 'remark']); + if (!isAdmin && profile?.userId) { + form.setFieldValue('userId', profile.userId); + } + resetAudioState(); + }; + const startTimer = () => { setSeconds(0); timerRef.current = setInterval(() => { @@ -183,7 +203,7 @@ const SpeakerReg: React.FC = () => { }; const handleSubmit = async () => { - if (!audioBlob) { + if (!audioBlob && !editingSpeaker) { message.warning('请先录制或上传声纹文件'); return; } @@ -191,14 +211,14 @@ const SpeakerReg: React.FC = () => { const values = await form.validateFields(); setLoading(true); await registerSpeaker({ + id: editingSpeaker?.id, name: values.name.trim(), userId: values.userId ? Number(values.userId) : undefined, remark: values.remark?.trim(), - file: audioBlob + file: audioBlob || undefined }); - message.success('声纹录入成功'); - form.resetFields(['name', 'userId', 'remark']); - resetAudioState(); + message.success(editingSpeaker ? '声纹更新成功' : '声纹录入成功'); + resetFormState(); void fetchSpeakers(); } catch (err) { 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 ( -
+
声纹库管理 支持录入仅有名称的声纹样本,也支持绑定系统用户,提交后同步到第三方声纹管理服务。 - + { backdropFilter: 'blur(16px)' }} > -
+ - + { } placeholder="例如:张三 / 财务总监 / 访客A" maxLength={100} /> - + - - - - - - - -
- -
- - ) - }, - { - key: "password", - label: {t("profile.security")}, - children: ( -
- - - - - - - ({ - validator(_, value) { - if (!value || getFieldValue("newPassword") === value) { - return Promise.resolve(); - } - return Promise.reject(new Error(t("profile.passwordsDoNotMatch"))); - } - }) - ]} - > - - -
- -
-
- ) - }, - { - key: "bot-credential", - label: {t("profile.botCredentialTab")}, - children: ( -
- - - - {t("profile.botBound")} - : {t("profile.botUnbound")} - }, - { - key: "bot-id", - label: "X-Bot-Id", - children: credential?.botId ? ( - - {credential.botId} - - ) : "-" - }, - { - key: "bot-secret", - label: "X-Bot-Secret", - children: credential?.botSecret ? ( - - {credential.botSecret} - - ) : 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) - } - ]} - /> - -
- -
-
-
- ) - } - ]} + + + {t("profile.basicInfo")}, + children: ( +
+ + + + + + + + + +
+ +
+
+ ) + }, + { + key: "password", + label: {t("profile.security")}, + children: ( +
+ + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue("newPassword") === value) { + return Promise.resolve(); + } + return Promise.reject(new Error(t("profile.passwordsDoNotMatch"))); + } + }) + ]} + > + + +
+ +
+
+ ) + }, + { + key: "bot-credential", + label: {t("profile.botCredentialTab")}, + children: ( +
+ + - - - -
- ); + + {t("profile.botBound")} + : {t("profile.botUnbound")} + }, + { + key: "bot-id", + label: "X-Bot-Id", + children: credential?.botId ? ( + + {credential.botId} + + ) : "-" + }, + { + key: "bot-secret", + label: "X-Bot-Secret", + children: credential?.botSecret ? ( + + {credential.botSecret} + + ) : 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) + } + ]} + /> + +
+ +
+
+
+ ) + } + ]} + /> + + + +
+ ); } diff --git a/frontend/src/pages/system/logs/index.tsx b/frontend/src/pages/system/logs/index.tsx index 3b9edb0..3cee63b 100644 --- a/frontend/src/pages/system/logs/index.tsx +++ b/frontend/src/pages/system/logs/index.tsx @@ -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) => { setLoading(true); @@ -190,16 +190,16 @@ export default function Logs() { width: 180, ellipsis: true, render: (method: string) => ( - {method} @@ -300,4 +300,4 @@ export default function Logs() {
); -} \ No newline at end of file +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 25611f8..7492654 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -31,6 +31,8 @@ export interface UserProfile { status?: number; isAdmin: boolean; isPlatformAdmin?: boolean; + isTenantAdmin?: boolean; + hasPlatformAdminPrivilege?: boolean; pwdResetRequired?: number; }