feat: 增强会议总结和积分管理功能

- 添加 `MeetingSummaryPromptAssembler` 依赖并更新相关方法
- 优化 `resolveHostName` 方法为 `resolveMeetingUserName`,并调整相关调用
- 更新 `updateMeetingBasic` 方法,支持更新摘要模型 ID 和提示 ID
- 在 `retrySummary` 和 `retryChapter` 方法中添加对摘要模型 ID 和提示 ID 的处理
- 优化前端积分管理页面,新增个人账户余额画廊和统计卡片
- 调整积分管理页面的布局和样式,提升用户体验
dev_na
chenhao 2026-06-10 20:43:35 +08:00
parent 178b920581
commit 2e05a25e63
19 changed files with 727 additions and 152 deletions

View File

@ -6,6 +6,7 @@ import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys;
import com.imeeting.common.exception.ExistingOfflineMeetingException;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidOfflineMeetingCreateCommand;
import com.imeeting.dto.android.AndroidMeetingCreateResponse;
import com.imeeting.dto.android.AndroidMeetingConfigVo;
import com.imeeting.dto.android.AndroidMeetingListItemVO;
@ -15,7 +16,6 @@ import com.imeeting.dto.android.AndroidUnifiedMeetingStatusRequest;
import com.imeeting.dto.android.AndroidUnifiedMeetingStatusResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest;
import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest;
import com.imeeting.dto.android.legacy.LegacyMeetingPreviewDataResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult;
import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse;
@ -161,8 +161,7 @@ public class AndroidMeetingController {
@Anonymous
@Log(value = "新增Android会议", type = "Android会议管理")
public ApiResponse<Object> create(HttpServletRequest request,
@RequestBody LegacyMeetingCreateRequest command) {
@RequestBody AndroidOfflineMeetingCreateCommand command) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "创建离线会议接口", "request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
resolvePublicDeviceTenantId(request, command, authContext);
@ -478,7 +477,7 @@ public class AndroidMeetingController {
}
private void resolvePublicDeviceTenantId(HttpServletRequest request,
LegacyMeetingCreateRequest command,
AndroidOfflineMeetingCreateCommand command,
AndroidAuthContext authContext) {
if (command == null || command.getTenantId() != null || authContext == null || !authContext.isAnonymous()) {
return;

View File

@ -569,6 +569,7 @@ public class MeetingController {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
assertPromptAvailable(command.getPromptId(), loginUser);
command.setMeetingId(id);
meetingCommandService.updateMeetingBasic(command);
return ApiResponse.ok(true);

View File

@ -33,7 +33,9 @@ public class MeetingPointsManagementController {
@GetMapping("/overview")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingPointsOverviewVO> getOverview() {
return ApiResponse.ok(meetingPointsQueryService.getOverview(currentLoginUser().getTenantId()));
LoginUser loginUser = currentLoginUser();
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
return ApiResponse.ok(meetingPointsQueryService.getOverview(loginUser.getTenantId(), loginUser.getUserId(), isAdmin));
}
@Operation(summary = "分页查询积分消耗流水")

View File

@ -0,0 +1,29 @@
package com.imeeting.dto.android;
import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "Android离线会议创建请求")
public class AndroidOfflineMeetingCreateCommand extends LegacyMeetingCreateRequest {
@Schema(description = "总结模型ID")
private Long summaryModelId;
@Schema(description = "总结模板ID")
private Long promptId;
@Schema(
description = "总结详细程度DETAILED=详细STANDARD=标准BRIEF=简洁",
allowableValues = {
MeetingConstants.SUMMARY_DETAIL_DETAILED,
MeetingConstants.SUMMARY_DETAIL_STANDARD,
MeetingConstants.SUMMARY_DETAIL_BRIEF
}
)
private String summaryDetailLevel;
}

View File

@ -3,6 +3,8 @@ package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "积分管理总览视图")
public class MeetingPointsOverviewVO {
@ -18,10 +20,10 @@ public class MeetingPointsOverviewVO {
@Schema(description = "公共账户累计消耗积分")
private Long publicTotalPointsUsed;
@Schema(description = "个人账户余额汇总")
@Schema(description = "个人账户余额")
private Long personalBalance;
@Schema(description = "个人账户累计消耗积分汇总")
@Schema(description = "个人账户累计消耗积分")
private Long personalTotalPointsUsed;
@Schema(description = "当前模式下可用总积分")
@ -29,4 +31,10 @@ public class MeetingPointsOverviewVO {
@Schema(description = "累计消耗次数")
private Long totalChargeCount;
@Schema(description = "当前用户是否管理员")
private Boolean admin;
@Schema(description = "管理员可见的个人账户列表")
private List<MeetingPointsPersonalAccountVO> personalAccounts;
}

View File

@ -3,6 +3,7 @@ package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@ -12,16 +13,22 @@ import java.util.Map;
public class MeetingVO {
@Schema(description = "会议 ID")
private Long id;
@Schema(description = "租户 ID")
private Long tenantId;
@Schema(description = "创建人用户ID")
private Long creatorId;
@Schema(description = "创建人名称")
private String creatorName;
@Schema(description = "主持人用户ID")
private Long hostUserId;
@Schema(description = "主持人名称")
private String hostName;
@Schema(description = "会议标题")
private String title;
@ -31,56 +38,88 @@ public class MeetingVO {
@Schema(description = "参会人ID串逗号分隔")
private String participants;
@Schema(description = "参会人ID列表")
private List<Long> participantIds;
@Schema(description = "标签串")
private String tags;
@Schema(description = "音频地址")
private String audioUrl;
@Schema(description = "浏览器播放音频地址")
private String playbackAudioUrl;
@Schema(description = "会议类型")
private String meetingType;
@Schema(description = "会议来源")
private String meetingSource;
@Schema(description = "来源设备编码")
private String sourceDeviceCode;
@Schema(description = "来源设备模式")
private String sourceDeviceMode;
@Schema(description = "离线录音阶段ACTIVE / PRE_END / UPLOAD_FINISHED")
private String offlineRecordingStatus;
@Schema(description = "总结详细程度")
private String summaryDetailLevel;
@Schema(description = "总结模型ID")
private Long summaryModelId;
@Schema(description = "总结模板ID")
private Long promptId;
@Schema(description = "音频保存状态")
private String audioSaveStatus;
@Schema(description = "音频保存说明")
private String audioSaveMessage;
@Schema(description = "访问密码")
private String accessPassword;
@Schema(description = "音频时长,单位秒")
private Integer duration;
@Schema(description = "会议最终有效录音时长,单位秒")
private Integer effectiveAudioDurationSeconds;
@Schema(description = "会议摘要内容")
private String summaryContent;
@Schema(description = "最后一次用户补充提示词")
private String lastUserPrompt;
@Schema(description = "分析结果")
private Map<String, Object> analysis;
@Schema(description = "最近一次总结尝试任务 ID")
private Long latestSummaryAttemptTaskId;
@Schema(description = "最近一次总结尝试任务状态")
private Integer latestSummaryAttemptStatus;
@Schema(description = "最近一次总结尝试错误信息")
private String latestSummaryAttemptErrorMsg;
@Schema(description = "最近一次总结尝试阻塞原因")
private String latestSummaryAttemptBlockedReason;
@Schema(description = "最近一次章节尝试任务 ID")
private Long latestChapterAttemptTaskId;
@Schema(description = "最近一次章节尝试任务状态")
private Integer latestChapterAttemptStatus;
@Schema(description = "最近一次章节尝试错误信息")
private String latestChapterAttemptErrorMsg;
@Schema(description = "会议状态")
private Integer status;

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 lombok.Data;
import java.time.LocalDateTime;
@ -14,6 +16,17 @@ public class UpdateMeetingBasicCommand {
private LocalDateTime meetingTime;
private String tags;
private String accessPassword;
private Long promptId;
private Long summaryModelId;
@Schema(
description = "总结详细程度DETAILED=详细STANDARD=标准BRIEF=简洁",
allowableValues = {
MeetingConstants.SUMMARY_DETAIL_DETAILED,
MeetingConstants.SUMMARY_DETAIL_STANDARD,
MeetingConstants.SUMMARY_DETAIL_BRIEF
}
)
private String summaryDetailLevel;
}

View File

@ -53,6 +53,12 @@ public class Meeting extends BaseEntity {
@Schema(description = "总结详细程度")
private String summaryDetailLevel;
@Schema(description = "总结模型ID")
private Long summaryModelId;
@Schema(description = "总结模板ID")
private Long promptId;
@Schema(description = "会议最终有效录音时长(秒)")
private Integer effectiveAudioDurationSeconds;
@ -63,7 +69,6 @@ public class Meeting extends BaseEntity {
private String audioSaveMessage;
@Schema(description = "访问密码")
private String accessPassword;
@Schema(description = "创建人ID")

View File

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.exception.ExistingOfflineMeetingException;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidOfflineMeetingCreateCommand;
import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.dto.biz.MeetingVO;
@ -99,6 +100,31 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
throw new ExistingOfflineMeetingException(existingMeeting.getId());
}
Long requestedSummaryModelId = request instanceof AndroidOfflineMeetingCreateCommand androidCommand
? androidCommand.getSummaryModelId()
: null;
Long requestedPromptId = request instanceof AndroidOfflineMeetingCreateCommand androidCommand
? androidCommand.getPromptId()
: null;
String requestedSummaryDetailLevel = request instanceof AndroidOfflineMeetingCreateCommand androidCommand
? androidCommand.getSummaryDetailLevel()
: MeetingConstants.SUMMARY_DETAIL_STANDARD;
RealtimeMeetingRuntimeProfile runtimeProfile = runtimeProfileResolver.resolve(
tenantId,
null,
requestedSummaryModelId,
requestedPromptId,
null,
null,
null,
null,
null,
null,
null,
null,
List.of()
);
String resolvedCreatorName = meetingDomainSupport.resolveUserDisplayName(creatorUserId, creatorName);
Meeting meeting = meetingDomainSupport.initMeeting(
request.getTitle().trim(),
meetingTime,
@ -109,10 +135,12 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
MeetingConstants.SOURCE_ANDROID,
tenantId,
creatorUserId,
creatorName,
resolvedCreatorName,
creatorUserId,
creatorName,
MeetingConstants.SUMMARY_DETAIL_STANDARD,
resolvedCreatorName,
runtimeProfile.getResolvedSummaryModelId(),
runtimeProfile.getResolvedPromptId(),
requestedSummaryDetailLevel,
0,
authContext == null ? null : authContext.getDeviceId(),
sourceDeviceMode
@ -152,8 +180,12 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
if (transcriptCount > 0) {
throw new RuntimeException("当前会议已存在转录内容,旧版 Android 上传不支持替换已生成的转录");
}
if (promptId != null && !promptTemplateService.isTemplateEnabledForUser(
promptId,
Long effectivePromptId = promptId != null ? promptId : meeting.getPromptId();
Long effectiveSummaryModelId = modelCode != null && !modelCode.isBlank()
? resolveSummaryModelId(modelCode, loginUser.getTenantId())
: meeting.getSummaryModelId();
if (effectivePromptId != null && !promptTemplateService.isTemplateEnabledForUser(
effectivePromptId,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
@ -164,8 +196,8 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
RealtimeMeetingRuntimeProfile profile = runtimeProfileResolver.resolve(
loginUser.getTenantId(),
null,
resolveSummaryModelId(modelCode, loginUser.getTenantId()),
promptId,
effectiveSummaryModelId,
effectivePromptId,
null,
null,
null,
@ -181,6 +213,8 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
meetingDomainSupport.applyMeetingAudioMetadata(meeting, relocatedUrl);
meeting.setSummaryModelId(profile.getResolvedSummaryModelId());
meeting.setPromptId(profile.getResolvedPromptId());
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
meeting.setAudioSaveMessage(null);
meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED);
@ -188,8 +222,8 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
meetingService.updateById(meeting);
resetOrCreateAsrTask(meetingId, profile);
resetOrCreateChapterTask(meetingId, profile);
resetOrCreateSummaryTask(meetingId, profile);
resetOrCreateChapterTask(meetingId, profile, null, meeting.getSummaryDetailLevel());
resetOrCreateSummaryTask(meetingId, profile, null, meeting.getSummaryDetailLevel());
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
});
return new LegacyUploadAudioResponse(meetingId, relocatedUrl);
@ -223,8 +257,12 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
throw new RuntimeException("当前会议已存在转录内容,不支持替换已生成的转录");
}
LoginUser loginUser = toMeetingOwnerLoginUser(meeting);
if (promptId != null && !promptTemplateService.isTemplateEnabledForUser(
promptId,
Long effectivePromptId = promptId != null ? promptId : meeting.getPromptId();
Long effectiveSummaryModelId = modelCode != null && !modelCode.isBlank()
? resolveSummaryModelId(modelCode, meeting.getTenantId())
: meeting.getSummaryModelId();
if (effectivePromptId != null && !promptTemplateService.isTemplateEnabledForUser(
effectivePromptId,
loginUser.getTenantId(),
loginUser.getUserId(),
Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()),
@ -234,8 +272,8 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
RealtimeMeetingRuntimeProfile profile = runtimeProfileResolver.resolve(
meeting.getTenantId(),
null,
resolveSummaryModelId(modelCode, meeting.getTenantId()),
promptId,
effectiveSummaryModelId,
effectivePromptId,
null,
null,
null,
@ -250,6 +288,8 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
meetingDomainSupport.applyMeetingAudioMetadata(meeting, relocatedUrl);
meeting.setSummaryModelId(profile.getResolvedSummaryModelId());
meeting.setPromptId(profile.getResolvedPromptId());
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
meeting.setAudioSaveMessage(null);
meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED);
@ -257,8 +297,8 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
meetingService.updateById(meeting);
resetOrCreateAsrTask(meetingId, profile);
resetOrCreateChapterTask(meetingId, profile);
resetOrCreateSummaryTask(meetingId, profile);
resetOrCreateChapterTask(meetingId, profile, null, meeting.getSummaryDetailLevel());
resetOrCreateSummaryTask(meetingId, profile, null, meeting.getSummaryDetailLevel());
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
});
return new LegacyUploadAudioResponse(meetingId, relocatedUrl);

View File

@ -8,7 +8,7 @@ import com.unisbase.dto.PageResult;
import java.util.List;
public interface MeetingPointsQueryService {
MeetingPointsOverviewVO getOverview(Long tenantId);
MeetingPointsOverviewVO getOverview(Long tenantId, Long userId, boolean isAdmin);
PageResult<List<MeetingPointsLedgerListItemVO>> pageLedgers(Long tenantId,
Integer current,

View File

@ -80,6 +80,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
private final MeetingProgressService meetingProgressService;
private final MeetingPointsService meetingPointsService;
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
private final ObjectMapper objectMapper;
private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger;
private final AndroidPushMessageService androidPushMessageService;
@ -105,6 +106,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService,
MeetingProgressService meetingProgressService,
MeetingPointsService meetingPointsService,
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
ObjectMapper objectMapper,
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
AndroidPushMessageService androidPushMessageService,
@ -125,6 +127,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
this.realtimeMeetingAudioStorageService = realtimeMeetingAudioStorageService;
this.meetingProgressService = meetingProgressService;
this.meetingPointsService = meetingPointsService;
this.meetingSummaryPromptAssembler = meetingSummaryPromptAssembler;
this.objectMapper = objectMapper;
this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger;
this.androidPushMessageService = androidPushMessageService;
@ -138,10 +141,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) {
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
String resolvedCreatorName = resolveMeetingUserName(creatorId, creatorName);
String hostName = resolveMeetingUserName(hostUserId, resolvedCreatorName);
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, summaryDetailLevel, 0);
command.getAudioUrl(), MeetingConstants.TYPE_OFFLINE, meetingSource, tenantId, creatorId, resolvedCreatorName,
hostUserId, hostName, runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId(), summaryDetailLevel, 0);
meetingService.save(meeting);
AiTask asrTask = new AiTask();
@ -215,10 +220,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource) {
RealtimeMeetingRuntimeProfile runtimeProfile = resolveCreateProfile(command, tenantId);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
String resolvedCreatorName = resolveMeetingUserName(creatorId, creatorName);
String hostName = resolveMeetingUserName(hostUserId, resolvedCreatorName);
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, summaryDetailLevel, 0);
null, MeetingConstants.TYPE_REALTIME, meetingSource, tenantId, creatorId, resolvedCreatorName,
hostUserId, hostName, runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId(), summaryDetailLevel, 0);
meetingService.save(meeting);
Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : runtimeProfile.getResolvedSummaryModelId();
meetingDomainSupport.createChapterTask(
@ -278,7 +285,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
command.getHotWords()
);
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
String resolvedCreatorName = resolveMeetingUserName(creatorId, creatorName);
String hostName = resolveMeetingUserName(hostUserId, resolvedCreatorName);
String summaryDetailLevel = resolveSummaryDetailLevel(command.getSummaryDetailLevel());
Meeting meeting = meetingDomainSupport.initMeeting(
command.getTitle(),
@ -290,9 +298,11 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
MeetingConstants.SOURCE_ANDROID,
tenantId,
creatorId,
creatorName,
resolvedCreatorName,
hostUserId,
hostName,
runtimeProfile.getResolvedSummaryModelId(),
runtimeProfile.getResolvedPromptId(),
summaryDetailLevel,
0,
deviceCode,
@ -691,12 +701,36 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override
@Transactional(rollbackFor = Exception.class)
public void updateMeetingBasic(UpdateMeetingBasicCommand command) {
if (command.getSummaryModelId() != null || command.getPromptId() != null) {
Meeting meeting = meetingService.getById(command.getMeetingId());
if (meeting == null) {
throw new RuntimeException("会议不存在");
}
meetingRuntimeProfileResolver.resolve(
meeting.getTenantId(),
null,
command.getSummaryModelId() != null ? command.getSummaryModelId() : meeting.getSummaryModelId(),
command.getPromptId() != null ? command.getPromptId() : meeting.getPromptId(),
null,
null,
null,
null,
null,
null,
null,
null,
List.of()
);
}
meetingService.update(new LambdaUpdateWrapper<Meeting>()
.eq(Meeting::getId, command.getMeetingId())
.set(command.getTitle() != null, Meeting::getTitle, command.getTitle())
.set(command.getMeetingTime() != null, Meeting::getMeetingTime, command.getMeetingTime())
.set(command.getTags() != null, Meeting::getTags, command.getTags())
.set(command.getAccessPassword() != null, Meeting::getAccessPassword, normalizeAccessPassword(command.getAccessPassword())));
.set(command.getAccessPassword() != null, Meeting::getAccessPassword, normalizeAccessPassword(command.getAccessPassword()))
.set(command.getSummaryModelId() != null, Meeting::getSummaryModelId, command.getSummaryModelId())
.set(command.getPromptId() != null, Meeting::getPromptId, command.getPromptId())
.set(command.getSummaryDetailLevel() != null, Meeting::getSummaryDetailLevel, resolveSummaryDetailLevel(command.getSummaryDetailLevel())));
}
private String normalizeAccessPassword(String accessPassword) {
@ -997,15 +1031,33 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
throw new RuntimeException("会议不存在");
}
String effectiveSummaryDetailLevel = resolveSummaryDetailLevel(summaryDetailLevel != null ? summaryDetailLevel : meeting.getSummaryDetailLevel());
AiTask latestSummaryTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "SUMMARY")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
Long effectiveSummaryModelId = summaryModelId != null ? summaryModelId : resolveMeetingSummaryModelId(meeting, latestSummaryTask);
Long effectivePromptId = promptId != null ? promptId : resolveMeetingPromptId(meeting, latestSummaryTask);
Long effectiveChapterModelId = chapterModelId != null ? chapterModelId : resolveMeetingChapterModelId(meeting, latestSummaryTask, effectiveSummaryModelId);
String effectiveSummaryDetailLevel = resolveSummaryDetailLevel(summaryDetailLevel != null ? summaryDetailLevel : resolveMeetingSummaryDetailLevel(meeting, latestSummaryTask));
String effectiveUserPrompt = userPrompt != null ? userPrompt : resolveMeetingUserPrompt(latestSummaryTask);
if (effectiveSummaryModelId == null) {
throw new RuntimeException("缺少 summaryModelId无法创建总结任务");
}
if (effectivePromptId == null) {
throw new RuntimeException("缺少 promptId无法创建总结任务");
}
meetingDomainSupport.createSummaryTask(
meetingId,
summaryModelId,
promptId,
userPrompt,
effectiveSummaryModelId,
effectiveChapterModelId,
effectivePromptId,
effectiveUserPrompt,
effectiveSummaryDetailLevel,
"RESUMMARY"
);
meeting.setSummaryModelId(effectiveSummaryModelId);
meeting.setPromptId(effectivePromptId);
meeting.setSummaryDetailLevel(effectiveSummaryDetailLevel);
meeting.setStatus(MeetingStatusEnum.SUMMARIZING.getCode());
meetingService.updateById(meeting);
@ -1057,31 +1109,48 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
resetAiTask(asrTask, new HashMap<>(asrTask.getTaskConfig()));
aiTaskService.updateById(asrTask);
Long effectiveSummaryModelId = resolveMeetingSummaryModelId(meeting, summaryTask);
Long effectivePromptId = resolveMeetingPromptId(meeting, summaryTask);
Long effectiveChapterModelId = resolveMeetingChapterModelId(meeting, summaryTask, effectiveSummaryModelId);
String effectiveUserPrompt = resolveMeetingUserPrompt(summaryTask);
String effectiveSummaryDetailLevel = resolveMeetingSummaryDetailLevel(meeting, summaryTask);
AiTask chapterTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "CHAPTER")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (chapterTask == null) {
Long summaryModelId = longValue(summaryTask.getTaskConfig().get("summaryModelId"));
Long chapterModelId = longValue(summaryTask.getTaskConfig().get("chapterModelId"));
Long promptId = longValue(summaryTask.getTaskConfig().get("promptId"));
String userPrompt = stringValue(summaryTask.getTaskConfig().get("userPrompt"));
chapterTask = meetingDomainSupport.createChapterTask(
meetingId,
summaryModelId,
chapterModelId != null ? chapterModelId : summaryModelId,
promptId,
userPrompt,
meeting.getSummaryDetailLevel()
effectiveSummaryModelId,
effectiveChapterModelId,
effectivePromptId,
effectiveUserPrompt,
effectiveSummaryDetailLevel
);
} else {
resetAiTask(chapterTask, new HashMap<>(chapterTask.getTaskConfig()));
resetAiTask(chapterTask, meetingSummaryPromptAssembler.buildTaskConfig(
effectiveSummaryModelId,
effectiveChapterModelId,
effectivePromptId,
effectiveUserPrompt,
effectiveSummaryDetailLevel
));
aiTaskService.updateById(chapterTask);
}
resetAiTask(summaryTask, new HashMap<>(summaryTask.getTaskConfig()));
resetAiTask(summaryTask, buildSummaryTaskConfigForRetry(
summaryTask,
effectiveSummaryModelId,
effectiveChapterModelId,
effectivePromptId,
effectiveUserPrompt,
effectiveSummaryDetailLevel
));
aiTaskService.updateById(summaryTask);
meeting.setSummaryModelId(effectiveSummaryModelId);
meeting.setPromptId(effectivePromptId);
meeting.setSummaryDetailLevel(effectiveSummaryDetailLevel);
meeting.setStatus(MeetingStatusEnum.TRANSCRIBING.getCode());
meetingService.updateById(meeting);
clearLegacyDispatchState(meetingId);
@ -1115,8 +1184,19 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
if (!Integer.valueOf(3).equals(summaryTask.getStatus())) {
throw new RuntimeException("当前总结环节未失败,无需重试");
}
resetAiTask(summaryTask, new HashMap<>(summaryTask.getTaskConfig()));
Long effectiveSummaryModelId = resolveMeetingSummaryModelId(meeting, summaryTask);
resetAiTask(summaryTask, buildSummaryTaskConfigForRetry(
summaryTask,
effectiveSummaryModelId,
resolveMeetingChapterModelId(meeting, summaryTask, effectiveSummaryModelId),
resolveMeetingPromptId(meeting, summaryTask),
resolveMeetingUserPrompt(summaryTask),
resolveMeetingSummaryDetailLevel(meeting, summaryTask)
));
aiTaskService.updateById(summaryTask);
meeting.setSummaryModelId(effectiveSummaryModelId);
meeting.setPromptId(resolveMeetingPromptId(meeting, summaryTask));
meeting.setSummaryDetailLevel(resolveMeetingSummaryDetailLevel(meeting, summaryTask));
meeting.setStatus(MeetingStatusEnum.SUMMARIZING.getCode());
meetingService.updateById(meeting);
updateMeetingProgress(meetingId, 90, "已重新提交总结任务,正在生成总结...", 0);
@ -1149,8 +1229,18 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
if (!Integer.valueOf(3).equals(chapterTask.getStatus())) {
throw new RuntimeException("当前 AI 目录环节未失败,无需重试");
}
resetAiTask(chapterTask, new HashMap<>(chapterTask.getTaskConfig()));
Long effectiveSummaryModelId = resolveMeetingSummaryModelId(meeting, chapterTask);
resetAiTask(chapterTask, meetingSummaryPromptAssembler.buildTaskConfig(
effectiveSummaryModelId,
resolveMeetingChapterModelId(meeting, chapterTask, effectiveSummaryModelId),
resolveMeetingPromptId(meeting, chapterTask),
resolveMeetingUserPrompt(chapterTask),
resolveMeetingSummaryDetailLevel(meeting, chapterTask)
));
aiTaskService.updateById(chapterTask);
meeting.setSummaryModelId(effectiveSummaryModelId);
meeting.setPromptId(resolveMeetingPromptId(meeting, chapterTask));
meeting.setSummaryDetailLevel(resolveMeetingSummaryDetailLevel(meeting, chapterTask));
meeting.setStatus(MeetingStatusEnum.SUMMARIZING.getCode());
meetingService.updateById(meeting);
updateMeetingProgress(meetingId, 85, "已重新提交 AI 目录任务,正在生成目录...", 0);
@ -1222,6 +1312,58 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
return value == null ? null : String.valueOf(value);
}
private Long resolveMeetingSummaryModelId(Meeting meeting, AiTask task) {
if (meeting != null && meeting.getSummaryModelId() != null) {
return meeting.getSummaryModelId();
}
return task == null || task.getTaskConfig() == null ? null : longValue(task.getTaskConfig().get("summaryModelId"));
}
private Long resolveMeetingChapterModelId(Meeting meeting, AiTask task, Long fallbackSummaryModelId) {
Long chapterModelId = task == null || task.getTaskConfig() == null ? null : longValue(task.getTaskConfig().get("chapterModelId"));
return chapterModelId != null ? chapterModelId : fallbackSummaryModelId;
}
private Long resolveMeetingPromptId(Meeting meeting, AiTask task) {
if (meeting != null && meeting.getPromptId() != null) {
return meeting.getPromptId();
}
return task == null || task.getTaskConfig() == null ? null : longValue(task.getTaskConfig().get("promptId"));
}
private String resolveMeetingSummaryDetailLevel(Meeting meeting, AiTask task) {
if (meeting != null && meeting.getSummaryDetailLevel() != null && !meeting.getSummaryDetailLevel().isBlank()) {
return resolveSummaryDetailLevel(meeting.getSummaryDetailLevel());
}
return resolveSummaryDetailLevel(task == null || task.getTaskConfig() == null ? null : stringValue(task.getTaskConfig().get("summaryDetailLevel")));
}
private String resolveMeetingUserPrompt(AiTask task) {
return task == null || task.getTaskConfig() == null ? null : stringValue(task.getTaskConfig().get("userPrompt"));
}
private Map<String, Object> buildSummaryTaskConfigForRetry(AiTask summaryTask,
Long summaryModelId,
Long chapterModelId,
Long promptId,
String userPrompt,
String summaryDetailLevel) {
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
summaryModelId,
chapterModelId,
promptId,
userPrompt,
summaryDetailLevel
);
String chargeTriggerType = summaryTask == null || summaryTask.getTaskConfig() == null
? null
: stringValue(summaryTask.getTaskConfig().get("chargeTriggerType"));
taskConfig.put("chargeTriggerType", chargeTriggerType == null || chargeTriggerType.isBlank()
? "AUTO_SUMMARY"
: chargeTriggerType.trim().toUpperCase());
return taskConfig;
}
private AiTask resolveOwnedTask(Long taskId, Long meetingId, String expectedType) {
if (taskId == null) {
return null;
@ -1424,14 +1566,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
return requestedHostUserId != null ? requestedHostUserId : creatorId;
}
private String resolveHostName(String requestedHostName, String creatorName, Long creatorId, Long hostUserId) {
if (requestedHostName != null && !requestedHostName.isBlank()) {
return requestedHostName.trim();
}
if (hostUserId != null && Objects.equals(hostUserId, creatorId)) {
return creatorName;
}
return null;
private String resolveMeetingUserName(Long userId, String fallbackName) {
return meetingDomainSupport.resolveUserDisplayName(userId, fallbackName);
}
private String resolveSummaryDetailLevel(String requestedSummaryDetailLevel) {

View File

@ -64,16 +64,20 @@ 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, String summaryDetailLevel, int status) {
Long hostUserId, String hostName,
Long summaryModelId, Long promptId,
String summaryDetailLevel, int status) {
return initMeeting(title, meetingTime, participants, tags, audioUrl, meetingType, meetingSource,
tenantId, creatorId, creatorName, hostUserId, hostName, summaryDetailLevel, status,
tenantId, creatorId, creatorName, hostUserId, hostName, summaryModelId, promptId, summaryDetailLevel, status,
null, null);
}
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, String summaryDetailLevel, int status,
Long hostUserId, String hostName,
Long summaryModelId, Long promptId,
String summaryDetailLevel, int status,
String sourceDeviceCode, String sourceDeviceMode) {
Meeting meeting = new Meeting();
meeting.setTitle(title);
@ -90,6 +94,8 @@ public class MeetingDomainSupport {
meeting.setAudioUrl(audioUrl);
meeting.setSourceDeviceCode(sourceDeviceCode);
meeting.setSourceDeviceMode(sourceDeviceMode);
meeting.setSummaryModelId(summaryModelId);
meeting.setPromptId(promptId);
meeting.setSummaryDetailLevel(normalizeSummaryDetailLevel(summaryDetailLevel));
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_NONE);
if (MeetingConstants.TYPE_OFFLINE.equalsIgnoreCase(meetingType)) {
@ -99,16 +105,21 @@ public class MeetingDomainSupport {
return meeting;
}
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) {
return createSummaryTask(
meetingId,
summaryModelId,
summaryModelId,
promptId,
userPrompt,
MeetingConstants.SUMMARY_DETAIL_STANDARD,
"AUTO_SUMMARY"
);
public String resolveUserDisplayName(Long userId, String fallbackName) {
if (userId == null) {
return fallbackName;
}
SysUser user = sysUserMapper.selectById(userId);
if (user == null) {
return fallbackName;
}
if (user.getDisplayName() != null && !user.getDisplayName().isBlank()) {
return user.getDisplayName().trim();
}
if (user.getUsername() != null && !user.getUsername().isBlank()) {
return user.getUsername().trim();
}
return fallbackName;
}
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long promptId,
@ -137,16 +148,6 @@ public class MeetingDomainSupport {
);
}
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) {
@ -166,17 +167,7 @@ public class MeetingDomainSupport {
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,
"AUTO_SUMMARY"
);
}
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId,
String userPrompt, String summaryDetailLevel) {
@ -406,6 +397,8 @@ public class MeetingDomainSupport {
vo.setSourceDeviceCode(meeting.getSourceDeviceCode());
vo.setSourceDeviceMode(meeting.getSourceDeviceMode());
vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus());
vo.setSummaryModelId(meeting.getSummaryModelId());
vo.setPromptId(meeting.getPromptId());
vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel()));
vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage());

View File

@ -7,6 +7,7 @@ import com.imeeting.dto.biz.MeetingPointsChargeItemVO;
import com.imeeting.dto.biz.MeetingPointsLedgerDetailVO;
import com.imeeting.dto.biz.MeetingPointsLedgerListItemVO;
import com.imeeting.dto.biz.MeetingPointsOverviewVO;
import com.imeeting.dto.biz.MeetingPointsPersonalAccountVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingPointsAccount;
import com.imeeting.entity.biz.MeetingPointsLedger;
@ -71,22 +72,28 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
}
@Override
public MeetingPointsOverviewVO getOverview(Long tenantId) {
public MeetingPointsOverviewVO getOverview(Long tenantId, Long userId, boolean isAdmin) {
String accountMode = resolveAccountMode();
String chargePriority = resolveChargePriority();
MeetingPointsAccount publicAccount = findAccount(tenantId, PUBLIC_ACCOUNT_USER_ID);
long publicBalance = publicAccount == null ? 0L : defaultLong(publicAccount.getCurrentBalance());
long publicTotalUsed = publicAccount == null ? 0L : defaultLong(publicAccount.getTotalPointsUsed());
long personalBalance = 0L;
long personalTotalUsed = 0L;
List<MeetingPointsAccount> personalAccounts = meetingPointsAccountService.list(new LambdaQueryWrapper<MeetingPointsAccount>()
.eq(MeetingPointsAccount::getTenantId, tenantId)
.ne(MeetingPointsAccount::getUserId, PUBLIC_ACCOUNT_USER_ID));
long personalBalance = 0L;
long personalTotalUsed = 0L;
if (isAdmin) {
for (MeetingPointsAccount account : personalAccounts) {
personalBalance += defaultLong(account.getCurrentBalance());
personalTotalUsed += defaultLong(account.getTotalPointsUsed());
}
} else {
MeetingPointsAccount currentUserAccount = findAccount(tenantId, userId);
personalBalance = currentUserAccount == null ? 0L : defaultLong(currentUserAccount.getCurrentBalance());
personalTotalUsed = currentUserAccount == null ? 0L : defaultLong(currentUserAccount.getTotalPointsUsed());
}
List<Long> scopedOwnerUserIds = resolveScopedOwnerUserIds(tenantId);
long totalChargeCount = 0L;
@ -110,6 +117,8 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
vo.setPersonalTotalPointsUsed(personalTotalUsed);
vo.setTotalAvailableBalance(resolveVisibleTotalBalance(accountMode, publicBalance, personalBalance));
vo.setTotalChargeCount(totalChargeCount);
vo.setAdmin(isAdmin);
vo.setPersonalAccounts(buildPersonalAccountOverview(accountMode, isAdmin, personalAccounts));
return vo;
}
@ -422,6 +431,39 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
return publicBalance + personalBalance;
}
private List<MeetingPointsPersonalAccountVO> buildPersonalAccountOverview(String accountMode,
boolean isAdmin,
List<MeetingPointsAccount> personalAccounts) {
if (!isAdmin || (!ACCOUNT_MODE_PERSONAL.equals(accountMode) && !ACCOUNT_MODE_BOTH.equals(accountMode))) {
return Collections.emptyList();
}
if (personalAccounts == null || personalAccounts.isEmpty()) {
return Collections.emptyList();
}
List<Long> userIds = personalAccounts.stream()
.map(MeetingPointsAccount::getUserId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, SysUser> userMap = sysUserMapper.selectBatchIds(userIds).stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(SysUser::getUserId, user -> user, (left, right) -> left, HashMap::new));
return personalAccounts.stream()
.sorted((left, right) -> Long.compare(defaultLong(right.getCurrentBalance()), defaultLong(left.getCurrentBalance())))
.map(account -> {
SysUser user = userMap.get(account.getUserId());
MeetingPointsPersonalAccountVO item = new MeetingPointsPersonalAccountVO();
item.setUserId(account.getUserId());
item.setUsername(user == null ? null : user.getUsername());
item.setDisplayName(resolveOwnerName(user, account.getUserId()));
item.setCurrentBalance(defaultLong(account.getCurrentBalance()));
item.setTotalPointsUsed(defaultLong(account.getTotalPointsUsed()));
return item;
})
.toList();
}
private long defaultLong(Long value) {
return value == null ? 0L : value;
}

View File

@ -146,9 +146,21 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
throw new RuntimeException("缺少可用的总结任务配置");
}
Long summaryModelId = firstLong(request == null ? null : request.getSummaryModelId(), latestSummaryTask.getTaskConfig().get("summaryModelId"));
Long chapterModelId = firstLong(request == null ? null : request.getChapterModelId(), latestSummaryTask.getTaskConfig().get("chapterModelId"), summaryModelId);
Long promptId = firstLong(request == null ? null : request.getPromptId(), latestSummaryTask.getTaskConfig().get("promptId"));
Long summaryModelId = firstLong(
request == null ? null : request.getSummaryModelId(),
meeting.getSummaryModelId(),
latestSummaryTask.getTaskConfig().get("summaryModelId")
);
Long chapterModelId = firstLong(
request == null ? null : request.getChapterModelId(),
latestSummaryTask.getTaskConfig().get("chapterModelId"),
summaryModelId
);
Long promptId = firstLong(
request == null ? null : request.getPromptId(),
meeting.getPromptId(),
latestSummaryTask.getTaskConfig().get("promptId")
);
String userPrompt = request != null && request.getUserPrompt() != null
? request.getUserPrompt()
: stringValue(latestSummaryTask.getTaskConfig().get("userPrompt"));

View File

@ -33,6 +33,8 @@ export interface MeetingVO {
sourceDeviceCode?: string;
sourceDeviceMode?: "PUBLIC" | "PRIVATE";
summaryDetailLevel?: SummaryDetailLevel;
summaryModelId: number;
promptId?: number;
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
audioSaveMessage?: string;
accessPassword?: string;
@ -150,6 +152,9 @@ export interface UpdateMeetingBasicCommand {
meetingTime?: string;
tags?: string;
accessPassword?: string | null;
summaryModelId: number;
promptId?: number;
summaryDetailLevel?: SummaryDetailLevel;
}
export type MeetingUpdateBasicDTO = UpdateMeetingBasicCommand;

View File

@ -9,6 +9,16 @@ export interface MeetingPointsOverviewVO {
personalTotalPointsUsed: number;
totalAvailableBalance: number;
totalChargeCount: number;
admin?: boolean;
personalAccounts?: MeetingPointsPersonalAccountVO[];
}
export interface MeetingPointsPersonalAccountVO {
userId: number;
username?: string;
displayName?: string;
currentBalance: number;
totalPointsUsed: number;
}
export interface MeetingPointsChargeItemVO {

View File

@ -1799,9 +1799,13 @@ const MeetingDetail: React.FC = () => {
summaryForm.setFieldsValue({
summaryModelId:
summaryForm.getFieldValue('summaryModelId') ??
meeting?.summaryModelId ??
llmModels.find((model) => model.isDefault === 1)?.id ??
llmModels[0]?.id,
promptId: summaryForm.getFieldValue('promptId') ?? prompts[0]?.id,
promptId:
summaryForm.getFieldValue('promptId') ??
meeting?.promptId ??
prompts[0]?.id,
userPrompt: meeting?.lastUserPrompt ?? '',
summaryDetailLevel:
summaryForm.getFieldValue('summaryDetailLevel') ??

View File

@ -1,10 +1,11 @@
import { EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
import { listUsers } from "@/api";
import {
Button,
Card,
Col,
Descriptions,
Empty,
Form,
Input,
InputNumber,
@ -31,10 +32,11 @@ import {
type MeetingPointsLedgerDetailVO,
type MeetingPointsLedgerListItemVO,
type MeetingPointsOverviewVO,
type MeetingPointsPersonalAccountVO,
} from "@/api/business/meetingPoints";
import type { SysUser } from "@/types";
const { Text } = Typography;
const { Text, Title } = Typography;
const POINTS_TYPE_OPTIONS = [
{ label: "全部类型", value: "" },
@ -42,9 +44,13 @@ const POINTS_TYPE_OPTIONS = [
{ label: "总结", value: "LLM" },
];
const ACCOUNT_MODE_PUBLIC = "PUBLIC";
const ACCOUNT_MODE_PERSONAL = "PERSONAL";
const ACCOUNT_MODE_BOTH = "BOTH";
function getAccountModeLabel(mode?: string) {
if (mode === "PERSONAL") return "个人账户";
if (mode === "BOTH") return "公共和个人共存";
if (mode === ACCOUNT_MODE_PERSONAL) return "个人账户";
if (mode === ACCOUNT_MODE_BOTH) return "公共 + 个人";
return "公共账户";
}
@ -83,11 +89,79 @@ function formatDateTime(value?: string) {
return value ? value.replace("T", " ").substring(0, 19) : "-";
}
function buildSummaryCards(overview: MeetingPointsOverviewVO | null) {
if (!overview) {
return [];
}
const isAdmin = Boolean(overview.admin);
const isPublicOnly = overview.accountMode === ACCOUNT_MODE_PUBLIC;
const isPersonalOnly = overview.accountMode === ACCOUNT_MODE_PERSONAL;
const showPublicSummary = !isPersonalOnly || isAdmin;
const showPersonalSummary = !isPublicOnly;
const cards: Array<{
key: string;
title: string;
value: number;
accent: string;
note: string;
}> = [];
if (showPublicSummary) {
cards.push(
{
key: "public-balance",
title: "公共账户余额",
value: overview.publicBalance ?? 0,
accent: "linear-gradient(135deg, rgba(18, 87, 241, 0.14), rgba(18, 87, 241, 0.02))",
note: "用于统一分配和公共扣费",
},
{
key: "public-used",
title: "公共账户累计消耗积分",
value: overview.publicTotalPointsUsed ?? 0,
accent: "linear-gradient(135deg, rgba(11, 132, 98, 0.14), rgba(11, 132, 98, 0.02))",
note: "公共账户历史累计扣减",
},
);
}
if (showPersonalSummary) {
cards.push(
{
key: "personal-balance",
title: isAdmin ? "个人账户余额汇总" : "个人账户余额",
value: overview.personalBalance ?? 0,
accent: "linear-gradient(135deg, rgba(148, 77, 255, 0.14), rgba(148, 77, 255, 0.02))",
note: isAdmin ? "管理员视角下的个人账户汇总" : "当前账号可用积分",
},
{
key: "personal-used",
title: isAdmin ? "个人账户累计消耗积分汇总" : "个人账户累计消耗积分",
value: overview.personalTotalPointsUsed ?? 0,
accent: "linear-gradient(135deg, rgba(237, 108, 2, 0.14), rgba(237, 108, 2, 0.02))",
note: isAdmin ? "管理员视角下的个人账户消耗汇总" : "当前账号历史累计扣减",
},
);
}
cards.push({
key: "charge-count",
title: "累计消耗次数",
value: overview.totalChargeCount ?? 0,
accent: "linear-gradient(135deg, rgba(48, 48, 48, 0.12), rgba(48, 48, 48, 0.02))",
note: "已发生扣费的总结记录数",
});
return cards;
}
export default function MeetingPointsManagement() {
const [overview, setOverview] = useState<MeetingPointsOverviewVO | null>(null);
const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [transferLoading, setTransferLoading] = useState(false);
const [usersLoading, setUsersLoading] = useState(false);
const [records, setRecords] = useState<MeetingPointsLedgerListItemVO[]>([]);
const [total, setTotal] = useState(0);
const [detailOpen, setDetailOpen] = useState(false);
@ -102,14 +176,26 @@ export default function MeetingPointsManagement() {
});
const [transferForm] = Form.useForm();
const isAdmin = Boolean(overview?.admin);
const isPublicOnly = overview?.accountMode === ACCOUNT_MODE_PUBLIC;
const isPersonalOnly = overview?.accountMode === ACCOUNT_MODE_PERSONAL;
const showTransferButton = isAdmin && !isPublicOnly;
const showPersonalGallery = isAdmin && !isPublicOnly;
const summaryCards = buildSummaryCards(overview);
const loadOverview = async () => {
const data = await getMeetingPointsOverview();
setOverview(data);
};
const loadUsers = async () => {
setUsersLoading(true);
try {
const data = await listUsers();
setUsers(data || []);
} finally {
setUsersLoading(false);
}
};
const loadPage = async (nextParams = params) => {
@ -124,7 +210,7 @@ export default function MeetingPointsManagement() {
};
useEffect(() => {
void Promise.all([loadOverview(), loadPage(), loadUsers()]);
void Promise.all([loadOverview(), loadPage()]);
}, []);
const handleSearch = () => {
@ -145,7 +231,7 @@ export default function MeetingPointsManagement() {
};
const handleRefresh = async () => {
await Promise.all([loadOverview(), loadPage(), loadUsers()]);
await Promise.all([loadOverview(), loadPage()]);
message.success("已刷新积分数据");
};
@ -160,6 +246,13 @@ export default function MeetingPointsManagement() {
}
};
const handleOpenTransfer = async () => {
setTransferOpen(true);
if (!users.length) {
await loadUsers();
}
};
const handleTransferSubmit = async () => {
const values = await transferForm.validateFields();
setTransferLoading(true);
@ -281,14 +374,16 @@ export default function MeetingPointsManagement() {
return (
<PageContainer
title="积分管理"
subtitle="查看公共账户、个人账户和总结扣费流水"
subtitle="根据当前扣费模式查看公共账户、个人账户和会议积分消耗轨迹"
headerExtra={
<Space>
<Tag color="processing">{getAccountModeLabel(overview?.accountMode)}</Tag>
<Tag color="blue">{getChargePriorityLabel(overview?.chargePriority)}</Tag>
<Button icon={<PlusOutlined />} onClick={() => setTransferOpen(true)}>
{showTransferButton ? (
<Button icon={<PlusOutlined />} onClick={() => void handleOpenTransfer()}>
</Button>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => void handleRefresh()}>
</Button>
@ -317,41 +412,176 @@ export default function MeetingPointsManagement() {
</Space>
}
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} md={6}>
<Card>
<Statistic title="当前模式可用总积分" value={overview?.totalAvailableBalance ?? 0} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card>
<Statistic title="公共账户余额" value={overview?.publicBalance ?? 0} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card>
<Statistic title="个人账户余额汇总" value={overview?.personalBalance ?? 0} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card>
<Statistic title="累计消耗次数" value={overview?.totalChargeCount ?? 0} />
</Card>
</Col>
</Row>
<Card
bordered={false}
style={{
marginBottom: 16,
borderRadius: 24,
background:
"linear-gradient(180deg, rgba(248,250,252,0.96) 0%, rgba(255,255,255,0.98) 100%)",
boxShadow: "0 18px 40px rgba(15, 23, 42, 0.06)",
}}
>
<Space direction="vertical" size={20} style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, flexWrap: "wrap" }}>
<div>
<Text type="secondary" style={{ letterSpacing: 1.4 }}>
POINTS OPERATIONS BOARD
</Text>
<Title level={4} style={{ margin: "8px 0 0" }}>
{isPublicOnly
? "当前为公共账户结算视图"
: isPersonalOnly
? "当前为个人账户结算视图"
: "当前为公共与个人混合结算视图"}
</Title>
</div>
<div style={{ textAlign: "right" }}>
<Text type="secondary"></Text>
<div style={{ marginTop: 6 }}>
<Tag color={isAdmin ? "gold" : "default"}>{isAdmin ? "管理员视角" : "当前用户视角"}</Tag>
</div>
</div>
</div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} md={12}>
<Card>
<Statistic title="公共账户累计消耗积分" value={overview?.publicTotalPointsUsed ?? 0} />
</Card>
</Col>
<Col xs={24} md={12}>
<Card>
<Statistic title="个人账户累计消耗积分汇总" value={overview?.personalTotalPointsUsed ?? 0} />
<Row gutter={[16, 16]}>
{summaryCards.map((item) => (
<Col xs={24} md={12} xl={24 / Math.min(summaryCards.length, 5)} key={item.key}>
<Card
size="small"
style={{
borderRadius: 20,
border: "1px solid rgba(15, 23, 42, 0.06)",
background: item.accent,
minHeight: 142,
}}
styles={{ body: { padding: 18 } }}
>
<Space direction="vertical" size={10} style={{ width: "100%" }}>
<Text type="secondary">{item.title}</Text>
<Statistic value={item.value} valueStyle={{ fontSize: 30, fontWeight: 700, color: "#111827" }} />
<Text type="secondary" style={{ fontSize: 12 }}>
{item.note}
</Text>
</Space>
</Card>
</Col>
))}
</Row>
</Space>
</Card>
{showPersonalGallery ? (
<Card
bordered={false}
style={{
marginBottom: 16,
borderRadius: 24,
background: "linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(245,247,250,0.96) 100%)",
boxShadow: "0 16px 34px rgba(15, 23, 42, 0.05)",
}}
>
<Space direction="vertical" size={18} style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, flexWrap: "wrap" }}>
<div>
<Title level={5} style={{ margin: 0 }}>
</Title>
<Text type="secondary">
</Text>
</div>
<Tag color="purple">{overview?.personalAccounts?.length ?? 0} </Tag>
</div>
{overview?.personalAccounts?.length ? (
<Row gutter={[16, 16]}>
{overview.personalAccounts.map((account: MeetingPointsPersonalAccountVO, index) => (
<Col xs={24} sm={12} xl={8} xxl={6} key={account.userId}>
<Card
size="small"
style={{
borderRadius: 20,
border: "1px solid rgba(124, 58, 237, 0.08)",
background:
index < 3
? "linear-gradient(145deg, rgba(255,255,255,1) 0%, rgba(245,241,255,0.98) 100%)"
: "linear-gradient(145deg, rgba(255,255,255,1) 0%, rgba(249,250,251,0.98) 100%)",
minHeight: 158,
}}
styles={{ body: { padding: 18 } }}
>
<Space direction="vertical" size={14} style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
<Space size={12} align="start">
<div
style={{
width: 38,
height: 38,
borderRadius: 14,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(124, 58, 237, 0.10)",
color: "#7c3aed",
flexShrink: 0,
}}
>
<UserOutlined />
</div>
<div>
<Text strong style={{ display: "block" }}>
{account.displayName || account.username || `用户 #${account.userId}`}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{account.username ? `@${account.username}` : `ID ${account.userId}`}
</Text>
</div>
</Space>
{index < 3 ? <Tag color="gold">TOP {index + 1}</Tag> : null}
</div>
<div>
<Text type="secondary"></Text>
<div style={{ fontSize: 30, fontWeight: 700, lineHeight: 1.2, color: "#111827" }}>
{account.currentBalance ?? 0}
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 10,
padding: 12,
borderRadius: 16,
background: "rgba(15, 23, 42, 0.03)",
}}
>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<div style={{ fontWeight: 600 }}>{account.totalPointsUsed ?? 0}</div>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<div style={{ fontWeight: 600 }}></div>
</div>
</div>
</Space>
</Card>
</Col>
))}
</Row>
) : (
<Empty description="当前没有可展示的个人账户数据" />
)}
</Space>
</Card>
) : null}
<Card
className="app-page__content-card"
@ -365,7 +595,7 @@ export default function MeetingPointsManagement() {
dataSource={records}
loading={loading}
totalCount={total}
scroll={{ y: "calc(100vh - 470px)", x: 1200 }}
scroll={{ y: "calc(100vh - 510px)", x: 1200 }}
pagination={false}
/>
</div>
@ -447,6 +677,7 @@ export default function MeetingPointsManagement() {
<Form.Item name="targetUserId" label="目标用户" rules={[{ required: true, message: "请选择目标用户" }]}>
<Select
showSearch
loading={usersLoading}
optionFilterProp="label"
placeholder="请选择用户"
options={users

View File

@ -44,6 +44,9 @@ export interface MeetingVO {
meetingSource?: "WEB" | "ANDROID";
sourceDeviceCode?: string;
sourceDeviceMode?: "PUBLIC" | "PRIVATE";
summaryDetailLevel?: "DETAILED" | "STANDARD" | "BRIEF";
summaryModelId?: number;
promptId?: number;
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
audioSaveMessage?: string;
accessPassword?: string | null;
@ -103,6 +106,9 @@ export interface UpdateMeetingBasicCommand {
meetingTime?: string;
tags?: string;
accessPassword?: string | null;
summaryModelId?: number;
promptId?: number;
summaryDetailLevel?: "DETAILED" | "STANDARD" | "BRIEF";
}
export interface MeetingPageResult {