feat: 添加总结详细程度选项和优化用户认证逻辑
- 在 `MeetingCommandService` 中添加 `summaryDetailLevel` 参数 - 更新 `MeetingSummaryPromptAssembler` 以支持不同详细程度的总结 - 在前端 `useAuth` 钩子中添加 `profileVersion` 状态,同步用户资料更新 - 优化密码重置流程,保持登录状态并更新用户资料 - 在 `Meeting` 实体中添加 `summaryDetailLevel` 字段 - 更新相关服务和控制器以处理新的总结详细程度选项dev_na
parent
51190f330c
commit
f7480df565
|
|
@ -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() {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<MeetingCreateConfigVO> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String> hotWords;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "音频保存说明")
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ public class Meeting extends BaseEntity {
|
|||
|
||||
@Schema(description = "会议来源")
|
||||
private String meetingSource;
|
||||
@Schema(description = "总结详细程度")
|
||||
private String summaryDetailLevel;
|
||||
|
||||
@Schema(description = "音频保存状态")
|
||||
private String audioSaveStatus;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
resolveCreatorName(loginUser),
|
||||
loginUser.getUserId(),
|
||||
resolveCreatorName(loginUser),
|
||||
MeetingConstants.SUMMARY_DETAIL_STANDARD,
|
||||
0
|
||||
);
|
||||
meetingService.save(meeting);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, chapterModelId, promptId, userPrompt);
|
||||
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
|
||||
summaryModelId,
|
||||
chapterModelId,
|
||||
promptId,
|
||||
userPrompt,
|
||||
summaryDetailLevel
|
||||
);
|
||||
MeetingSummaryPromptContextVO context = new MeetingSummaryPromptContextVO();
|
||||
context.setPromptSchemaVersion(String.valueOf(taskConfig.get("promptSchemaVersion")));
|
||||
context.setSystemMessage(meetingSummaryPromptAssembler.buildSystemMessage(taskConfig));
|
||||
|
|
|
|||
|
|
@ -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<String, Object> 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<String, Object> buildTaskConfig(Long summaryModelId, Long chapterModelId, Long promptId, String userPrompt) {
|
||||
return buildTaskConfig(
|
||||
summaryModelId,
|
||||
chapterModelId,
|
||||
promptId,
|
||||
userPrompt,
|
||||
MeetingConstants.SUMMARY_DETAIL_STANDARD
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> buildTaskConfig(Long summaryModelId, Long chapterModelId, Long promptId,
|
||||
String userPrompt, String summaryDetailLevel) {
|
||||
Map<String, Object> 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(标准):保持信息完整和篇幅平衡,覆盖核心过程、结论和待办,不做过度展开。";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object> taskConfig = assembler.buildTaskConfig(2L, 3L, " 关注风险项 ");
|
||||
Map<String, Object> 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` 中返回章节列表"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<SysParamVO[]>;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,29 +84,54 @@ 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<MeetingCreateDrawerProps> = ({ open, initialType = 'upload', onCancel, onSuccess }) => {
|
||||
export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({
|
||||
open,
|
||||
initialType = "upload",
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [type, setType] = useState<MeetingCreateType>(initialType);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [configLoaded, setConfigLoaded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>({
|
||||
offlineEnabled: false,
|
||||
realtimeEnabled: false,
|
||||
offlineAudioMaxSizeMb: DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB,
|
||||
});
|
||||
|
||||
const [asrModels, setAsrModels] = useState<AiModelVO[]>([]);
|
||||
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
||||
|
|
@ -74,76 +139,83 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
|
||||
const [hotWordGroups, setHotWordGroups] = useState<HotWordGroupVO[]>([]);
|
||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||
const [hotWordGroupTouched, setHotWordGroupTouched] = useState(false);
|
||||
|
||||
const [audioUrl, setAudioUrl] = useState('');
|
||||
const [audioUrl, setAudioUrl] = useState("");
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [fileList, setFileList] = useState<any[]>([]);
|
||||
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);
|
||||
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);
|
||||
|
||||
setAsrModels(activeAsrModels);
|
||||
setLlmModels(activeLlmModels);
|
||||
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<MeetingCreateDrawerProps> = ({ 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<MeetingCreateDrawerProps> = ({ 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -247,70 +314,72 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
.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<MeetingCreateDrawerProps> = ({ open,
|
|||
|
||||
return (
|
||||
<Drawer
|
||||
title={null}
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
width={960}
|
||||
forceRender
|
||||
destroyOnClose={false}
|
||||
placement="right"
|
||||
closable={false}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right', padding: '16px 32px' }}>
|
||||
title={null}
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
width={960}
|
||||
forceRender
|
||||
destroyOnClose={false}
|
||||
placement="right"
|
||||
closable={false}
|
||||
footer={
|
||||
configLoaded ? (
|
||||
<div style={{ textAlign: "right", padding: "16px 32px" }}>
|
||||
<Space size={16}>
|
||||
<Button onClick={onCancel} size="large" style={{ borderRadius: 8, minWidth: 120 }}>取消</Button>
|
||||
<Button type="primary" onClick={handleOk} loading={submitting} size="large" style={{ borderRadius: 8, minWidth: 140, fontWeight: 500 }}>
|
||||
{type === 'upload' ? (audioUrl ? '开始分析' : '创建并上传') : '创建并进入识别'}
|
||||
{type === "upload" ? (audioUrl ? "完成并分析" : "上传并分析") : "创建并进入识别"}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
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)' }
|
||||
}}
|
||||
>
|
||||
<div style={{ background: 'var(--app-bg-surface)', padding: '24px 32px', borderBottom: '1px solid var(--app-border-color)' }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space size={16}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 12, background: 'var(--app-bg-surface-strong)', color: 'var(--app-text-main)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, border: '1px solid var(--app-border-color)' }}>
|
||||
{type === 'upload' ? <CloudUploadOutlined /> : <AudioOutlined />}
|
||||
</div>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0, fontWeight: 600 }}>{type === 'upload' ? '上传录音分析' : '创建实时会议'}</Title>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>{type === 'upload' ? '上传已有录音文件进行转写和总结' : '创建会议实时进行语音转写和内容分析'}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" size="large">
|
||||
<Radio.Button value="upload" style={{ padding: '0 24px' }}><CloudUploadOutlined style={{ marginRight: 6 }} /> 上传录音</Radio.Button>
|
||||
<Radio.Button value="realtime" style={{ padding: '0 24px' }}><AudioOutlined style={{ marginRight: 6 }} /> 实时识别</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
) : 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 ? (
|
||||
<div style={{ padding: "40px" }}>
|
||||
<Skeleton active paragraph={{ rows: 12 }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ background: "var(--app-bg-surface)", padding: "24px 32px", borderBottom: "1px solid var(--app-border-color)" }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space size={16}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 12, background: "var(--app-bg-surface-strong)", color: "var(--app-text-main)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24, border: "1px solid var(--app-border-color)" }}>
|
||||
{type === "upload" ? <CloudUploadOutlined /> : <AudioOutlined />}
|
||||
</div>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0, fontWeight: 600 }}>{type === "upload" ? "上传录音发起分析" : "创建实时会议"}</Title>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>{type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" size="large">
|
||||
{createConfig.offlineEnabled && (
|
||||
<Radio.Button value="upload" style={{ padding: "0 24px" }}><CloudUploadOutlined style={{ marginRight: 6 }} /> 上传录音</Radio.Button>
|
||||
)}
|
||||
{createConfig.realtimeEnabled && (
|
||||
<Radio.Button value="realtime" style={{ padding: "0 24px" }}><AudioOutlined style={{ marginRight: 6 }} /> 实时会议</Radio.Button>
|
||||
)}
|
||||
</Radio.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '32px 40px', flex: 1, overflowY: 'auto', background: 'var(--app-bg-layout)' }}>
|
||||
<Form form={form} layout="vertical" disabled={loading}>
|
||||
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>基础信息</Title>
|
||||
</div>
|
||||
<div style={{ padding: "32px 40px", flex: 1, overflowY: "auto", background: "var(--app-bg-layout)" }}>
|
||||
<Form form={form} layout="vertical" disabled={loading}>
|
||||
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>基础信息</Title>
|
||||
</div>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
|
||||
<Input placeholder="输入会议标题" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
|
||||
<Input placeholder="输入会议标题" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]}>
|
||||
<DatePicker showTime style={{ width: '100%' }} size="large" />
|
||||
<DatePicker showTime style={{ width: "100%" }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -387,14 +468,14 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="participants" label="参会人员">
|
||||
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children" size="large">
|
||||
<Select mode="multiple" placeholder="选择参会人员" showSearch optionFilterProp="children" size="large">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="hostUserId" label="会议主持人">
|
||||
<Select allowClear placeholder="不选择则默认为创建人" showSearch optionFilterProp="children" size="large">
|
||||
<Select allowClear placeholder="默认为创建人" showSearch optionFilterProp="children" size="large">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
|
@ -409,21 +490,21 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ margin: '32px 0', borderTop: '1px solid var(--app-border-color)' }} />
|
||||
<div style={{ margin: "32px 0", borderTop: "1px solid var(--app-border-color)" }} />
|
||||
|
||||
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>AI 模型与分析配置</Title>
|
||||
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>模型与纪要配置</Title>
|
||||
</div>
|
||||
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="asrModelId" label="语音识别 (ASR)" rules={[{ required: true }]}>
|
||||
<Form.Item name="asrModelId" label="语音识别模型 (ASR)" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择 ASR 模型" size="large">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="summaryModelId" label="内容总结 (LLM)" rules={[{ required: true }]}>
|
||||
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择总结模型" size="large">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
|
@ -431,19 +512,19 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
|
||||
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]}>
|
||||
{prompts.length > 15 ? (
|
||||
<Select placeholder="请选择模板" showSearch optionFilterProp="children" size="large">
|
||||
<Select placeholder="请选择总结模板" showSearch optionFilterProp="children" size="large">
|
||||
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
|
||||
</Select>
|
||||
) : (
|
||||
<div style={{ padding: '2px' }}>
|
||||
<div style={{ padding: "2px" }}>
|
||||
<Row gutter={[12, 12]}>
|
||||
{prompts.map(p => {
|
||||
const isSelected = watchedPromptId === p.id;
|
||||
return (
|
||||
<Col span={8} key={p.id}>
|
||||
<div onClick={() => 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%' }}>
|
||||
<div style={{ fontSize: '14px', color: isSelected ? '#1890ff' : 'var(--app-text-main)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSelected ? 500 : 400 }}>{p.templateName}</div>
|
||||
{isSelected && <div style={{ position: 'absolute', top: -1, right: -1, width: 20, height: 20, background: '#1890ff', borderRadius: '0 8px 0 8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CheckOutlined style={{ color: '#fff', fontSize: 12 }} /></div>}
|
||||
<div onClick={() => 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%" }}>
|
||||
<div style={{ fontSize: "14px", color: isSelected ? "#1890ff" : "var(--app-text-main)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: isSelected ? 500 : 400 }}>{p.templateName}</div>
|
||||
{isSelected && <div style={{ position: "absolute", top: -1, right: -1, width: 20, height: 20, background: "#1890ff", borderRadius: "0 8px 0 8px", display: "flex", alignItems: "center", justifyContent: "center" }}><CheckOutlined style={{ color: "#fff", fontSize: 12 }} /></div>}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
|
|
@ -453,83 +534,35 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
)}
|
||||
</Form.Item>
|
||||
|
||||
{/*
|
||||
<Form.Item
|
||||
name="hotWordGroupId"
|
||||
label="热词组"
|
||||
tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : '模板未绑定热词组时可手动选择'}
|
||||
extra={watchedHotWordGroupId != null ? '创建会议时会优先使用这里选中的热词组' : undefined}
|
||||
>
|
||||
<Select
|
||||
placeholder={selectedPrompt?.hotWordGroupId ? '默认已带出模板热词组,可按需修改' : '请选择热词组'}
|
||||
size="large"
|
||||
options={[
|
||||
{ label: '不使用热词组', value: 0 },
|
||||
...hotWordGroups.map((item) => ({
|
||||
label: `${item.groupName} (${item.hotWordCount}/200)`,
|
||||
value: item.id,
|
||||
})),
|
||||
]}
|
||||
onChange={() => setHotWordGroupTouched(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
*/}
|
||||
|
||||
{/*
|
||||
|
||||
<Form.Item
|
||||
name="hotWordGroupId"
|
||||
label="热词组"
|
||||
tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : '模板未绑定热词组时可手动选择'}
|
||||
extra={watchedHotWordGroupId != null ? '创建会议时会优先使用这里选中的热词组' : undefined}
|
||||
>
|
||||
<Select
|
||||
placeholder={selectedPrompt?.hotWordGroupId ? '默认已带出模板热词组,可按需修改' : '请选择热词组'}
|
||||
size="large"
|
||||
options={[
|
||||
{ label: '不使用热词组', value: 0 },
|
||||
...hotWordGroups.map((item) => ({
|
||||
label: `${item.groupName} (${item.hotWordCount}/200)`,
|
||||
value: item.id,
|
||||
})),
|
||||
]}
|
||||
onChange={() => setHotWordGroupTouched(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
*/}
|
||||
|
||||
<Form.Item
|
||||
name="hotWordGroupId"
|
||||
label={"\u70ed\u8bcd\u7ec4"}
|
||||
tooltip={selectedPrompt?.hotWordGroupName ? `\u9ed8\u8ba4\u8ddf\u968f\u6a21\u677f\uff1a${selectedPrompt.hotWordGroupName}` : '\u6a21\u677f\u672a\u7ed1\u5b9a\u70ed\u8bcd\u7ec4\u65f6\u53ef\u624b\u52a8\u9009\u62e9'}
|
||||
extra={watchedHotWordGroupId != null ? '\u521b\u5efa\u4f1a\u8bae\u65f6\u4f1a\u4f18\u5148\u4f7f\u7528\u8fd9\u91cc\u9009\u4e2d\u7684\u70ed\u8bcd\u7ec4' : undefined}
|
||||
>
|
||||
<Select
|
||||
placeholder={selectedPrompt?.hotWordGroupId ? '\u9ed8\u8ba4\u5df2\u5e26\u51fa\u6a21\u677f\u70ed\u8bcd\u7ec4\uff0c\u53ef\u6309\u9700\u4fee\u6539' : '\u8bf7\u9009\u62e9\u70ed\u8bcd\u7ec4'}
|
||||
size="large"
|
||||
options={[
|
||||
{ label: '\u4e0d\u4f7f\u7528\u70ed\u8bcd\u7ec4', value: 0 },
|
||||
...hotWordGroups.map((item) => ({
|
||||
label: `${item.groupName} (${item.hotWordCount}/200)`,
|
||||
value: item.id,
|
||||
})),
|
||||
]}
|
||||
onChange={() => setHotWordGroupTouched(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Row gutter={32}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="hotWordGroupId" label="热词组" tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : "模板未绑定热词组时可手动选择"} extra={watchedHotWordGroupId != null ? "创建会议时会优先使用这里选中的热词组" : undefined}>
|
||||
<Select placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"} size="large" options={[{ label: "不使用热词组", value: 0 }, ...hotWordGroups.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="summaryDetailLevel" label="总结详细程度" rules={[{ required: true }]}>
|
||||
<Radio.Group size="large">
|
||||
<Radio value="DETAILED">详细</Radio>
|
||||
<Radio value="STANDARD">标准</Radio>
|
||||
<Radio value="BRIEF">简洁</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition="end"
|
||||
style={{ marginBottom: 24, background: 'var(--app-bg-surface)', border: '1px solid var(--app-border-color)', borderRadius: 8, overflow: 'hidden' }}
|
||||
style={{ marginBottom: 24, background: "var(--app-bg-surface)", border: "1px solid var(--app-border-color)", borderRadius: 8, overflow: "hidden" }}
|
||||
items={[
|
||||
{
|
||||
key: 'advanced',
|
||||
key: "advanced",
|
||||
forceRender: true,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', height: '32px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', fontWeight: 600, color: 'var(--app-text-main)', fontSize: 15 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: '#f0f5ff', color: '#1677ff', display: 'flex', alignItems: 'center', justifyContent: 'center', marginRight: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", width: "100%", height: "32px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", fontWeight: 600, color: "var(--app-text-main)", fontSize: 15 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: "#f0f5ff", color: "#1677ff", display: "flex", alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||
<SettingOutlined style={{ fontSize: 16 }} />
|
||||
</div>
|
||||
高级设置
|
||||
|
|
@ -537,40 +570,38 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
</div>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 8, borderTop: '1px dashed var(--app-border-color)' }}>
|
||||
<div style={{ paddingTop: 8, borderTop: "1px dashed var(--app-border-color)" }}>
|
||||
<Row gutter={32}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="useSpkId" label={<span>声纹区分 <Tooltip title="开启后将尝试区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}>
|
||||
<Form.Item name="useSpkId" label={<span>说话人区分 <Tooltip title="自动识别录音中的不同发言者并进行角色分离"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="enableTextRefine" label={<span>文本修正 <Tooltip title="开启后将尝试对识别文本进行修正"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked">
|
||||
<Form.Item name="enableTextRefine" label={<span>文本修正 <Tooltip title="自动识别并修正语音转写中的口语词、语气助词等"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{type === 'realtime' && (
|
||||
<Col span={8}>
|
||||
<Form.Item name="mode" label="识别模式">
|
||||
<Select size="large">
|
||||
<Option value="2pass">2pass (流式+离线修正)</Option>
|
||||
<Option value="online">online (纯流式)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{type === "realtime" && (
|
||||
<>
|
||||
<Col span={8}>
|
||||
<Form.Item name="saveAudio" label="保存录音" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="mode" label="识别模式">
|
||||
<Radio.Group>
|
||||
<Radio value="2pass">2pass(流式 + 离线修正)</Radio>
|
||||
<Radio value="online">online(纯流式)</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
<Form.Item
|
||||
name="userPrompt"
|
||||
label="用户提示词"
|
||||
extra="可选,用于补充本次会议总结的关注重点、表达偏好或输出侧重点"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="例如:请重点关注待办事项、风险点,并用适合汇报的表达方式输出"
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
showCount
|
||||
maxLength={1000}
|
||||
/>
|
||||
<Form.Item name="userPrompt" label="用户提示词" extra="提供补充信息以指导 AI 在生成会议总结时更侧重于特定方面或遵循特定风格">
|
||||
<Input.TextArea placeholder="请输入额外说明,例如:请重点关注会议中的待办事项、以摘要形式输出、或者采用严谨的公文风格等" autoSize={{ minRows: 3, maxRows: 6 }} showCount maxLength={1000} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
|
|
@ -578,20 +609,11 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
]}
|
||||
/>
|
||||
|
||||
{type === 'realtime' && (
|
||||
{type === "upload" && (
|
||||
<>
|
||||
<Form.Item name="language" hidden><Input /></Form.Item>
|
||||
<Form.Item name="enablePunctuation" hidden valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item name="enableItn" hidden valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item name="saveAudio" hidden valuePropName="checked"><Switch /></Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'upload' && (
|
||||
<>
|
||||
<div style={{ margin: '32px 0', borderTop: '1px solid var(--app-border-color)' }} />
|
||||
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
|
||||
<div style={{ margin: "32px 0", borderTop: "1px solid var(--app-border-color)" }} />
|
||||
<div style={{ marginBottom: 24, display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 4, height: 16, background: "#1890ff", borderRadius: 2, marginRight: 8 }} />
|
||||
<Title level={5} style={{ margin: 0 }}>上传录音文件</Title>
|
||||
</div>
|
||||
<Dragger
|
||||
|
|
@ -601,25 +623,22 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
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)' }}
|
||||
style={{ borderRadius: 12, padding: "32px 0", background: "var(--app-bg-surface)", border: "1px dashed var(--app-border-color)" }}
|
||||
>
|
||||
<div>
|
||||
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: '#1890ff' }} /></p>
|
||||
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: 'var(--app-text-main)' }}>点击或拖拽录音文件到此处</p>
|
||||
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: 'var(--app-text-secondary)' }}>支持 .mp3、.wav,以及 AAC 编码的 .m4a 音频</p>
|
||||
<p className="ant-upload-hint" style={{ fontSize: 13, marginTop: 8, color: 'var(--app-text-secondary)' }}>文件大小不超过 {offlineAudioMaxSizeMb}MB,取值来自系统参数配置</p>
|
||||
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: "#1890ff" }} /></p>
|
||||
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: "var(--app-text-main)" }}>点击或拖拽录音文件到此处</p>
|
||||
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: "var(--app-text-secondary)" }}>支持 mp3、wav、m4a 等格式,大小不超过 {createConfig.offlineAudioMaxSizeMb}MB</p>
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div style={{ width: '60%', margin: '32px auto 0' }}>
|
||||
<div style={{ width: "60%", margin: "32px auto 0" }}>
|
||||
<Progress percent={uploadProgress} size="small" />
|
||||
<div style={{ fontSize: 13, color: '#1890ff', marginTop: 8 }}>文件传输中,请稍候...</div>
|
||||
<div style={{ fontSize: 13, color: "#1890ff", marginTop: 8 }}>文件上传中,请稍候...</div>
|
||||
</div>
|
||||
)}
|
||||
{audioUrl && (
|
||||
<Tag color="processing" style={{ marginTop: 24, padding: '6px 16px', fontSize: 14, borderRadius: 6, maxWidth: '90%', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<span style={{ flexShrink: 0 }}>就绪:</span>
|
||||
<span style={{ marginLeft: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{audioUrl.split('/').pop()}
|
||||
</span>
|
||||
<Tag color="processing" style={{ marginTop: 24, padding: "6px 16px", fontSize: 14, borderRadius: 6, maxWidth: "90%", display: "inline-flex", alignItems: "center" }}>
|
||||
<span>已上传:</span>
|
||||
<span style={{ marginLeft: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{audioUrl.split("/").pop()}</span>
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -628,6 +647,8 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,17 +3,23 @@ import { UserProfile } from "../types";
|
|||
|
||||
export function useAuth() {
|
||||
const [accessToken, setAccessToken] = useState<string | null>(() => 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<UserProfile | null>(() => {
|
||||
const data = sessionStorage.getItem("userProfile");
|
||||
return data ? JSON.parse(data) : null;
|
||||
}, [accessToken]);
|
||||
}, [accessToken, profileVersion]);
|
||||
|
||||
const isAuthed = !!accessToken;
|
||||
const logout = () => {
|
||||
|
|
|
|||
|
|
@ -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<number[]>([]);
|
||||
const [scopeOrgTree, setScopeOrgTree] = useState<DataNode[]>([]);
|
||||
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 (
|
||||
<PageContainer
|
||||
title="角色管理"
|
||||
subtitle="维护角色基础信息、功能权限、数据权限与成员绑定"
|
||||
headerExtra={
|
||||
can("sys:role:create") && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增角色
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Row gutter={24} className="roles-layout__row" style={{ flex: 1, minHeight: 0 }}>
|
||||
<Col span={7} className="roles-layout__side">
|
||||
<Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} bordered={false} className="app-page__panel-card roles-side-card">
|
||||
<div className="role-search-panel">
|
||||
{isPlatformMode && (
|
||||
<Select
|
||||
placeholder="按租户筛选"
|
||||
style={{ width: "100%" }}
|
||||
allowClear
|
||||
suffixIcon={<FilterOutlined />}
|
||||
value={filterTenantId}
|
||||
onChange={(value) => { setFilterTenantId(normalizeNumber(value)); setRolePage((prev) => ({ ...prev, current: 1 })); }}
|
||||
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
|
||||
/>
|
||||
)}
|
||||
<div className="role-search-bar">
|
||||
<Input placeholder="输入角色名称或编码搜索" prefix={<SearchOutlined style={{ color: "#94a3b8" }} />} value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear />
|
||||
<Button type="default" onClick={() => { setSearchText(""); setFilterTenantId(undefined); setRolePage((prev) => ({ ...prev, current: 1 })); }}>{"重置"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-page roles-page-v2">
|
||||
<PageHeader title="角色管理" subtitle="维护角色基础信息、功能权限、数据权限与成员绑定" />
|
||||
|
||||
<div className="role-list-container-v3">
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="暂无角色数据" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
renderItem={(item) => (
|
||||
<div key={item.roleId} className={`role-item-card-v3 ${selectedRole?.roleId === item.roleId ? "active" : ""}`} onClick={() => void selectRole(item)}>
|
||||
<div className="role-item-symbol" aria-hidden="true">
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div className="role-item-main">
|
||||
<div className="role-item-name-row">
|
||||
<Text strong className="role-name">{item.roleName}</Text>
|
||||
{isPlatformMode && <Tag color="blue" style={{ fontSize: 10, scale: "0.8", margin: "0 0 0 4px", borderRadius: "10px" }}>{item.tenantId === 0 ? "平台租户" : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `租户:${item.tenantId}`}</Tag>}
|
||||
{item.status === 0 && <Tag color="error" style={{ fontSize: 10, scale: "0.8", margin: 0 }}>{"停用"}</Tag>}
|
||||
</div>
|
||||
<Text type="secondary" className="role-code">{item.roleCode}</Text>
|
||||
</div>
|
||||
{selectedRole?.roleId === item.roleId ? (
|
||||
<div className="role-item-selected-mark" aria-hidden="true">
|
||||
<CheckCircleFilled />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="role-item-actions">
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={(event) => openEditBasic(event, item)} />
|
||||
</Tooltip>
|
||||
{item.roleCode !== "ADMIN" && (
|
||||
<Popconfirm title="确定删除该角色吗?" okText="确定" cancelText="取消" onConfirm={(event) => void handleRemove(event!, item.roleId)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(event) => event.stopPropagation()} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-page__page-actions">
|
||||
{can("sys:role:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{"新增角色"}</Button>}
|
||||
</div>
|
||||
|
||||
<div className="roles-layout">
|
||||
<Row gutter={24} className="roles-layout__row">
|
||||
<Col span={7} className="roles-layout__side">
|
||||
<Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} bordered={false} className="app-page__panel-card roles-side-card">
|
||||
<div className="role-search-panel">
|
||||
{isPlatformMode && (
|
||||
<Select
|
||||
placeholder="按租户筛选"
|
||||
style={{ width: "100%" }}
|
||||
allowClear
|
||||
suffixIcon={<FilterOutlined />}
|
||||
value={filterTenantId}
|
||||
onChange={(value) => { setFilterTenantId(normalizeNumber(value)); setRolePage((prev) => ({ ...prev, current: 1 })); }}
|
||||
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="role-list-pagination">
|
||||
<Pagination
|
||||
{...getStandardPagination(rolePage.total, rolePage.current, rolePage.size, handleRolePageChange, { size: "small", showSizeChanger: true, pageSizeOptions: ["10", "20", "50"] })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<div className="role-search-bar">
|
||||
<Input placeholder="输入角色名称或编码搜索" prefix={<SearchOutlined style={{ color: "#94a3b8" }} />} value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear />
|
||||
<Button type="default" onClick={() => { setSearchText(""); setFilterTenantId(undefined); setRolePage((prev) => ({ ...prev, current: 1 })); }}>{"重置"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Col span={17} className="roles-layout__detail">
|
||||
{selectedRole ? (
|
||||
<Card
|
||||
className="app-page__panel-card roles-detail-card"
|
||||
bordered={false}
|
||||
title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>}
|
||||
extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handlePrimarySave} disabled={saveDisabled} style={{ borderRadius: "6px" }}>{saveLabel}</Button>}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as RoleTabKey)} className="role-detail-tabs">
|
||||
<Tabs.TabPane tab={<Space><KeyOutlined />{"功能权限"}</Space>} key="permissions">
|
||||
<div className="role-detail-pane">
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
checkStrictly={false}
|
||||
treeData={permissionTreeData}
|
||||
checkedKeys={selectedPermIds}
|
||||
onCheck={(keys, info) => {
|
||||
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
|
||||
/>
|
||||
<div className="role-list-container-v3">
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="暂无角色数据" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
renderItem={(item) => (
|
||||
<div key={item.roleId} className={`role-item-card-v3 ${selectedRole?.roleId === item.roleId ? "active" : ""}`} onClick={() => void selectRole(item)}>
|
||||
<div className="role-item-symbol" aria-hidden="true">
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div className="role-item-main">
|
||||
<div className="role-item-name-row">
|
||||
<Text strong className="role-name">{item.roleName}</Text>
|
||||
{isPlatformMode && <Tag color="blue" style={{ fontSize: 10, scale: "0.8", margin: "0 0 0 4px", borderRadius: "10px" }}>{item.tenantId === 0 ? "平台租户" : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `租户:${item.tenantId}`}</Tag>}
|
||||
{item.status === 0 && <Tag color="error" style={{ fontSize: 10, scale: "0.8", margin: 0 }}>{"停用"}</Tag>}
|
||||
</div>
|
||||
<Text type="secondary" className="role-code">{item.roleCode}</Text>
|
||||
</div>
|
||||
{selectedRole?.roleId === item.roleId ? (
|
||||
<div className="role-item-selected-mark" aria-hidden="true">
|
||||
<CheckCircleFilled />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="role-item-actions">
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={(event) => openEditBasic(event, item)} />
|
||||
</Tooltip>
|
||||
{item.roleCode !== "ADMIN" && (
|
||||
<Popconfirm title="确定删除该角色吗?" okText="确定" cancelText="取消" onConfirm={(event) => void handleRemove(event!, item.roleId)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(event) => event.stopPropagation()} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
|
||||
<div className="role-detail-pane">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group value={dataScopeType} onChange={(event) => setDataScopeType(event.target.value)} optionType="button" buttonStyle="solid">
|
||||
{DATA_SCOPE_OPTIONS.map((item) => (
|
||||
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16, color: "#64748b" }}>{getDataScopeDescription(dataScopeType)}</div>
|
||||
{dataScopeType === "CUSTOM" ? (
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="role-list-pagination">
|
||||
<Pagination
|
||||
{...getStandardPagination(rolePage.total, rolePage.current, rolePage.size, handleRolePageChange, { size: "small", showSizeChanger: true, pageSizeOptions: ["10", "20", "50"] })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={17} className="roles-layout__detail">
|
||||
{selectedRole ? (
|
||||
<Card
|
||||
className="app-page__panel-card roles-detail-card"
|
||||
bordered={false}
|
||||
title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>}
|
||||
extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handlePrimarySave} disabled={saveDisabled} style={{ borderRadius: "6px" }}>{saveLabel}</Button>}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as RoleTabKey)} className="role-detail-tabs">
|
||||
<Tabs.TabPane tab={<Space><KeyOutlined />{"功能权限"}</Space>} key="permissions">
|
||||
<div className="role-detail-pane">
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
treeData={scopeOrgTree}
|
||||
checkedKeys={scopeOrgIds}
|
||||
onCheck={(keys) => {
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
|
||||
)}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>} key="users">
|
||||
<div className="role-detail-pane">
|
||||
<div className="role-members-toolbar">
|
||||
<Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
|
||||
<Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{"绑定用户"}</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
loading={loadingUsers}
|
||||
dataSource={roleUsers}
|
||||
pagination={{ ...getStandardPagination(roleUsers.length, 1, 10, undefined, { size: "small", showSizeChanger: false }), current: undefined }}
|
||||
columns={[
|
||||
{
|
||||
title: "用户信息",
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{user.displayName}</div>
|
||||
<div style={{ fontSize: 11, color: "#bfbfbf" }}>@{user.username}</div>
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{ title: "手机号", dataIndex: "phone", className: "tabular-nums" },
|
||||
{ title: "状态", dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? "启用" : "停用"}</Tag> },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 80,
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Popconfirm title="确定解除该用户绑定吗?" okText="确定" cancelText="取消" onConfirm={() => void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} />
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
|
||||
<div className="role-detail-pane">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={dataScopeType}
|
||||
onChange={(event) => {
|
||||
setDataScopeType(event.target.value);
|
||||
setDataScopeDirty(true);
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
{DATA_SCOPE_OPTIONS.map((item) => (
|
||||
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16, color: "#64748b" }}>{getDataScopeDescription(dataScopeType)}</div>
|
||||
{dataScopeType === "CUSTOM" ? (
|
||||
<div className="permission-tree-wrapper">
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
treeData={scopeOrgTree}
|
||||
checkedKeys={scopeOrgIds}
|
||||
onCheck={(keys) => {
|
||||
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||
setScopeOrgIds(checked.map((key) => Number(key)));
|
||||
setDataScopeDirty(true);
|
||||
}}
|
||||
defaultExpandAll
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
|
||||
)}
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>} key="users">
|
||||
<div className="role-detail-pane">
|
||||
<div className="role-members-toolbar">
|
||||
<Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
|
||||
<Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{"绑定用户"}</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
loading={loadingUsers}
|
||||
dataSource={roleUsers}
|
||||
pagination={{ ...getStandardPagination(roleUsers.length, 1, 10, undefined, { size: "small", showSizeChanger: false }), current: undefined }}
|
||||
columns={[
|
||||
{
|
||||
title: "用户信息",
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{user.displayName}</div>
|
||||
<div style={{ fontSize: 11, color: "#bfbfbf" }}>@{user.username}</div>
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{ title: "手机号", dataIndex: "phone", className: "tabular-nums" },
|
||||
{ title: "状态", dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? "启用" : "停用"}</Tag> },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 80,
|
||||
render: (_: unknown, user: SysUser) => (
|
||||
<Popconfirm title="确定解除该用户绑定吗?" okText="确定" cancelText="取消" onConfirm={() => void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} />
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnClose>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
|
|
@ -636,6 +643,6 @@ export default function Roles() {
|
|||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Button, Card, Form, Input, Layout, Typography, message } from "antd";
|
|||
import { LockOutlined, LogoutOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { updateMyPassword } from "@/api";
|
||||
import { getCurrentUser, updateMyPassword } from "@/api";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
|
|
@ -18,9 +18,14 @@ export default function ResetPassword() {
|
|||
const [form] = Form.useForm<ResetPasswordFormValues>();
|
||||
|
||||
const goToLogin = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
navigate("/login");
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
localStorage.removeItem("displayName");
|
||||
localStorage.removeItem("username");
|
||||
localStorage.removeItem("availableTenants");
|
||||
localStorage.removeItem("activeTenantId");
|
||||
sessionStorage.removeItem("userProfile");
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
const onFinish = async (values: ResetPasswordFormValues) => {
|
||||
|
|
@ -30,8 +35,11 @@ export default function ResetPassword() {
|
|||
oldPassword: values.oldPassword,
|
||||
newPassword: values.newPassword
|
||||
});
|
||||
message.success("密码已更新,请重新登录");
|
||||
goToLogin();
|
||||
const profile = await getCurrentUser();
|
||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||
window.dispatchEvent(new Event("user-profile-updated"));
|
||||
message.success("密码已更新");
|
||||
navigate("/", { replace: true });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -45,7 +53,7 @@ export default function ResetPassword() {
|
|||
<Title level={3} style={{ marginTop: 16 }}>
|
||||
首次登录请修改密码
|
||||
</Title>
|
||||
<Text type="secondary">当前账号被要求更新初始密码,提交成功后会跳转到登录页。</Text>
|
||||
<Text type="secondary">当前账号需要先更新初始密码,提交成功后会直接进入系统。</Text>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
|
|
@ -77,7 +85,7 @@ export default function ResetPassword() {
|
|||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" block size="large" loading={loading} style={{ marginTop: 8 }}>
|
||||
提交并重新登录
|
||||
提交并保持登录
|
||||
</Button>
|
||||
|
||||
<Button type="link" block icon={<LogoutOutlined />} onClick={goToLogin} style={{ marginTop: 8 }}>
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ const AiModels: React.FC = () => {
|
|||
isDefaultChecked: false,
|
||||
statusChecked: true,
|
||||
sortOrder: 0,
|
||||
temperature: 0.7,
|
||||
temperature: 0.2,
|
||||
topP: 0.9,
|
||||
apiPath: "/v1/chat/completions",
|
||||
svThreshold: 0.45,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Alert, Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd';
|
||||
import { Alert, Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Radio, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd';
|
||||
import {
|
||||
AudioOutlined,
|
||||
CaretRightFilled,
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
CheckCircleFilled,
|
||||
FilePdfOutlined,
|
||||
FileWordOutlined,
|
||||
ShareAltOutlined,
|
||||
ShareAltOutlined, InfoCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
getTranscripts,
|
||||
MeetingChapterVO,
|
||||
MeetingProgress,
|
||||
SummaryDetailLevel,
|
||||
MeetingTranscriptVO,
|
||||
MeetingVO,
|
||||
reSummary,
|
||||
|
|
@ -52,7 +53,7 @@ import { listUsers } from '../../api';
|
|||
import { useDict } from '../../hooks/useDict';
|
||||
import { SysUser } from '../../types';
|
||||
import PageHeader from '../../components/shared/PageHeader';
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import PageContainer from "../../components/shared/PageContainer";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
|
@ -615,7 +616,7 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
|||
highlightKeyword = '',
|
||||
}) => {
|
||||
const [draftValue, setDraftValue] = useState(item.content);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
|
|
@ -751,7 +752,7 @@ const MeetingDetail: React.FC = () => {
|
|||
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
|
||||
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
||||
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
||||
const [, setUserList] = useState<SysUser[]>([]);
|
||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||
const { items: speakerLabels } = useDict('biz_speaker_label');
|
||||
const [sharePopoverOpen, setSharePopoverOpen] = useState(false);
|
||||
const [shareSaving, setShareSaving] = useState(false);
|
||||
|
|
@ -839,24 +840,24 @@ const MeetingDetail: React.FC = () => {
|
|||
);
|
||||
const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => {
|
||||
const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index]));
|
||||
const sourceChapters = meetingChapters.length
|
||||
const sourceChapters: MeetingChapterVO[] = meetingChapters.length
|
||||
? meetingChapters
|
||||
: analysis.chapters.map((item) => ({
|
||||
title: item.title,
|
||||
time: item.time,
|
||||
}));
|
||||
} as MeetingChapterVO));
|
||||
|
||||
return sourceChapters.map((chapter, index) => {
|
||||
let matchedTranscripts: MeetingTranscriptVO[] = [];
|
||||
const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds)
|
||||
? chapter.sourceTranscriptIds
|
||||
.map((item) => Number(item))
|
||||
.filter((item) => Number.isFinite(item) && transcriptIdToIndex.has(item))
|
||||
.map((item: number) => Number(item))
|
||||
.filter((item: number) => Number.isFinite(item) && transcriptIdToIndex.has(item))
|
||||
: [];
|
||||
|
||||
if (sourceTranscriptIds.length) {
|
||||
matchedTranscripts = sourceTranscriptIds
|
||||
.map((item) => transcripts[transcriptIdToIndex.get(item)!])
|
||||
.map((item: number) => transcripts[transcriptIdToIndex.get(item)!])
|
||||
.filter(Boolean);
|
||||
} else if (chapter.startTranscriptId && chapter.endTranscriptId) {
|
||||
const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId));
|
||||
|
|
@ -868,7 +869,7 @@ const MeetingDetail: React.FC = () => {
|
|||
const startMs = typeof chapter.startTime === 'number' ? chapter.startTime : parseChapterTimeToMs(chapter.time);
|
||||
const nextChapterStartMs = sourceChapters
|
||||
.slice(index + 1)
|
||||
.map((item) => (typeof item.startTime === 'number' ? item.startTime : parseChapterTimeToMs(item.time)))
|
||||
.map((item: MeetingChapterVO) => (typeof item.startTime === 'number' ? item.startTime : parseChapterTimeToMs(item.time)))
|
||||
.find((item): item is number => item !== null && startMs !== null && item > startMs);
|
||||
|
||||
if (startMs !== null) {
|
||||
|
|
@ -946,7 +947,12 @@ const MeetingDetail: React.FC = () => {
|
|||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
return {
|
||||
key: 'general-failure',
|
||||
title: '会议处理异常',
|
||||
description: '会议在处理过程中遇到了问题。您可以尝试重新发起识别或总结。',
|
||||
hasFallbackContent,
|
||||
};
|
||||
}, [meeting, meetingChapters.length]);
|
||||
const emptyTranscriptFailureNotice = useMemo(() => {
|
||||
if (!meeting || meeting.status !== 4 || transcripts.length > 0) {
|
||||
|
|
@ -962,24 +968,6 @@ const MeetingDetail: React.FC = () => {
|
|||
};
|
||||
}, [canRetryTranscription, meeting, transcripts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!generationFailureNotice) {
|
||||
return;
|
||||
}
|
||||
const acknowledgedKey = `meeting-failure-ack:${generationFailureNotice.key}`;
|
||||
if (sessionStorage.getItem(acknowledgedKey) === '1') {
|
||||
return;
|
||||
}
|
||||
Modal.warning({
|
||||
title: generationFailureNotice.title,
|
||||
content: generationFailureNotice.description,
|
||||
okText: '我知道了',
|
||||
onOk: () => {
|
||||
sessionStorage.setItem(acknowledgedKey, '1');
|
||||
},
|
||||
});
|
||||
}, [generationFailureNotice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playbackAudioUrl) {
|
||||
setShowFloatingTranscriptPlayer(false);
|
||||
|
|
@ -1174,6 +1162,7 @@ const MeetingDetail: React.FC = () => {
|
|||
summaryModelId: values.summaryModelId,
|
||||
promptId: values.promptId,
|
||||
userPrompt: values.userPrompt,
|
||||
summaryDetailLevel: values.summaryDetailLevel as SummaryDetailLevel,
|
||||
});
|
||||
message.success('已重新发起总结任务');
|
||||
setSummaryVisible(false);
|
||||
|
|
@ -1193,6 +1182,10 @@ const MeetingDetail: React.FC = () => {
|
|||
llmModels[0]?.id,
|
||||
promptId: summaryForm.getFieldValue('promptId') ?? prompts[0]?.id,
|
||||
userPrompt: meeting?.lastUserPrompt ?? '',
|
||||
summaryDetailLevel:
|
||||
summaryForm.getFieldValue('summaryDetailLevel') ??
|
||||
meeting?.summaryDetailLevel ??
|
||||
'STANDARD',
|
||||
});
|
||||
setSummaryVisible(true);
|
||||
};
|
||||
|
|
@ -1860,50 +1853,35 @@ const MeetingDetail: React.FC = () => {
|
|||
<Row gutter={24} style={{ height: '100%' }}>
|
||||
<Col xs={24} xl={13} style={{ height: '100%' }}>
|
||||
<div className="detail-side-column detail-left-column">
|
||||
{(generationFailureNotice || emptyTranscriptFailureNotice || meeting.audioSaveStatus === 'FAILED') && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16, borderRadius: 12 }}
|
||||
message={
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{generationFailureNotice?.title || emptyTranscriptFailureNotice?.title || '会议处理提醒'}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div style={{ fontSize: 13, marginTop: 4 }}>
|
||||
<div>{generationFailureNotice?.description || emptyTranscriptFailureNotice?.description}</div>
|
||||
{meeting.audioSaveStatus === 'FAILED' && (
|
||||
<div style={{ marginTop: 4, color: '#d48806' }}>
|
||||
<InfoCircleOutlined style={{ marginRight: 4 }} />
|
||||
{meeting.audioSaveMessage || '录音保存失败,当前无法播放'}
|
||||
</div>
|
||||
)}
|
||||
{canRetryTranscription && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{emptyTranscriptFailureNotice?.hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Card className="left-flow-card summary-panel" variant="borderless">
|
||||
<div className="summary-head">
|
||||
<div className="summary-title">
|
||||
<RobotOutlined style={{ color: '#5f51ff', fontSize: 20 }} />
|
||||
<span>AI 智能总结</span>
|
||||
</div>
|
||||
<div className="summary-head-actions">
|
||||
{meeting.summaryContent && (
|
||||
<span className="summary-head-link summary-head-link--static">
|
||||
<ClockCircleOutlined /> 总结记录
|
||||
</span>
|
||||
)}
|
||||
{meeting.summaryContent && isOwner && (
|
||||
<span
|
||||
className="summary-head-link"
|
||||
onClick={() => {
|
||||
if (isEditingSummary) {
|
||||
handleSaveSummary();
|
||||
} else {
|
||||
setSummaryDraft(meeting.summaryContent || '');
|
||||
setIsEditingSummary(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditingSummary ? <><CheckCircleFilled /> 保存</> : <><EditOutlined /> 编辑</>}
|
||||
</span>
|
||||
)}
|
||||
{isEditingSummary && (
|
||||
<span className="summary-head-link" onClick={() => setIsEditingSummary(false)}>
|
||||
取消
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generationFailureNotice && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={generationFailureNotice.title}
|
||||
description={generationFailureNotice.description}
|
||||
/>
|
||||
)}
|
||||
|
||||
{meeting.status === 2 ? (
|
||||
<div className="summary-progress-shell">
|
||||
|
|
@ -2029,14 +2007,6 @@ const MeetingDetail: React.FC = () => {
|
|||
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
|
||||
</div>
|
||||
)}
|
||||
{meeting.audioSaveStatus === 'FAILED' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
|
||||
/>
|
||||
)}
|
||||
<List
|
||||
className="transcript-list"
|
||||
dataSource={transcripts}
|
||||
|
|
@ -2160,14 +2130,6 @@ const MeetingDetail: React.FC = () => {
|
|||
<div className="empty-transcript-inline-note__text">{emptyTranscriptFailureNotice.description}</div>
|
||||
</div>
|
||||
)}
|
||||
{meeting.audioSaveStatus === 'FAILED' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ margin: '0 18px 16px' }}
|
||||
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
|
||||
/>
|
||||
)}
|
||||
<List
|
||||
className="transcript-list"
|
||||
dataSource={transcripts}
|
||||
|
|
@ -3560,7 +3522,18 @@ const MeetingDetail: React.FC = () => {
|
|||
<Form.Item name="tags" label="业务标签">
|
||||
<Select mode="tags" placeholder="输入标签后回车" />
|
||||
</Form.Item>
|
||||
<Text type="warning">参会人员 ID 当前不支持在这里直接修改,如需调整请联系管理员。</Text>
|
||||
<Form.Item name="participantIds" label="参会人员">
|
||||
<Select mode="multiple" placeholder="请选择参会人" showSearch optionFilterProp="children">
|
||||
{userList.map((u) => (
|
||||
<Option key={u.userId} value={u.userId}>
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} />
|
||||
{u.displayName || u.username}
|
||||
</Space>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
</Form>
|
||||
</Modal>
|
||||
|
|
@ -3647,6 +3620,13 @@ const MeetingDetail: React.FC = () => {
|
|||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="summaryDetailLevel" label="总结详细程度" rules={[{ required: true }]}>
|
||||
<Radio.Group>
|
||||
<Radio value="DETAILED">详细</Radio>
|
||||
<Radio value="STANDARD">标准</Radio>
|
||||
<Radio value="BRIEF">简洁</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="userPrompt"
|
||||
label="用户提示词"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<MeetingVO[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [configLoaded, setConfigLoaded] = useState(false);
|
||||
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>({
|
||||
offlineEnabled: false,
|
||||
realtimeEnabled: false,
|
||||
offlineAudioMaxSizeMb: 1024,
|
||||
});
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerType, setDrawerType] = useState<MeetingCreateType>('realtime');
|
||||
const [readCardIds, setReadCardIds] = useState<string[]>(() => {
|
||||
|
|
@ -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<QuickEntry[]>(
|
||||
const quickEntries = useMemo<(QuickEntry & { key: string })[]>(
|
||||
() => [
|
||||
{
|
||||
key: "realtime",
|
||||
title: "开启实时记录",
|
||||
icon: <AudioOutlined />,
|
||||
description: ["实时语音转文字", "同步翻译,智能总结要点"],
|
||||
|
|
@ -143,6 +160,7 @@ export default function HomePage() {
|
|||
}
|
||||
},
|
||||
{
|
||||
key: "offline",
|
||||
title: "上传音视频",
|
||||
icon: <VideoCameraAddOutlined />,
|
||||
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() {
|
|||
</Title>
|
||||
|
||||
<div className="home-quick-actions">
|
||||
{quickEntries.map((entry) => (
|
||||
<div
|
||||
className={`home-action-item home-action-item--${entry.accent}`}
|
||||
onClick={entry.onClick}
|
||||
key={entry.title}
|
||||
>
|
||||
<div className="home-action-badge">{entry.badge}</div>
|
||||
<div className="home-action-icon-wrapper">
|
||||
<div className="home-action-icon">{entry.icon}</div>
|
||||
<div className="home-action-icon-glow" />
|
||||
<div className="home-action-icon-circle" />
|
||||
{configLoaded ? (
|
||||
visibleQuickEntries.length > 0 ? visibleQuickEntries.map((entry) => (
|
||||
<div
|
||||
className={`home-action-item home-action-item--${entry.accent}`}
|
||||
onClick={entry.onClick}
|
||||
key={entry.title}
|
||||
>
|
||||
<div className="home-action-badge">{entry.badge}</div>
|
||||
<div className="home-action-icon-wrapper">
|
||||
<div className="home-action-icon">{entry.icon}</div>
|
||||
<div className="home-action-icon-glow" />
|
||||
<div className="home-action-icon-circle" />
|
||||
</div>
|
||||
<Title level={3} className="home-action-title">{entry.title}</Title>
|
||||
<div className="home-action-desc">
|
||||
{entry.description.map((line) => (
|
||||
<Text key={line} className="home-action-line">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Title level={3} className="home-action-title">{entry.title}</Title>
|
||||
<div className="home-action-desc">
|
||||
{entry.description.map((line) => (
|
||||
<Text key={line} className="home-action-line">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
)) : (
|
||||
<div className="home-action-item home-action-item--cyan" style={{ cursor: "default", opacity: 0.7 }}>
|
||||
<div className="home-action-badge">系统参数</div>
|
||||
<div className="home-action-icon-wrapper">
|
||||
<div className="home-action-icon"><AudioOutlined /></div>
|
||||
<div className="home-action-icon-glow" />
|
||||
<div className="home-action-icon-circle" />
|
||||
</div>
|
||||
<Title level={3} className="home-action-title">会议创建已关闭</Title>
|
||||
<div className="home-action-desc">
|
||||
<Text className="home-action-line">当前系统参数已关闭离线上传和实时会议入口</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
) : (
|
||||
// Loading state for quick actions to prevent jump
|
||||
[...Array(2)].map((_, i) => (
|
||||
<div key={i} className="home-action-item home-action-item--loading" style={{ background: 'rgba(255,255,255,0.4)', border: '1px dashed rgba(0,0,0,0.05)' }}>
|
||||
<Skeleton active avatar paragraph={{ rows: 2 }} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -259,8 +305,13 @@ export default function HomePage() {
|
|||
<VideoCameraOutlined className="home-recent-card-play-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-recent-card-tags">
|
||||
{card.tags.slice(0, 4).map((tag) => (
|
||||
<div className="home-recent-card-tags">
|
||||
{recentTaskMap.get(String(card.id))?.meetingSource && (
|
||||
<Tag key={`${card.id}-source`} className="home-recent-card-tag" bordered={false}>
|
||||
{recentTaskMap.get(String(card.id))?.meetingSource === "ANDROID" ? "安卓端" : "Web端"}
|
||||
</Tag>
|
||||
)}
|
||||
{card.tags.slice(0, 4).map((tag) => (
|
||||
<Tag key={`${card.id}-${tag}`} className="home-recent-card-tag" bordered={false}>
|
||||
{tag}
|
||||
</Tag>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<PageContainer
|
||||
title={t("profile.title")}
|
||||
subtitle={t("profile.subtitle")}
|
||||
style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}
|
||||
>
|
||||
<div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}>
|
||||
<PageHeader title={t("profile.title")} subtitle={t("profile.subtitle")} />
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card className="app-page__content-card text-center" loading={loading}>
|
||||
|
|
@ -307,8 +308,7 @@ export default function Profile() {
|
|||
onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))}
|
||||
onConfirm={handleUploadCroppedImage}
|
||||
/>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue