diff --git a/backend/src/main/java/com/imeeting/common/MeetingConstants.java b/backend/src/main/java/com/imeeting/common/MeetingConstants.java index cce19c6..ff8fe4c 100644 --- a/backend/src/main/java/com/imeeting/common/MeetingConstants.java +++ b/backend/src/main/java/com/imeeting/common/MeetingConstants.java @@ -7,6 +7,9 @@ public final class MeetingConstants { public static final String SOURCE_WEB = "WEB"; public static final String SOURCE_ANDROID = "ANDROID"; + public static final String SUMMARY_DETAIL_DETAILED = "DETAILED"; + public static final String SUMMARY_DETAIL_STANDARD = "STANDARD"; + public static final String SUMMARY_DETAIL_BRIEF = "BRIEF"; private MeetingConstants() { } diff --git a/backend/src/main/java/com/imeeting/common/SysParamKeys.java b/backend/src/main/java/com/imeeting/common/SysParamKeys.java index 840109f..1ab55a4 100644 --- a/backend/src/main/java/com/imeeting/common/SysParamKeys.java +++ b/backend/src/main/java/com/imeeting/common/SysParamKeys.java @@ -8,4 +8,6 @@ public final class SysParamKeys { public static final String MEETING_TRANSCRIPT_CLEANUP_FILLER_WORDS = "meeting.transcript.cleanup.filler_words"; public static final String MEETING_TRANSCRIPT_CLEANUP_REPLACEMENTS = "meeting.transcript.cleanup.replacements"; public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb"; + public static final String MEETING_CREATE_OFFLINE_ENABLED = "meeting.create.offline_enabled"; + public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled"; } diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java index ccd50b3..1c24ef5 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java @@ -200,6 +200,7 @@ public class AndroidMeetingRealtimeController { createCommand.setAsrModelId(runtimeProfile.getResolvedAsrModelId()); createCommand.setSummaryModelId(runtimeProfile.getResolvedSummaryModelId()); createCommand.setPromptId(runtimeProfile.getResolvedPromptId()); + createCommand.setSummaryDetailLevel(command == null ? null : command.getSummaryDetailLevel()); createCommand.setMode(runtimeProfile.getResolvedMode()); createCommand.setLanguage(runtimeProfile.getResolvedLanguage()); createCommand.setUseSpkId(runtimeProfile.getResolvedUseSpkId()); diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index 5b4ef23..850d84f 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -2,8 +2,10 @@ package com.imeeting.controller.biz; import com.imeeting.common.MeetingConstants; import com.imeeting.common.RedisKeys; +import com.imeeting.common.SysParamKeys; import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; +import com.imeeting.dto.biz.MeetingCreateConfigVO; import com.imeeting.dto.biz.MeetingResummaryDTO; import com.imeeting.dto.biz.MeetingSummaryOrchestrationTriggerResultVO; import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO; @@ -34,6 +36,7 @@ import com.unisbase.common.ApiResponse; import com.unisbase.common.annotation.Log; import com.unisbase.dto.PageResult; import com.unisbase.security.LoginUser; +import com.unisbase.service.SysParamService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -77,6 +80,7 @@ public class MeetingController { private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; private final MeetingAudioUploadSupport meetingAudioUploadSupport; private final StringRedisTemplate redisTemplate; + private final SysParamService sysParamService; public MeetingController(MeetingQueryService meetingQueryService, MeetingCommandService meetingCommandService, @@ -87,7 +91,8 @@ public class MeetingController { RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService, RealtimeMeetingSessionStateService realtimeMeetingSessionStateService, MeetingAudioUploadSupport meetingAudioUploadSupport, - StringRedisTemplate redisTemplate) { + StringRedisTemplate redisTemplate, + SysParamService sysParamService) { this.meetingQueryService = meetingQueryService; this.meetingCommandService = meetingCommandService; this.meetingAccessService = meetingAccessService; @@ -98,6 +103,7 @@ public class MeetingController { this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService; this.meetingAudioUploadSupport = meetingAudioUploadSupport; this.redisTemplate = redisTemplate; + this.sysParamService = sysParamService; } @Operation(summary = "查询会议处理进度") @@ -139,6 +145,17 @@ public class MeetingController { return ApiResponse.ok(meetingAudioUploadSupport.storeUploadedAudio(file)); } + @Operation(summary = "获取会议创建配置") + @GetMapping("/create-config") + @PreAuthorize("isAuthenticated()") + public ApiResponse getCreateConfig() { + MeetingCreateConfigVO vo = new MeetingCreateConfigVO(); + vo.setOfflineEnabled(resolveBooleanParam(SysParamKeys.MEETING_CREATE_OFFLINE_ENABLED, true)); + vo.setRealtimeEnabled(resolveBooleanParam(SysParamKeys.MEETING_CREATE_REALTIME_ENABLED, true)); + vo.setOfflineAudioMaxSizeMb(resolveLongParam(SysParamKeys.MEETING_OFFLINE_AUDIO_MAX_SIZE_MB, 1024L)); + return ApiResponse.ok(vo); + } + @Operation(summary = "创建离线会议") @PostMapping @PreAuthorize("isAuthenticated()") @@ -414,7 +431,14 @@ public class MeetingController { meetingAccessService.assertCanEditMeeting(meeting, loginUser); dto.setMeetingId(id); assertPromptAvailable(dto.getPromptId(), loginUser); - meetingCommandService.reSummary(dto.getMeetingId(), dto.getSummaryModelId(), dto.getChapterModelId(), dto.getPromptId(), dto.getUserPrompt()); + meetingCommandService.reSummary( + dto.getMeetingId(), + dto.getSummaryModelId(), + dto.getChapterModelId(), + dto.getPromptId(), + dto.getUserPrompt(), + dto.getSummaryDetailLevel() + ); return ApiResponse.ok(true); } @@ -501,4 +525,32 @@ public class MeetingController { private String resolveCreatorName(LoginUser loginUser) { return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); } + + private boolean resolveBooleanParam(String key, boolean defaultValue) { + String rawValue = sysParamService.getCachedParamValue(key, String.valueOf(defaultValue)); + if (rawValue == null || rawValue.isBlank()) { + return defaultValue; + } + String normalized = rawValue.trim().toLowerCase(); + if ("1".equals(normalized) || "true".equals(normalized) || "yes".equals(normalized) || "on".equals(normalized)) { + return true; + } + if ("0".equals(normalized) || "false".equals(normalized) || "no".equals(normalized) || "off".equals(normalized)) { + return false; + } + return defaultValue; + } + + private long resolveLongParam(String key, long defaultValue) { + String rawValue = sysParamService.getCachedParamValue(key, String.valueOf(defaultValue)); + if (rawValue == null || rawValue.isBlank()) { + return defaultValue; + } + try { + long parsed = Long.parseLong(rawValue.trim()); + return parsed > 0 ? parsed : defaultValue; + } catch (NumberFormatException ex) { + return defaultValue; + } + } } diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingCommand.java b/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingCommand.java index f3fb902..0dcda71 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingCommand.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingCommand.java @@ -1,6 +1,7 @@ package com.imeeting.dto.android; import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDateTime; @@ -20,6 +21,8 @@ public class AndroidCreateRealtimeMeetingCommand { private Long asrModelId; private Long summaryModelId; private Long promptId; + @Schema(description = "总结详细程度:DETAILED=详细,STANDARD=标准,BRIEF=简洁") + private String summaryDetailLevel; private String mode; private String language; private Integer useSpkId; diff --git a/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java b/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java index 35c7496..fa96e60 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java +++ b/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java @@ -1,6 +1,8 @@ package com.imeeting.dto.biz; import com.fasterxml.jackson.annotation.JsonFormat; +import com.imeeting.common.MeetingConstants; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -9,6 +11,7 @@ import lombok.Data; import java.time.LocalDateTime; import java.util.List; +@Schema(description = "创建离线会议请求") @Data public class CreateMeetingCommand { @NotBlank(message = "标题不能为空") @@ -42,6 +45,16 @@ public class CreateMeetingCommand { @Size(max = 2000, message = "userPrompt length must be <= 2000") private String userPrompt; + @Schema( + description = "总结详细程度:DETAILED=详细,STANDARD=标准,BRIEF=简洁", + allowableValues = { + MeetingConstants.SUMMARY_DETAIL_DETAILED, + MeetingConstants.SUMMARY_DETAIL_STANDARD, + MeetingConstants.SUMMARY_DETAIL_BRIEF + } + ) + private String summaryDetailLevel; + private Integer useSpkId; private Boolean enableTextRefine; private List hotWords; diff --git a/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java b/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java index efe7f17..4f47c0e 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java +++ b/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java @@ -1,6 +1,8 @@ package com.imeeting.dto.biz; import com.fasterxml.jackson.annotation.JsonFormat; +import com.imeeting.common.MeetingConstants; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -9,6 +11,7 @@ import lombok.Data; import java.time.LocalDateTime; import java.util.List; +@Schema(description = "创建实时会议请求") @Data public class CreateRealtimeMeetingCommand { @NotBlank(message = "标题不能为空") @@ -39,6 +42,16 @@ public class CreateRealtimeMeetingCommand { @Size(max = 2000, message = "userPrompt length must be <= 2000") private String userPrompt; + @Schema( + description = "总结详细程度:DETAILED=详细,STANDARD=标准,BRIEF=简洁", + allowableValues = { + MeetingConstants.SUMMARY_DETAIL_DETAILED, + MeetingConstants.SUMMARY_DETAIL_STANDARD, + MeetingConstants.SUMMARY_DETAIL_BRIEF + } + ) + private String summaryDetailLevel; + private String mode; private String language; private Integer useSpkId; diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java index 57e0283..f0f0693 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingResummaryDTO.java @@ -1,8 +1,11 @@ package com.imeeting.dto.biz; -import lombok.Data; +import com.imeeting.common.MeetingConstants; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Size; +import lombok.Data; +@Schema(description = "重新生成会议摘要请求") @Data public class MeetingResummaryDTO { private Long meetingId; @@ -12,4 +15,14 @@ public class MeetingResummaryDTO { @Size(max = 2000, message = "userPrompt length must be <= 2000") private String userPrompt; + + @Schema( + description = "总结详细程度:DETAILED=详细,STANDARD=标准,BRIEF=简洁", + allowableValues = { + MeetingConstants.SUMMARY_DETAIL_DETAILED, + MeetingConstants.SUMMARY_DETAIL_STANDARD, + MeetingConstants.SUMMARY_DETAIL_BRIEF + } + ) + private String summaryDetailLevel; } 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 6603988..fd44af3 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -43,6 +43,8 @@ public class MeetingVO { private String meetingType; @Schema(description = "会议来源") private String meetingSource; + @Schema(description = "总结详细程度") + private String summaryDetailLevel; @Schema(description = "音频保存状态") private String audioSaveStatus; @Schema(description = "音频保存说明") 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 ed29c0d..4415341 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java @@ -40,6 +40,8 @@ public class Meeting extends BaseEntity { @Schema(description = "会议来源") private String meetingSource; + @Schema(description = "总结详细程度") + private String summaryDetailLevel; @Schema(description = "音频保存状态") private String audioSaveStatus; diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java index 530d226..4c672eb 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java @@ -81,6 +81,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ resolveCreatorName(loginUser), loginUser.getUserId(), resolveCreatorName(loginUser), + MeetingConstants.SUMMARY_DETAIL_STANDARD, 0 ); meetingService.save(meeting); diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java index 7e2ab0c..16a036c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java @@ -37,7 +37,7 @@ public interface MeetingCommandService { void updateSummaryContent(Long meetingId, String summaryContent); - void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt); + void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt, String summaryDetailLevel); void retryTranscription(Long meetingId); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java index e80426a..2dc459f 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java @@ -940,7 +940,7 @@ public class AiModelServiceImpl implements AiModelService { entity.setApiPath(dto.getApiPath()); entity.setApiKey(dto.getApiKey()); entity.setModelCode(dto.getModelCode()); - entity.setTemperature(dto.getTemperature() == null ? BigDecimal.valueOf(0.7) : dto.getTemperature()); + entity.setTemperature(dto.getTemperature() == null ? BigDecimal.valueOf(0.2) : dto.getTemperature()); entity.setTopP(dto.getTopP() == null ? BigDecimal.valueOf(0.9) : dto.getTopP()); entity.setIsDefault(dto.getIsDefault()); entity.setStatus(dto.getStatus()); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index dfeceba..fede1f7 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -83,8 +83,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId); Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId); + String summaryDetailLevel = resolveSummaryDetailLevel(command.getSummaryDetailLevel()); Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), - command.getAudioUrl(), MeetingConstants.TYPE_OFFLINE, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, 0); + command.getAudioUrl(), MeetingConstants.TYPE_OFFLINE, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, summaryDetailLevel, 0); meetingService.save(meeting); AiTask asrTask = new AiTask(); @@ -118,14 +119,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { runtimeProfile.getResolvedSummaryModelId(), chapterModelId, runtimeProfile.getResolvedPromptId(), - command.getUserPrompt() + command.getUserPrompt(), + summaryDetailLevel ); if (Objects.equals(chapterModelId, runtimeProfile.getResolvedSummaryModelId())) { meetingDomainSupport.createSummaryTask( meeting.getId(), runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId(), - command.getUserPrompt() + command.getUserPrompt(), + summaryDetailLevel ); } else { meetingDomainSupport.createSummaryTask( @@ -133,7 +136,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { runtimeProfile.getResolvedSummaryModelId(), chapterModelId, runtimeProfile.getResolvedPromptId(), - command.getUserPrompt() + command.getUserPrompt(), + summaryDetailLevel ); } meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl())); @@ -152,8 +156,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId); Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId); + String summaryDetailLevel = resolveSummaryDetailLevel(command.getSummaryDetailLevel()); Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), - null, MeetingConstants.TYPE_REALTIME, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, 0); + null, MeetingConstants.TYPE_REALTIME, meetingSource, tenantId, creatorId, creatorName, hostUserId, hostName, summaryDetailLevel, 0); meetingService.save(meeting); Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId(); meetingDomainSupport.createChapterTask( @@ -161,14 +166,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { runtimeProfile.getResolvedSummaryModelId(), chapterModelId, runtimeProfile.getResolvedPromptId(), - command.getUserPrompt() + command.getUserPrompt(), + summaryDetailLevel ); if (Objects.equals(chapterModelId, runtimeProfile.getResolvedSummaryModelId())) { meetingDomainSupport.createSummaryTask( meeting.getId(), runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId(), - command.getUserPrompt() + command.getUserPrompt(), + summaryDetailLevel ); } else { meetingDomainSupport.createSummaryTask( @@ -176,7 +183,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { runtimeProfile.getResolvedSummaryModelId(), chapterModelId, runtimeProfile.getResolvedPromptId(), - command.getUserPrompt() + command.getUserPrompt(), + summaryDetailLevel ); } realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId); @@ -582,7 +590,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { summaryModelId, chapterModelId, promptId, - command.getUserPrompt() + command.getUserPrompt(), + meeting.getSummaryDetailLevel() ); } @@ -627,14 +636,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { meeting.getId(), summaryModelId, promptId, - userPrompt + userPrompt, + meeting.getSummaryDetailLevel() ) : meetingDomainSupport.createSummaryTask( meeting.getId(), summaryModelId, chapterModelId, promptId, - userPrompt + userPrompt, + meeting.getSummaryDetailLevel() ); meeting.setLatestSummaryTaskId(createdSummaryTask.getId()); meeting.setStatus(2); @@ -803,31 +814,35 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { @Override @Transactional(rollbackFor = Exception.class) - public void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) { + public void reSummary(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt, String summaryDetailLevel) { Meeting meeting = meetingService.getById(meetingId); if (meeting == null) { throw new RuntimeException("会议不存在"); } + String effectiveSummaryDetailLevel = resolveSummaryDetailLevel(summaryDetailLevel != null ? summaryDetailLevel : meeting.getSummaryDetailLevel()); Long effectiveChapterModelId = chapterModelId != null ? chapterModelId : summaryModelId; meetingDomainSupport.createChapterTask( meetingId, summaryModelId, effectiveChapterModelId, promptId, - userPrompt + userPrompt, + effectiveSummaryDetailLevel ); if (Objects.equals(effectiveChapterModelId, summaryModelId)) { - meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId, userPrompt); + meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId, userPrompt, effectiveSummaryDetailLevel); } else { meetingDomainSupport.createSummaryTask( meetingId, summaryModelId, effectiveChapterModelId, promptId, - userPrompt + userPrompt, + effectiveSummaryDetailLevel ); } + meeting.setSummaryDetailLevel(effectiveSummaryDetailLevel); meeting.setStatus(2); meetingService.updateById(meeting); if ("EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode)) { @@ -890,7 +905,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { summaryModelId, chapterModelId != null ? chapterModelId : summaryModelId, promptId, - userPrompt + userPrompt, + meeting.getSummaryDetailLevel() ); } else { resetAiTask(chapterTask, new HashMap<>(chapterTask.getTaskConfig())); @@ -1150,4 +1166,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { } return null; } + + private String resolveSummaryDetailLevel(String requestedSummaryDetailLevel) { + if (requestedSummaryDetailLevel == null || requestedSummaryDetailLevel.isBlank()) { + return MeetingConstants.SUMMARY_DETAIL_STANDARD; + } + String normalized = requestedSummaryDetailLevel.trim().toUpperCase(); + if (MeetingConstants.SUMMARY_DETAIL_DETAILED.equals(normalized) + || MeetingConstants.SUMMARY_DETAIL_BRIEF.equals(normalized)) { + return normalized; + } + return MeetingConstants.SUMMARY_DETAIL_STANDARD; + } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java index 21c5a78..702666d 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -1,6 +1,7 @@ package com.imeeting.service.biz.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.common.MeetingConstants; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.MeetingTranscript; @@ -52,7 +53,7 @@ public class MeetingDomainSupport { public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags, String audioUrl, String meetingType, String meetingSource, Long tenantId, Long creatorId, String creatorName, - Long hostUserId, String hostName, int status) { + Long hostUserId, String hostName, String summaryDetailLevel, int status) { Meeting meeting = new Meeting(); meeting.setTitle(title); meeting.setMeetingTime(meetingTime); @@ -66,31 +67,87 @@ public class MeetingDomainSupport { meeting.setHostName(hostName); meeting.setTenantId(tenantId != null ? tenantId : 0L); meeting.setAudioUrl(audioUrl); + meeting.setSummaryDetailLevel(normalizeSummaryDetailLevel(summaryDetailLevel)); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_NONE); meeting.setStatus(status); return meeting; } public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) { - return createSummaryTask(meetingId, summaryModelId, summaryModelId, promptId, userPrompt); + return createSummaryTask( + meetingId, + summaryModelId, + summaryModelId, + promptId, + userPrompt, + MeetingConstants.SUMMARY_DETAIL_STANDARD + ); + } + + public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long promptId, + String userPrompt, String summaryDetailLevel) { + return createSummaryTask( + meetingId, + summaryModelId, + summaryModelId, + promptId, + userPrompt, + summaryDetailLevel + ); } public AiTask createChapterTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) { + return createChapterTask( + meetingId, + summaryModelId, + chapterModelId, + promptId, + userPrompt, + MeetingConstants.SUMMARY_DETAIL_STANDARD + ); + } + + public AiTask createChapterTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, + String userPrompt, String summaryDetailLevel) { AiTask chapterTask = new AiTask(); chapterTask.setMeetingId(meetingId); chapterTask.setTaskType("CHAPTER"); chapterTask.setStatus(0); - chapterTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt)); + chapterTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig( + summaryModelId, + chapterModelId, + promptId, + userPrompt, + normalizeSummaryDetailLevel(summaryDetailLevel) + )); aiTaskService.save(chapterTask); return chapterTask; } public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) { + return createSummaryTask( + meetingId, + summaryModelId, + chapterModelId, + promptId, + userPrompt, + MeetingConstants.SUMMARY_DETAIL_STANDARD + ); + } + + public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, + String userPrompt, String summaryDetailLevel) { AiTask sumTask = new AiTask(); sumTask.setMeetingId(meetingId); sumTask.setTaskType("SUMMARY"); sumTask.setStatus(0); - sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt)); + sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig( + summaryModelId, + chapterModelId, + promptId, + userPrompt, + normalizeSummaryDetailLevel(summaryDetailLevel) + )); aiTaskService.save(sumTask); return sumTask; } @@ -306,6 +363,7 @@ public class MeetingDomainSupport { } vo.setMeetingType(meeting.getMeetingType()); vo.setMeetingSource(meeting.getMeetingSource()); + vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel())); vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); vo.setAccessPassword(meeting.getAccessPassword()); @@ -415,6 +473,18 @@ public class MeetingDomainSupport { return normalized.isEmpty() ? null : normalized; } + private String normalizeSummaryDetailLevel(String summaryDetailLevel) { + if (summaryDetailLevel == null || summaryDetailLevel.isBlank()) { + return MeetingConstants.SUMMARY_DETAIL_STANDARD; + } + String normalized = summaryDetailLevel.trim().toUpperCase(); + if (MeetingConstants.SUMMARY_DETAIL_DETAILED.equals(normalized) + || MeetingConstants.SUMMARY_DETAIL_BRIEF.equals(normalized)) { + return normalized; + } + return MeetingConstants.SUMMARY_DETAIL_STANDARD; + } + private record AudioRelocationPlan(Path sourcePath, Path targetPath, Path backupPath, String relocatedUrl) { } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java index 9dfb3bc..2efe69c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java @@ -147,8 +147,18 @@ public class MeetingQueryServiceImpl implements MeetingQueryService { String userPrompt = request != null && request.getUserPrompt() != null ? request.getUserPrompt() : stringValue(latestSummaryTask.getTaskConfig().get("userPrompt")); + String summaryDetailLevel = stringValue(latestSummaryTask.getTaskConfig().get("summaryDetailLevel")); + if (summaryDetailLevel == null) { + summaryDetailLevel = meeting.getSummaryDetailLevel(); + } - Map taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt); + Map taskConfig = meetingSummaryPromptAssembler.buildTaskConfig( + summaryModelId, + chapterModelId, + promptId, + userPrompt, + summaryDetailLevel + ); MeetingSummaryPromptContextVO context = new MeetingSummaryPromptContextVO(); context.setPromptSchemaVersion(String.valueOf(taskConfig.get("promptSchemaVersion"))); context.setSystemMessage(meetingSummaryPromptAssembler.buildSystemMessage(taskConfig)); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java index b3efd2f..9491b25 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssembler.java @@ -1,5 +1,6 @@ package com.imeeting.service.biz.impl; +import com.imeeting.common.MeetingConstants; import com.imeeting.common.SysParamKeys; import com.imeeting.dto.biz.MeetingSummarySource; import com.imeeting.entity.biz.Meeting; @@ -26,15 +27,33 @@ public class MeetingSummaryPromptAssembler { private final SysParamService sysParamService; public Map buildTaskConfig(Long summaryModelId, Long promptId, String userPrompt) { - return buildTaskConfig(summaryModelId, summaryModelId, promptId, userPrompt); + return buildTaskConfig( + summaryModelId, + summaryModelId, + promptId, + userPrompt, + MeetingConstants.SUMMARY_DETAIL_STANDARD + ); } public Map buildTaskConfig(Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) { + return buildTaskConfig( + summaryModelId, + chapterModelId, + promptId, + userPrompt, + MeetingConstants.SUMMARY_DETAIL_STANDARD + ); + } + + public Map buildTaskConfig(Long summaryModelId, Long chapterModelId, Long promptId, + String userPrompt, String summaryDetailLevel) { Map taskConfig = new HashMap<>(); taskConfig.put("summaryModelId", summaryModelId); taskConfig.put("chapterModelId", chapterModelId != null ? chapterModelId : summaryModelId); taskConfig.put("promptSchemaVersion", PROMPT_SCHEMA_VERSION); taskConfig.put("effectiveSystemPrompt", resolveSystemPrompt()); + taskConfig.put("summaryDetailLevel", normalizeSummaryDetailLevel(summaryDetailLevel)); String templatePrompt = resolveTemplatePrompt(promptId); taskConfig.put("effectiveTemplatePrompt", templatePrompt); @@ -59,11 +78,13 @@ public class MeetingSummaryPromptAssembler { stringValue(taskConfig, "promptContent"), "请输出结构清晰、信息完整、适合直接阅读和导出的正式会议纪要。" ); + String summaryDetailLevel = normalizeSummaryDetailLevel(stringValue(taskConfig, "summaryDetailLevel")); return String.join("\n\n", "你是一名擅长中文会议纪要、结构化分析和待办提取的助手。", "系统提示词(最高优先级):\n" + systemPrompt, "模板提示词(结构和风格要求):\n" + templatePrompt, + "总结详细程度要求:\n" + buildSummaryDetailInstruction(summaryDetailLevel), """ 输出要求: 1. 最终只允许输出一个 JSON 对象,不要输出 Markdown 代码块、解释说明或额外前后缀。 @@ -193,4 +214,27 @@ public class MeetingSummaryPromptAssembler { } return null; } + + private String normalizeSummaryDetailLevel(String summaryDetailLevel) { + if (!StringUtils.hasText(summaryDetailLevel)) { + return MeetingConstants.SUMMARY_DETAIL_STANDARD; + } + String normalized = summaryDetailLevel.trim().toUpperCase(); + if (MeetingConstants.SUMMARY_DETAIL_DETAILED.equals(normalized) + || MeetingConstants.SUMMARY_DETAIL_BRIEF.equals(normalized)) { + return normalized; + } + return MeetingConstants.SUMMARY_DETAIL_STANDARD; + } + + private String buildSummaryDetailInstruction(String summaryDetailLevel) { + return switch (normalizeSummaryDetailLevel(summaryDetailLevel)) { + case MeetingConstants.SUMMARY_DETAIL_DETAILED -> + "DETAILED(详细):尽量完整覆盖会议背景、讨论过程、分歧、结论、负责人、时间点和风险,适当展开章节内容。"; + case MeetingConstants.SUMMARY_DETAIL_BRIEF -> + "BRIEF(简洁):只保留核心结论、关键决策、待办事项和必要上下文,避免冗长铺陈。"; + default -> + "STANDARD(标准):保持信息完整和篇幅平衡,覆盖核心过程、结论和待办,不做过度展开。"; + }; + } } diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java index a064454..934a917 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingSummaryPromptAssemblerTest.java @@ -1,5 +1,6 @@ package com.imeeting.service.biz.impl; +import com.imeeting.common.MeetingConstants; import com.imeeting.common.SysParamKeys; import com.imeeting.dto.biz.MeetingSummarySource; import com.imeeting.entity.biz.Meeting; @@ -22,7 +23,7 @@ import static org.mockito.Mockito.when; class MeetingSummaryPromptAssemblerTest { @Test - void buildTaskConfigShouldCaptureEffectivePromptsAndUserPrompt() { + void buildTaskConfigShouldCaptureEffectivePromptsUserPromptAndDetailLevel() { PromptTemplateService promptTemplateService = mock(PromptTemplateService.class); SysParamService sysParamService = mock(SysParamService.class); PromptTemplate template = new PromptTemplate(); @@ -33,20 +34,27 @@ class MeetingSummaryPromptAssemblerTest { MeetingSummaryPromptAssembler assembler = new MeetingSummaryPromptAssembler(promptTemplateService, sysParamService); - Map taskConfig = assembler.buildTaskConfig(2L, 3L, " 关注风险项 "); + Map taskConfig = assembler.buildTaskConfig( + 2L, + 5L, + 3L, + " 关注风险项 ", + MeetingConstants.SUMMARY_DETAIL_DETAILED + ); assertEquals(2L, taskConfig.get("summaryModelId")); - assertEquals(2L, taskConfig.get("chapterModelId")); + assertEquals(5L, taskConfig.get("chapterModelId")); assertEquals(3L, taskConfig.get("promptId")); assertEquals("v2", taskConfig.get("promptSchemaVersion")); assertEquals("系统提示词", taskConfig.get("effectiveSystemPrompt")); assertEquals("模板提示词", taskConfig.get("effectiveTemplatePrompt")); assertEquals("关注风险项", taskConfig.get("userPrompt")); assertEquals("模板提示词", taskConfig.get("promptContent")); + assertEquals(MeetingConstants.SUMMARY_DETAIL_DETAILED, taskConfig.get("summaryDetailLevel")); } @Test - void buildSystemMessageShouldFallbackToLegacyPromptContent() { + void buildSystemMessageShouldContainSummaryDetailInstruction() { SysParamService sysParamService = mock(SysParamService.class); when(sysParamService.getCachedParamValue(eq(SysParamKeys.MEETING_SUMMARY_SYSTEM_PROMPT), eq(""))) .thenReturn("系统提示词"); @@ -55,9 +63,14 @@ class MeetingSummaryPromptAssemblerTest { sysParamService ); - String systemMessage = assembler.buildSystemMessage(Map.of("promptContent", "旧模板提示词")); + String systemMessage = assembler.buildSystemMessage(Map.of( + "promptContent", "旧模板提示词", + "summaryDetailLevel", MeetingConstants.SUMMARY_DETAIL_BRIEF + )); assertTrue(systemMessage.contains("旧模板提示词")); + assertTrue(systemMessage.contains("BRIEF")); + assertTrue(systemMessage.contains("简洁")); } @Test @@ -89,8 +102,8 @@ class MeetingSummaryPromptAssemblerTest { meeting.setParticipants("Alice,Bob"); MeetingSummarySource source = MeetingSummarySource.builder() - .chapterOutlineText("第1章 预算评审\n章节摘要:讨论320万预算") - .rawTranscriptText("Alice: 今天讨论320万预算,时间定在2026年5月7日15:30。") + .chapterOutlineText("第1章 预算评审\n章节摘要:讨论 20 万预算") + .rawTranscriptText("Alice: 今天讨论 320 万预算,时间定在 2026 年 5 月 8 日 15:30。") .build(); String userMessage = assembler.buildUserMessage(meeting, source, "关注预算结论"); @@ -98,7 +111,7 @@ class MeetingSummaryPromptAssemblerTest { assertTrue(userMessage.contains("章节辅助结构如下")); assertTrue(userMessage.contains("第1章 预算评审")); assertTrue(userMessage.contains("原始会议转录如下")); - assertTrue(userMessage.contains("Alice: 今天讨论320万预算")); + assertTrue(userMessage.contains("Alice: 今天讨论 320 万预算")); assertTrue(userMessage.contains("最终不要在 `analysis` 中返回章节列表")); } diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index 9631db4..4f96a8e 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -4,6 +4,14 @@ import axios from "axios"; const MEETING_UPLOAD_FLOW_TIMEOUT = 600000; const MEETING_DETAIL_TIMEOUT = 120000; +export type SummaryDetailLevel = "DETAILED" | "STANDARD" | "BRIEF"; + +export interface MeetingCreateConfig { + offlineEnabled: boolean; + realtimeEnabled: boolean; + offlineAudioMaxSizeMb: number; +} + export interface MeetingVO { id: number; tenantId: number; @@ -20,6 +28,7 @@ export interface MeetingVO { playbackAudioUrl?: string; meetingType?: "OFFLINE" | "REALTIME"; meetingSource?: "WEB" | "ANDROID"; + summaryDetailLevel?: SummaryDetailLevel; audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED"; audioSaveMessage?: string; accessPassword?: string; @@ -80,6 +89,7 @@ export interface CreateMeetingCommand { promptId: number; hotWordGroupId?: number; userPrompt?: string; + summaryDetailLevel?: SummaryDetailLevel; useSpkId?: number; enableTextRefine?: boolean; hotWords?: string[]; @@ -99,6 +109,7 @@ export interface CreateRealtimeMeetingCommand { promptId: number; hotWordGroupId?: number; userPrompt?: string; + summaryDetailLevel?: SummaryDetailLevel; mode?: string; language?: string; useSpkId?: number; @@ -285,6 +296,12 @@ export const getMeetingDetail = (id: number, options?: { suppressErrorToast?: bo ); }; +export const getMeetingCreateConfig = () => { + return http.get<{ code: string; data: MeetingCreateConfig; msg: string }>( + "/api/biz/meeting/create-config" + ); +}; + export const getTranscripts = (id: number) => { return http.get<{ code: string; data: MeetingTranscriptVO[]; msg: string }>( `/api/biz/meeting/${id}/transcripts` @@ -345,6 +362,7 @@ export interface MeetingResummaryDTO { summaryModelId: number; promptId: number; userPrompt?: string; + summaryDetailLevel?: SummaryDetailLevel; } export const reSummary = (params: MeetingResummaryDTO) => { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index a931f1e..b89445c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -4,6 +4,11 @@ import { PermissionNode } from "../types"; +export interface RoleAuthorizationPayload { + permissionIds?: number[]; + dataScope?: RoleDataScope; +} + export async function pageParams(params: SysParamQuery) { const resp = await http.get("/sys/api/params/page", { params }); return resp.data.data as PageResult; @@ -189,6 +194,11 @@ export async function saveRolePermissions(roleId: number, permIds: number[]) { return resp.data.data as boolean; } +export async function saveRoleAuthorization(roleId: number, payload: RoleAuthorizationPayload) { + const resp = await http.post(`/sys/api/roles/${roleId}/authorization`, payload); + return resp.data.data as boolean; +} + export async function getRoleDataScope(roleId: number) { const resp = await http.get(`/sys/api/roles/${roleId}/data-scope`); return resp.data.data as RoleDataScope; diff --git a/frontend/src/components/business/MeetingCreateDrawer.tsx b/frontend/src/components/business/MeetingCreateDrawer.tsx index fd82677..9d452ce 100644 --- a/frontend/src/components/business/MeetingCreateDrawer.tsx +++ b/frontend/src/components/business/MeetingCreateDrawer.tsx @@ -1,25 +1,65 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { Drawer, Form, Input, Select, DatePicker, Switch, Upload, Progress, Space, Avatar, Row, Col, Radio, Typography, Tooltip, App, Tag, Button, Collapse } from 'antd'; -import { UserOutlined, CloudUploadOutlined, AudioOutlined, QuestionCircleOutlined, CheckOutlined, LinkOutlined, SettingOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import { useNavigate } from 'react-router-dom'; +import React, { useEffect, useMemo, useState } from "react"; +import { + App, + Avatar, + Button, + Col, + Collapse, + DatePicker, + Drawer, + Form, + Input, + Progress, + Radio, + Row, + Select, + Skeleton, + Space, + Switch, + Tag, + Tooltip, + Typography, + Upload, +} from "antd"; +import { + AudioOutlined, + CheckOutlined, + CloudUploadOutlined, + QuestionCircleOutlined, + SettingOutlined, + UserOutlined, +} from "@ant-design/icons"; +import dayjs from "dayjs"; +import { useNavigate } from "react-router-dom"; -import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel'; -import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; -import { getHotWordPage, HotWordVO } from '../../api/business/hotword'; -import { getHotWordGroupOptions, HotWordGroupVO } from '../../api/business/hotwordGroup'; -import { listUsers, pageParams } from '../../api'; -import { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting'; -import { SysUser } from '../../types'; +import { listUsers } from "../../api"; +import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel"; +import { getHotWordPage, type HotWordVO } from "../../api/business/hotword"; +import { getHotWordGroupOptions, type HotWordGroupVO } from "../../api/business/hotwordGroup"; +import { + createMeeting, + createRealtimeMeeting, + getMeetingCreateConfig, + type CreateRealtimeMeetingCommand, + type MeetingCreateConfig, + type SummaryDetailLevel, + uploadAudio, +} from "../../api/business/meeting"; +import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt"; +import type { SysUser } from "../../types"; -const { Option } = Select; const { Dragger } = Upload; +const { Option } = Select; const { Text, Title } = Typography; -export type MeetingCreateType = 'upload' | 'realtime'; +export type MeetingCreateType = "upload" | "realtime"; const DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB = 1024; -const OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY = 'meeting.offline_audio.max_size_mb'; +const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = { + offlineEnabled: true, + realtimeEnabled: true, + offlineAudioMaxSizeMb: DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB, +}; interface MeetingCreateDrawerProps { open: boolean; @@ -44,106 +84,138 @@ type RealtimeMeetingSessionDraft = { hotwords: Array<{ hotword: string; weight: number }>; }; +function resolveAvailableCreateTypes(config: MeetingCreateConfig): MeetingCreateType[] { + const types: MeetingCreateType[] = []; + if (config.offlineEnabled) { + types.push("upload"); + } + if (config.realtimeEnabled) { + types.push("realtime"); + } + return types; +} + +function resolveAvailableCreateType(initialType: MeetingCreateType, config: MeetingCreateConfig): MeetingCreateType { + const availableTypes = resolveAvailableCreateTypes(config); + if (availableTypes.includes(initialType)) { + return initialType; + } + return availableTypes[0] || initialType; +} + function resolveWsUrl(model?: AiModelVO | null) { if (model?.wsUrl) return model.wsUrl; if (model?.baseUrl) return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://"); return ""; } -function buildRealtimeProxyPreviewUrl() { - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - return `${protocol}://${window.location.host}/ws/meeting/realtime`; -} - function getSessionKey(meetingId: number) { return `realtimeMeetingSession:${meetingId}`; } -export const MeetingCreateDrawer: React.FC = ({ open, initialType = 'upload', onCancel, onSuccess }) => { +export const MeetingCreateDrawer: React.FC = ({ + open, + initialType = "upload", + onCancel, + onSuccess, +}) => { const { message } = App.useApp(); const navigate = useNavigate(); const [form] = Form.useForm(); - + const [type, setType] = useState(initialType); const [loading, setLoading] = useState(false); + const [configLoaded, setConfigLoaded] = useState(false); const [submitting, setSubmitting] = useState(false); - + const [createConfig, setCreateConfig] = useState({ + offlineEnabled: false, + realtimeEnabled: false, + offlineAudioMaxSizeMb: DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB, + }); + const [asrModels, setAsrModels] = useState([]); const [llmModels, setLlmModels] = useState([]); const [prompts, setPrompts] = useState([]); const [hotwordList, setHotwordList] = useState([]); const [hotWordGroups, setHotWordGroups] = useState([]); const [userList, setUserList] = useState([]); - const [hotWordGroupTouched, setHotWordGroupTouched] = useState(false); - - const [audioUrl, setAudioUrl] = useState(''); + + const [audioUrl, setAudioUrl] = useState(""); const [uploadProgress, setUploadProgress] = useState(0); const [fileList, setFileList] = useState([]); - const [offlineAudioMaxSizeMb, setOfflineAudioMaxSizeMb] = useState(DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB); const watchedAsrModelId = Form.useWatch("asrModelId", form); + const watchedSummaryModelId = Form.useWatch("summaryModelId", form); const watchedPromptId = Form.useWatch("promptId", form); const watchedHotWordGroupId = Form.useWatch("hotWordGroupId", form); - const watchedSummaryModelId = Form.useWatch("summaryModelId", form); - const selectedAsrModel = useMemo(() => asrModels.find((item) => item.id === watchedAsrModelId) || null, [asrModels, watchedAsrModelId]); - const selectedSummaryModel = useMemo(() => llmModels.find((item) => item.id === watchedSummaryModelId) || null, [llmModels, watchedSummaryModelId]); - const selectedPrompt = useMemo(() => prompts.find((item) => item.id === watchedPromptId) || null, [prompts, watchedPromptId]); - const offlineAudioMaxSizeBytes = useMemo(() => offlineAudioMaxSizeMb * 1024 * 1024, [offlineAudioMaxSizeMb]); + const selectedAsrModel = useMemo( + () => asrModels.find((item) => item.id === watchedAsrModelId) || null, + [asrModels, watchedAsrModelId] + ); + const selectedSummaryModel = useMemo( + () => llmModels.find((item) => item.id === watchedSummaryModelId) || null, + [llmModels, watchedSummaryModelId] + ); + const selectedPrompt = useMemo( + () => prompts.find((item) => item.id === watchedPromptId) || null, + [prompts, watchedPromptId] + ); + + const availableTypes = useMemo(() => resolveAvailableCreateTypes(createConfig), [createConfig]); + const offlineAudioMaxSizeBytes = useMemo( + () => (createConfig.offlineAudioMaxSizeMb || DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB) * 1024 * 1024, + [createConfig] + ); useEffect(() => { - if (open) { - setType(initialType); - setHotWordGroupTouched(false); - loadInitialData(); - setAudioUrl(''); - setUploadProgress(0); - setFileList([]); - } - }, [open, initialType]); - - useEffect(() => { - if (!open || hotWordGroupTouched) { + if (!open) { return; } - form.setFieldValue('hotWordGroupId', selectedPrompt?.hotWordGroupId); - }, [form, hotWordGroupTouched, open, selectedPrompt]); + setAudioUrl(""); + setUploadProgress(0); + setFileList([]); + void loadInitialData(); + }, [open, initialType]); const loadInitialData = async () => { setLoading(true); try { - const [asrRes, llmRes, promptRes, hotwordRes, hotWordGroupRes, users, defaultAsr, defaultLlm] = await Promise.all([ - getAiModelPage({ current: 1, size: 100, type: 'ASR' }), - getAiModelPage({ current: 1, size: 100, type: 'LLM' }), + const [asrRes, llmRes, promptRes, hotwordRes, hotWordGroupRes, users, defaultAsr, defaultLlm, createConfigRes] = await Promise.all([ + getAiModelPage({ current: 1, size: 100, type: "ASR" }), + getAiModelPage({ current: 1, size: 100, type: "LLM" }), getPromptPage({ current: 1, size: 100 }), getHotWordPage({ current: 1, size: 1000 }), getHotWordGroupOptions(), listUsers(), getAiModelDefault("ASR"), getAiModelDefault("LLM"), + getMeetingCreateConfig(), ]); - - const activeAsrModels = asrRes.data.data.records.filter((m: AiModelVO) => m.status === 1); - const activeLlmModels = llmRes.data.data.records.filter((m: AiModelVO) => m.status === 1); - const activePrompts = promptRes.data.data.records.filter((p: PromptTemplateVO) => p.status === 1); - const activeHotwords = hotwordRes.data.data.records.filter((h: HotWordVO) => h.status === 1); - setAsrModels(activeAsrModels); - setLlmModels(activeLlmModels); + const nextConfig = createConfigRes.data.data || DEFAULT_CREATE_CONFIG; + const nextType = resolveAvailableCreateType(initialType, nextConfig); + const activePrompts = promptRes.data.data.records.filter((item: PromptTemplateVO) => item.status === 1); + + setCreateConfig(nextConfig); + setConfigLoaded(true); + setType(nextType); + setAsrModels(asrRes.data.data.records.filter((item: AiModelVO) => item.status === 1)); + setLlmModels(llmRes.data.data.records.filter((item: AiModelVO) => item.status === 1)); setPrompts(activePrompts); - setHotwordList(activeHotwords); + setHotwordList(hotwordRes.data.data.records.filter((item: HotWordVO) => item.status === 1)); setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1)); setUserList(users || []); - setOfflineAudioMaxSizeMb(await loadOfflineAudioMaxSizeMb()); - const defaultPrompt = activePrompts.length > 0 ? activePrompts[0] : undefined; + const defaultPrompt = activePrompts[0]; form.setFieldsValue({ - title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`, + title: nextType === "upload" ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`, meetingTime: dayjs(), asrModelId: defaultAsr.data.data?.id, summaryModelId: defaultLlm.data.data?.id, promptId: defaultPrompt?.id, - hotWordGroupId: defaultPrompt?.hotWordGroupId, + hotWordGroupId: defaultPrompt?.hotWordGroupId ?? 0, + summaryDetailLevel: "STANDARD", useSpkId: 1, enableTextRefine: false, mode: "2pass", @@ -152,24 +224,36 @@ export const MeetingCreateDrawer: React.FC = ({ open, enableItn: true, saveAudio: false, }); - } catch (err) { + } catch { message.error("加载配置失败"); } finally { setLoading(false); } }; - // Sync title when type changes useEffect(() => { - if (!open) return; - const currentTitle = form.getFieldValue('title'); - if (currentTitle && (currentTitle.startsWith('文件会议') || currentTitle.startsWith('实时会议'))) { + if (!open) { + return; + } + const currentTitle = form.getFieldValue("title"); + if (typeof currentTitle !== "string") { + return; + } + if (currentTitle.startsWith("文件会议") || currentTitle.startsWith("实时会议")) { form.setFieldsValue({ - title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`, - useSpkId: 1 + title: type === "upload" ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`, }); } - }, [type, form, open]); + }, [form, open, type]); + + const beforeAudioUpload = (file: File) => { + if (file.size > offlineAudioMaxSizeBytes) { + message.error(`录音文件大小不能超过 ${createConfig.offlineAudioMaxSizeMb}MB`); + setUploadProgress(0); + return Upload.LIST_IGNORE; + } + return true; + }; const customUpload = async (options: any) => { const { file, onSuccess: uploadSuccess, onError, onProgress } = options; @@ -177,62 +261,45 @@ export const MeetingCreateDrawer: React.FC = ({ open, try { const res = await uploadAudio(file, (progressEvent) => { if (progressEvent.total) { - const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); - // Only show up to 99% during upload, save 100% for actual completion - const displayPercent = percentCompleted > 99 ? 99 : percentCompleted; - setUploadProgress(displayPercent); - onProgress({ percent: displayPercent }); + const percent = Math.min(99, Math.round((progressEvent.loaded * 100) / progressEvent.total)); + setUploadProgress(percent); + onProgress({ percent }); } }); setUploadProgress(100); onProgress({ percent: 100 }); setAudioUrl(res.data.data); uploadSuccess(res.data.data); - message.success('录音上传成功'); + message.success("录音上传成功"); } catch (err) { onError(err); - if (!(err instanceof Error) || !err.message) { - message.error('文件上传失败'); - } - } - }; - - const beforeAudioUpload = (file: File) => { - if (file.size > offlineAudioMaxSizeBytes) { - message.error(`录音文件大小不能超过 ${offlineAudioMaxSizeMb}MB`); - setUploadProgress(0); - return Upload.LIST_IGNORE; - } - return true; - }; - - const loadOfflineAudioMaxSizeMb = async () => { - try { - const result = await pageParams({ - paramKey: OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY, - pageNum: 1, - pageSize: 10, - }); - const matched = (result.records || []).find((item) => item.paramKey === OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY); - const parsed = Number(matched?.paramValue); - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB; - } catch { - return DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB; + message.error("文件上传失败"); } }; const handleOk = async () => { - if (type === 'upload' && !audioUrl) { - message.error('请先上传录音文件'); + if (availableTypes.length === 0) { + message.error("当前系统参数已关闭全部会议创建入口"); return; } - + if (!availableTypes.includes(type)) { + const fallbackType = availableTypes[0]; + if (fallbackType) { + setType(fallbackType); + } + message.warning("当前入口已关闭,已切换到可用创建方式"); + return; + } + if (type === "upload" && !audioUrl) { + message.error("请先上传录音文件"); + return; + } + const values = await form.validateFields(); - - if (type === 'realtime') { + if (type === "realtime") { const wsUrl = resolveWsUrl(selectedAsrModel); if (!wsUrl) { - message.error("当前 ASR 模型没有配置 WebSocket 地址"); + message.error("当前 ASR 模型未配置 WebSocket 地址"); return; } } @@ -246,71 +313,73 @@ export const MeetingCreateDrawer: React.FC = ({ open, .filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId) .map((item) => item.word) .filter((word) => !!word?.trim()); - - if (type === 'upload') { + + if (type === "upload") { await createMeeting({ - ...meetingValues, - ...(hostUserId != null ? { hostUserId } : {}), - meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'), - audioUrl, - participants: meetingValues.participants?.join(','), - tags: meetingValues.tags?.join(','), - hotWords: selectedHotWords - }); - message.success('会议发起成功'); - onSuccess(); - onCancel(); - } else { - const selectedHotwords = hotwordList - .filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId && meetingValues.hotWordGroupId !== 0) - .map((item) => ({ - hotword: item.word, - weight: Number(item.weight || 2) / 10, - })); - - const payload: CreateRealtimeMeetingCommand = { ...meetingValues, ...(hostUserId != null ? { hostUserId } : {}), meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"), - participants: meetingValues.participants?.join(",") || "", - tags: meetingValues.tags?.join(",") || "", - mode: meetingValues.mode || "2pass", - language: meetingValues.language || "auto", - useSpkId: meetingValues.useSpkId == null ? 1 : (meetingValues.useSpkId ? 1 : 0), - enablePunctuation: meetingValues.enablePunctuation !== false, - enableItn: meetingValues.enableItn !== false, - enableTextRefine: !!meetingValues.enableTextRefine, - saveAudio: !!meetingValues.saveAudio, + audioUrl, + participants: meetingValues.participants?.join(","), + tags: meetingValues.tags?.join(","), + summaryDetailLevel: meetingValues.summaryDetailLevel as SummaryDetailLevel, hotWords: selectedHotWords, - }; - - const res = await createRealtimeMeeting(payload); - const createdMeeting = res.data.data; - - const sessionDraft: RealtimeMeetingSessionDraft = { - meetingId: createdMeeting.id, - meetingTitle: createdMeeting.title, - asrModelName: selectedAsrModel?.modelName || "ASR", - summaryModelName: selectedSummaryModel?.modelName || "LLM", - asrModelId: selectedAsrModel?.id || values.asrModelId, - mode: values.mode || "2pass", - language: values.language || "auto", - useSpkId: values.useSpkId == null ? 1 : (values.useSpkId ? 1 : 0), - enablePunctuation: values.enablePunctuation !== false, - enableItn: values.enableItn !== false, - enableTextRefine: !!values.enableTextRefine, - saveAudio: !!values.saveAudio, - hotwords: selectedHotwords, - }; - - sessionStorage.setItem(getSessionKey(createdMeeting.id), JSON.stringify(sessionDraft)); - message.success("会议已创建,即将进入实时识别"); + }); + message.success("会议发起成功"); onSuccess(); onCancel(); - navigate(`/meeting-live-session/${createdMeeting.id}`); + return; } - } catch (err) { - message.error(type === 'upload' ? '创建会议失败' : '创建实时会议失败'); + + const selectedHotwords = hotwordList + .filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId && meetingValues.hotWordGroupId !== 0) + .map((item) => ({ + hotword: item.word, + weight: Number(item.weight || 2) / 10, + })); + + const payload: CreateRealtimeMeetingCommand = { + ...meetingValues, + ...(hostUserId != null ? { hostUserId } : {}), + meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"), + participants: meetingValues.participants?.join(",") || "", + tags: meetingValues.tags?.join(",") || "", + summaryDetailLevel: meetingValues.summaryDetailLevel as SummaryDetailLevel, + mode: meetingValues.mode || "2pass", + language: meetingValues.language || "auto", + useSpkId: meetingValues.useSpkId == null ? 1 : (meetingValues.useSpkId ? 1 : 0), + enablePunctuation: meetingValues.enablePunctuation !== false, + enableItn: meetingValues.enableItn !== false, + enableTextRefine: !!meetingValues.enableTextRefine, + saveAudio: !!meetingValues.saveAudio, + hotWords: selectedHotWords, + }; + + const res = await createRealtimeMeeting(payload); + const createdMeeting = res.data.data; + const sessionDraft: RealtimeMeetingSessionDraft = { + meetingId: createdMeeting.id, + meetingTitle: createdMeeting.title, + asrModelName: selectedAsrModel?.modelName || "ASR", + summaryModelName: selectedSummaryModel?.modelName || "LLM", + asrModelId: selectedAsrModel?.id || values.asrModelId, + mode: values.mode || "2pass", + language: values.language || "auto", + useSpkId: values.useSpkId == null ? 1 : (values.useSpkId ? 1 : 0), + enablePunctuation: values.enablePunctuation !== false, + enableItn: values.enableItn !== false, + enableTextRefine: !!values.enableTextRefine, + saveAudio: !!values.saveAudio, + hotwords: selectedHotwords, + }; + + sessionStorage.setItem(getSessionKey(createdMeeting.id), JSON.stringify(sessionDraft)); + message.success("会议已创建,即将进入实时识别"); + onSuccess(); + onCancel(); + navigate(`/meeting-live-session/${createdMeeting.id}`); + } catch { + message.error(type === "upload" ? "创建会议失败" : "创建实时会议失败"); } finally { setSubmitting(false); } @@ -318,68 +387,80 @@ export const MeetingCreateDrawer: React.FC = ({ open, return ( + title={null} + open={open} + onClose={onCancel} + width={960} + forceRender + destroyOnClose={false} + placement="right" + closable={false} + footer={ + configLoaded ? ( +
- } - styles={{ - header: { display: 'none' }, - body: { padding: 0, display: 'flex', flexDirection: 'column', background: 'var(--app-bg-layout)' }, - footer: { padding: 0, borderTop: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface)' } - }} - > -
- - - -
- {type === 'upload' ? : } -
-
- {type === 'upload' ? '上传录音分析' : '创建实时会议'} - {type === 'upload' ? '上传已有录音文件进行转写和总结' : '创建会议实时进行语音转写和内容分析'} -
-
- - - setType(e.target.value)} optionType="button" buttonStyle="solid" size="large"> - 上传录音 - 实时识别 - - -
+ ) : null + } + styles={{ + header: { display: "none" }, + body: { padding: 0, display: "flex", flexDirection: "column", background: "var(--app-bg-layout)" }, + footer: { padding: 0, borderTop: "1px solid var(--app-border-color)", background: "var(--app-bg-surface)", minHeight: configLoaded ? 72 : 0 } + }} + > + {!configLoaded ? ( +
+
+ ) : ( + <> +
+ + + +
+ {type === "upload" ? : } +
+
+ {type === "upload" ? "上传录音发起分析" : "创建实时会议"} + {type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"} +
+
+ + + setType(e.target.value)} optionType="button" buttonStyle="solid" size="large"> + {createConfig.offlineEnabled && ( + 上传录音 + )} + {createConfig.realtimeEnabled && ( + 实时会议 + )} + + +
+
-
-
-
-
- 基础信息 -
- - - - - - - +
+ +
+
+ 基础信息 +
+ + + + + + + - + @@ -387,14 +468,14 @@ export const MeetingCreateDrawer: React.FC = ({ open, - {userList.map(u => ())} - {userList.map(u => ())} @@ -409,21 +490,21 @@ export const MeetingCreateDrawer: React.FC = ({ open, -
+
-
-
- AI 模型与分析配置 +
+
+ 模型与纪要配置
- + - + - + @@ -431,19 +512,19 @@ export const MeetingCreateDrawer: React.FC = ({ open, {prompts.length > 15 ? ( - {prompts.map(p => )} ) : ( -
+
{prompts.map(p => { const isSelected = watchedPromptId === p.id; return ( -
form.setFieldsValue({ promptId: p.id })} style={{ padding: '12px 16px', borderRadius: 8, border: `1px solid ${isSelected ? '#1890ff' : 'var(--app-border-color)'}`, background: isSelected ? '#e6f7ff' : 'var(--app-bg-surface)', cursor: 'pointer', position: 'relative', transition: 'all 0.2s', display: 'flex', alignItems: 'center', height: '100%' }}> -
{p.templateName}
- {isSelected &&
} +
form.setFieldsValue({ promptId: p.id })} style={{ padding: "12px 16px", borderRadius: 8, border: `1px solid ${isSelected ? "#1890ff" : "var(--app-border-color)"}`, background: isSelected ? "#e6f7ff" : "var(--app-bg-surface)", cursor: "pointer", position: "relative", transition: "all 0.2s", display: "flex", alignItems: "center", height: "100%" }}> +
{p.templateName}
+ {isSelected &&
}
); @@ -453,83 +534,35 @@ export const MeetingCreateDrawer: React.FC = ({ open, )} - {/* - - ({ - label: `${item.groupName} (${item.hotWordCount}/200)`, - value: item.id, - })), - ]} - onChange={() => setHotWordGroupTouched(true)} - /> - - */} - - - ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} /> + + + + + + 详细 + 标准 + 简洁 + + + + -
-
+
+
+
高级设置 @@ -537,40 +570,38 @@ export const MeetingCreateDrawer: React.FC = ({ open,
), children: ( -
+
- 声纹区分 } valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}> + 说话人区分 } valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}> - 文本修正 } valuePropName="checked"> + 文本修正 } valuePropName="checked"> - {type === 'realtime' && ( - - - - - + {type === "realtime" && ( + <> + + + + + + + + + 2pass(流式 + 离线修正) + online(纯流式) + + + + )} - - + +
), @@ -578,48 +609,36 @@ export const MeetingCreateDrawer: React.FC = ({ open, ]} /> - {type === 'realtime' && ( + {type === "upload" && ( <> - - - - - - )} - - {type === 'upload' && ( - <> -
-
-
+
+
+
上传录音文件
- setFileList(info.fileList.slice(-1))} - maxCount={1} - style={{ borderRadius: 12, padding: '32px 0', background: 'var(--app-bg-surface)', border: '1px dashed var(--app-border-color)' }} + fileList={fileList} + customRequest={customUpload} + onChange={info => setFileList(info.fileList.slice(-1))} + maxCount={1} + style={{ borderRadius: 12, padding: "32px 0", background: "var(--app-bg-surface)", border: "1px dashed var(--app-border-color)" }} >
-

-

点击或拖拽录音文件到此处

-

支持 .mp3、.wav,以及 AAC 编码的 .m4a 音频

-

文件大小不超过 {offlineAudioMaxSizeMb}MB,取值来自系统参数配置

+

+

点击或拖拽录音文件到此处

+

支持 mp3、wav、m4a 等格式,大小不超过 {createConfig.offlineAudioMaxSizeMb}MB

{uploadProgress > 0 && uploadProgress < 100 && ( -
+
-
文件传输中,请稍候...
+
文件上传中,请稍候...
)} {audioUrl && ( - - 就绪: - - {audioUrl.split('/').pop()} - + + 已上传: + {audioUrl.split("/").pop()} )}
@@ -628,6 +647,8 @@ export const MeetingCreateDrawer: React.FC = ({ open, )}
- + + )} + ); -}; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index f14b267..e9d72d9 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -3,17 +3,23 @@ import { UserProfile } from "../types"; export function useAuth() { const [accessToken, setAccessToken] = useState(() => localStorage.getItem("accessToken")); + const [profileVersion, setProfileVersion] = useState(0); useEffect(() => { - const handler = () => setAccessToken(localStorage.getItem("accessToken")); - window.addEventListener("storage", handler); - return () => window.removeEventListener("storage", handler); + const syncAuthState = () => setAccessToken(localStorage.getItem("accessToken")); + const syncProfileState = () => setProfileVersion((value) => value + 1); + window.addEventListener("storage", syncAuthState); + window.addEventListener("user-profile-updated", syncProfileState); + return () => { + window.removeEventListener("storage", syncAuthState); + window.removeEventListener("user-profile-updated", syncProfileState); + }; }, []); const profile = useMemo(() => { const data = sessionStorage.getItem("userProfile"); return data ? JSON.parse(data) : null; - }, [accessToken]); + }, [accessToken, profileVersion]); const isAuthed = !!accessToken; const logout = () => { diff --git a/frontend/src/pages/access/roles/index.tsx b/frontend/src/pages/access/roles/index.tsx index 6409e83..132480d 100644 --- a/frontend/src/pages/access/roles/index.tsx +++ b/frontend/src/pages/access/roles/index.tsx @@ -28,15 +28,13 @@ import { listTenants, listUsers, pageRoles, - saveRoleDataScope, - saveRolePermissions, + saveRoleAuthorization, unbindUserFromRole, updateRole } from "@/api"; import { useDict } from "@/hooks/useDict"; import { usePermission } from "@/hooks/usePermission"; import PageHeader from "@/components/shared/PageHeader"; -import PageContainer from "@/components/shared/PageContainer"; import { getStandardPagination } from "@/utils/pagination"; import type { RoleDataScope, SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types"; import "./index.less"; @@ -192,6 +190,8 @@ export default function Roles() { const [dataScopeType, setDataScopeType] = useState("SELF"); const [scopeOrgIds, setScopeOrgIds] = useState([]); const [scopeOrgTree, setScopeOrgTree] = useState([]); + const [permissionsDirty, setPermissionsDirty] = useState(false); + const [dataScopeDirty, setDataScopeDirty] = useState(false); const [form] = Form.useForm(); const isPlatformMode = useMemo(() => { @@ -246,6 +246,8 @@ export default function Roles() { setDataScopeType(dataScope?.scopeType || role.dataScopeType || "SELF"); setScopeOrgIds((dataScope?.orgIds || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id))); setScopeOrgTree(toOrgTreeData(buildOrgTree(orgs || []))); + setPermissionsDirty(false); + setDataScopeDirty(false); } finally { setLoadingUsers(false); } @@ -272,6 +274,8 @@ export default function Roles() { setDataScopeType("SELF"); setScopeOrgIds([]); setScopeOrgTree([]); + setPermissionsDirty(false); + setDataScopeDirty(false); } else if (!selectedRole) { await selectRole(roles[0], permissionList); } else { @@ -376,239 +380,242 @@ export default function Roles() { setRolePage((prev) => ({ ...prev, current: page, size: pageSize })); }; - const savePermissions = async () => { - if (!selectedRole) return; - setSaving(true); - try { - await saveRolePermissions(selectedRole.roleId, Array.from(new Set([...selectedPermIds, ...halfCheckedIds]))); - message.success("操作成功"); - } finally { - setSaving(false); - } - }; - - const saveDataScope = async () => { - if (!selectedRole) return; - if (dataScopeType === "CUSTOM" && scopeOrgIds.length === 0) { + const saveAuthorizationChanges = async () => { + if (!selectedRole || (!permissionsDirty && !dataScopeDirty)) return; + if (dataScopeDirty && dataScopeType === "CUSTOM" && scopeOrgIds.length === 0) { message.warning("请选择至少一个部门"); return; } + setSaving(true); try { - const payload: RoleDataScope = { - roleId: selectedRole.roleId, - scopeType: dataScopeType, - orgIds: dataScopeType === "CUSTOM" ? scopeOrgIds : [] - }; - await saveRoleDataScope(selectedRole.roleId, payload); - message.success("操作成功"); + await saveRoleAuthorization(selectedRole.roleId, { + permissionIds: Array.from(new Set([...selectedPermIds, ...halfCheckedIds])), + dataScope: { + roleId: selectedRole.roleId, + scopeType: dataScopeType, + orgIds: dataScopeType === "CUSTOM" ? scopeOrgIds : [] + } + }); + setPermissionsDirty(false); + setDataScopeDirty(false); setSelectedRole((prev) => (prev ? { ...prev, dataScopeType } : prev)); setData((prev) => prev.map((item) => item.roleId === selectedRole.roleId ? { ...item, dataScopeType } : item)); + await selectRole({ ...selectedRole, dataScopeType }); + message.success("操作成功"); } finally { setSaving(false); } }; const handlePrimarySave = () => { - if (activeTab === "permissions") { - void savePermissions(); - return; - } - if (activeTab === "dataScope") { - void saveDataScope(); - } + if (activeTab === "users") return; + void saveAuthorizationChanges(); }; - const saveDisabled = !selectedRole || activeTab === "users" || (activeTab === "permissions" && !can("sys:role:permission:save")) || (activeTab === "dataScope" && !can("sys:role:update")); - const saveLabel = activeTab === "dataScope" ? "保存数据权限" : "保存"; + const needsSave = permissionsDirty || dataScopeDirty; + const saveDisabled = !selectedRole + || activeTab === "users" + || !needsSave + || (permissionsDirty && !can("sys:role:permission:save")) + || (dataScopeDirty && !can("sys:role:update")); + const saveLabel = "同步保存"; return ( - } onClick={openCreate}> - 新增角色 - - ) - } - > - - - {"角色列表"}} bordered={false} className="app-page__panel-card roles-side-card"> -
- {isPlatformMode && ( - } value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear /> - -
-
+
+ -
- }} - renderItem={(item) => ( -
void selectRole(item)}> - -
-
- {item.roleName} - {isPlatformMode && {item.tenantId === 0 ? "平台租户" : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `租户:${item.tenantId}`}} - {item.status === 0 && {"停用"}} -
- {item.roleCode} -
- {selectedRole?.roleId === item.roleId ? ( - - ) : null} -
- - -
-
+
+ {can("sys:role:create") && } +
+ +
+ + + {"角色列表"}} bordered={false} className="app-page__panel-card roles-side-card"> +
+ {isPlatformMode && ( + } value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear /> + +
+
- - {selectedRole ? ( -
{selectedRole.roleName}
{selectedRole.roleCode}
} - extra={} - > - setActiveTab(key as RoleTabKey)} className="role-detail-tabs"> - {"功能权限"}} key="permissions"> -
-
- { - const checked = Array.isArray(keys) ? keys : keys.checked; - const halfChecked = info.halfCheckedKeys || []; - setSelectedPermIds(checked.map((key) => Number(key))); - setHalfCheckedIds(halfChecked.map((key) => Number(key))); - }} - defaultExpandAll - /> +
+ }} + renderItem={(item) => ( +
void selectRole(item)}> + +
+
+ {item.roleName} + {isPlatformMode && {item.tenantId === 0 ? "平台租户" : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `租户:${item.tenantId}`}} + {item.status === 0 && {"停用"}} +
+ {item.roleCode} +
+ {selectedRole?.roleId === item.roleId ? ( + + ) : null} +
+ + +
-
- - {"数据权限"}} key="dataScope"> -
-
- setDataScopeType(event.target.value)} optionType="button" buttonStyle="solid"> - {DATA_SCOPE_OPTIONS.map((item) => ( - {item.label} - ))} - -
-
{getDataScopeDescription(dataScopeType)}
- {dataScopeType === "CUSTOM" ? ( + )} + /> +
+
+ +
+ + + + + {selectedRole ? ( +
{selectedRole.roleName}
{selectedRole.roleCode}
} + extra={} + > + setActiveTab(key as RoleTabKey)} className="role-detail-tabs"> + {"功能权限"}} key="permissions"> +
{ + checkStrictly={false} + treeData={permissionTreeData} + checkedKeys={selectedPermIds} + onCheck={(keys, info) => { const checked = Array.isArray(keys) ? keys : keys.checked; - setScopeOrgIds(checked.map((key) => Number(key))); + const halfChecked = info.halfCheckedKeys || []; + setSelectedPermIds(checked.map((key) => Number(key))); + setHalfCheckedIds(halfChecked.map((key) => Number(key))); + setPermissionsDirty(true); }} defaultExpandAll />
- ) : ( - - )} -
-
- {`成员管理 (${roleUsers.length})`}} key="users"> -
-
- {"已绑定用户"} -
- ( - - } style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} /> -
-
{user.displayName}
-
@{user.username}
-
-
- ) - }, - { title: "手机号", dataIndex: "phone", className: "tabular-nums" }, - { title: "状态", dataIndex: "status", width: 80, render: (status: number) => {status === 1 ? "启用" : "停用"} }, - { - title: "操作", - key: "action", - width: 80, - render: (_: unknown, user: SysUser) => ( - void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}> - + +
( + + } style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} /> +
+
{user.displayName}
+
@{user.username}
+
+
+ ) + }, + { title: "手机号", dataIndex: "phone", className: "tabular-nums" }, + { title: "状态", dataIndex: "status", width: 80, render: (status: number) => {status === 1 ? "启用" : "停用"} }, + { + title: "操作", + key: "action", + width: 80, + render: (_: unknown, user: SysUser) => ( + void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}> +
+ {(generationFailureNotice || emptyTranscriptFailureNotice || meeting.audioSaveStatus === 'FAILED') && ( + + {generationFailureNotice?.title || emptyTranscriptFailureNotice?.title || '会议处理提醒'} +
+ } + description={ +
+
{generationFailureNotice?.description || emptyTranscriptFailureNotice?.description}
+ {meeting.audioSaveStatus === 'FAILED' && ( +
+ + {meeting.audioSaveMessage || '录音保存失败,当前无法播放'} +
+ )} + {canRetryTranscription && ( +
+ {emptyTranscriptFailureNotice?.hint} +
+ )} +
+ } + /> + )} -
-
- - AI 智能总结 -
-
- {meeting.summaryContent && ( - - 总结记录 - - )} - {meeting.summaryContent && isOwner && ( - { - if (isEditingSummary) { - handleSaveSummary(); - } else { - setSummaryDraft(meeting.summaryContent || ''); - setIsEditingSummary(true); - } - }} - > - {isEditingSummary ? <> 保存 : <> 编辑} - - )} - {isEditingSummary && ( - setIsEditingSummary(false)}> - 取消 - - )} -
-
- - {generationFailureNotice && ( - - )} {meeting.status === 2 ? (
@@ -2029,14 +2007,6 @@ const MeetingDetail: React.FC = () => {
{emptyTranscriptFailureNotice.description}
)} - {meeting.audioSaveStatus === 'FAILED' && ( - - )} {
{emptyTranscriptFailureNotice.description}
)} - {meeting.audioSaveStatus === 'FAILED' && ( - - )} { + {userList.map((u) => ( + + ))} + + @@ -3647,6 +3620,13 @@ const MeetingDetail: React.FC = () => { ))} + + + 详细 + 标准 + 简洁 + + - item.meetingType === 'REALTIME' || (!item.meetingType && item.status === 0 && !item.audioUrl); + item.meetingType === "REALTIME" || (!item.meetingType && item.status === 0 && !item.audioUrl); const canControlRealtimeFromCurrentPlatform = (item: MeetingVO) => !item.meetingSource || item.meetingSource === CURRENT_PLATFORM; -const getRealtimeSourceLabel = (item: MeetingVO) => item.meetingSource === 'ANDROID' ? '安卓端' : 'Web 端'; +const getMeetingSourceLabel = (source?: MeetingVO["meetingSource"]) => + source === "ANDROID" ? "安卓端" : "Web端"; + +const getRealtimeSourceLabel = (item: MeetingVO) => getMeetingSourceLabel(item.meetingSource); const isPausedRealtimeSessionStatus = (status?: RealtimeMeetingSessionStatus["status"]) => - status === 'PAUSED_EMPTY' || status === 'PAUSED_RESUMABLE'; + status === "PAUSED_EMPTY" || status === "PAUSED_RESUMABLE"; const canOpenRealtimeSession = (status?: RealtimeMeetingSessionStatus["status"]) => - status === 'PAUSED_EMPTY' - || status === 'PAUSED_RESUMABLE' - || status === 'ACTIVE' - || status === 'IDLE'; + status === "PAUSED_EMPTY" + || status === "PAUSED_RESUMABLE" + || status === "ACTIVE" + || status === "IDLE"; const shouldPollMeetingCard = (item: MeetingVO) => item.status === 1 || item.status === 2 - || item.realtimeSessionStatus === 'ACTIVE' + || item.realtimeSessionStatus === "ACTIVE" || isPausedRealtimeSessionStatus(item.realtimeSessionStatus); const canManageMeeting = (meeting: MeetingVO) => { try { - const profileStr = sessionStorage.getItem('userProfile'); + const profileStr = sessionStorage.getItem("userProfile"); if (!profileStr) { return false; } @@ -98,259 +114,280 @@ const canManageMeeting = (meeting: MeetingVO) => { } }; -const canManageRealtimeMeeting = (meeting: MeetingVO) => canManageMeeting(meeting); - const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => { if (!sessionStatus) { return item; } - if (sessionStatus.status === 'PAUSED_EMPTY' || sessionStatus.status === 'PAUSED_RESUMABLE') { - return { - ...item, - displayStatus: PAUSED_DISPLAY_STATUS, - realtimeSessionStatus: sessionStatus.status - }; + if (sessionStatus.status === "PAUSED_EMPTY" || sessionStatus.status === "PAUSED_RESUMABLE") { + return { ...item, displayStatus: PAUSED_DISPLAY_STATUS, realtimeSessionStatus: sessionStatus.status }; } - if (sessionStatus.status === 'ACTIVE') { - return { - ...item, - displayStatus: REALTIME_ACTIVE_DISPLAY_STATUS, - realtimeSessionStatus: sessionStatus.status - }; + if (sessionStatus.status === "ACTIVE") { + return { ...item, displayStatus: REALTIME_ACTIVE_DISPLAY_STATUS, realtimeSessionStatus: sessionStatus.status }; } - if (sessionStatus.status === 'IDLE' && isRealtimeMeetingCandidate(item)) { - return { - ...item, - displayStatus: REALTIME_IDLE_DISPLAY_STATUS, - realtimeSessionStatus: sessionStatus.status - }; + if (sessionStatus.status === "IDLE" && isRealtimeMeetingCandidate(item)) { + return { ...item, displayStatus: REALTIME_IDLE_DISPLAY_STATUS, realtimeSessionStatus: sessionStatus.status }; } - return { - ...item, - realtimeSessionStatus: sessionStatus.status - }; + return { ...item, realtimeSessionStatus: sessionStatus.status }; }; -// --- 进度感知 Hook --- const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => { const [progress, setProgress] = useState(null); + useEffect(() => { - if (meeting.status !== 1 && meeting.status !== 2) return; + if (meeting.status !== 1 && meeting.status !== 2) { + return; + } const fetchProgress = async () => { try { const res = await getMeetingProgress(meeting.id, { suppressErrorToast: true }); - if (res.data && res.data.data) { - setProgress(res.data.data); - if ((res.data.data.percent === 100 || res.data.data.percent < 0) && onComplete) { + const nextProgress = res.data?.data; + if (nextProgress) { + setProgress(nextProgress); + if ((nextProgress.percent === 100 || nextProgress.percent < 0) && onComplete) { onComplete(); } } - } catch (err) {} + } catch {} }; - fetchProgress(); + void fetchProgress(); const timer = setInterval(fetchProgress, 3000); return () => clearInterval(timer); - }, [meeting.id, meeting.status]); + }, [meeting.id, meeting.status, onComplete]); + return progress; }; -// --- 状态标签组件 --- -const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgress | null }> = ({ meeting, progress }) => { +const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => { const effectiveStatus = meeting.displayStatus ?? meeting.status; - const statusConfig: Record = { - 0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' }, - 1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' }, - 2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' }, - 3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' }, - 4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' }, - 5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' }, - 6: { text: '实时进行中', color: '#1677ff', bgColor: '#e6f4ff' }, - 7: { text: '待开始', color: '#595959', bgColor: '#f5f5f5' } + const statusConfig: Record = { + 0: { text: "排队中", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: }, + 1: { text: "识别中", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: }, + 2: { text: "总结中", color: "#faad14", bgColor: "rgba(250, 173, 20, 0.1)", icon: }, + 3: { text: "已完成", color: "#52c41a", bgColor: "rgba(82, 196, 26, 0.1)", icon: }, + 4: { text: "失败", color: "#ff4d4f", bgColor: "rgba(255, 77, 79, 0.1)", icon: }, + 5: { text: "暂停中", color: "#d48806", bgColor: "rgba(212, 136, 6, 0.1)", icon: }, + 6: { text: "进行中", color: "#5f51ff", bgColor: "rgba(95, 81, 255, 0.1)", icon: }, + 7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: }, }; const config = statusConfig[effectiveStatus] || statusConfig[0]; - const percent = (meeting.status === 1 || meeting.status === 2) ? (progress?.percent || 0) : 0; + const percent = meeting.status === 1 || meeting.status === 2 ? progress?.percent || 0 : 0; const isProcessing = meeting.status === 1 || meeting.status === 2; + return ( -
+
{isProcessing && percent > 0 && ( -
+
)} - - {isProcessing ? : null} - {config.text} - {isProcessing && {percent}%} + + {isProcessing ? : config.icon} + {config.text} + {isProcessing && {percent}%}
); }; -// --- 表格状态单元格 --- -const TableStatusCell: React.FC<{ meeting: MeetingVO, fetchData: () => void }> = ({ meeting, fetchData }) => { - const progress = useMeetingProgress(meeting, () => fetchData()); +const TableStatusCell: React.FC<{ meeting: MeetingVO; fetchData: () => void }> = ({ meeting, fetchData }) => { + const progress = useMeetingProgress(meeting, fetchData); return ; }; -// --- 卡片项组件 --- -const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void, onOpenMeeting: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants, onOpenMeeting }) => { - const progress = useMeetingProgress(item, () => fetchData()); +const MeetingCardItem: React.FC<{ + item: MeetingVO; + config: { text: string; color: string; bgColor: string }; + fetchData: () => void; + onOpenMeeting: (meeting: MeetingVO) => void; +}> = ({ item, config, fetchData, onOpenMeeting }) => { + const progress = useMeetingProgress(item, fetchData); const effectiveStatus = item.displayStatus ?? item.status; const isProcessing = item.status === 1 || item.status === 2; const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS; const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS; const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS; const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item); - const crossPlatformHint = `该实时会议需在${getRealtimeSourceLabel(item)}继续`; + const crossPlatformHint = `在${getRealtimeSourceLabel(item)}继续`; + + const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6"; return ( - - onOpenMeeting(item)} className="meeting-card" style={{ borderRadius: 16, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', backdropFilter: 'blur(16px)', height: '220px', position: 'relative', boxShadow: 'var(--app-shadow)', transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)' }} styles={{ body: { padding: 0, display: 'flex', flexDirection: 'row', height: '100%' } }}> -
-
+ + onOpenMeeting(item)} + className="meeting-card-v2" + style={{ + borderRadius: "20px", + border: "1px solid rgba(0, 0, 0, 0.04)", + background: "#ffffff", + overflow: "hidden", + transition: "all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1)", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.02), 0 1px 2px rgba(0, 0, 0, 0.02)", + height: "100%", + display: "flex", + flexDirection: "column", + minHeight: "200px" + }} + styles={{ body: { padding: "20px 24px", flex: 1, display: "flex", flexDirection: "column" } }} + > + {/* Top Section: Status & Source */} +
+ +
+
+ {getMeetingSourceLabel(item.meetingSource)} +
+
+ + {/* Middle Section: Title & Actions */} +
+ + {item.title} + + {canManageMeeting(item) && ( -
e.stopPropagation()}> - -
onEditParticipants(item)} />
+
e.stopPropagation()} + > { e?.stopPropagation(); deleteMeeting(item.id).then(() => fetchData()); }} - okText={t('common.confirm')} - cancelText={t('common.cancel')} - onCancel={(e) => e?.stopPropagation()} + title="确定删除会议吗?" + description="删除后将无法找回该会议记录。" + onConfirm={() => deleteMeeting(item.id).then(fetchData)} + okText="删除" + cancelText="取消" + okButtonProps={{ danger: true }} > -
e.stopPropagation()}>
+
+
)} +
-
-
- -
-
- {item.title} -
- -
-
{dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')}
-
{item.creatorName || '未知'}
+ {/* Content Section: Progress or Metadata */} +
+ {(isProcessing || isPaused || isRealtimeActive || isRealtimeIdle) ? ( +
+
+ {isProcessing ? : } + + {isProcessing ? (progress?.message || "深度分析中...") : (isCrossPlatformRealtime ? crossPlatformHint : config.text)} +
- {isProcessing ? ( -
- - - {progress?.message || '等待引擎调度...'} - -
- ) : isCrossPlatformRealtime ? ( -
- - - {crossPlatformHint} - -
- ) : isPaused ? ( -
- - - 会议已暂停,可继续识别 - -
- ) : isRealtimeActive ? ( -
- - - 实时会议进行中,可直接进入控制页 - -
- ) : isRealtimeIdle ? ( -
- - - 实时会议尚未开始,可进入继续操作 - -
- ) : ( +
+ ) : ( +
+
+ + {dayjs(item.meetingTime).format("MM-DD HH:mm")} +
+
+ + {item.creatorName || "未知"} +
+
+ )} +
-
{item.participants || '无参与人员'}
- )} - + {/* Bottom Section: Tags & Navigate */} +
+
+ {item.tags?.split(",").filter(Boolean).slice(0, 2).map(tag => ( + + #{tag} + + )) || 无标签}
-
-
{item.tags?.split(',').slice(0, 2).map(t => ( - {t} - ))}
- +
+ + 查看详情
@@ -358,7 +395,6 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => ); }; -// --- 主组件 --- const Meetings: React.FC = () => { const { message } = App.useApp(); const { t } = useTranslation(); @@ -369,255 +405,277 @@ const Meetings: React.FC = () => { const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [current, setCurrent] = useState(1); - const [displayMode, setDisplayMode] = useState<'card' | 'list'>('card'); + const [displayMode, setDisplayMode] = useState<"card" | "list">("card"); const [size, setSize] = useState(8); - const [searchTitle, setSearchTitle] = useState(''); - const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all'); + const [searchTitle, setSearchTitle] = useState(""); + const [viewType, setViewType] = useState<"all" | "created" | "involved">("all"); const [createDrawerVisible, setCreateDrawerVisible] = useState(false); - const [createDrawerType, setCreateDrawerType] = useState('upload'); + const [createDrawerType, setCreateDrawerType] = useState("upload"); + const [configLoaded, setConfigLoaded] = useState(false); + const [createConfig, setCreateConfig] = useState({ + offlineEnabled: false, + realtimeEnabled: false, + offlineAudioMaxSizeMb: 1024, + }); const [userList, setUserList] = useState([]); - const [participantsEditVisible, setParticipantsEditVisible] = useState(false); - const [editingMeeting, setEditingMeeting] = useState(null); - const [participantsEditLoading, setParticipantsEditLoading] = useState(false); - const [participantsEditForm] = Form.useForm(); - const hasRunningTasks = data.some((item) => shouldPollMeetingCard(item)); - const handleDisplayModeChange = (mode: 'card' | 'list') => { + const hasRunningTasks = data.some(shouldPollMeetingCard); + + const handleDisplayModeChange = (mode: "card" | "list") => { setDisplayMode(mode); - setSize(mode === 'card' ? 8 : 10); + setSize(mode === "card" ? 8 : 10); setCurrent(1); }; useEffect(() => { - const action = searchParams.get('action'); - const type = searchParams.get('type') as MeetingCreateType; - if (action === 'create' && (type === 'realtime' || type === 'upload')) { + const action = searchParams.get("action"); + const type = searchParams.get("type") as MeetingCreateType; + if (action === "create" && (type === "realtime" || type === "upload")) { setCreateDrawerType(type); setCreateDrawerVisible(true); setSearchParams({}); } }, [searchParams, setSearchParams]); - useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]); useEffect(() => { - if (!hasRunningTasks) return; - const timer = setInterval(() => fetchData(true), 5000); + void fetchData(); + }, [current, size, searchTitle, viewType]); + + useEffect(() => { + if (!hasRunningTasks) { + return; + } + const timer = setInterval(() => void fetchData(true), 5000); return () => clearInterval(timer); }, [hasRunningTasks, current, size, searchTitle, viewType]); + useEffect(() => { listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([])); + getMeetingCreateConfig() + .then((res) => { + setCreateConfig(res.data.data || DEFAULT_CREATE_CONFIG); + setConfigLoaded(true); + }) + .catch(() => { + setCreateConfig(DEFAULT_CREATE_CONFIG); + setConfigLoaded(true); + }); }, []); const fetchData = async (silent = false) => { - if (!silent) setLoading(true); + if (!silent) { + setLoading(true); + } try { const res = await getMeetingPage({ current, size, title: searchTitle, viewType }); - if (res.data && res.data.data) { - const records = res.data.data.records || []; - let statusMap: Record = {}; - const realtimeCandidates = records.filter(isRealtimeMeetingCandidate); - if (realtimeCandidates.length > 0) { - try { - const sessionRes = await getRealtimeMeetingSessionStatuses(realtimeCandidates.map((item) => item.id)); - statusMap = sessionRes.data?.data || {}; - } catch {} - } - const withDisplayStatus = records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id])); - setData(withDisplayStatus); - setTotal(res.data.data.total); + const records = res.data?.data?.records || []; + let statusMap: Record = {}; + const realtimeCandidates = records.filter(isRealtimeMeetingCandidate); + if (realtimeCandidates.length > 0) { + try { + const sessionRes = await getRealtimeMeetingSessionStatuses(realtimeCandidates.map((item) => item.id)); + statusMap = sessionRes.data?.data || {}; + } catch {} } - } catch (err) {} finally { if (!silent) setLoading(false); } + setData(records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id]))); + setTotal(res.data?.data?.total || 0); + } finally { + if (!silent) { + setLoading(false); + } + } }; const handleOpenMeeting = async (meeting: MeetingVO) => { if (!isRealtimeMeetingCandidate(meeting)) { - navigate(`/meetings/${meeting.id}`); + navigate("/meetings/" + meeting.id); return; } - if (!canControlRealtimeFromCurrentPlatform(meeting)) { - message.info(`该实时会议需在${getRealtimeSourceLabel(meeting)}继续,当前仅支持查看详情`); - navigate(`/meetings/${meeting.id}`); + message.info("该实时会议需在" + getRealtimeSourceLabel(meeting) + "继续,当前仅支持查看详情"); + navigate("/meetings/" + meeting.id); return; } - if (canOpenRealtimeSession(meeting.realtimeSessionStatus)) { - navigate(`/meeting-live-session/${meeting.id}`); + navigate("/meeting-live-session/" + meeting.id); return; } - - if (!canManageRealtimeMeeting(meeting)) { - navigate(`/meetings/${meeting.id}`); + if (!canManageMeeting(meeting)) { + navigate("/meetings/" + meeting.id); return; } - try { const res = await getRealtimeMeetingSessionStatus(meeting.id); - const sessionStatus = res.data?.data?.status; - if (canOpenRealtimeSession(sessionStatus)) { - navigate(`/meeting-live-session/${meeting.id}`); + if (canOpenRealtimeSession(res.data?.data?.status)) { + navigate("/meeting-live-session/" + meeting.id); return; } - } catch (error) {} - navigate(`/meetings/${meeting.id}`); - }; - - const openEditParticipants = (meeting: MeetingVO) => { - if (!canManageMeeting(meeting)) { - message.warning('只有会议创建人或管理员可以修改参会人'); - return; - } - setEditingMeeting(meeting); - participantsEditForm.setFieldsValue({ - participantIds: meeting.participantIds || [] - }); - setParticipantsEditVisible(true); - }; - - const handleUpdateParticipants = async () => { - if (!editingMeeting) { - return; - } - const values = await participantsEditForm.validateFields(); - const participantIds: number[] = values.participantIds || []; - setParticipantsEditLoading(true); - try { - await updateMeetingParticipants({ - meetingId: editingMeeting.id, - participants: participantIds.join(",") - }); - message.success("参会人已更新"); - setParticipantsEditVisible(false); - fetchData(); - } finally { - setParticipantsEditLoading(false); - } - }; - - const statusConfig: Record = { - 0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' }, - 1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' }, - 2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' }, - 3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' }, - 4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' }, - 5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' }, - 6: { text: '实时进行中', color: '#1677ff', bgColor: '#e6f4ff' }, - 7: { text: '待开始', color: '#595959', bgColor: '#f5f5f5' } + } catch {} + navigate("/meetings/" + meeting.id); }; const tableColumns = [ { - title: '会议标题', - dataIndex: 'title', - key: 'title', - render: (text: string, record: MeetingVO) => ( - handleOpenMeeting(record)}>{text} - ) + title: "会议标题", + dataIndex: "title", + key: "title", + render: (text: string, record: MeetingVO) => handleOpenMeeting(record)}>{text}, }, { - title: '状态', - key: 'status', + title: "状态", + key: "status", width: 150, - render: (_: any, record: MeetingVO) => ( - - ) + render: (_: unknown, record: MeetingVO) => void fetchData()} />, }, { - title: '会议时间', - dataIndex: 'meetingTime', - key: 'meetingTime', - width: 180, - render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm') - }, - { - title: '创建人', - dataIndex: 'creatorName', - key: 'creatorName', - width: 120, - render: (text: string) => {text || '未知'} - }, - { - title: '参会人', - dataIndex: 'participants', - key: 'participants', - render: (text: string) => ( - {text || '无参与人员'} - ) - }, - { - title: '操作', - key: 'action', + title: "会议时间", + dataIndex: "meetingTime", + key: "meetingTime", width: 160, - render: (_: any, record: MeetingVO) => canManageMeeting(record) ? ( + render: (text: string) => dayjs(text).format("YYYY-MM-DD HH:mm"), + }, + { + title: "创建人", + dataIndex: "creatorName", + key: "creatorName", + width: 100, + render: (text: string) => {text || "未知"}, + }, + { + title: "来源", + dataIndex: "meetingSource", + key: "meetingSource", + width: 80, + render: (value: MeetingVO["meetingSource"]) => {getMeetingSourceLabel(value)}, + }, + { + title: "参会人", + dataIndex: "participants", + key: "participants", + render: (text: string) => {text || "无参与人员"}, + }, + { + title: "操作", + key: "action", + width: 160, + render: (_: unknown, record: MeetingVO) => ( - - { e?.stopPropagation(); deleteMeeting(record.id).then(() => fetchData()); }} - okText={t('common.confirm')} - cancelText={t('common.cancel')} - onCancel={(e) => e?.stopPropagation()} - > - - + + {canManageMeeting(record) && ( + deleteMeeting(record.id).then(() => fetchData())}> + + + )} - ) : - - } + ), + }, ]; + const statusConfig: Record = { + 0: { text: "排队中", color: "#8c8c8c", bgColor: "#f5f5f5" }, + 1: { text: "识别中", color: "#1890ff", bgColor: "#e6f7ff" }, + 2: { text: "总结中", color: "#faad14", bgColor: "#fff7e6" }, + 3: { text: "已完成", color: "#52c41a", bgColor: "#f6ffed" }, + 4: { text: "失败", color: "#ff4d4f", bgColor: "#fff1f0" }, + 5: { text: "会议暂停", color: "#d48806", bgColor: "#fff7e6" }, + 6: { text: "实时进行中", color: "#1677ff", bgColor: "#e6f4ff" }, + 7: { text: "待开始", color: "#595959", bgColor: "#f5f5f5" }, + }; + return ( - handleDisplayModeChange(e.target.value)} buttonStyle="solid"> + handleDisplayModeChange(e.target.value)} buttonStyle="solid"> - + {configLoaded && ( + <> + {!createConfig.offlineEnabled && !createConfig.realtimeEnabled ? ( + + ) : createConfig.offlineEnabled && createConfig.realtimeEnabled ? ( + , + label: "上传录音", + onClick: () => { + setCreateDrawerType("upload"); + setCreateDrawerVisible(true); + }, + }, + { + key: "realtime", + icon: , + label: "实时会议", + onClick: () => { + setCreateDrawerType("realtime"); + setCreateDrawerVisible(true); + }, + }, + ], + }} + placement="bottomRight" + > + + + ) : ( + + )} + + )} } toolbar={ <> - { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid"> + { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid"> 全部 我发起 我参与 } allowClear - onPressEnter={(e) => { setSearchTitle((e.target as any).value); setCurrent(1); }} - style={{ width: 220 }} + onPressEnter={(e) => { setSearchTitle((e.target as HTMLInputElement).value); setCurrent(1); }} + style={{ width: 200 }} /> } > - -
- {displayMode === 'card' ? ( - +
+ {displayMode === "card" ? ( + { const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0]; - return ; + return void fetchData()} onOpenMeeting={handleOpenMeeting} />; }} locale={{ emptyText: }} /> @@ -629,17 +687,14 @@ const Meetings: React.FC = () => { rowKey="id" loading={loading} pagination={false} - scroll={{ x: 'max-content' }} - onRow={(record) => ({ - onClick: () => handleOpenMeeting(record), - style: { cursor: 'pointer' } - })} + scroll={{ x: "max-content" }} + onRow={(record) => ({ onClick: () => handleOpenMeeting(record), style: { cursor: "pointer" } })} locale={{ emptyText: }} /> )}
-
+
{ setCurrent(p); setSize(s); }} />
@@ -650,49 +705,38 @@ const Meetings: React.FC = () => { onCancel={() => setCreateDrawerVisible(false)} onSuccess={() => { setCreateDrawerVisible(false); - fetchData(); + void fetchData(); }} /> - { - setParticipantsEditVisible(false); - setEditingMeeting(null); - }} - onOk={handleUpdateParticipants} - confirmLoading={participantsEditLoading} - destroyOnHidden - forceRender - width={500} - > -
- - - - -
- ); diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx index 58b776d..9b557d6 100644 --- a/frontend/src/pages/home/index.tsx +++ b/frontend/src/pages/home/index.tsx @@ -8,7 +8,7 @@ import { Button, Empty, Skeleton, Tag, Typography } from "antd"; import { useNavigate } from "react-router-dom"; import dayjs from "dayjs"; import { getRecentTasks } from "@/api/business/dashboard"; -import type { MeetingVO } from "@/api/business/meeting"; +import { getMeetingCreateConfig, type MeetingCreateConfig, type MeetingVO } from "@/api/business/meeting"; import "./index.less"; import RightVisual from "./RightVisual"; import { MeetingCreateDrawer, MeetingCreateType } from "@/components/business/MeetingCreateDrawer"; @@ -76,6 +76,12 @@ export default function HomePage() { const navigate = useNavigate(); const [recentTasks, setRecentTasks] = useState([]); const [loading, setLoading] = useState(true); + const [configLoaded, setConfigLoaded] = useState(false); + const [createConfig, setCreateConfig] = useState({ + offlineEnabled: false, + realtimeEnabled: false, + offlineAudioMaxSizeMb: 1024, + }); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerType, setDrawerType] = useState('realtime'); const [readCardIds, setReadCardIds] = useState(() => { @@ -108,11 +114,21 @@ export default function HomePage() { useEffect(() => { const fetchRecentTasks = async () => { try { - const response = await getRecentTasks(); + const [response, configResponse] = await Promise.all([ + getRecentTasks(), + getMeetingCreateConfig(), + ]); const payload: any = (response as any).data || response; setRecentTasks(payload?.data || payload || []); + setCreateConfig(configResponse.data.data || { + offlineEnabled: true, + realtimeEnabled: true, + offlineAudioMaxSizeMb: 1024, + }); + setConfigLoaded(true); } catch (error) { console.error("Home recent tasks load failed", error); + setConfigLoaded(true); } finally { setLoading(false); } @@ -129,9 +145,10 @@ export default function HomePage() { window.localStorage.setItem(RECENT_CARD_READ_STORAGE_KEY, JSON.stringify(readCardIds)); }, [readCardIds]); - const quickEntries = useMemo( + const quickEntries = useMemo<(QuickEntry & { key: string })[]>( () => [ { + key: "realtime", title: "开启实时记录", icon: , description: ["实时语音转文字", "同步翻译,智能总结要点"], @@ -143,6 +160,7 @@ export default function HomePage() { } }, { + key: "offline", title: "上传音视频", icon: , description: ["音视频转文字", "区分发言人,一键导出"], @@ -157,7 +175,13 @@ export default function HomePage() { [] ); + const visibleQuickEntries = useMemo( + () => quickEntries.filter((item) => (item.key === "realtime" ? createConfig.realtimeEnabled : createConfig.offlineEnabled)), + [quickEntries, createConfig] + ); + const recentCards = useMemo(() => buildRecentCards(recentTasks), [recentTasks]); + const recentTaskMap = useMemo(() => new Map(recentTasks.map((item) => [String(item.id), item])), [recentTasks]); const handleRecentCardClick = (card: RecentCard) => { const cardId = String(card.id); @@ -195,28 +219,50 @@ export default function HomePage() {
- {quickEntries.map((entry) => ( -
-
{entry.badge}
-
-
{entry.icon}
-
-
+ {configLoaded ? ( + visibleQuickEntries.length > 0 ? visibleQuickEntries.map((entry) => ( +
+
{entry.badge}
+
+
{entry.icon}
+
+
+
+ {entry.title} +
+ {entry.description.map((line) => ( + + {line} + + ))} +
- {entry.title} -
- {entry.description.map((line) => ( - - {line} - - ))} + )) : ( +
+
系统参数
+
+
+
+
+
+ 会议创建已关闭 +
+ 当前系统参数已关闭离线上传和实时会议入口 +
-
- ))} + ) + ) : ( + // Loading state for quick actions to prevent jump + [...Array(2)].map((_, i) => ( +
+ +
+ )) + )}
@@ -259,8 +305,13 @@ export default function HomePage() {
-
- {card.tags.slice(0, 4).map((tag) => ( +
+ {recentTaskMap.get(String(card.id))?.meetingSource && ( + + {recentTaskMap.get(String(card.id))?.meetingSource === "ANDROID" ? "安卓端" : "Web端"} + + )} + {card.tags.slice(0, 4).map((tag) => ( {tag} diff --git a/frontend/src/pages/profile/index.tsx b/frontend/src/pages/profile/index.tsx index 62eaccd..657df28 100644 --- a/frontend/src/pages/profile/index.tsx +++ b/frontend/src/pages/profile/index.tsx @@ -4,7 +4,6 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api"; import PageHeader from "@/components/shared/PageHeader"; -import PageContainer from "@/components/shared/PageContainer"; import type { BotCredential, UserProfile } from "@/types"; import AvatarCropDialog, { type CropModalState } from "./AvatarCropDialog"; @@ -106,6 +105,10 @@ export default function Profile() { const values = await pwdForm.validateFields(); setSaving(true); await updateMyPassword(values); + const profile = await getCurrentUser(); + setUser(profile); + sessionStorage.setItem("userProfile", JSON.stringify(profile)); + window.dispatchEvent(new Event("user-profile-updated")); message.success(t("common.success")); pwdForm.resetFields(); } finally { @@ -129,11 +132,9 @@ export default function Profile() { const avatarUrl = avatarUrlValue?.trim() || undefined; return ( - +
+ +
@@ -307,8 +308,7 @@ export default function Profile() { onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))} onConfirm={handleUploadCroppedImage} /> - + ); } -