From 80a468275734597d148e20afabf71f637571de1f Mon Sep 17 00:00:00 2001 From: chenhao Date: Wed, 4 Mar 2026 15:19:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A3=B0=E7=BA=B9?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=BC=80=E5=85=B3=E5=92=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E7=AE=A1=E7=90=86=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在会议创建页面添加声纹识别开关 - 优化提示模板的权限管理,区分平台级、租户级和个人级 - 更新后端逻辑处理不同层级的模板权限 - 重构前端模板选择界面,增强用户体验 - 添加热词权重显示并更新数据库表结构 - 修复和优化多处代码逻辑和样式问题 --- backend/design/db_schema_pgsql.sql | 1 + .../biz/PromptTemplateController.java | 87 ++++++++---- .../java/com/imeeting/dto/biz/MeetingDTO.java | 1 + .../java/com/imeeting/dto/biz/MeetingVO.java | 1 + .../imeeting/dto/biz/PromptTemplateDTO.java | 1 + .../java/com/imeeting/entity/biz/Meeting.java | 2 + .../service/biz/PromptTemplateService.java | 5 +- .../service/biz/impl/AiTaskServiceImpl.java | 68 +++++++++- .../service/biz/impl/MeetingServiceImpl.java | 2 + .../biz/impl/PromptTemplateServiceImpl.java | 59 ++++++-- .../service/biz/impl/SpeakerServiceImpl.java | 1 + .../src/main/resources/application-test.yml | 4 +- backend/src/main/resources/application.yml | 4 +- frontend/src/layouts/AppLayout.tsx | 35 ++--- frontend/src/pages/Dashboard.tsx | 26 +--- frontend/src/pages/business/AiModels.tsx | 10 +- frontend/src/pages/business/HotWords.tsx | 6 + frontend/src/pages/business/MeetingCreate.tsx | 126 +++++++++++++++--- .../src/pages/business/PromptTemplates.tsx | 119 ++++++++++++++--- 19 files changed, 427 insertions(+), 131 deletions(-) diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index de75f55..4b6ba77 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -345,6 +345,7 @@ CREATE TABLE biz_meetings ( asr_model_id BIGINT, -- ASR模型ID summary_model_id BIGINT, -- LLM模型ID prompt_content TEXT, -- 发起任务时的提示词模板快照 + use_spk_id SMALLINT DEFAULT 1, -- 是否开启声纹识别 (1:开启, 0:关闭) hot_words JSONB, -- 任务发起时的热词快照 summary_content TEXT, -- Markdown 总结结果 status SMALLINT DEFAULT 0, -- 0:待处理, 1:处理中, 2:成功, 3:失败 diff --git a/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java b/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java index 3c33094..c0b14f2 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java @@ -27,11 +27,28 @@ public class PromptTemplateController { @PreAuthorize("isAuthenticated()") public ApiResponse save(@RequestBody PromptTemplateDTO dto) { LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - // Only platform admin can create system templates - if (Integer.valueOf(1).equals(dto.getIsSystem()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { - return ApiResponse.error("无权创建系统模板"); + + // 权限校验逻辑 + if (Integer.valueOf(1).equals(dto.getIsSystem())) { + // 只有平台管理员能创建平台级模板(tenantId=0) + // 只有租户管理员能创建租户级模板(tenantId>0) + if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { + return ApiResponse.error("无权创建系统模板"); + } + + // 如果是租户管理员创建系统模板,确保 tenantId 是其所属租户 + if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { + dto.setTenantId(loginUser.getTenantId()); + } else { + // 平台管理员:如果 DTO 没传 tenantId,默认设为 0 + if (dto.getTenantId() == null) dto.setTenantId(0L); + } + } else { + // 普通模板 + dto.setTenantId(loginUser.getTenantId()); } - return ApiResponse.ok(promptTemplateService.saveTemplate(dto, loginUser.getUserId())); + + return ApiResponse.ok(promptTemplateService.saveTemplate(dto, loginUser.getUserId(), loginUser.getTenantId())); } @PutMapping @@ -43,16 +60,21 @@ public class PromptTemplateController { return ApiResponse.error("模板不存在"); } - // System template protection - if (Integer.valueOf(1).equals(existing.getIsSystem())) { - if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { - return ApiResponse.error("无权修改系统模板"); - } + // 核心权限判定 + boolean canModify = false; + if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { + // 平台管理员只能修改平台级模板 (tenantId = 0) + canModify = existing.getTenantId() == 0L; + } else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { + // 租户管理员可以修改本租户的所有模板 (租户预置 + 个人模板) + canModify = existing.getTenantId().equals(loginUser.getTenantId()); } else { - // Personal template protection - if (!existing.getCreatorId().equals(loginUser.getUserId())) { - return ApiResponse.error("无权修改他人模板"); - } + // 普通用户仅限自己的个人模板 + canModify = existing.getCreatorId().equals(loginUser.getUserId()); + } + + if (!canModify) { + return ApiResponse.error("无权修改此模板"); } return ApiResponse.ok(promptTemplateService.updateTemplate(dto)); @@ -65,14 +87,17 @@ public class PromptTemplateController { PromptTemplate existing = promptTemplateService.getById(id); if (existing == null) return ApiResponse.error("模板不存在"); - if (Integer.valueOf(1).equals(existing.getIsSystem())) { - if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { - return ApiResponse.error("无权修改系统模板"); - } + boolean canModify = false; + if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { + canModify = existing.getTenantId() == 0L; + } else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { + canModify = existing.getTenantId().equals(loginUser.getTenantId()); } else { - if (!existing.getCreatorId().equals(loginUser.getUserId())) { - return ApiResponse.error("无权修改他人模板"); - } + canModify = existing.getCreatorId().equals(loginUser.getUserId()); + } + + if (!canModify) { + return ApiResponse.error("无权修改此模板"); } existing.setStatus(status); @@ -88,14 +113,17 @@ public class PromptTemplateController { return ApiResponse.ok(true); } - if (Integer.valueOf(1).equals(existing.getIsSystem())) { - if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { - return ApiResponse.error("无权删除系统模板"); - } + boolean canModify = false; + if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { + canModify = existing.getTenantId() == 0L; + } else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { + canModify = existing.getTenantId().equals(loginUser.getTenantId()); } else { - if (!existing.getCreatorId().equals(loginUser.getUserId())) { - return ApiResponse.error("无权删除他人模板"); - } + canModify = existing.getCreatorId().equals(loginUser.getUserId()); + } + + if (!canModify) { + return ApiResponse.error("无权删除此模板"); } return ApiResponse.ok(promptTemplateService.removeById(id)); @@ -110,6 +138,9 @@ public class PromptTemplateController { @RequestParam(required = false) String category) { LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - return ApiResponse.ok(promptTemplateService.pageTemplates(current, size, name, category, loginUser.getTenantId(), loginUser.getUserId())); + return ApiResponse.ok(promptTemplateService.pageTemplates( + current, size, name, category, + loginUser.getTenantId(), loginUser.getUserId(), + loginUser.getIsPlatformAdmin(), loginUser.getIsTenantAdmin())); } } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingDTO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingDTO.java index cb6aab5..5027ec0 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingDTO.java @@ -22,5 +22,6 @@ public class MeetingDTO { private Long asrModelId; private Long summaryModelId; private Long promptId; + private Integer useSpkId; private List hotWords; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java index a7a9651..e3ef373 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -16,6 +16,7 @@ public class MeetingVO { private String participants; private String tags; + private Integer useSpkId; private String audioUrl; private String summaryContent; private Integer status; diff --git a/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java index 34c70b3..b1cb798 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java @@ -5,6 +5,7 @@ import lombok.Data; @Data public class PromptTemplateDTO { private Long id; + private Long tenantId; private String templateName; private String category; private Integer isSystem; diff --git a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java index 5a57f88..3c36993 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java @@ -39,6 +39,8 @@ public class Meeting extends BaseEntity { private String promptContent; + private Integer useSpkId; + @TableField(typeHandler = JacksonTypeHandler.class) private List hotWords; diff --git a/backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java b/backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java index 464805e..a38bc63 100644 --- a/backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java +++ b/backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java @@ -10,7 +10,8 @@ import com.imeeting.common.PageResult; import java.util.List; public interface PromptTemplateService extends IService { - PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId); + PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId, Long tenantId); PromptTemplateVO updateTemplate(PromptTemplateDTO dto); - PageResult> pageTemplates(Integer current, Integer size, String name, String category, Long tenantId, Long userId); + PageResult> pageTemplates(Integer current, Integer size, String name, String category, + Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index b10c1b3..ed3eb33 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -8,11 +8,15 @@ import com.imeeting.entity.biz.AiModel; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.MeetingTranscript; +import com.imeeting.entity.biz.HotWord; +import com.imeeting.entity.SysUser; import com.imeeting.mapper.biz.AiTaskMapper; import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper; +import com.imeeting.mapper.SysUserMapper; import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.AiTaskService; +import com.imeeting.service.biz.HotWordService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -20,6 +24,8 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -37,6 +43,8 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private final MeetingTranscriptMapper transcriptMapper; private final AiModelService aiModelService; private final ObjectMapper objectMapper; + private final SysUserMapper sysUserMapper; + private final HotWordService hotWordService; @Value("${app.server-base-url}") private String serverBaseUrl; @@ -102,10 +110,44 @@ public class AiTaskServiceImpl extends ServiceImpl impleme // 构建请求参数 Map req = new HashMap<>(); - String fullAudioUrl = serverBaseUrl + meeting.getAudioUrl(); + + String rawAudioUrl = meeting.getAudioUrl(); + String encodedAudioUrl = Arrays.stream(rawAudioUrl.split("/")) + .map(part -> { + try { + return URLEncoder.encode(part, StandardCharsets.UTF_8.toString()).replace("+", "%20"); + } catch (Exception e) { + return part; + } + }) + .collect(Collectors.joining("/")); + + String fullAudioUrl = serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl; + req.put("file_path", fullAudioUrl); - req.put("hotwords", meeting.getHotWords() != null ? meeting.getHotWords() : Collections.emptyList()); - req.put("use_spk_id", true); + + List> formattedHotwords = new ArrayList<>(); + if (meeting.getHotWords() != null && !meeting.getHotWords().isEmpty()) { + List hotWordEntities = hotWordService.list(new LambdaQueryWrapper() + .eq(HotWord::getTenantId, meeting.getTenantId()) + .in(HotWord::getWord, meeting.getHotWords())); + + Map wordToWeightMap = hotWordEntities.stream() + .collect(Collectors.toMap(HotWord::getWord, hw -> hw.getWeight() != null ? hw.getWeight() : 10, (v1, v2) -> v1)); + + for (String word : meeting.getHotWords()) { + Map hwMap = new HashMap<>(); + hwMap.put("hotword", word); + // Default weight is 1.0 (assuming 10 corresponds to 1.0, 20 corresponds to 2.0 etc., or keep original logic) + // Let's map 1-100 to 0.1-10.0 or just keep integer? The prompt shows weight: 2.0 + // Assuming weight in DB is 10 for normal, maybe weight / 10.0 + double calculatedWeight = wordToWeightMap.getOrDefault(word, 10) / 10.0; + hwMap.put("weight", calculatedWeight); + formattedHotwords.add(hwMap); + } + } + req.put("hotwords", formattedHotwords); + req.put("use_spk_id", meeting.getUseSpkId() != null && meeting.getUseSpkId() == 1); AiTask taskRecord = createAiTask(meeting.getId(), "ASR", req); @@ -113,7 +155,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition"; String respBody = postJson(submitUrl, req); JsonNode submitNode = objectMapper.readTree(respBody); - + log.info(respBody); if (submitNode.get("code").asInt() != 200) { updateAiTaskFail(taskRecord, "Submission Failed: " + respBody); throw new RuntimeException("ASR submission failed"); @@ -150,8 +192,22 @@ public class AiTaskServiceImpl extends ServiceImpl impleme for (JsonNode seg : resultNode.get("segments")) { MeetingTranscript mt = new MeetingTranscript(); mt.setMeetingId(meeting.getId()); - mt.setSpeakerId(seg.has("speaker") ? seg.get("speaker").asText() : "spk_0"); - mt.setSpeakerName(mt.getSpeakerId()); + + String speakerIdStr = seg.has("speaker") ? seg.get("speaker").asText() : "spk_0"; + mt.setSpeakerId(speakerIdStr); + + String speakerName = speakerIdStr; + try { + Long userId = Long.valueOf(speakerIdStr); + SysUser user = sysUserMapper.selectById(userId); + if (user != null) { + speakerName = user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(); + } + } catch (NumberFormatException e) { + // Not a user ID, keep the original speaker_id as name + } + mt.setSpeakerName(speakerName); + mt.setContent(seg.get("text").asText()); if (seg.has("timestamp")) { mt.setStartTime(seg.get("timestamp").get(0).asInt()); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java index 4aada93..0ce9e43 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java @@ -57,6 +57,7 @@ public class MeetingServiceImpl extends ServiceImpl impl meeting.setAudioUrl(dto.getAudioUrl()); meeting.setAsrModelId(dto.getAsrModelId()); meeting.setSummaryModelId(dto.getSummaryModelId()); + meeting.setUseSpkId(dto.getUseSpkId() != null ? dto.getUseSpkId() : 1); meeting.setCreatorId(dto.getCreatorId()); meeting.setCreatorName(dto.getCreatorName()); @@ -214,6 +215,7 @@ public class MeetingServiceImpl extends ServiceImpl impl vo.setTitle(meeting.getTitle()); vo.setMeetingTime(meeting.getMeetingTime()); vo.setTags(meeting.getTags()); + vo.setUseSpkId(meeting.getUseSpkId()); vo.setAudioUrl(meeting.getAudioUrl()); vo.setStatus(meeting.getStatus()); vo.setSummaryContent(meeting.getSummaryContent()); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java index 46a89c8..9f5ca2c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java @@ -20,15 +20,25 @@ public class PromptTemplateServiceImpl extends ServiceImpl> pageTemplates(Integer current, Integer size, String name, String category, Long tenantId, Long userId) { - Page page = this.page(new Page<>(current, size), - new LambdaQueryWrapper() - .and(wrapper -> wrapper.eq(PromptTemplate::getCreatorId, userId) - .or() - .eq(PromptTemplate::getIsSystem, 1)) - .like(name != null && !name.isEmpty(), PromptTemplate::getTemplateName, name) - .eq(category != null && !category.isEmpty(), PromptTemplate::getCategory, category) - .orderByDesc(PromptTemplate::getIsSystem) - .orderByDesc(PromptTemplate::getCreatedAt)); + public PageResult> pageTemplates(Integer current, Integer size, String name, String category, + Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) { + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 核心过滤逻辑:分层可见性 (精细化处理) + if (Boolean.TRUE.equals(isPlatformAdmin)) { + // 平台管理员:可以看到所有平台级模板 (tenantId=0) + wrapper.eq(PromptTemplate::getTenantId, 0L); + } else if (Boolean.TRUE.equals(isTenantAdmin)) { + // 租户管理员: + // 1. 本租户所有模板 (tenantId=currentTenantId) + // 2. 平台预置 (tenantId=0 & isSystem=1) + wrapper.and(w -> w + .eq(PromptTemplate::getTenantId, tenantId) + .or(sw -> sw.eq(PromptTemplate::getTenantId, 0L).eq(PromptTemplate::getIsSystem, 1)) + ); + } else { + // 普通个人用户: + // 1. 个人创建 (creatorId=currentUserId) + // 2. 平台预置 (tenantId=0 & isSystem=1) + // 3. 租户预置 (tenantId=currentTenantId & isSystem=1) + wrapper.and(w -> w + .eq(PromptTemplate::getCreatorId, userId) + .or(sw -> sw.eq(PromptTemplate::getTenantId, 0L).eq(PromptTemplate::getIsSystem, 1)) + .or(sw -> sw.eq(PromptTemplate::getTenantId, tenantId).eq(PromptTemplate::getIsSystem, 1)) + ); + } + // 通用过滤条件 + wrapper.like(name != null && !name.isEmpty(), PromptTemplate::getTemplateName, name) + .eq(category != null && !category.isEmpty(), PromptTemplate::getCategory, category) + .orderByDesc(PromptTemplate::getIsSystem) + .orderByDesc(PromptTemplate::getCreatedAt); + + Page page = this.page(new Page<>(current, size), wrapper); List vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList()); PageResult> result = new PageResult<>(); @@ -65,6 +100,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl impl Map body = new HashMap<>(); body.put("name", String.valueOf(speaker.getUserId())); + body.put("user_id", speaker.getUserId()); // 拼接完整下载路径: serverBaseUrl + resourcePrefix + voicePath String fullPath = serverBaseUrl; diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml index 3c21ddc..4a4cd1f 100644 --- a/backend/src/main/resources/application-test.yml +++ b/backend/src/main/resources/application-test.yml @@ -16,8 +16,8 @@ spring: type: redis servlet: multipart: - max-file-size: 100MB - max-request-size: 100MB + max-file-size: 2048MB + max-request-size: 2048MB jackson: date-format: yyyy-MM-dd HH:mm:ss serialization: diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index e1b5d85..3c8f665 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -16,8 +16,8 @@ spring: type: redis servlet: multipart: - max-file-size: 100MB - max-request-size: 100MB + max-file-size: 2048MB + max-request-size: 2048MB jackson: date-format: yyyy-MM-dd HH:mm:ss diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index 147a875..949bdf9 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -1,4 +1,4 @@ -import { Layout, Menu, Button, Space, Avatar, Dropdown, message, type MenuProps, Select } from "antd"; +import { Layout, Menu, Button, Space, Avatar, Dropdown, message, type MenuProps, Select, Tooltip } from "antd"; import { useEffect, useState, useMemo, useCallback } from "react"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -308,22 +308,27 @@ export default function AppLayout() { style={{ fontSize: '16px', width: 64, height: 64 }} />
- {availableTenants.length > 0 && ( - - - - + {!(watchedModelType === 'ASR' && provider === 'Custom') && ( + + + + )} 业务参数 diff --git a/frontend/src/pages/business/HotWords.tsx b/frontend/src/pages/business/HotWords.tsx index 07bbebb..0cf7bbf 100644 --- a/frontend/src/pages/business/HotWords.tsx +++ b/frontend/src/pages/business/HotWords.tsx @@ -174,6 +174,12 @@ const HotWords: React.FC = () => { key: 'isPublic', render: (val: number) => val === 1 ? 公开 : 私有 }, + { + title: '权重', + dataIndex: 'weight', + key: 'weight', + render: (val: number) => {val} + }, { title: '状态', dataIndex: 'status', diff --git a/frontend/src/pages/business/MeetingCreate.tsx b/frontend/src/pages/business/MeetingCreate.tsx index f44a17c..cf61b50 100644 --- a/frontend/src/pages/business/MeetingCreate.tsx +++ b/frontend/src/pages/business/MeetingCreate.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from 'react'; -import { Card, Button, Form, Input, Space, Select, Tag, message, Typography, Divider, Row, Col, DatePicker, Upload, Progress, Tooltip, Avatar } from 'antd'; +import { Card, Button, Form, Input, Space, Select, Tag, message, Typography, Divider, Row, Col, DatePicker, Upload, Progress, Tooltip, Avatar, Switch } from 'antd'; import { AudioOutlined, CheckCircleOutlined, UserOutlined, CloudUploadOutlined, LeftOutlined, SettingOutlined, QuestionCircleOutlined, InfoCircleOutlined, - CalendarOutlined, TeamOutlined, RobotOutlined, RocketOutlined + CalendarOutlined, TeamOutlined, RobotOutlined, RocketOutlined, + FileTextOutlined, CheckOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import dayjs from 'dayjs'; @@ -29,6 +30,8 @@ const MeetingCreate: React.FC = () => { const [hotwordList, setHotwordList] = useState([]); const [userList, setUserList] = useState([]); + const watchedPromptId = Form.useWatch('promptId', form); + const [fileList, setFileList] = useState([]); const [uploadProgress, setUploadProgress] = useState(0); const [audioUrl, setAudioUrl] = useState(''); @@ -49,7 +52,8 @@ const MeetingCreate: React.FC = () => { setAsrModels(asrRes.data.data.records.filter(m => m.status === 1)); setLlmModels(llmRes.data.data.records.filter(m => m.status === 1)); - setPrompts(promptRes.data.data.records.filter(p => p.status === 1)); + const activePrompts = promptRes.data.data.records.filter(p => p.status === 1); + setPrompts(activePrompts); setHotwordList(hotwordRes.data.data.records.filter(h => h.status === 1)); setUserList(users || []); @@ -59,7 +63,9 @@ const MeetingCreate: React.FC = () => { form.setFieldsValue({ asrModelId: defaultAsr.data.data?.id, summaryModelId: defaultLlm.data.data?.id, - meetingTime: dayjs() + promptId: activePrompts.length > 0 ? activePrompts[0].id : undefined, + meetingTime: dayjs(), + useSpkId: 1 }); } catch (err) {} }; @@ -203,23 +209,105 @@ const MeetingCreate: React.FC = () => { - - + +
+ + {prompts.map(p => { + const isSelected = watchedPromptId === p.id; + return ( + +
form.setFieldsValue({ promptId: p.id })} + style={{ + padding: '8px 6px', + borderRadius: 8, + border: `1.5px solid ${isSelected ? '#1890ff' : '#f0f0f0'}`, + backgroundColor: isSelected ? '#f0f7ff' : '#fff', + cursor: 'pointer', + transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', + position: 'relative', + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center', + boxShadow: isSelected ? '0 2px 6px rgba(24, 144, 255, 0.12)' : 'none' + }} + > +
+ +
+
+ {p.templateName} +
+ {isSelected && ( +
+ +
+ )} +
+ + ); + })} +
+
- 纠错热词 } - style={{ marginBottom: 16 }} - > - - + + + 纠错热词 } + style={{ marginBottom: 0 }} + > + + + + + 声纹识别 } + valuePropName="checked" + getValueProps={(value) => ({ checked: value === 1 })} + normalize={(value) => (value ? 1 : 0)} + style={{ marginBottom: 0 }} + > + + + +
diff --git a/frontend/src/pages/business/PromptTemplates.tsx b/frontend/src/pages/business/PromptTemplates.tsx index fabaf68..64a7876 100644 --- a/frontend/src/pages/business/PromptTemplates.tsx +++ b/frontend/src/pages/business/PromptTemplates.tsx @@ -21,6 +21,7 @@ const PromptTemplates: React.FC = () => { const [searchForm] = Form.useForm(); const { items: categories, loading: dictLoading } = useDict('biz_prompt_category'); const { items: dictTags } = useDict('biz_prompt_tag'); + const { items: promptLevels } = useDict('biz_prompt_level'); const [loading, setLoading] = useState(false); const [data, setData] = useState([]); @@ -34,7 +35,10 @@ const PromptTemplates: React.FC = () => { return profileStr ? JSON.parse(profileStr) : {}; }, []); + const activeTenantId = React.useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []); + const isPlatformAdmin = userProfile.isPlatformAdmin === true; + const isTenantAdmin = userProfile.isTenantAdmin === true; useEffect(() => { fetchData(); @@ -77,19 +81,30 @@ const PromptTemplates: React.FC = () => { form.setFieldsValue({ ...record, templateName: `${record.templateName} (副本)`, - isSystem: 0, - id: undefined + isSystem: 0, // 副本强制设为普通模板 + id: undefined, + tenantId: undefined }); setPreviewContent(record.promptContent); } else { - if (record.isSystem === 1 && !isPlatformAdmin) { - message.error('无权编辑系统模板'); - return; - } - if (record.isSystem === 0 && record.creatorId !== userProfile.userId) { - message.error('无权编辑他人模板'); + const isPlatformLevel = Number(record.tenantId) === 0; + const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1; + + // 权限判定逻辑 + let canEdit = false; + if (isPlatformAdmin) { + canEdit = isPlatformLevel; + } else if (isTenantAdmin) { + canEdit = Number(record.tenantId) === activeTenantId; + } else { + canEdit = Number(record.creatorId) === currentUserId; + } + + if (!canEdit) { + message.warning('您无权修改此层级的模板'); return; } + setEditingId(record.id); form.setFieldsValue(record); setPreviewContent(record.promptContent); @@ -97,7 +112,11 @@ const PromptTemplates: React.FC = () => { } else { setEditingId(null); form.resetFields(); - form.setFieldsValue({ status: 1, isSystem: 0 }); + // 租户管理员或平台管理员新增默认选系统/租户预置 + form.setFieldsValue({ + status: 1, + isSystem: (isTenantAdmin || isPlatformAdmin) ? 1 : 0 + }); setPreviewContent(''); } setDrawerVisible(true); @@ -122,6 +141,12 @@ const PromptTemplates: React.FC = () => { try { const values = await form.validateFields(); setSubmitLoading(true); + + // 处理 tenantId,如果是新增且是平台管理员设为系统模板,手动设置 tenantId 为 0 + if (!editingId && isPlatformAdmin && values.isSystem === 1) { + values.tenantId = 0; + } + if (editingId) { await updatePromptTemplate({ ...values, id: editingId }); message.success('更新成功'); @@ -149,9 +174,33 @@ const PromptTemplates: React.FC = () => { }, [data]); const renderCard = (item: PromptTemplateVO) => { - const isMine = item.creatorId === userProfile.userId; const isSystem = item.isSystem === 1; - const canEdit = isSystem ? isPlatformAdmin : isMine; + const isPlatformLevel = Number(item.tenantId) === 0; + const isTenantLevel = Number(item.tenantId) > 0 && isSystem; + + // 权限判定逻辑 (使用 Number 强制转换防止类型不匹配) + let canEdit = false; + const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1; + + if (isPlatformAdmin) { + // 平台管理员管理平台级 (tenantId = 0) + canEdit = isPlatformLevel; + } else if (isTenantAdmin) { + // 租户管理员管理本租户所有模板 + canEdit = Number(item.tenantId) === activeTenantId; + } else { + // 普通用户仅限自己的个人模板 + canEdit = Number(item.creatorId) === currentUserId; + } + + // 标签颜色与文字 + const levelTag = isPlatformLevel ? ( + 平台级 + ) : isTenantLevel ? ( + 租户级 + ) : ( + 个人级 + ); return ( { bodyStyle={{ padding: '24px' }} >
-
- +
+
+ +
+ {levelTag}
e.stopPropagation()}> {canEdit && handleOpenDrawer(item)} />} @@ -274,9 +326,38 @@ const PromptTemplates: React.FC = () => { >
- - - + + + + {(isPlatformAdmin || isTenantAdmin) && ( + + + + + + )} + + + + + + + + + +