feat: 添加总结详细程度选项和优化用户认证逻辑

- 在 `MeetingCommandService` 中添加 `summaryDetailLevel` 参数
- 更新 `MeetingSummaryPromptAssembler` 以支持不同详细程度的总结
- 在前端 `useAuth` 钩子中添加 `profileVersion` 状态,同步用户资料更新
- 优化密码重置流程,保持登录状态并更新用户资料
- 在 `Meeting` 实体中添加 `summaryDetailLevel` 字段
- 更新相关服务和控制器以处理新的总结详细程度选项
dev_na
chenhao 2026-05-13 14:31:48 +08:00
parent 51190f330c
commit f7480df565
29 changed files with 1583 additions and 1168 deletions

View File

@ -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() {
}

View File

@ -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";
}

View File

@ -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());

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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 = "音频保存说明")

View File

@ -40,6 +40,8 @@ public class Meeting extends BaseEntity {
@Schema(description = "会议来源")
private String meetingSource;
@Schema(description = "总结详细程度")
private String summaryDetailLevel;
@Schema(description = "音频保存状态")
private String audioSaveStatus;

View File

@ -81,6 +81,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
resolveCreatorName(loginUser),
loginUser.getUserId(),
resolveCreatorName(loginUser),
MeetingConstants.SUMMARY_DETAIL_STANDARD,
0
);
meetingService.save(meeting);

View File

@ -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);

View File

@ -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());

View File

@ -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;
}
}

View File

@ -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) {
}
}

View File

@ -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));

View File

@ -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标准保持信息完整和篇幅平衡覆盖核心过程、结论和待办不做过度展开。";
};
}
}

View File

@ -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` 中返回章节列表"));
}

View File

@ -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) => {

View File

@ -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;

View File

@ -1,25 +1,65 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Drawer, Form, Input, Select, DatePicker, Switch, Upload, Progress, Space, Avatar, Row, Col, Radio, Typography, Tooltip, App, Tag, Button, Collapse } from 'antd';
import { UserOutlined, CloudUploadOutlined, AudioOutlined, QuestionCircleOutlined, CheckOutlined, LinkOutlined, SettingOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import React, { useEffect, useMemo, useState } from "react";
import {
App,
Avatar,
Button,
Col,
Collapse,
DatePicker,
Drawer,
Form,
Input,
Progress,
Radio,
Row,
Select,
Skeleton,
Space,
Switch,
Tag,
Tooltip,
Typography,
Upload,
} from "antd";
import {
AudioOutlined,
CheckOutlined,
CloudUploadOutlined,
QuestionCircleOutlined,
SettingOutlined,
UserOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { useNavigate } from "react-router-dom";
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
import { getHotWordGroupOptions, HotWordGroupVO } from '../../api/business/hotwordGroup';
import { listUsers, pageParams } from '../../api';
import { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting';
import { SysUser } from '../../types';
import { listUsers } from "../../api";
import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel";
import { getHotWordPage, type HotWordVO } from "../../api/business/hotword";
import { getHotWordGroupOptions, type HotWordGroupVO } from "../../api/business/hotwordGroup";
import {
createMeeting,
createRealtimeMeeting,
getMeetingCreateConfig,
type CreateRealtimeMeetingCommand,
type MeetingCreateConfig,
type SummaryDetailLevel,
uploadAudio,
} from "../../api/business/meeting";
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
import type { SysUser } from "../../types";
const { Option } = Select;
const { Dragger } = Upload;
const { Option } = Select;
const { Text, Title } = Typography;
export type MeetingCreateType = 'upload' | 'realtime';
export type MeetingCreateType = "upload" | "realtime";
const DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB = 1024;
const OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY = 'meeting.offline_audio.max_size_mb';
const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
offlineEnabled: true,
realtimeEnabled: true,
offlineAudioMaxSizeMb: DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB,
};
interface MeetingCreateDrawerProps {
open: boolean;
@ -44,106 +84,138 @@ type RealtimeMeetingSessionDraft = {
hotwords: Array<{ hotword: string; weight: number }>;
};
function resolveAvailableCreateTypes(config: MeetingCreateConfig): MeetingCreateType[] {
const types: MeetingCreateType[] = [];
if (config.offlineEnabled) {
types.push("upload");
}
if (config.realtimeEnabled) {
types.push("realtime");
}
return types;
}
function resolveAvailableCreateType(initialType: MeetingCreateType, config: MeetingCreateConfig): MeetingCreateType {
const availableTypes = resolveAvailableCreateTypes(config);
if (availableTypes.includes(initialType)) {
return initialType;
}
return availableTypes[0] || initialType;
}
function resolveWsUrl(model?: AiModelVO | null) {
if (model?.wsUrl) return model.wsUrl;
if (model?.baseUrl) return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
return "";
}
function buildRealtimeProxyPreviewUrl() {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
return `${protocol}://${window.location.host}/ws/meeting/realtime`;
}
function getSessionKey(meetingId: number) {
return `realtimeMeetingSession:${meetingId}`;
}
export const MeetingCreateDrawer: React.FC<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[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
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);
setAsrModels(activeAsrModels);
setLlmModels(activeLlmModels);
const nextConfig = createConfigRes.data.data || DEFAULT_CREATE_CONFIG;
const nextType = resolveAvailableCreateType(initialType, nextConfig);
const activePrompts = promptRes.data.data.records.filter((item: PromptTemplateVO) => item.status === 1);
setCreateConfig(nextConfig);
setConfigLoaded(true);
setType(nextType);
setAsrModels(asrRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
setLlmModels(llmRes.data.data.records.filter((item: AiModelVO) => item.status === 1));
setPrompts(activePrompts);
setHotwordList(activeHotwords);
setHotwordList(hotwordRes.data.data.records.filter((item: HotWordVO) => item.status === 1));
setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1));
setUserList(users || []);
setOfflineAudioMaxSizeMb(await loadOfflineAudioMaxSizeMb());
const defaultPrompt = activePrompts.length > 0 ? activePrompts[0] : undefined;
const defaultPrompt = activePrompts[0];
form.setFieldsValue({
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
title: nextType === "upload" ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
meetingTime: dayjs(),
asrModelId: defaultAsr.data.data?.id,
summaryModelId: defaultLlm.data.data?.id,
promptId: defaultPrompt?.id,
hotWordGroupId: defaultPrompt?.hotWordGroupId,
hotWordGroupId: defaultPrompt?.hotWordGroupId ?? 0,
summaryDetailLevel: "STANDARD",
useSpkId: 1,
enableTextRefine: false,
mode: "2pass",
@ -152,24 +224,36 @@ export const MeetingCreateDrawer: React.FC<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;
}
}
@ -246,71 +313,73 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
.filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId)
.map((item) => item.word)
.filter((word) => !!word?.trim());
if (type === 'upload') {
if (type === "upload") {
await createMeeting({
...meetingValues,
...(hostUserId != null ? { hostUserId } : {}),
meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
audioUrl,
participants: meetingValues.participants?.join(','),
tags: meetingValues.tags?.join(','),
hotWords: selectedHotWords
});
message.success('会议发起成功');
onSuccess();
onCancel();
} else {
const selectedHotwords = hotwordList
.filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId && meetingValues.hotWordGroupId !== 0)
.map((item) => ({
hotword: item.word,
weight: Number(item.weight || 2) / 10,
}));
const payload: CreateRealtimeMeetingCommand = {
...meetingValues,
...(hostUserId != null ? { hostUserId } : {}),
meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
participants: meetingValues.participants?.join(",") || "",
tags: meetingValues.tags?.join(",") || "",
mode: meetingValues.mode || "2pass",
language: meetingValues.language || "auto",
useSpkId: meetingValues.useSpkId == null ? 1 : (meetingValues.useSpkId ? 1 : 0),
enablePunctuation: meetingValues.enablePunctuation !== false,
enableItn: meetingValues.enableItn !== false,
enableTextRefine: !!meetingValues.enableTextRefine,
saveAudio: !!meetingValues.saveAudio,
audioUrl,
participants: meetingValues.participants?.join(","),
tags: meetingValues.tags?.join(","),
summaryDetailLevel: meetingValues.summaryDetailLevel as SummaryDetailLevel,
hotWords: selectedHotWords,
};
const res = await createRealtimeMeeting(payload);
const createdMeeting = res.data.data;
const sessionDraft: RealtimeMeetingSessionDraft = {
meetingId: createdMeeting.id,
meetingTitle: createdMeeting.title,
asrModelName: selectedAsrModel?.modelName || "ASR",
summaryModelName: selectedSummaryModel?.modelName || "LLM",
asrModelId: selectedAsrModel?.id || values.asrModelId,
mode: values.mode || "2pass",
language: values.language || "auto",
useSpkId: values.useSpkId == null ? 1 : (values.useSpkId ? 1 : 0),
enablePunctuation: values.enablePunctuation !== false,
enableItn: values.enableItn !== false,
enableTextRefine: !!values.enableTextRefine,
saveAudio: !!values.saveAudio,
hotwords: selectedHotwords,
};
sessionStorage.setItem(getSessionKey(createdMeeting.id), JSON.stringify(sessionDraft));
message.success("会议已创建,即将进入实时识别");
});
message.success("会议发起成功");
onSuccess();
onCancel();
navigate(`/meeting-live-session/${createdMeeting.id}`);
return;
}
} catch (err) {
message.error(type === 'upload' ? '创建会议失败' : '创建实时会议失败');
const selectedHotwords = hotwordList
.filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId && meetingValues.hotWordGroupId !== 0)
.map((item) => ({
hotword: item.word,
weight: Number(item.weight || 2) / 10,
}));
const payload: CreateRealtimeMeetingCommand = {
...meetingValues,
...(hostUserId != null ? { hostUserId } : {}),
meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
participants: meetingValues.participants?.join(",") || "",
tags: meetingValues.tags?.join(",") || "",
summaryDetailLevel: meetingValues.summaryDetailLevel as SummaryDetailLevel,
mode: meetingValues.mode || "2pass",
language: meetingValues.language || "auto",
useSpkId: meetingValues.useSpkId == null ? 1 : (meetingValues.useSpkId ? 1 : 0),
enablePunctuation: meetingValues.enablePunctuation !== false,
enableItn: meetingValues.enableItn !== false,
enableTextRefine: !!meetingValues.enableTextRefine,
saveAudio: !!meetingValues.saveAudio,
hotWords: selectedHotWords,
};
const res = await createRealtimeMeeting(payload);
const createdMeeting = res.data.data;
const sessionDraft: RealtimeMeetingSessionDraft = {
meetingId: createdMeeting.id,
meetingTitle: createdMeeting.title,
asrModelName: selectedAsrModel?.modelName || "ASR",
summaryModelName: selectedSummaryModel?.modelName || "LLM",
asrModelId: selectedAsrModel?.id || values.asrModelId,
mode: values.mode || "2pass",
language: values.language || "auto",
useSpkId: values.useSpkId == null ? 1 : (values.useSpkId ? 1 : 0),
enablePunctuation: values.enablePunctuation !== false,
enableItn: values.enableItn !== false,
enableTextRefine: !!values.enableTextRefine,
saveAudio: !!values.saveAudio,
hotwords: selectedHotwords,
};
sessionStorage.setItem(getSessionKey(createdMeeting.id), JSON.stringify(sessionDraft));
message.success("会议已创建,即将进入实时识别");
onSuccess();
onCancel();
navigate(`/meeting-live-session/${createdMeeting.id}`);
} catch {
message.error(type === "upload" ? "创建会议失败" : "创建实时会议失败");
} finally {
setSubmitting(false);
}
@ -318,68 +387,80 @@ export const MeetingCreateDrawer: React.FC<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>
<Row gutter={32}>
<Col span={12}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
<Input placeholder="输入会议标题" size="large" />
</Form.Item>
</Col>
<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>
<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,48 +609,36 @@ 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
accept=".mp3,.wav,.m4a"
<Dragger
accept=".mp3,.wav,.m4a"
beforeUpload={beforeAudioUpload}
fileList={fileList}
customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))}
maxCount={1}
style={{ borderRadius: 12, padding: '32px 0', background: 'var(--app-bg-surface)', border: '1px dashed var(--app-border-color)' }}
fileList={fileList}
customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))}
maxCount={1}
style={{ borderRadius: 12, padding: "32px 0", background: "var(--app-bg-surface)", border: "1px dashed var(--app-border-color)" }}
>
<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)" }}> mp3wavm4a {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>
);
};
};

View File

@ -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 = () => {

View File

@ -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>
);
}

View File

@ -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 }}>

View File

@ -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,

View File

@ -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

View File

@ -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>

View File

@ -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>
);
}