feat(core): 实现热词批量创建及新增会议终端枚举

后端新增热词批量创建接口与相关DTO和VO,新增QtMeetingController支持Qt端会议业务,并新增MeetingTerminalEnum统一管理多终端类型。前端同步适配热词批量创建API并完善会议详情页的终端展示。
dev_na
chenhao 2026-07-28 16:44:16 +08:00
parent c296600060
commit eb32f13507
16 changed files with 496 additions and 118 deletions

View File

@ -2,6 +2,8 @@ package com.imeeting.controller.biz;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.dto.biz.HotWordBatchCreateDTO;
import com.imeeting.dto.biz.HotWordBatchCreateResultVO;
import com.imeeting.dto.biz.HotWordBatchGroupDTO;
import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO;
@ -52,6 +54,18 @@ public class HotWordController {
return ApiResponse.ok(hotWordService.saveHotWord(hotWordDTO, loginUser.getUserId(), targetTenantId));
}
@Operation(summary = "批量新增热词")
@PostMapping("/batch")
@PreAuthorize("isAuthenticated()")
@Log(value = "批量新增热词", type = "热词管理")
public ApiResponse<HotWordBatchCreateResultVO> saveBatch(@RequestBody HotWordBatchCreateDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId());
boolean platformAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin());
return ApiResponse.ok(
hotWordService.saveHotWordsBatch(dto, loginUser.getUserId(), targetTenantId, platformAdmin));
}
@Operation(summary = "修改热词")
@PutMapping
@PreAuthorize("isAuthenticated()")

View File

@ -26,11 +26,8 @@ public class HotWordGroupController {
this.hotWordGroupService = hotWordGroupService;
}
private Long resolveTargetTenantId(LoginUser loginUser, Long tenantId) {
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && Long.valueOf(0L).equals(tenantId)) {
return 0L;
}
return null;
private Long resolveTargetTenantId(LoginUser loginUser) {
return loginUser.getTenantId();
}
@Operation(summary = "新增热词组")
@ -39,7 +36,7 @@ public class HotWordGroupController {
@Log(value = "新增热词组", type = "热词组管理")
public ApiResponse<HotWordGroupVO> save(@RequestBody HotWordGroupDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId());
Long targetTenantId = resolveTargetTenantId(loginUser);
return ApiResponse.ok(hotWordGroupService.saveGroup(dto, loginUser.getUserId(), targetTenantId));
}
@ -49,7 +46,7 @@ public class HotWordGroupController {
@Log(value = "修改热词组", type = "热词组管理")
public ApiResponse<HotWordGroupVO> update(@RequestBody HotWordGroupDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId());
Long targetTenantId = resolveTargetTenantId(loginUser);
HotWordGroupVO existing = hotWordGroupService.listVisibleOptions(targetTenantId).stream()
.filter(item -> item.getId().equals(dto.getId()))
.findFirst()
@ -57,7 +54,6 @@ public class HotWordGroupController {
if (existing == null) {
return ApiResponse.error("热词组不存在");
}
dto.setTenantId(targetTenantId);
return ApiResponse.ok(hotWordGroupService.updateGroup(dto));
}
@ -65,9 +61,9 @@ public class HotWordGroupController {
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除热词组", type = "热词组管理")
public ApiResponse<Boolean> delete(@PathVariable Long id, @RequestParam(required = false) Long tenantId) {
public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, tenantId);
Long targetTenantId = resolveTargetTenantId(loginUser);
return ApiResponse.ok(hotWordGroupService.removeGroupById(id, targetTenantId));
}
@ -78,19 +74,18 @@ public class HotWordGroupController {
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String name,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) Long tenantId) {
@RequestParam(required = false) Integer status) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, tenantId);
Long targetTenantId = resolveTargetTenantId(loginUser);
return ApiResponse.ok(hotWordGroupService.pageGroups(current, size, name, status, targetTenantId));
}
@Operation(summary = "查询热词组选项")
@GetMapping("/options")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<HotWordGroupVO>> options(@RequestParam(required = false) Long tenantId) {
public ApiResponse<List<HotWordGroupVO>> options() {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, tenantId);
Long targetTenantId = resolveTargetTenantId(loginUser);
return ApiResponse.ok(hotWordGroupService.listVisibleOptions(targetTenantId));
}
}

View File

@ -0,0 +1,96 @@
package com.imeeting.controller.qt;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.MeetingAuthorizationService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Qt会议管理")
@RestController
@RequestMapping("/api/qt/meetings")
@RequiredArgsConstructor
@Slf4j
public class QtMeetingController {
private final AndroidAuthService androidAuthService;
private final MeetingAuthorizationService meetingAuthorizationService;
private final MeetingCommandService meetingCommandService;
private final PromptTemplateService promptTemplateService;
@Operation(summary = "创建 Qt 离线会议")
@PostMapping
@Anonymous
@Log(value = "新增 Qt 离线会议", type = "Qt会议管理")
public ApiResponse<MeetingVO> createMeeting(HttpServletRequest request,
@Valid @RequestBody CreateMeetingCommand command) {
AndroidRequestLogHelper.logRequest(log, "Qt会议", "创建离线会议接口", "request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
meetingAuthorizationService.assertCanCreateMeeting(authContext);
assertPromptAvailable(command.getPromptId(), authContext);
MeetingVO meeting = meetingCommandService.createMeeting(
command,
authContext.getTenantId(),
authContext.getUserId(),
resolveCreatorName(authContext),
MeetingTerminalEnum.resolve(authContext.getPlatform()).getCode(),
authContext.getDeviceId(),
resolveSourceDeviceMode(authContext)
);
return ApiResponse.ok(meeting);
}
private String resolveCreatorName(AndroidAuthContext authContext) {
if (hasText(authContext.getDisplayName())) {
return authContext.getDisplayName().trim();
}
if (hasText(authContext.getUsername())) {
return authContext.getUsername().trim();
}
return hasText(authContext.getDeviceId()) ? "qt:" + authContext.getDeviceId().trim() : "qt";
}
private void assertPromptAvailable(Long promptId, AndroidAuthContext authContext) {
if (promptId == null) {
return;
}
boolean enabled = promptTemplateService.isTemplateEnabledForUser(
promptId,
authContext.getTenantId(),
authContext.getUserId(),
authContext.getPlatformAdmin(),
authContext.getTenantAdmin()
);
if (!enabled) {
throw new RuntimeException("总结模板不可用");
}
}
private String resolveSourceDeviceMode(AndroidAuthContext authContext) {
return authContext.isAnonymous()
? MeetingConstants.DEVICE_MODE_PUBLIC
: MeetingConstants.DEVICE_MODE_PRIVATE;
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
}

View File

@ -0,0 +1,23 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "热词批量新增请求参数")
public class HotWordBatchCreateDTO {
@Schema(description = "租户 ID平台管理员可传 0 表示平台范围")
private Long tenantId;
@Schema(description = "待新增的热词内容列表")
private List<String> words;
@Schema(description = "所属热词组 ID为空表示未分组")
private Long hotWordGroupId;
@Schema(description = "备注")
private String remark;
}

View File

@ -0,0 +1,17 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "热词批量新增结果")
public class HotWordBatchCreateResultVO {
@Schema(description = "实际新增热词数量")
private Integer createdCount;
@Schema(description = "目标热词组中已存在的热词")
private List<String> existingWords;
}

View File

@ -72,9 +72,15 @@ public class MeetingVO {
@Schema(description = "总结模型ID")
private Long summaryModelId;
@Schema(description = "总结模型名称")
private String summaryModelName;
@Schema(description = "总结模板ID")
private Long promptId;
@Schema(description = "总结模板名称")
private String promptName;
@Schema(description = "最终生效热词组ID")
private Long hotWordGroupId;

View File

@ -0,0 +1,56 @@
package com.imeeting.enums;
import com.unisbase.common.exception.BusinessException;
import lombok.Getter;
@Getter
public enum MeetingTerminalEnum {
WINDOWS("WINDOWS", "Windows"),
MACOS("MACOS", "macOS"),
KYLIN("KYLIN", "麒麟"),
UOS("UOS", "统信"),
HARMONYOS("HARMONYOS", "鸿蒙"),
WEB("WEB", "Web端"),
CUSTOM_TERMINAL("CUSTOM_TERMINAL", "定制终端");
private static final String LEGACY_ANDROID_CODE = "ANDROID";
private final String code;
private final String description;
MeetingTerminalEnum(String code, String description) {
this.code = code;
this.description = description;
}
public static boolean isCustomTerminalSource(String source) {
return CUSTOM_TERMINAL.matches(source) || LEGACY_ANDROID_CODE.equalsIgnoreCase(source);
}
public static MeetingTerminalEnum resolve(String source) {
if (source == null || source.isBlank()) {
throw new BusinessException("会议终端类型不能为空");
}
String normalized = source.trim();
if (LEGACY_ANDROID_CODE.equalsIgnoreCase(normalized)) {
return CUSTOM_TERMINAL;
}
for (MeetingTerminalEnum terminal : values()) {
if (terminal.matches(normalized)) {
return terminal;
}
}
throw new BusinessException("会议终端类型无效: " + source);
}
public static boolean isSameTerminal(String source, String target) {
if (isCustomTerminalSource(source) && isCustomTerminalSource(target)) {
return true;
}
return source != null && target != null && source.equalsIgnoreCase(target);
}
public boolean matches(String source) {
return source != null && code.equalsIgnoreCase(source);
}
}

View File

@ -1,6 +1,8 @@
package com.imeeting.service.biz;
import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.dto.biz.HotWordBatchCreateDTO;
import com.imeeting.dto.biz.HotWordBatchCreateResultVO;
import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO;
import com.imeeting.entity.biz.HotWord;
@ -9,6 +11,8 @@ import java.util.List;
public interface HotWordService extends IService<HotWord> {
HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId);
HotWordBatchCreateResultVO saveHotWordsBatch(HotWordBatchCreateDTO dto, Long userId, Long tenantId, boolean platformAdmin);
HotWordVO updateHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId);
Integer updateHotWordGroupBatch(List<Long> ids, Long hotWordGroupId, Long tenantId);
List<String> generatePinyin(String word);

View File

@ -3,6 +3,8 @@ package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.dto.biz.HotWordBatchCreateDTO;
import com.imeeting.dto.biz.HotWordBatchCreateResultVO;
import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO;
import com.imeeting.entity.biz.HotWord;
@ -10,6 +12,7 @@ import com.imeeting.entity.biz.HotWordGroup;
import com.imeeting.mapper.biz.HotWordGroupMapper;
import com.imeeting.mapper.biz.HotWordMapper;
import com.imeeting.service.biz.HotWordService;
import com.unisbase.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sourceforge.pinyin4j.PinyinHelper;
@ -33,6 +36,9 @@ import java.util.stream.Collectors;
public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> implements HotWordService {
private static final int MAX_HOT_WORDS_PER_GROUP = 200;
private static final int DEFAULT_MATCH_STRATEGY = 1;
private static final int DEFAULT_WEIGHT = 2;
private static final int ENABLED_STATUS = 1;
private final HotWordGroupMapper hotWordGroupMapper;
@ -52,12 +58,39 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
return toVO(hotWord);
}
@Override
@Transactional(rollbackFor = Exception.class)
public HotWordBatchCreateResultVO saveHotWordsBatch(HotWordBatchCreateDTO dto, Long userId, Long tenantId, boolean platformAdmin) {
Set<String> words = normalizeWords(dto.getWords());
if (words.isEmpty()) {
throw new BusinessException("请选择有效热词");
}
Long groupId = validateGroupForBatchCreate(dto.getHotWordGroupId(), tenantId, platformAdmin);
Set<String> existingWords = findExistingWords(words, groupId);
List<String> existingWordList = words.stream().filter(existingWords::contains).toList();
List<HotWord> hotWords = words.stream()
.filter(word -> !existingWords.contains(word))
.map(word -> buildBatchHotWord(word, groupId, dto.getRemark(), userId))
.toList();
HotWordBatchCreateResultVO result = new HotWordBatchCreateResultVO();
result.setExistingWords(existingWordList);
if (hotWords.isEmpty()) {
result.setCreatedCount(0);
return result;
}
validateGroupCapacityForBatchCreate(groupId, hotWords.size());
result.setCreatedCount(this.saveBatch(hotWords) ? hotWords.size() : 0);
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public HotWordVO updateHotWord(HotWordDTO hotWordDTO, Long userId, Long tenantId) {
HotWord hotWord = this.getById(hotWordDTO.getId());
if (hotWord == null) {
throw new IllegalArgumentException("热词不存在");
throw new BusinessException("热词不存在");
}
String oldWord = hotWord.getWord();
@ -76,20 +109,20 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
@Transactional(rollbackFor = Exception.class)
public Integer updateHotWordGroupBatch(List<Long> ids, Long hotWordGroupId, Long tenantId) {
if (ids == null || ids.isEmpty()) {
throw new IllegalArgumentException("请选择热词");
throw new BusinessException("请选择热词");
}
Set<Long> uniqueIds = ids.stream()
.filter(id -> id != null)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (uniqueIds.isEmpty()) {
throw new IllegalArgumentException("请选择热词");
throw new BusinessException("请选择热词");
}
List<HotWord> hotWords = this.list(new LambdaQueryWrapper<HotWord>()
.in(HotWord::getId, uniqueIds)
.eq(HotWord::getTenantId, tenantId));
if (hotWords.size() != uniqueIds.size()) {
throw new IllegalArgumentException("部分热词不存在或无权操作");
throw new BusinessException("部分热词不存在或无权操作");
}
if (hotWordGroupId != null) {
@ -160,16 +193,16 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
}
HotWordGroup group = hotWordGroupMapper.selectById(groupId);
if (group == null || !tenantId.equals(group.getTenantId())) {
throw new IllegalArgumentException("热词组不存在");
throw new BusinessException("热词组不存在");
}
if (!Integer.valueOf(1).equals(group.getStatus())) {
throw new IllegalArgumentException("热词组已禁用");
throw new BusinessException("热词组已禁用");
}
long currentCount = this.count(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getHotWordGroupId, groupId)
.ne(currentHotWordId != null, HotWord::getId, currentHotWordId));
if (currentCount >= MAX_HOT_WORDS_PER_GROUP) {
throw new IllegalArgumentException("热词组最多只能包含 200 个热词");
throw new BusinessException("热词组最多只能包含 200 个热词");
}
return group.getId();
}
@ -177,10 +210,10 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
private void validateGroupCapacity(Long groupId, Long tenantId, List<HotWord> movingHotWords) {
HotWordGroup group = hotWordGroupMapper.selectById(groupId);
if (group == null || !tenantId.equals(group.getTenantId())) {
throw new IllegalArgumentException("热词组不存在");
throw new BusinessException("热词组不存在");
}
if (!Integer.valueOf(1).equals(group.getStatus())) {
throw new IllegalArgumentException("热词组已禁用");
throw new BusinessException("热词组已禁用");
}
Set<Long> movingIds = movingHotWords.stream().map(HotWord::getId).collect(Collectors.toSet());
long currentCount = this.count(new LambdaQueryWrapper<HotWord>()
@ -190,10 +223,71 @@ public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> impl
.filter(item -> !groupId.equals(item.getHotWordGroupId()))
.count();
if (currentCount + incomingCount > MAX_HOT_WORDS_PER_GROUP) {
throw new IllegalArgumentException("热词组最多只能包含 200 个热词");
throw new BusinessException("热词组最多只能包含 200 个热词");
}
}
private Set<String> normalizeWords(List<String> words) {
if (words == null || words.isEmpty()) {
return Collections.emptySet();
}
return words.stream()
.filter(word -> word != null)
.map(String::trim)
.filter(word -> !word.isEmpty() && !"无".equals(word))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
private Set<String> findExistingWords(Set<String> words, Long groupId) {
return this.list(new LambdaQueryWrapper<HotWord>()
.eq(groupId != null, HotWord::getHotWordGroupId, groupId)
.isNull(groupId == null, HotWord::getHotWordGroupId)
.in(HotWord::getWord, words))
.stream()
.map(HotWord::getWord)
.collect(Collectors.toSet());
}
private Long validateGroupForBatchCreate(Long groupId, Long tenantId, boolean platformAdmin) {
if (groupId == null) {
return null;
}
HotWordGroup group = hotWordGroupMapper.selectById(groupId);
if (group == null || (!platformAdmin && !tenantId.equals(group.getTenantId()))) {
throw new BusinessException("热词组不存在");
}
if (!Integer.valueOf(ENABLED_STATUS).equals(group.getStatus())) {
throw new BusinessException("热词组已禁用");
}
return group.getId();
}
private void validateGroupCapacityForBatchCreate(Long groupId, int incomingCount) {
if (groupId == null) {
return;
}
long currentCount = this.count(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getHotWordGroupId, groupId));
if (currentCount + incomingCount > MAX_HOT_WORDS_PER_GROUP) {
throw new BusinessException("热词组最多只能包含 200 个热词");
}
}
private HotWord buildBatchHotWord(String word, Long groupId, String remark, Long userId) {
HotWord hotWord = new HotWord();
hotWord.setWord(word);
hotWord.setPinyinList(generatePinyin(word));
hotWord.setMatchStrategy(DEFAULT_MATCH_STRATEGY);
hotWord.setCategory("");
hotWord.setHotWordGroupId(groupId);
hotWord.setWeight(DEFAULT_WEIGHT);
hotWord.setStatus(ENABLED_STATUS);
hotWord.setIsPublic(1);
hotWord.setCreatorId(userId);
hotWord.setRemark(remark);
return hotWord;
}
private void generateCombinations(List<List<String>> matrix, int index, String current, List<String> result) {
if (index == matrix.size()) {
result.add(current.trim());

View File

@ -8,11 +8,15 @@ import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWordGroup;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.event.MeetingCreatedEvent;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordGroupService;
import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
@ -62,6 +66,8 @@ public class MeetingDomainSupport {
private final MeetingPlaybackAudioResolver meetingPlaybackAudioResolver;
private final HotWordGroupService hotWordGroupService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final AiModelService aiModelService;
private final PromptTemplateService promptTemplateService;
private final SysParamService sysParamService;
@Value("${unisbase.app.upload-path}")
@ -433,6 +439,7 @@ public class MeetingDomainSupport {
vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus());
vo.setSummaryModelId(meeting.getSummaryModelId());
vo.setPromptId(meeting.getPromptId());
fillSummaryConfigurationNames(meeting, vo);
fillEffectiveHotWordGroup(meeting, vo);
vo.setAiCatalogEnabled(resolveAiCatalogEnabled());
vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel()));
@ -475,6 +482,21 @@ public class MeetingDomainSupport {
}
}
private void fillSummaryConfigurationNames(Meeting meeting, com.imeeting.dto.biz.MeetingVO vo) {
if (meeting.getSummaryModelId() != null) {
AiModelVO summaryModel = aiModelService.getModelById(meeting.getSummaryModelId(), "LLM");
if (summaryModel != null) {
vo.setSummaryModelName(summaryModel.getModelName());
}
}
if (meeting.getPromptId() != null) {
PromptTemplate promptTemplate = promptTemplateService.getById(meeting.getPromptId());
if (promptTemplate != null) {
vo.setPromptName(promptTemplate.getTemplateName());
}
}
}
private void fillEffectiveHotWordGroup(Meeting meeting, com.imeeting.dto.biz.MeetingVO vo) {
Long hotWordGroupId = resolveEffectiveHotWordGroupId(meeting);
vo.setHotWordGroupId(hotWordGroupId);

View File

@ -36,6 +36,18 @@ export interface HotWordBatchGroupDTO {
hotWordGroupId?: number;
}
export interface HotWordBatchCreateDTO {
tenantId?: number;
words: string[];
hotWordGroupId?: number;
remark?: string;
}
export interface HotWordBatchCreateResultVO {
createdCount: number;
existingWords: string[];
}
export const getHotWordPage = (params: {
current: number;
size: number;
@ -64,6 +76,13 @@ export const saveHotWord = (data: HotWordDTO) => {
);
};
export const createHotWordBatch = (data: HotWordBatchCreateDTO) => {
return http.post<{ code: string; data: HotWordBatchCreateResultVO; msg: string }>(
"/api/biz/hotword/batch",
data
);
};
export const updateHotWord = (data: HotWordDTO) => {
return http.put<{ code: string; data: HotWordVO; msg: string }>(
"/api/biz/hotword",

View File

@ -14,7 +14,6 @@ export interface HotWordGroupVO {
export interface HotWordGroupDTO {
id?: number;
tenantId?: number;
groupName: string;
status: number;
remark?: string;
@ -25,7 +24,6 @@ export const getHotWordGroupPage = (params: {
size: number;
name?: string;
status?: number;
tenantId?: number;
}) => {
return http.get<{ code: string; data: { records: HotWordGroupVO[]; total: number }; msg: string }>(
"/api/biz/hotword-group/page",
@ -33,10 +31,9 @@ export const getHotWordGroupPage = (params: {
);
};
export const getHotWordGroupOptions = (tenantId?: number) => {
export const getHotWordGroupOptions = () => {
return http.get<{ code: string; data: HotWordGroupVO[]; msg: string }>(
"/api/biz/hotword-group/options",
{ params: { tenantId } }
);
};
@ -54,9 +51,8 @@ export const updateHotWordGroup = (data: HotWordGroupDTO) => {
);
};
export const deleteHotWordGroup = (id: number, tenantId?: number) => {
export const deleteHotWordGroup = (id: number) => {
return http.delete<{ code: string; data: boolean; msg: string }>(
`/api/biz/hotword-group/${id}`,
{ params: { tenantId } }
`/api/biz/hotword-group/${id}`
);
};

View File

@ -36,7 +36,9 @@ export interface MeetingVO {
sourceDeviceMode?: "PUBLIC" | "PRIVATE";
summaryDetailLevel?: SummaryDetailLevel;
summaryModelId: number;
summaryModelName?: string;
promptId?: number;
promptName?: string;
hotWordGroupId?: number;
hotWordGroupName?: string;
aiCatalogEnabled?: boolean;

View File

@ -112,7 +112,7 @@ const HotWords: React.FC = () => {
const [searchWord, setSearchWord] = useState("");
const [searchCategory, setSearchCategory] = useState<string | undefined>(undefined);
const [searchGroupId, setSearchGroupId] = useState<number | undefined>(undefined);
const [hotWordGroupFilter, setHotWordGroupFilter] = useState<HotWordGroupFilter>("all");
const [hotWordGroupFilter, setHotWordGroupFilter] = useState<HotWordGroupFilter>("ungrouped");
const [modalVisible, setModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
@ -196,7 +196,7 @@ const HotWords: React.FC = () => {
};
const loadGroupOptions = async () => {
const res = await getHotWordGroupOptions(isPlatformAdmin ? activeTenantId : undefined);
const res = await getHotWordGroupOptions();
setGroupOptions(res.data?.data || []);
};
@ -208,7 +208,6 @@ const HotWords: React.FC = () => {
size: groupSize,
name: groupSearchName || undefined,
status: groupSearchStatus,
tenantId: isPlatformAdmin ? activeTenantId : undefined,
});
setGroupData(res.data?.data?.records || []);
setGroupTotal(res.data?.data?.total || 0);
@ -315,10 +314,10 @@ const HotWords: React.FC = () => {
const values = await groupForm.validateFields();
setGroupSubmitLoading(true);
if (editingGroupId) {
await updateHotWordGroup({ ...values, id: editingGroupId, tenantId: isPlatformAdmin ? activeTenantId : undefined });
await updateHotWordGroup({...values, id: editingGroupId});
message.success("热词组更新成功");
} else {
await saveHotWordGroup({ ...values, tenantId: isPlatformAdmin ? activeTenantId : undefined });
await saveHotWordGroup(values);
message.success("热词组创建成功");
}
setGroupEditorVisible(false);
@ -330,7 +329,7 @@ const HotWords: React.FC = () => {
const handleDeleteGroup = async (id: number, e?: React.MouseEvent) => {
e?.stopPropagation();
await deleteHotWordGroup(id, isPlatformAdmin ? activeTenantId : undefined);
await deleteHotWordGroup(id);
message.success("热词组删除成功");
if (searchGroupId === id) {
setSearchGroupId(undefined);
@ -401,23 +400,25 @@ const HotWords: React.FC = () => {
setSearchCategory(undefined);
setSearchGroupId(undefined);
setSelectedGroupName(undefined);
setHotWordGroupFilter("all");
setHotWordGroupFilter("ungrouped");
bulkGroupForm.resetFields();
setBulkGroupEditorVisible(false);
setCurrent(1);
void fetchData({ current: 1, word: "", category: null, groupFilter: "all", searchGroupId: null });
void fetchData({current: 1, word: "", category: null, groupFilter: "ungrouped", searchGroupId: null});
};
const handleSelectGroup = (item: GroupListItem) => {
setSearchGroupId(item.id);
setSelectedGroupName(item.id ? item.groupName : undefined);
setHotWordGroupFilter(item.id ?? "all");
setHotWordGroupFilter(item.id ?? "ungrouped");
setCurrent(1);
};
const hotWordGroupTitle = searchGroupId
? selectedGroupName || groupData.find((item) => item.id === searchGroupId)?.groupName || groupNameMap[searchGroupId] || "热词列表"
: "全部热词";
const hotWordGroupTitle = hotWordGroupFilter === "ungrouped"
? "未分组"
: typeof hotWordGroupFilter === "number"
? selectedGroupName || groupData.find((item) => item.id === hotWordGroupFilter)?.groupName || groupNameMap[hotWordGroupFilter] || "热词列表"
: "热词列表";
const groupFilterOptions = useMemo(
() => [
@ -428,7 +429,7 @@ const HotWords: React.FC = () => {
[groupOptions]
);
const groupListData: GroupListItem[] = [{ id: undefined, groupName: "全部热词" }, ...groupData];
const groupListData: GroupListItem[] = [{id: undefined, groupName: "未分组"}, ...groupData];
const columns = [
{
@ -558,7 +559,7 @@ const HotWords: React.FC = () => {
loading={groupLoading}
dataSource={groupListData}
renderItem={(item) => {
const isSelected = searchGroupId === item.id;
const isSelected = item.id ? hotWordGroupFilter === item.id : hotWordGroupFilter === "ungrouped";
const actions = [];
if (item.id) {
actions.push(
@ -611,7 +612,7 @@ const HotWords: React.FC = () => {
<span>{item.remark || "暂无备注"}</span>
</span>
)
: "查看所有热词"
: "查看未分组热词"
}
/>
</List.Item>
@ -689,7 +690,8 @@ const HotWords: React.FC = () => {
className="hotwords-search__category"
options={categories.map((c) => ({ label: c.itemLabel, value: c.itemValue }))}
/>
<Button onClick={handleResetFilters} disabled={selectedHotWordIds.length === 0 && searchWord === "" && !searchCategory && !searchGroupId && hotWordGroupFilter === "all"}>
<Button onClick={handleResetFilters}
disabled={selectedHotWordIds.length === 0 && searchWord === "" && !searchCategory && !searchGroupId && hotWordGroupFilter === "ungrouped"}>
</Button>
</Space>

View File

@ -49,7 +49,8 @@ import {
updateSpeakerInfo,
} from '../../api/business/meeting';
import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel';
import { getHotWordPage, getPinyinSuggestion, saveHotWord } from '../../api/business/hotword';
import {createHotWordBatch} from '../../api/business/hotword';
import {getHotWordGroupOptions, type HotWordGroupVO} from '../../api/business/hotwordGroup';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { listUsers } from '../../api';
import { useDict } from '../../hooks/useDict';
@ -248,18 +249,20 @@ const parseBulletList = (content?: string | null) =>
const parseOverviewSection = (markdown: string) =>
extractSection(markdown, ['全文概要', '概要', '摘要', '概览']) || markdown.replace(/^---[\s\S]*?---/, '').trim();
const isValidKeyword = (value: string) => value.trim() !== '' && value.trim() !== '无';
const parseKeywordsSection = (markdown: string, tags: string) => {
const section = extractSection(markdown, ['关键词', '关键字', '标签']);
const fromSection = parseBulletList(section)
.flatMap((line) => line.split(/[,、/]/))
.map((item) => item.trim())
.filter(Boolean);
.filter(isValidKeyword);
if (fromSection.length) {
return Array.from(new Set(fromSection)).slice(0, 12);
}
return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(Boolean))).slice(0, 12);
return Array.from(new Set((tags || '').split(',').map((item) => item.trim()).filter(isValidKeyword))).slice(0, 12);
};
const buildMeetingAnalysis = (
@ -280,7 +283,7 @@ const buildMeetingAnalysis = (
return {
overview: String(parsed.overview || '').trim(),
keywords: Array.from(
new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(Boolean)),
new Set((Array.isArray(parsed.keywords) ? parsed.keywords : []).map((item) => String(item).trim()).filter(isValidKeyword)),
).slice(0, 12),
chapters: chapters
.map((item: any) => ({
@ -1227,6 +1230,10 @@ const MeetingDetail: React.FC = () => {
const [isEditingSummary, setIsEditingSummary] = useState(false);
const [summaryDraft, setSummaryDraft] = useState('');
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [hotWordGroupModalOpen, setHotWordGroupModalOpen] = useState(false);
const [hotWordGroupOptions, setHotWordGroupOptions] = useState<HotWordGroupVO[]>([]);
const [selectedHotWordGroupId, setSelectedHotWordGroupId] = useState<number | undefined>();
const [hotWordGroupLoading, setHotWordGroupLoading] = useState(false);
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('transcript');
const [addingHotwords, setAddingHotwords] = useState(false);
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
@ -1288,15 +1295,20 @@ const MeetingDetail: React.FC = () => {
setLoading(false);
}
}, []);
const userProfile = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? JSON.parse(profileStr) : {};
}, []);
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
const isPlatformAdmin = userProfile.isPlatformAdmin === true;
const analysis = useMemo(
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ''),
[meeting?.analysis, meeting?.summaryContent, meeting?.tags],
);
const expandKeywords = false;
const expandKeywords = true;
const visibleKeywords = expandKeywords ? analysis.keywords : analysis.keywords.slice(0, 9);
const meetingTags = useMemo(
() => (meeting?.tags?.split(',').map((item) => item.trim()).filter(Boolean) || []),
() => (meeting?.tags?.split(',').map((item) => item.trim()).filter(isValidKeyword) || []),
[meeting?.tags],
);
const discussionItems = useMemo(() => {
@ -1388,25 +1400,23 @@ const MeetingDetail: React.FC = () => {
return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId);
}, [meetingShareBaseUrl, meeting?.id, id]);
const summaryModelDisplayName = useMemo(() => {
const matchedModel = llmModels.find((item) => item.id === meeting?.summaryModelId);
if (matchedModel?.modelName) {
return matchedModel.modelName;
if (meeting?.summaryModelName?.trim()) {
return meeting.summaryModelName.trim();
}
if (meeting?.summaryModelId) {
return `模型 #${meeting.summaryModelId}`;
}
return '未配置';
}, [llmModels, meeting?.summaryModelId]);
}, [meeting?.summaryModelId, meeting?.summaryModelName]);
const promptDisplayName = useMemo(() => {
const matchedPrompt = prompts.find((item) => item.id === meeting?.promptId);
if (matchedPrompt?.templateName) {
return matchedPrompt.templateName;
if (meeting?.promptName?.trim()) {
return meeting.promptName.trim();
}
if (meeting?.promptId) {
return `模板 #${meeting.promptId}`;
}
return '未配置';
}, [meeting?.promptId, prompts]);
}, [meeting?.promptId, meeting?.promptName]);
const hotWordGroupDisplayName = useMemo(() => {
if (meeting?.hotWordGroupName?.trim()) {
return meeting.hotWordGroupName.trim();
@ -1582,7 +1592,6 @@ const MeetingDetail: React.FC = () => {
useEffect(() => {
if (!id) return;
fetchData(Number(id));
loadAiConfigs();
loadUsers();
}, [id, fetchData]);
@ -1648,11 +1657,13 @@ const MeetingDetail: React.FC = () => {
getPromptPage({ current: 1, size: 100 }),
getAiModelDefault('LLM'),
]);
setLlmModels((modelRes.data?.data?.records || []).filter((item) => item.status === 1));
setPrompts((promptRes.data?.data?.records || []).filter((item) => item.status === 1));
summaryForm.setFieldsValue({ summaryModelId: defaultRes.data.data?.id });
const models = (modelRes.data?.data?.records || []).filter((item) => item.status === 1);
const promptTemplates = (promptRes.data?.data?.records || []).filter((item) => item.status === 1);
setLlmModels(models);
setPrompts(promptTemplates);
return {models, promptTemplates, defaultModelId: defaultRes.data.data?.id};
} catch {
// ignore
return {models: [], promptTemplates: [], defaultModelId: undefined};
}
};
@ -1743,17 +1754,19 @@ const MeetingDetail: React.FC = () => {
}
};
const handleOpenSummaryDrawer = () => {
const handleOpenSummaryDrawer = async () => {
const {models, promptTemplates, defaultModelId} = await loadAiConfigs();
summaryForm.setFieldsValue({
summaryModelId:
summaryForm.getFieldValue('summaryModelId') ??
meeting?.summaryModelId ??
llmModels.find((model) => model.isDefault === 1)?.id ??
llmModels[0]?.id,
defaultModelId ??
models.find((model) => model.isDefault === 1)?.id ??
models[0]?.id,
promptId:
summaryForm.getFieldValue('promptId') ??
meeting?.promptId ??
prompts[0]?.id,
promptTemplates[0]?.id,
userPrompt: meeting?.lastUserPrompt ?? '',
summaryDetailLevel:
summaryForm.getFieldValue('summaryDetailLevel') ??
@ -1868,58 +1881,51 @@ const MeetingDetail: React.FC = () => {
});
};
const handleOpenHotWordGroupModal = async () => {
if (!selectedKeywords.length) {
message.warning('请先选择关键词');
return;
}
setHotWordGroupLoading(true);
try {
const response = await getHotWordGroupOptions();
const options = (response.data?.data || []).filter((item) => item.status === 1 && item.hotWordCount < 200);
setHotWordGroupOptions(options);
setSelectedHotWordGroupId(options.some((item) => item.id === meeting?.hotWordGroupId) ? meeting?.hotWordGroupId : options[0]?.id);
setHotWordGroupModalOpen(true);
} catch (error) {
console.error(error);
} finally {
setHotWordGroupLoading(false);
}
};
const handleAddSelectedHotwords = async () => {
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
const keywords = selectedKeywords.map((item) => item.trim()).filter(isValidKeyword);
if (!keywords.length) {
message.warning('请先选择关键词');
return;
}
if (selectedHotWordGroupId === undefined) {
message.warning('请选择热词组');
return;
}
setAddingHotwords(true);
try {
const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' });
const existingWords = new Set(
(existingRes.data?.data?.records || [])
.map((item) => item.word?.trim())
.filter(Boolean),
);
const toCreate = keywords.filter((item) => !existingWords.has(item));
if (!toCreate.length) {
message.info('所选关键词已存在于热词库');
return;
}
await Promise.all(
toCreate.map((word) =>
(async () => {
let pinyinList: string[] = [];
try {
const pinyinRes = await getPinyinSuggestion(word);
pinyinList = (pinyinRes.data?.data || []).map((item) => item.trim()).filter(Boolean);
} catch {
pinyinList = [];
}
return saveHotWord({
word,
pinyinList,
matchStrategy: 1,
category: '',
weight: 2,
status: 1,
remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词',
});
})(),
),
);
const skippedCount = keywords.length - toCreate.length;
const response = await createHotWordBatch({
words: keywords,
hotWordGroupId: selectedHotWordGroupId || undefined,
remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词',
});
const result = response.data?.data;
const existingWords = result?.existingWords || [];
message.success(
skippedCount > 0
? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项`
: `已新增 ${toCreate.length} 个热词`,
`新增 ${result?.createdCount || 0} 个热词${existingWords.length ? `[${existingWords.join(', ')}]已存在热词组` : ''}`,
);
setSelectedKeywords([]);
setHotWordGroupModalOpen(false);
} catch (error) {
console.error(error);
} finally {
@ -2392,7 +2398,7 @@ const MeetingDetail: React.FC = () => {
</Button>
)}
{canRetrySummary && (
<Button icon={<SyncOutlined />} onClick={handleOpenSummaryDrawer} disabled={actionLoading}>
<Button icon={<SyncOutlined/>} onClick={() => void handleOpenSummaryDrawer()} disabled={actionLoading}>
</Button>
)}
@ -2578,9 +2584,9 @@ const MeetingDetail: React.FC = () => {
ghost
disabled={!selectedKeywords.length}
loading={addingHotwords}
onClick={handleAddSelectedHotwords}
onClick={() => void handleOpenHotWordGroupModal()}
>
{selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
{selectedKeywords.length > 0 ? `(${selectedKeywords.length})` : ''}
</Button>
)}
</div>
@ -4238,6 +4244,33 @@ const MeetingDetail: React.FC = () => {
}
`}</style>
<Modal
title="加入热词组"
open={hotWordGroupModalOpen}
onCancel={() => setHotWordGroupModalOpen(false)}
onOk={() => void handleAddSelectedHotwords()}
confirmLoading={addingHotwords}
okText="加入"
destroyOnHidden
>
<Form layout="vertical" style={{marginTop: 16}}>
<Form.Item label="目标热词组" required>
<Select
value={selectedHotWordGroupId}
placeholder={hotWordGroupLoading ? '正在加载热词组' : '请选择热词组'}
loading={hotWordGroupLoading}
onChange={setSelectedHotWordGroupId}
options={[
...hotWordGroupOptions.map((item) => ({
label: `${item.groupName} (${item.hotWordCount}/200)`,
value: item.id,
})),
]}
/>
</Form.Item>
</Form>
</Modal>
{isOwner && (
<Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600} forceRender>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>

View File

@ -95,8 +95,7 @@ const PromptTemplates: React.FC = () => {
}, [isPlatformAdmin, templateLevel, activeTenantId]);
const loadGroupOptions = async () => {
const targetTenantId = isPlatformAdmin && Number(templateLevel) === 1 ? 0 : undefined;
const res = await getHotWordGroupOptions(targetTenantId ?? (isPlatformAdmin && activeTenantId === 0 ? 0 : undefined));
const res = await getHotWordGroupOptions();
setGroupOptions(res.data?.data || []);
};