feat: 添加声纹识别开关和优化模板管理权限
- 在会议创建页面添加声纹识别开关 - 优化提示模板的权限管理,区分平台级、租户级和个人级 - 更新后端逻辑处理不同层级的模板权限 - 重构前端模板选择界面,增强用户体验 - 添加热词权重显示并更新数据库表结构 - 修复和优化多处代码逻辑和样式问题dev_na
parent
eaadc4ee51
commit
80a4682757
|
|
@ -345,6 +345,7 @@ CREATE TABLE biz_meetings (
|
||||||
asr_model_id BIGINT, -- ASR模型ID
|
asr_model_id BIGINT, -- ASR模型ID
|
||||||
summary_model_id BIGINT, -- LLM模型ID
|
summary_model_id BIGINT, -- LLM模型ID
|
||||||
prompt_content TEXT, -- 发起任务时的提示词模板快照
|
prompt_content TEXT, -- 发起任务时的提示词模板快照
|
||||||
|
use_spk_id SMALLINT DEFAULT 1, -- 是否开启声纹识别 (1:开启, 0:关闭)
|
||||||
hot_words JSONB, -- 任务发起时的热词快照
|
hot_words JSONB, -- 任务发起时的热词快照
|
||||||
summary_content TEXT, -- Markdown 总结结果
|
summary_content TEXT, -- Markdown 总结结果
|
||||||
status SMALLINT DEFAULT 0, -- 0:待处理, 1:处理中, 2:成功, 3:失败
|
status SMALLINT DEFAULT 0, -- 0:待处理, 1:处理中, 2:成功, 3:失败
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,28 @@ public class PromptTemplateController {
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<PromptTemplateVO> save(@RequestBody PromptTemplateDTO dto) {
|
public ApiResponse<PromptTemplateVO> save(@RequestBody PromptTemplateDTO dto) {
|
||||||
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
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
|
@PutMapping
|
||||||
|
|
@ -43,16 +60,21 @@ public class PromptTemplateController {
|
||||||
return ApiResponse.error("模板不存在");
|
return ApiResponse.error("模板不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
// System template protection
|
// 核心权限判定
|
||||||
if (Integer.valueOf(1).equals(existing.getIsSystem())) {
|
boolean canModify = false;
|
||||||
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
|
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
|
||||||
return ApiResponse.error("无权修改系统模板");
|
// 平台管理员只能修改平台级模板 (tenantId = 0)
|
||||||
}
|
canModify = existing.getTenantId() == 0L;
|
||||||
|
} else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
|
||||||
|
// 租户管理员可以修改本租户的所有模板 (租户预置 + 个人模板)
|
||||||
|
canModify = existing.getTenantId().equals(loginUser.getTenantId());
|
||||||
} else {
|
} else {
|
||||||
// Personal template protection
|
// 普通用户仅限自己的个人模板
|
||||||
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
|
canModify = existing.getCreatorId().equals(loginUser.getUserId());
|
||||||
return ApiResponse.error("无权修改他人模板");
|
}
|
||||||
}
|
|
||||||
|
if (!canModify) {
|
||||||
|
return ApiResponse.error("无权修改此模板");
|
||||||
}
|
}
|
||||||
|
|
||||||
return ApiResponse.ok(promptTemplateService.updateTemplate(dto));
|
return ApiResponse.ok(promptTemplateService.updateTemplate(dto));
|
||||||
|
|
@ -65,14 +87,17 @@ public class PromptTemplateController {
|
||||||
PromptTemplate existing = promptTemplateService.getById(id);
|
PromptTemplate existing = promptTemplateService.getById(id);
|
||||||
if (existing == null) return ApiResponse.error("模板不存在");
|
if (existing == null) return ApiResponse.error("模板不存在");
|
||||||
|
|
||||||
if (Integer.valueOf(1).equals(existing.getIsSystem())) {
|
boolean canModify = false;
|
||||||
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
|
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
|
||||||
return ApiResponse.error("无权修改系统模板");
|
canModify = existing.getTenantId() == 0L;
|
||||||
}
|
} else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
|
||||||
|
canModify = existing.getTenantId().equals(loginUser.getTenantId());
|
||||||
} else {
|
} else {
|
||||||
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
|
canModify = existing.getCreatorId().equals(loginUser.getUserId());
|
||||||
return ApiResponse.error("无权修改他人模板");
|
}
|
||||||
}
|
|
||||||
|
if (!canModify) {
|
||||||
|
return ApiResponse.error("无权修改此模板");
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.setStatus(status);
|
existing.setStatus(status);
|
||||||
|
|
@ -88,14 +113,17 @@ public class PromptTemplateController {
|
||||||
return ApiResponse.ok(true);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Integer.valueOf(1).equals(existing.getIsSystem())) {
|
boolean canModify = false;
|
||||||
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
|
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
|
||||||
return ApiResponse.error("无权删除系统模板");
|
canModify = existing.getTenantId() == 0L;
|
||||||
}
|
} else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
|
||||||
|
canModify = existing.getTenantId().equals(loginUser.getTenantId());
|
||||||
} else {
|
} else {
|
||||||
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
|
canModify = existing.getCreatorId().equals(loginUser.getUserId());
|
||||||
return ApiResponse.error("无权删除他人模板");
|
}
|
||||||
}
|
|
||||||
|
if (!canModify) {
|
||||||
|
return ApiResponse.error("无权删除此模板");
|
||||||
}
|
}
|
||||||
|
|
||||||
return ApiResponse.ok(promptTemplateService.removeById(id));
|
return ApiResponse.ok(promptTemplateService.removeById(id));
|
||||||
|
|
@ -110,6 +138,9 @@ public class PromptTemplateController {
|
||||||
@RequestParam(required = false) String category) {
|
@RequestParam(required = false) String category) {
|
||||||
|
|
||||||
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,6 @@ public class MeetingDTO {
|
||||||
private Long asrModelId;
|
private Long asrModelId;
|
||||||
private Long summaryModelId;
|
private Long summaryModelId;
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
private Integer useSpkId;
|
||||||
private List<String> hotWords;
|
private List<String> hotWords;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ public class MeetingVO {
|
||||||
|
|
||||||
private String participants;
|
private String participants;
|
||||||
private String tags;
|
private String tags;
|
||||||
|
private Integer useSpkId;
|
||||||
private String audioUrl;
|
private String audioUrl;
|
||||||
private String summaryContent;
|
private String summaryContent;
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import lombok.Data;
|
||||||
@Data
|
@Data
|
||||||
public class PromptTemplateDTO {
|
public class PromptTemplateDTO {
|
||||||
private Long id;
|
private Long id;
|
||||||
|
private Long tenantId;
|
||||||
private String templateName;
|
private String templateName;
|
||||||
private String category;
|
private String category;
|
||||||
private Integer isSystem;
|
private Integer isSystem;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ public class Meeting extends BaseEntity {
|
||||||
|
|
||||||
private String promptContent;
|
private String promptContent;
|
||||||
|
|
||||||
|
private Integer useSpkId;
|
||||||
|
|
||||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||||
private List<String> hotWords;
|
private List<String> hotWords;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ import com.imeeting.common.PageResult;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface PromptTemplateService extends IService<PromptTemplate> {
|
public interface PromptTemplateService extends IService<PromptTemplate> {
|
||||||
PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId);
|
PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId, Long tenantId);
|
||||||
PromptTemplateVO updateTemplate(PromptTemplateDTO dto);
|
PromptTemplateVO updateTemplate(PromptTemplateDTO dto);
|
||||||
PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category, Long tenantId, Long userId);
|
PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category,
|
||||||
|
Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,15 @@ import com.imeeting.entity.biz.AiModel;
|
||||||
import com.imeeting.entity.biz.AiTask;
|
import com.imeeting.entity.biz.AiTask;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.entity.biz.MeetingTranscript;
|
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.AiTaskMapper;
|
||||||
import com.imeeting.mapper.biz.MeetingMapper;
|
import com.imeeting.mapper.biz.MeetingMapper;
|
||||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||||
|
import com.imeeting.mapper.SysUserMapper;
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.AiTaskService;
|
import com.imeeting.service.biz.AiTaskService;
|
||||||
|
import com.imeeting.service.biz.HotWordService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
@ -20,6 +24,8 @@ import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
|
|
@ -37,6 +43,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
private final MeetingTranscriptMapper transcriptMapper;
|
private final MeetingTranscriptMapper transcriptMapper;
|
||||||
private final AiModelService aiModelService;
|
private final AiModelService aiModelService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final SysUserMapper sysUserMapper;
|
||||||
|
private final HotWordService hotWordService;
|
||||||
|
|
||||||
@Value("${app.server-base-url}")
|
@Value("${app.server-base-url}")
|
||||||
private String serverBaseUrl;
|
private String serverBaseUrl;
|
||||||
|
|
@ -102,10 +110,44 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
|
|
||||||
// 构建请求参数
|
// 构建请求参数
|
||||||
Map<String, Object> req = new HashMap<>();
|
Map<String, Object> 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("file_path", fullAudioUrl);
|
||||||
req.put("hotwords", meeting.getHotWords() != null ? meeting.getHotWords() : Collections.emptyList());
|
|
||||||
req.put("use_spk_id", true);
|
List<Map<String, Object>> formattedHotwords = new ArrayList<>();
|
||||||
|
if (meeting.getHotWords() != null && !meeting.getHotWords().isEmpty()) {
|
||||||
|
List<HotWord> hotWordEntities = hotWordService.list(new LambdaQueryWrapper<HotWord>()
|
||||||
|
.eq(HotWord::getTenantId, meeting.getTenantId())
|
||||||
|
.in(HotWord::getWord, meeting.getHotWords()));
|
||||||
|
|
||||||
|
Map<String, Integer> wordToWeightMap = hotWordEntities.stream()
|
||||||
|
.collect(Collectors.toMap(HotWord::getWord, hw -> hw.getWeight() != null ? hw.getWeight() : 10, (v1, v2) -> v1));
|
||||||
|
|
||||||
|
for (String word : meeting.getHotWords()) {
|
||||||
|
Map<String, Object> 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);
|
AiTask taskRecord = createAiTask(meeting.getId(), "ASR", req);
|
||||||
|
|
||||||
|
|
@ -113,7 +155,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition";
|
String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition";
|
||||||
String respBody = postJson(submitUrl, req);
|
String respBody = postJson(submitUrl, req);
|
||||||
JsonNode submitNode = objectMapper.readTree(respBody);
|
JsonNode submitNode = objectMapper.readTree(respBody);
|
||||||
|
log.info(respBody);
|
||||||
if (submitNode.get("code").asInt() != 200) {
|
if (submitNode.get("code").asInt() != 200) {
|
||||||
updateAiTaskFail(taskRecord, "Submission Failed: " + respBody);
|
updateAiTaskFail(taskRecord, "Submission Failed: " + respBody);
|
||||||
throw new RuntimeException("ASR submission failed");
|
throw new RuntimeException("ASR submission failed");
|
||||||
|
|
@ -150,8 +192,22 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
for (JsonNode seg : resultNode.get("segments")) {
|
for (JsonNode seg : resultNode.get("segments")) {
|
||||||
MeetingTranscript mt = new MeetingTranscript();
|
MeetingTranscript mt = new MeetingTranscript();
|
||||||
mt.setMeetingId(meeting.getId());
|
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());
|
mt.setContent(seg.get("text").asText());
|
||||||
if (seg.has("timestamp")) {
|
if (seg.has("timestamp")) {
|
||||||
mt.setStartTime(seg.get("timestamp").get(0).asInt());
|
mt.setStartTime(seg.get("timestamp").get(0).asInt());
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
meeting.setAudioUrl(dto.getAudioUrl());
|
meeting.setAudioUrl(dto.getAudioUrl());
|
||||||
meeting.setAsrModelId(dto.getAsrModelId());
|
meeting.setAsrModelId(dto.getAsrModelId());
|
||||||
meeting.setSummaryModelId(dto.getSummaryModelId());
|
meeting.setSummaryModelId(dto.getSummaryModelId());
|
||||||
|
meeting.setUseSpkId(dto.getUseSpkId() != null ? dto.getUseSpkId() : 1);
|
||||||
meeting.setCreatorId(dto.getCreatorId());
|
meeting.setCreatorId(dto.getCreatorId());
|
||||||
meeting.setCreatorName(dto.getCreatorName());
|
meeting.setCreatorName(dto.getCreatorName());
|
||||||
|
|
||||||
|
|
@ -214,6 +215,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
vo.setTitle(meeting.getTitle());
|
vo.setTitle(meeting.getTitle());
|
||||||
vo.setMeetingTime(meeting.getMeetingTime());
|
vo.setMeetingTime(meeting.getMeetingTime());
|
||||||
vo.setTags(meeting.getTags());
|
vo.setTags(meeting.getTags());
|
||||||
|
vo.setUseSpkId(meeting.getUseSpkId());
|
||||||
vo.setAudioUrl(meeting.getAudioUrl());
|
vo.setAudioUrl(meeting.getAudioUrl());
|
||||||
vo.setStatus(meeting.getStatus());
|
vo.setStatus(meeting.getStatus());
|
||||||
vo.setSummaryContent(meeting.getSummaryContent());
|
vo.setSummaryContent(meeting.getSummaryContent());
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,25 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId) {
|
public PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId, Long tenantId) {
|
||||||
PromptTemplate entity = new PromptTemplate();
|
PromptTemplate entity = new PromptTemplate();
|
||||||
copyProperties(dto, entity);
|
copyProperties(dto, entity);
|
||||||
|
|
||||||
entity.setCreatorId(userId);
|
entity.setCreatorId(userId);
|
||||||
|
|
||||||
|
// 逻辑纠偏:如果是平台管理员设置了 tenantId 为 0,则设为 0;否则强制设为用户当前租户
|
||||||
|
if (dto.getTenantId() != null && dto.getTenantId() == 0L) {
|
||||||
|
entity.setTenantId(0L);
|
||||||
|
} else {
|
||||||
|
entity.setTenantId(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
entity.setUsageCount(0);
|
entity.setUsageCount(0);
|
||||||
this.save(entity);
|
this.save(entity);
|
||||||
return toVO(entity);
|
return toVO(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public PromptTemplateVO updateTemplate(PromptTemplateDTO dto) {
|
public PromptTemplateVO updateTemplate(PromptTemplateDTO dto) {
|
||||||
|
|
@ -42,17 +52,42 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category, Long tenantId, Long userId) {
|
public PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category,
|
||||||
Page<PromptTemplate> page = this.page(new Page<>(current, size),
|
Long tenantId, Long userId, Boolean isPlatformAdmin, Boolean isTenantAdmin) {
|
||||||
new LambdaQueryWrapper<PromptTemplate>()
|
|
||||||
.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));
|
|
||||||
|
|
||||||
|
LambdaQueryWrapper<PromptTemplate> 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<PromptTemplate> page = this.page(new Page<>(current, size), wrapper);
|
||||||
List<PromptTemplateVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
|
List<PromptTemplateVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
|
||||||
|
|
||||||
PageResult<List<PromptTemplateVO>> result = new PageResult<>();
|
PageResult<List<PromptTemplateVO>> result = new PageResult<>();
|
||||||
|
|
@ -65,6 +100,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
|
||||||
entity.setTemplateName(dto.getTemplateName());
|
entity.setTemplateName(dto.getTemplateName());
|
||||||
entity.setCategory(dto.getCategory());
|
entity.setCategory(dto.getCategory());
|
||||||
entity.setIsSystem(dto.getIsSystem());
|
entity.setIsSystem(dto.getIsSystem());
|
||||||
|
entity.setTenantId(dto.getTenantId());
|
||||||
entity.setPromptContent(dto.getPromptContent());
|
entity.setPromptContent(dto.getPromptContent());
|
||||||
entity.setTags(dto.getTags());
|
entity.setTags(dto.getTags());
|
||||||
entity.setStatus(dto.getStatus());
|
entity.setStatus(dto.getStatus());
|
||||||
|
|
@ -75,6 +111,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
|
||||||
PromptTemplateVO vo = new PromptTemplateVO();
|
PromptTemplateVO vo = new PromptTemplateVO();
|
||||||
vo.setId(entity.getId());
|
vo.setId(entity.getId());
|
||||||
vo.setTenantId(entity.getTenantId());
|
vo.setTenantId(entity.getTenantId());
|
||||||
|
vo.setCreatorId(entity.getCreatorId());
|
||||||
vo.setTemplateName(entity.getTemplateName());
|
vo.setTemplateName(entity.getTemplateName());
|
||||||
vo.setCategory(entity.getCategory());
|
vo.setCategory(entity.getCategory());
|
||||||
vo.setIsSystem(entity.getIsSystem());
|
vo.setIsSystem(entity.getIsSystem());
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
|
||||||
|
|
||||||
Map<String, Object> body = new HashMap<>();
|
Map<String, Object> body = new HashMap<>();
|
||||||
body.put("name", String.valueOf(speaker.getUserId()));
|
body.put("name", String.valueOf(speaker.getUserId()));
|
||||||
|
body.put("user_id", speaker.getUserId());
|
||||||
|
|
||||||
// 拼接完整下载路径: serverBaseUrl + resourcePrefix + voicePath
|
// 拼接完整下载路径: serverBaseUrl + resourcePrefix + voicePath
|
||||||
String fullPath = serverBaseUrl;
|
String fullPath = serverBaseUrl;
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ spring:
|
||||||
type: redis
|
type: redis
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 100MB
|
max-file-size: 2048MB
|
||||||
max-request-size: 100MB
|
max-request-size: 2048MB
|
||||||
jackson:
|
jackson:
|
||||||
date-format: yyyy-MM-dd HH:mm:ss
|
date-format: yyyy-MM-dd HH:mm:ss
|
||||||
serialization:
|
serialization:
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ spring:
|
||||||
type: redis
|
type: redis
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 100MB
|
max-file-size: 2048MB
|
||||||
max-request-size: 100MB
|
max-request-size: 2048MB
|
||||||
|
|
||||||
jackson:
|
jackson:
|
||||||
date-format: yyyy-MM-dd HH:mm:ss
|
date-format: yyyy-MM-dd HH:mm:ss
|
||||||
|
|
|
||||||
|
|
@ -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 { useEffect, useState, useMemo, useCallback } from "react";
|
||||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
@ -308,22 +308,27 @@ export default function AppLayout() {
|
||||||
style={{ fontSize: '16px', width: 64, height: 64 }}
|
style={{ fontSize: '16px', width: 64, height: 64 }}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', paddingLeft: 12 }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', paddingLeft: 12 }}>
|
||||||
{availableTenants.length > 0 && (
|
{/* 租户切换已移至右侧头像旁 */}
|
||||||
<Select
|
|
||||||
value={currentTenantId}
|
|
||||||
onChange={handleSwitchTenant}
|
|
||||||
style={{ width: 200 }}
|
|
||||||
placeholder="切换租户"
|
|
||||||
variant="borderless"
|
|
||||||
suffixIcon={<ShopOutlined />}
|
|
||||||
options={availableTenants.map(t => ({
|
|
||||||
label: t.tenantName,
|
|
||||||
value: t.tenantId
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Space size={20}>
|
<Space size={20}>
|
||||||
|
{availableTenants.length > 0 && (
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: availableTenants.map(t => ({
|
||||||
|
key: String(t.tenantId),
|
||||||
|
label: t.tenantName,
|
||||||
|
icon: <ShopOutlined />,
|
||||||
|
style: t.tenantId === currentTenantId ? { color: '#1677ff', fontWeight: 500, backgroundColor: '#e6f4ff' } : {}
|
||||||
|
})),
|
||||||
|
onClick: ({ key }) => handleSwitchTenant(Number(key))
|
||||||
|
}}
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
|
||||||
|
<ShopOutlined style={{ fontSize: '18px', color: '#666', cursor: 'pointer', padding: '4px' }} />
|
||||||
|
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
<Dropdown menu={{ items: langMenuItems }} placement="bottomRight">
|
<Dropdown menu={{ items: langMenuItems }} placement="bottomRight">
|
||||||
<GlobalOutlined style={{ fontSize: '18px', color: '#666', cursor: 'pointer' }} />
|
<GlobalOutlined style={{ fontSize: '18px', color: '#666', cursor: 'pointer' }} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,16 @@ import React, { useState, useEffect } from 'react';
|
||||||
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
|
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
|
||||||
import {
|
import {
|
||||||
HistoryOutlined, CheckCircleOutlined, LoadingOutlined,
|
HistoryOutlined, CheckCircleOutlined, LoadingOutlined,
|
||||||
ArrowRightOutlined, AudioOutlined, RobotOutlined,
|
AudioOutlined, RobotOutlined,
|
||||||
CalendarOutlined, TeamOutlined, RiseOutlined, ClockCircleOutlined,
|
CalendarOutlined, TeamOutlined, RiseOutlined, ClockCircleOutlined,
|
||||||
PlayCircleOutlined, FileTextOutlined
|
PlayCircleOutlined, FileTextOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import { getDashboardStats, getRecentTasks, DashboardStats } from '../api/business/dashboard';
|
import { getDashboardStats, getRecentTasks, DashboardStats } from '../api/business/dashboard';
|
||||||
import { MeetingVO } from '../api/business/meeting';
|
import { MeetingVO } from '../api/business/meeting';
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -148,26 +147,6 @@ const Dashboard: React.FC = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* 针对已完成任务展示 AI 总结摘要 */}
|
|
||||||
{item.status === 3 && item.summaryContent && (
|
|
||||||
<div style={{
|
|
||||||
marginTop: 20,
|
|
||||||
padding: '16px 20px',
|
|
||||||
backgroundColor: '#fafafa',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: '1px solid #f0f0f0',
|
|
||||||
marginLeft: '32px'
|
|
||||||
}}>
|
|
||||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center' }}>
|
|
||||||
<RobotOutlined style={{ marginRight: 8, color: '#722ed1' }} />
|
|
||||||
<Text strong style={{ color: '#722ed1', fontSize: 13 }}>AI 总结摘要:</Text>
|
|
||||||
</div>
|
|
||||||
<div className="summary-preview" style={{ maxHeight: 60, overflow: 'hidden' }}>
|
|
||||||
<ReactMarkdown>{item.summaryContent.length > 150 ? item.summaryContent.substring(0, 150) + '...' : item.summaryContent}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
|
|
@ -176,7 +155,6 @@ const Dashboard: React.FC = () => {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<style>{`
|
<style>{`
|
||||||
.summary-preview p { margin-bottom: 0; font-size: 13px; color: #595959; }
|
|
||||||
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
|
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
|
||||||
.ant-steps-item-description { font-size: 11px !important; }
|
.ant-steps-item-description { font-size: 11px !important; }
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ const AiModels: React.FC = () => {
|
||||||
const [remoteModels, setRemoteModels] = useState<string[]>([]);
|
const [remoteModels, setRemoteModels] = useState<string[]>([]);
|
||||||
|
|
||||||
const [modelType, setModelType] = useState<'ASR' | 'LLM'>('ASR');
|
const [modelType, setModelType] = useState<'ASR' | 'LLM'>('ASR');
|
||||||
|
const watchedModelType = Form.useWatch('modelType', form);
|
||||||
|
const provider = Form.useWatch('provider', form);
|
||||||
|
|
||||||
// Check if current user is platform admin
|
// Check if current user is platform admin
|
||||||
const isPlatformAdmin = React.useMemo(() => {
|
const isPlatformAdmin = React.useMemo(() => {
|
||||||
|
|
@ -247,9 +249,11 @@ const AiModels: React.FC = () => {
|
||||||
<Input placeholder="https://api.example.com/v1" />
|
<Input placeholder="https://api.example.com/v1" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="apiKey" label="API Key / Secret" tooltip="密钥将加密存储,仅在更新时需重新输入">
|
{!(watchedModelType === 'ASR' && provider === 'Custom') && (
|
||||||
<Input.Password placeholder="输入您的 API 密钥" />
|
<Form.Item name="apiKey" label="API Key / Secret" tooltip="密钥将加密存储,仅在更新时需重新输入">
|
||||||
</Form.Item>
|
<Input.Password placeholder="输入您的 API 密钥" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider orientation="left" style={{ fontSize: '14px', color: '#999' }}>业务参数</Divider>
|
<Divider orientation="left" style={{ fontSize: '14px', color: '#999' }}>业务参数</Divider>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,12 @@ const HotWords: React.FC = () => {
|
||||||
key: 'isPublic',
|
key: 'isPublic',
|
||||||
render: (val: number) => val === 1 ? <Tag color="green">公开</Tag> : <Tag color="blue">私有</Tag>
|
render: (val: number) => val === 1 ? <Tag color="green">公开</Tag> : <Tag color="blue">私有</Tag>
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '权重',
|
||||||
|
dataIndex: 'weight',
|
||||||
|
key: 'weight',
|
||||||
|
render: (val: number) => <Tag color="orange">{val}</Tag>
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
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 {
|
import {
|
||||||
AudioOutlined, CheckCircleOutlined, UserOutlined, CloudUploadOutlined,
|
AudioOutlined, CheckCircleOutlined, UserOutlined, CloudUploadOutlined,
|
||||||
LeftOutlined, SettingOutlined, QuestionCircleOutlined, InfoCircleOutlined,
|
LeftOutlined, SettingOutlined, QuestionCircleOutlined, InfoCircleOutlined,
|
||||||
CalendarOutlined, TeamOutlined, RobotOutlined, RocketOutlined
|
CalendarOutlined, TeamOutlined, RobotOutlined, RocketOutlined,
|
||||||
|
FileTextOutlined, CheckOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
@ -29,6 +30,8 @@ const MeetingCreate: React.FC = () => {
|
||||||
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
|
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
|
||||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||||
|
|
||||||
|
const watchedPromptId = Form.useWatch('promptId', form);
|
||||||
|
|
||||||
const [fileList, setFileList] = useState<any[]>([]);
|
const [fileList, setFileList] = useState<any[]>([]);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [audioUrl, setAudioUrl] = useState('');
|
const [audioUrl, setAudioUrl] = useState('');
|
||||||
|
|
@ -49,7 +52,8 @@ const MeetingCreate: React.FC = () => {
|
||||||
|
|
||||||
setAsrModels(asrRes.data.data.records.filter(m => m.status === 1));
|
setAsrModels(asrRes.data.data.records.filter(m => m.status === 1));
|
||||||
setLlmModels(llmRes.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));
|
setHotwordList(hotwordRes.data.data.records.filter(h => h.status === 1));
|
||||||
setUserList(users || []);
|
setUserList(users || []);
|
||||||
|
|
||||||
|
|
@ -59,7 +63,9 @@ const MeetingCreate: React.FC = () => {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
asrModelId: defaultAsr.data.data?.id,
|
asrModelId: defaultAsr.data.data?.id,
|
||||||
summaryModelId: defaultLlm.data.data?.id,
|
summaryModelId: defaultLlm.data.data?.id,
|
||||||
meetingTime: dayjs()
|
promptId: activePrompts.length > 0 ? activePrompts[0].id : undefined,
|
||||||
|
meetingTime: dayjs(),
|
||||||
|
useSpkId: 1
|
||||||
});
|
});
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
};
|
};
|
||||||
|
|
@ -203,23 +209,105 @@ const MeetingCreate: React.FC = () => {
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
|
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]} style={{ marginBottom: 12 }}>
|
||||||
<Select placeholder="选择模板">
|
<div style={{ maxHeight: 180, overflowY: 'auto', overflowX: 'hidden', padding: '2px 4px' }}>
|
||||||
{prompts.map(p => (
|
<Row gutter={[8, 8]} style={{ margin: 0 }}>
|
||||||
<Option key={p.id} value={p.id}>{p.templateName}</Option>
|
{prompts.map(p => {
|
||||||
))}
|
const isSelected = watchedPromptId === p.id;
|
||||||
</Select>
|
return (
|
||||||
|
<Col span={8} key={p.id}>
|
||||||
|
<div
|
||||||
|
onClick={() => 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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: isSelected ? '#1890ff' : '#f5f5f5',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 4
|
||||||
|
}}>
|
||||||
|
<FileTextOutlined style={{ color: isSelected ? '#fff' : '#999', fontSize: 12 }} />
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: isSelected ? '#1890ff' : '#434343',
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
lineHeight: 1.2
|
||||||
|
}} title={p.templateName}>
|
||||||
|
{p.templateName}
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
backgroundColor: '#1890ff',
|
||||||
|
borderRadius: '0 6px 0 6px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<CheckOutlined style={{ color: '#fff', fontSize: 8 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Row gutter={16} align="middle" style={{ marginBottom: 16 }}>
|
||||||
name="hotWords"
|
<Col flex="auto">
|
||||||
label={<span>纠错热词 <Tooltip title="不选默认应用全部启用热词"><QuestionCircleOutlined /></Tooltip></span>}
|
<Form.Item
|
||||||
style={{ marginBottom: 16 }}
|
name="hotWords"
|
||||||
>
|
label={<span>纠错热词 <Tooltip title="不选默认应用全部启用热词"><QuestionCircleOutlined /></Tooltip></span>}
|
||||||
<Select mode="multiple" placeholder="可选热词" allowClear maxTagCount="responsive">
|
style={{ marginBottom: 0 }}
|
||||||
{hotwordList.map(h => <Option key={h.word} value={h.word}>{h.word}</Option>)}
|
>
|
||||||
</Select>
|
<Select mode="multiple" placeholder="可选热词" allowClear maxTagCount="responsive">
|
||||||
</Form.Item>
|
{hotwordList.map(h => <Option key={h.word} value={h.word}>{h.word}</Option>)}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Form.Item
|
||||||
|
name="useSpkId"
|
||||||
|
label={<span>声纹识别 <Tooltip title="开启后将区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>}
|
||||||
|
valuePropName="checked"
|
||||||
|
getValueProps={(value) => ({ checked: value === 1 })}
|
||||||
|
normalize={(value) => (value ? 1 : 0)}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flexShrink: 0 }}>
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const PromptTemplates: React.FC = () => {
|
||||||
const [searchForm] = Form.useForm();
|
const [searchForm] = Form.useForm();
|
||||||
const { items: categories, loading: dictLoading } = useDict('biz_prompt_category');
|
const { items: categories, loading: dictLoading } = useDict('biz_prompt_category');
|
||||||
const { items: dictTags } = useDict('biz_prompt_tag');
|
const { items: dictTags } = useDict('biz_prompt_tag');
|
||||||
|
const { items: promptLevels } = useDict('biz_prompt_level');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<PromptTemplateVO[]>([]);
|
const [data, setData] = useState<PromptTemplateVO[]>([]);
|
||||||
|
|
||||||
|
|
@ -34,7 +35,10 @@ const PromptTemplates: React.FC = () => {
|
||||||
return profileStr ? JSON.parse(profileStr) : {};
|
return profileStr ? JSON.parse(profileStr) : {};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const activeTenantId = React.useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
|
||||||
|
|
||||||
const isPlatformAdmin = userProfile.isPlatformAdmin === true;
|
const isPlatformAdmin = userProfile.isPlatformAdmin === true;
|
||||||
|
const isTenantAdmin = userProfile.isTenantAdmin === true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|
@ -77,19 +81,30 @@ const PromptTemplates: React.FC = () => {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
...record,
|
...record,
|
||||||
templateName: `${record.templateName} (副本)`,
|
templateName: `${record.templateName} (副本)`,
|
||||||
isSystem: 0,
|
isSystem: 0, // 副本强制设为普通模板
|
||||||
id: undefined
|
id: undefined,
|
||||||
|
tenantId: undefined
|
||||||
});
|
});
|
||||||
setPreviewContent(record.promptContent);
|
setPreviewContent(record.promptContent);
|
||||||
} else {
|
} else {
|
||||||
if (record.isSystem === 1 && !isPlatformAdmin) {
|
const isPlatformLevel = Number(record.tenantId) === 0;
|
||||||
message.error('无权编辑系统模板');
|
const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1;
|
||||||
return;
|
|
||||||
}
|
// 权限判定逻辑
|
||||||
if (record.isSystem === 0 && record.creatorId !== userProfile.userId) {
|
let canEdit = false;
|
||||||
message.error('无权编辑他人模板');
|
if (isPlatformAdmin) {
|
||||||
|
canEdit = isPlatformLevel;
|
||||||
|
} else if (isTenantAdmin) {
|
||||||
|
canEdit = Number(record.tenantId) === activeTenantId;
|
||||||
|
} else {
|
||||||
|
canEdit = Number(record.creatorId) === currentUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
message.warning('您无权修改此层级的模板');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEditingId(record.id);
|
setEditingId(record.id);
|
||||||
form.setFieldsValue(record);
|
form.setFieldsValue(record);
|
||||||
setPreviewContent(record.promptContent);
|
setPreviewContent(record.promptContent);
|
||||||
|
|
@ -97,7 +112,11 @@ const PromptTemplates: React.FC = () => {
|
||||||
} else {
|
} else {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({ status: 1, isSystem: 0 });
|
// 租户管理员或平台管理员新增默认选系统/租户预置
|
||||||
|
form.setFieldsValue({
|
||||||
|
status: 1,
|
||||||
|
isSystem: (isTenantAdmin || isPlatformAdmin) ? 1 : 0
|
||||||
|
});
|
||||||
setPreviewContent('');
|
setPreviewContent('');
|
||||||
}
|
}
|
||||||
setDrawerVisible(true);
|
setDrawerVisible(true);
|
||||||
|
|
@ -122,6 +141,12 @@ const PromptTemplates: React.FC = () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setSubmitLoading(true);
|
setSubmitLoading(true);
|
||||||
|
|
||||||
|
// 处理 tenantId,如果是新增且是平台管理员设为系统模板,手动设置 tenantId 为 0
|
||||||
|
if (!editingId && isPlatformAdmin && values.isSystem === 1) {
|
||||||
|
values.tenantId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await updatePromptTemplate({ ...values, id: editingId });
|
await updatePromptTemplate({ ...values, id: editingId });
|
||||||
message.success('更新成功');
|
message.success('更新成功');
|
||||||
|
|
@ -149,9 +174,33 @@ const PromptTemplates: React.FC = () => {
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const renderCard = (item: PromptTemplateVO) => {
|
const renderCard = (item: PromptTemplateVO) => {
|
||||||
const isMine = item.creatorId === userProfile.userId;
|
|
||||||
const isSystem = item.isSystem === 1;
|
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 ? (
|
||||||
|
<Tag color="gold" style={{ borderRadius: 4 }}>平台级</Tag>
|
||||||
|
) : isTenantLevel ? (
|
||||||
|
<Tag color="blue" style={{ borderRadius: 4 }}>租户级</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="cyan" style={{ borderRadius: 4 }}>个人级</Tag>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|
@ -162,11 +211,14 @@ const PromptTemplates: React.FC = () => {
|
||||||
bodyStyle={{ padding: '24px' }}
|
bodyStyle={{ padding: '24px' }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<div style={{
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
width: 40, height: 40, borderRadius: 10, backgroundColor: '#e6f7ff',
|
<div style={{
|
||||||
display: 'flex', justifyContent: 'center', alignItems: 'center'
|
width: 40, height: 40, borderRadius: 10, backgroundColor: isPlatformLevel ? '#fffbe6' : '#e6f7ff',
|
||||||
}}>
|
display: 'flex', justifyContent: 'center', alignItems: 'center'
|
||||||
<StarFilled style={{ fontSize: 20, color: '#1890ff' }} />
|
}}>
|
||||||
|
<StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
{levelTag}
|
||||||
</div>
|
</div>
|
||||||
<Space onClick={e => e.stopPropagation()}>
|
<Space onClick={e => e.stopPropagation()}>
|
||||||
{canEdit && <EditOutlined style={{ fontSize: 18, color: '#bfbfbf', cursor: 'pointer' }} onClick={() => handleOpenDrawer(item)} />}
|
{canEdit && <EditOutlined style={{ fontSize: 18, color: '#bfbfbf', cursor: 'pointer' }} onClick={() => handleOpenDrawer(item)} />}
|
||||||
|
|
@ -274,9 +326,38 @@ const PromptTemplates: React.FC = () => {
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Row gutter={24}>
|
<Row gutter={24}>
|
||||||
<Col span={12}><Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
|
<Col span={(isPlatformAdmin || isTenantAdmin) ? 8 : 12}>
|
||||||
<Col span={6}><Form.Item name="category" label="分类" rules={[{ required: true }]}><Select loading={dictLoading}>{categories.map(i => <Option key={i.itemValue} value={i.itemValue}>{i.itemLabel}</Option>)}</Select></Form.Item></Col>
|
<Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Col span={6}><Form.Item name="status" label="状态"><Select><Option value={1}>启用</Option><Option value={0}>禁用</Option></Select></Form.Item></Col>
|
</Col>
|
||||||
|
{(isPlatformAdmin || isTenantAdmin) && (
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="isSystem" label="模板属性" rules={[{ required: true }]}>
|
||||||
|
<Select placeholder="选择属性">
|
||||||
|
{promptLevels.length > 0 ? (
|
||||||
|
promptLevels.map(i => <Option key={i.itemValue} value={Number(i.itemValue)}>{i.itemLabel}</Option>)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Option value={1}>{isPlatformAdmin ? '系统预置 (全局)' : '租户预置 (全员)'}</Option>
|
||||||
|
<Option value={0}>个人模板</Option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
<Col span={(isPlatformAdmin || isTenantAdmin) ? 5 : 6}>
|
||||||
|
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
|
||||||
|
<Select loading={dictLoading}>{categories.map(i => <Option key={i.itemValue} value={i.itemValue}>{i.itemLabel}</Option>)}</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={isPlatformAdmin ? 5 : 6}>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select>
|
||||||
|
<Option value={1}>启用</Option>
|
||||||
|
<Option value={0}>禁用</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Form.Item name="tags" label="业务标签" tooltip="可从现有标签中选择,也可输入新内容按回车保存">
|
<Form.Item name="tags" label="业务标签" tooltip="可从现有标签中选择,也可输入新内容按回车保存">
|
||||||
<Select
|
<Select
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue