diff --git a/backend/src/main/java/com/imeeting/common/exception/ExistingOfflineMeetingException.java b/backend/src/main/java/com/imeeting/common/exception/ExistingOfflineMeetingException.java new file mode 100644 index 0000000..07865b8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/common/exception/ExistingOfflineMeetingException.java @@ -0,0 +1,13 @@ +package com.imeeting.common.exception; + +import lombok.Getter; + +@Getter +public class ExistingOfflineMeetingException extends RuntimeException { + private final Long meetingId; + + public ExistingOfflineMeetingException(Long meetingId) { + super("有未结束会议"); + this.meetingId = meetingId; + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java index 2c6b072..c983422 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -9,6 +9,8 @@ import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidMeetingConfigVo; import com.imeeting.dto.android.AndroidOfflineMeetingConflictVO; import com.imeeting.dto.android.AndroidOfflineMeetingFinishRequest; +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; @@ -17,8 +19,10 @@ import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult; import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse; import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; import com.imeeting.dto.biz.AiModelVO; +import com.imeeting.dto.biz.MeetingTranscriptVO; import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.PromptTemplateVO; +import com.imeeting.dto.biz.UnifiedMeetingStatusVO; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.PromptTemplate; @@ -93,6 +97,7 @@ public class AndroidMeetingController { private final SysDictItemService dictItemService; private final SysParamService paramService; private final MeetingProgressService meetingProgressService; + private final MeetingUnifiedStatusService meetingUnifiedStatusService; @Autowired public AndroidMeetingController(AndroidAuthService androidAuthService, @@ -108,7 +113,8 @@ public class AndroidMeetingController { AiModelService aiModelService, SysDictItemService dictItemService, SysParamService paramService, - MeetingProgressService meetingProgressService) { + MeetingProgressService meetingProgressService, + MeetingUnifiedStatusService meetingUnifiedStatusService) { this.androidAuthService = androidAuthService; this.androidChunkUploadService = androidChunkUploadService; this.legacyMeetingAdapterService = legacyMeetingAdapterService; @@ -123,6 +129,7 @@ public class AndroidMeetingController { this.aiModelService = aiModelService; this.paramService = paramService; this.dictItemService = dictItemService; + this.meetingUnifiedStatusService = meetingUnifiedStatusService; } @Operation(summary = "创建Android离线会议") @@ -280,6 +287,41 @@ public class AndroidMeetingController { return new ApiResponse<>(result.getCode(), result.getMessage(), result.getData()); } + @Operation(summary = "查询Android会议统一状态") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回统一状态及可选内容", + content = @Content(schema = @Schema(implementation = AndroidUnifiedMeetingStatusResponse.class)) + ) + }) + @PostMapping("/{meetingId}/status") + @Anonymous + public ApiResponse getUnifiedStatus(HttpServletRequest request, + @PathVariable Long meetingId, + @RequestBody(required = false) AndroidUnifiedMeetingStatusRequest command) { + AndroidRequestLogHelper.logRequest(log, "Android会议", "查询会议统一状态", + "meetingId", meetingId, + "request", command); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); + MeetingVO meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser); + UnifiedMeetingStatusVO status = meetingUnifiedStatusService.resolve(meetingId); + boolean includeTranscript = Boolean.TRUE.equals(command == null ? null : command.getIncludeTranscript()); + boolean includeSummary = Boolean.TRUE.equals(command == null ? null : command.getIncludeSummary()); + List transcripts = includeTranscript ? meetingQueryService.getTranscripts(meetingId) : null; + String summaryContent = includeSummary ? meetingQueryService.getDetailIgnoreTenant(meetingId).getSummaryContent() : null; + return ApiResponse.ok(AndroidUnifiedMeetingStatusResponse.builder() + .meetingId(meetingId) + .status(status) + .meeting(meeting) + .includesTranscript(includeTranscript) + .transcripts(transcripts) + .includesSummary(includeSummary) + .summaryContent(summaryContent) + .build()); + } + @Operation(summary = "更新Android会议访问密码") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index e0c1f64..cbd4b88 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -31,6 +31,7 @@ import com.imeeting.service.biz.MeetingExportService; import com.imeeting.service.biz.MeetingProgressService; import com.imeeting.service.biz.MeetingQueryService; import com.imeeting.service.biz.MeetingTranscriptFileService; +import com.imeeting.service.biz.MeetingUnifiedStatusService; import com.imeeting.service.biz.PromptTemplateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSocketSessionService; @@ -87,6 +88,7 @@ public class MeetingController { private final MeetingProgressService meetingProgressService; private final SysParamService sysParamService; private final AiTaskService aiTaskService; + private final MeetingUnifiedStatusService meetingUnifiedStatusService; @Value("${imeeting.h5.base-url:}") private String h5BaseUrl; @@ -103,7 +105,8 @@ public class MeetingController { MeetingAudioUploadSupport meetingAudioUploadSupport, MeetingProgressService meetingProgressService, SysParamService sysParamService, - AiTaskService aiTaskService) { + AiTaskService aiTaskService, + MeetingUnifiedStatusService meetingUnifiedStatusService) { this.meetingQueryService = meetingQueryService; this.meetingCommandService = meetingCommandService; this.meetingAccessService = meetingAccessService; @@ -116,6 +119,7 @@ public class MeetingController { this.meetingProgressService = meetingProgressService; this.sysParamService = sysParamService; this.aiTaskService = aiTaskService; + this.meetingUnifiedStatusService = meetingUnifiedStatusService; } @Operation(summary = "查询会议处理进度") @@ -139,7 +143,9 @@ public class MeetingController { return ApiResponse.ok(Map.of("percent", 5, "message", "识别中,等待进度刷新...")); } } - return ApiResponse.ok(progress); + Map payload = new LinkedHashMap<>(progress); + payload.put("unifiedStatus", meetingUnifiedStatusService.resolve(id)); + return ApiResponse.ok(payload); } @@ -172,7 +178,9 @@ public class MeetingController { progress = Map.of("percent", 5, "message", "识别中,等待进度刷新..."); } } - result.put(id, progress); + Map payload = new LinkedHashMap<>(progress); + payload.put("unifiedStatus", meetingUnifiedStatusService.resolve(id)); + result.put(id, payload); } catch (RuntimeException ignored) { // Ignore inaccessible meetings in batch mode. } diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidOfflineMeetingConflictVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidOfflineMeetingConflictVO.java new file mode 100644 index 0000000..ec14438 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidOfflineMeetingConflictVO.java @@ -0,0 +1,13 @@ +package com.imeeting.dto.android; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +@Schema(description = "Android 离线会议冲突信息") +public class AndroidOfflineMeetingConflictVO { + @Schema(description = "未结束会议ID") + private Long meetingId; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidOfflineMeetingFinishRequest.java b/backend/src/main/java/com/imeeting/dto/android/AndroidOfflineMeetingFinishRequest.java new file mode 100644 index 0000000..bed647d --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidOfflineMeetingFinishRequest.java @@ -0,0 +1,17 @@ +package com.imeeting.dto.android; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "Android 离线会议结束请求") +public class AndroidOfflineMeetingFinishRequest { + @JsonProperty("finish_stage") + @Schema(description = "结束阶段:PRE_END / UPLOAD_FINISHED") + private String finishStage; + + @JsonProperty("total_chunks") + @Schema(description = "总分片数,finish_stage=UPLOAD_FINISHED 时必填") + private Integer totalChunks; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidPublicLoginConfirmPayload.java b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicLoginConfirmPayload.java new file mode 100644 index 0000000..4361a00 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicLoginConfirmPayload.java @@ -0,0 +1,23 @@ +package com.imeeting.dto.android; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "公有设备扫码登录确认消息载荷") +public class AndroidPublicLoginConfirmPayload { + @Schema(description = "扫码会话ID") + private String sessionId; + + @Schema(description = "租户ID") + private Long tenantId; + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "显示名称") + private String displayName; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionResultVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionResultVO.java new file mode 100644 index 0000000..58c2bd0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionResultVO.java @@ -0,0 +1,17 @@ +package com.imeeting.dto.android; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "公有设备扫码会话结果") +public class AndroidPublicMeetingSessionResultVO { + @Schema(description = "返回模式:QR_CODE / PENDING_MESSAGE") + private String mode; + + @Schema(description = "二维码信息,仅 mode=QR_CODE 时返回") + private AndroidPublicMeetingSessionVO qrCode; + + @Schema(description = "待处理消息,仅 mode=PENDING_MESSAGE 时返回") + private AndroidPushMessageVO message; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidPushMessageVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidPushMessageVO.java new file mode 100644 index 0000000..da0d6ea --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidPushMessageVO.java @@ -0,0 +1,26 @@ +package com.imeeting.dto.android; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "Android 推送消息视图") +public class AndroidPushMessageVO { + @Schema(description = "消息ID") + private String messageId; + + @Schema(description = "消息时间戳,毫秒") + private Long timestamp; + + @Schema(description = "消息类型") + private String type; + + @Schema(description = "消息标题") + private String title; + + @Schema(description = "消息内容") + private String content; + + @Schema(description = "是否需要ACK") + private Boolean needAck; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidUnifiedMeetingStatusRequest.java b/backend/src/main/java/com/imeeting/dto/android/AndroidUnifiedMeetingStatusRequest.java new file mode 100644 index 0000000..3e5564f --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidUnifiedMeetingStatusRequest.java @@ -0,0 +1,14 @@ +package com.imeeting.dto.android; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "Android 统一会议状态查询请求") +public class AndroidUnifiedMeetingStatusRequest { + @Schema(description = "是否附带转录内容,默认 false") + private Boolean includeTranscript; + + @Schema(description = "是否附带总结内容,默认 false") + private Boolean includeSummary; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidUnifiedMeetingStatusResponse.java b/backend/src/main/java/com/imeeting/dto/android/AndroidUnifiedMeetingStatusResponse.java new file mode 100644 index 0000000..839befc --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidUnifiedMeetingStatusResponse.java @@ -0,0 +1,36 @@ +package com.imeeting.dto.android; + +import com.imeeting.dto.biz.MeetingTranscriptVO; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.dto.biz.UnifiedMeetingStatusVO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +@Schema(description = "Android 统一会议状态响应") +public class AndroidUnifiedMeetingStatusResponse { + @Schema(description = "会议ID") + private Long meetingId; + + @Schema(description = "统一状态") + private UnifiedMeetingStatusVO status; + + @Schema(description = "会议基础信息") + private MeetingVO meeting; + + @Schema(description = "是否附带转录内容") + private Boolean includesTranscript; + + @Schema(description = "转录内容,可选返回") + private List transcripts; + + @Schema(description = "是否附带总结内容") + private Boolean includesSummary; + + @Schema(description = "总结内容,可选返回") + private String summaryContent; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/UnifiedMeetingStatusStage.java b/backend/src/main/java/com/imeeting/dto/biz/UnifiedMeetingStatusStage.java new file mode 100644 index 0000000..1831b96 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/UnifiedMeetingStatusStage.java @@ -0,0 +1,25 @@ +package com.imeeting.dto.biz; + +import lombok.Getter; + +@Getter +public enum UnifiedMeetingStatusStage { + WAITING_UPLOAD("WAITING_UPLOAD", "待上传录音文件", false), + INITIALIZING("INITIALIZING", "数据初始化", false), + TRANSCRIBING("TRANSCRIBING", "转译音频", false), + SUMMARIZING("SUMMARIZING", "生成总结", false), + COMPLETED("COMPLETED", "处理完成", true), + FAILED_INITIALIZING("FAILED_INITIALIZING", "数据初始化失败", true), + FAILED_TRANSCRIBING("FAILED_TRANSCRIBING", "转译音频失败", true), + FAILED_SUMMARIZING("FAILED_SUMMARIZING", "生成总结失败", true); + + private final String code; + private final String text; + private final boolean terminal; + + UnifiedMeetingStatusStage(String code, String text, boolean terminal) { + this.code = code; + this.text = text; + this.terminal = terminal; + } +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/UnifiedMeetingStatusVO.java b/backend/src/main/java/com/imeeting/dto/biz/UnifiedMeetingStatusVO.java new file mode 100644 index 0000000..bf4107c --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/UnifiedMeetingStatusVO.java @@ -0,0 +1,20 @@ +package com.imeeting.dto.biz; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class UnifiedMeetingStatusVO { + private Long meetingId; + private String statusCode; + private String statusText; + private Integer percent; + private String message; + private Integer eta; + private String failedStageCode; + private String failedStageText; + private Boolean canViewTranscript; + private Boolean canViewAiChapters; + private Boolean canViewSummary; +} diff --git a/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java b/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java index 723007a..4582fb7 100644 --- a/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java +++ b/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java @@ -6,7 +6,8 @@ import lombok.Getter; public enum MeetingPushTypeEnum { PUBLIC_MEETING_LOGIN_CONFIRM("PUBLIC_MEETING_LOGIN_CONFIRM", "公有设备扫码登录确认消息"), MEETING_PENDING("MEETING_PENDING", "待开始会议通知"), - MEETING_COMPLETED("MEETING_COMPLETED", "会议完成通知"); + MEETING_COMPLETED("MEETING_COMPLETED", "会议完成通知"), + MEETING_STATUS_CHANGED("MEETING_STATUS_CHANGED", "会议状态变更通知"); private final String code; private final String desc; diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java b/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java index 72e1939..84328df 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java @@ -8,4 +8,6 @@ public interface AndroidMeetingPushService { void pushPublicLoginConfirm(String deviceId, AndroidPublicLoginConfirmPayload payload); void pushMeetingCompleted(Long meetingId); + + void pushMeetingStatusChanged(Long meetingId, String statusCode); } diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java index d93c083..8bff325 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java @@ -2,18 +2,16 @@ package com.imeeting.service.android.impl; import cn.hutool.json.JSONUtil; import com.imeeting.dto.android.AndroidPublicLoginConfirmPayload; -import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.entity.biz.Meeting; import com.imeeting.enums.MeetingPushTypeEnum; import com.imeeting.grpc.push.PushMessage; +import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidMeetingPushService; import com.imeeting.service.android.AndroidPushMessageService; -import com.imeeting.service.biz.MeetingQueryService; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -26,13 +24,15 @@ import java.util.UUID; @Service public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService { private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + @Autowired - @Lazy - private MeetingQueryService meetingQueryService; - @Autowired - private AndroidGatewayPushService androidGatewayPushService; - @Autowired - private AndroidPushMessageService androidPushMessageService; + private MeetingMapper meetingMapper; + + @Autowired + private AndroidGatewayPushService androidGatewayPushService; + + @Autowired + private AndroidPushMessageService androidPushMessageService; @Value("${imeeting.android.push.pending-expire-minutes:30}") private long pendingExpireMinutes; @@ -42,7 +42,7 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService if (meetingId == null || deviceId == null || deviceId.isBlank()) { return; } - MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId); + Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId); if (meeting == null) { return; } @@ -89,7 +89,7 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService if (meetingId == null) { return; } - MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId); + Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId); if (meeting == null || meeting.getTenantId() == null || meeting.getCreatorId() == null) { return; } @@ -106,29 +106,51 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService meetingId, meeting.getTenantId(), meeting.getCreatorId(), pushed); } - private String resolvePendingTitle(MeetingVO meeting) { + @Override + public void pushMeetingStatusChanged(Long meetingId, String statusCode) { + if (meetingId == null || statusCode == null || statusCode.isBlank()) { + return; + } + Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId); + if (meeting == null || meeting.getSourceDeviceCode() == null || meeting.getSourceDeviceCode().isBlank()) { + return; + } + PushMessage message = PushMessage.newBuilder() + .setMessageId("meeting_status_changed:" + meetingId + ":" + UUID.randomUUID()) + .setTimestamp(System.currentTimeMillis()) + .setType(MeetingPushTypeEnum.MEETING_STATUS_CHANGED.getCode()) + .setTitle("会议状态已更新") + .setContent(buildStatusChangedContent(meetingId, statusCode)) + .setNeedAck(false) + .build(); + int pushed = androidGatewayPushService.pushToDevice(meeting.getSourceDeviceCode(), message); + log.info("Android meeting status change push finished, meetingId={}, deviceId={}, statusCode={}, pushedConnections={}", + meetingId, meeting.getSourceDeviceCode(), statusCode, pushed); + } + + private String resolvePendingTitle(Meeting meeting) { String title = meeting.getTitle(); if (title != null && !title.isBlank()) { - return "待开始会议: " + title.trim(); + return "待开始会议 " + title.trim(); } LocalDateTime meetingTime = meeting.getMeetingTime(); return meetingTime == null ? "待开始会议" - : "待开始会议: " + TITLE_TIME_FORMATTER.format(meetingTime); + : "待开始会议 " + TITLE_TIME_FORMATTER.format(meetingTime); } - private String resolveCompletedTitle(MeetingVO meeting) { + private String resolveCompletedTitle(Meeting meeting) { String title = meeting.getTitle(); if (title != null && !title.isBlank()) { - return "会议已完成: " + title.trim(); + return "会议已完成 " + title.trim(); } LocalDateTime meetingTime = meeting.getMeetingTime(); return meetingTime == null ? "会议已完成" - : "会议已完成: " + TITLE_TIME_FORMATTER.format(meetingTime); + : "会议已完成 " + TITLE_TIME_FORMATTER.format(meetingTime); } - private String buildPendingContent(MeetingVO meeting) { + private String buildPendingContent(Meeting meeting) { Map result = new HashMap<>(); result.put("meetingId", meeting.getId()); result.put("title", meeting.getTitle()); @@ -139,9 +161,16 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService return JSONUtil.toJsonStr(result); } - private String buildCompletedContent(MeetingVO meeting) { + private String buildCompletedContent(Meeting meeting) { Map result = new HashMap<>(); result.put("meetingId", meeting.getId()); return JSONUtil.toJsonStr(result); } + + private String buildStatusChangedContent(Long meetingId, String statusCode) { + Map result = new HashMap<>(); + result.put("meetingId", meetingId); + result.put("statusCode", statusCode); + return JSONUtil.toJsonStr(result); + } } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingUnifiedStatusService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingUnifiedStatusService.java new file mode 100644 index 0000000..a07d29f --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingUnifiedStatusService.java @@ -0,0 +1,11 @@ +package com.imeeting.service.biz; + +import com.imeeting.dto.biz.MeetingProgressSnapshot; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.dto.biz.UnifiedMeetingStatusVO; + +public interface MeetingUnifiedStatusService { + UnifiedMeetingStatusVO resolve(MeetingVO meeting, MeetingProgressSnapshot snapshot); + + UnifiedMeetingStatusVO resolve(Long meetingId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index 976627d..2e0bf17 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -1387,7 +1387,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { command.getUseSpkId(), null, null, - command.getEnableTextRefine(), + command.getEnableTextRefine() == null || command.getEnableTextRefine(), null, command.getHotWordGroupId(), command.getHotWords() diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java index 90e6921..6fb76be 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java @@ -4,11 +4,15 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.common.MeetingProgressStage; import com.imeeting.dto.biz.MeetingProgressSnapshot; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.dto.biz.UnifiedMeetingStatusVO; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; import com.imeeting.mapper.biz.AiTaskMapper; import com.imeeting.mapper.biz.MeetingMapper; +import com.imeeting.service.android.AndroidMeetingPushService; import com.imeeting.service.biz.MeetingProgressService; +import com.imeeting.service.biz.MeetingUnifiedStatusService; import com.imeeting.support.redis.MeetingProgressCache; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -29,6 +33,8 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { private final AiTaskMapper aiTaskMapper; private final MeetingProgressCache meetingProgressCache; private final ObjectMapper objectMapper; + private final MeetingUnifiedStatusService meetingUnifiedStatusService; + private final AndroidMeetingPushService androidMeetingPushService; @Override public void clear(Long meetingId) { @@ -116,6 +122,60 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { return; } meetingProgressCache.saveSnapshot(snapshot); + notifyUnifiedStatusChangedIfNeeded(snapshot.getMeetingId(), existing, snapshot); + } + + private void notifyUnifiedStatusChangedIfNeeded(Long meetingId, + MeetingProgressSnapshot existing, + MeetingProgressSnapshot candidate) { + if (meetingId == null || candidate == null) { + return; + } + Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId); + if (meeting == null) { + return; + } + MeetingVO meetingVO = toMeetingVO(meeting); + UnifiedMeetingStatusVO currentStatus = meetingUnifiedStatusService.resolve(meetingVO, candidate); + if (currentStatus == null || currentStatus.getStatusCode() == null || currentStatus.getStatusCode().isBlank()) { + return; + } + UnifiedMeetingStatusVO previousStatus = existing == null ? null : meetingUnifiedStatusService.resolve(meetingVO, existing); + if (previousStatus != null && currentStatus.getStatusCode().equals(previousStatus.getStatusCode())) { + return; + } + androidMeetingPushService.pushMeetingStatusChanged(meetingId, currentStatus.getStatusCode()); + } + + private MeetingVO toMeetingVO(Meeting meeting) { + if (meeting == null) { + return null; + } + MeetingVO vo = new MeetingVO(); + vo.setId(meeting.getId()); + vo.setTenantId(meeting.getTenantId()); + vo.setCreatorId(meeting.getCreatorId()); + vo.setCreatorName(meeting.getCreatorName()); + vo.setHostUserId(meeting.getHostUserId()); + vo.setHostName(meeting.getHostName()); + vo.setTitle(meeting.getTitle()); + vo.setMeetingTime(meeting.getMeetingTime()); + vo.setParticipants(meeting.getParticipants()); + vo.setTags(meeting.getTags()); + vo.setAudioUrl(meeting.getAudioUrl()); + vo.setMeetingType(meeting.getMeetingType()); + vo.setMeetingSource(meeting.getMeetingSource()); + vo.setSourceDeviceCode(meeting.getSourceDeviceCode()); + vo.setSourceDeviceMode(meeting.getSourceDeviceMode()); + vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus()); + vo.setSummaryDetailLevel(meeting.getSummaryDetailLevel()); + vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); + vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); + vo.setAccessPassword(meeting.getAccessPassword()); + vo.setEffectiveAudioDurationSeconds(meeting.getEffectiveAudioDurationSeconds()); + vo.setStatus(meeting.getStatus()); + vo.setCreatedAt(meeting.getCreatedAt()); + return vo; } private MeetingProgressSnapshot buildSnapshot(Long meetingId, diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImpl.java index ed6f69d..157c170 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImpl.java @@ -64,7 +64,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR profile.setResolvedUseSpkId(useSpkId != null ? useSpkId : 1); profile.setResolvedEnablePunctuation(enablePunctuation != null ? enablePunctuation : Boolean.TRUE); profile.setResolvedEnableItn(enableItn != null ? enableItn : Boolean.TRUE); - profile.setResolvedEnableTextRefine(Boolean.TRUE.equals(enableTextRefine)); + profile.setResolvedEnableTextRefine(enableTextRefine==null|| enableTextRefine); profile.setResolvedSaveAudio(Boolean.TRUE.equals(saveAudio)); profile.setResolvedHotWords(resolveHotWords(resolvedTenantId, promptTemplate, hotWordGroupId, hotWords)); return profile; diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUnifiedStatusServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUnifiedStatusServiceImpl.java new file mode 100644 index 0000000..7f17162 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUnifiedStatusServiceImpl.java @@ -0,0 +1,281 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.common.MeetingConstants; +import com.imeeting.dto.biz.MeetingProgressSnapshot; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.dto.biz.UnifiedMeetingStatusStage; +import com.imeeting.dto.biz.UnifiedMeetingStatusVO; +import com.imeeting.entity.biz.AiTask; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.entity.biz.MeetingTranscript; +import com.imeeting.entity.biz.MeetingTranscriptChapterVersion; +import com.imeeting.mapper.biz.AiTaskMapper; +import com.imeeting.mapper.biz.MeetingMapper; +import com.imeeting.mapper.biz.MeetingTranscriptChapterVersionMapper; +import com.imeeting.mapper.biz.MeetingTranscriptMapper; +import com.imeeting.service.biz.MeetingUnifiedStatusService; +import com.imeeting.support.redis.MeetingProgressCache; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusService { + + private final MeetingMapper meetingMapper; + private final AiTaskMapper aiTaskMapper; + private final MeetingTranscriptMapper meetingTranscriptMapper; + private final MeetingTranscriptChapterVersionMapper chapterVersionMapper; + private final MeetingProgressCache meetingProgressCache; + + @Override + public UnifiedMeetingStatusVO resolve(MeetingVO meeting, MeetingProgressSnapshot snapshot) { + if (meeting == null || meeting.getId() == null) { + return null; + } + + UnifiedMeetingStatusStage stage = resolveStage(meeting, snapshot); + UnifiedMeetingStatusStage failedStage = resolveFailedStage(meeting); + boolean failed = failedStage != null; + UnifiedMeetingStatusStage effectiveStage = failed ? failedStage : stage; + + return UnifiedMeetingStatusVO.builder() + .meetingId(meeting.getId()) + .statusCode(effectiveStage.getCode()) + .statusText(effectiveStage.getText()) + .percent(resolvePercent(snapshot, effectiveStage)) + .message(resolveMessage(meeting, snapshot, effectiveStage)) + .eta(snapshot == null ? null : snapshot.getEta()) + .failedStageCode(failedStage == null ? null : failedStage.getCode()) + .failedStageText(failedStage == null ? null : failedStage.getText()) + .canViewTranscript(canViewTranscript(meeting.getId())) + .canViewAiChapters(canViewAiChapters(meeting.getId())) + .canViewSummary(canViewSummary(meeting)) + .build(); + } + + @Override + public UnifiedMeetingStatusVO resolve(Long meetingId) { + if (meetingId == null) { + return null; + } + Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId); + if (meeting == null) { + return null; + } + return resolve(toMeetingVO(meeting), meetingProgressCache.getSnapshot(meetingId)); + } + + private MeetingUnifiedStageContext buildStageContext(Long meetingId, MeetingProgressSnapshot snapshot) { + AiTask latestAsr = findLatestTask(meetingId, "ASR"); + AiTask latestChapter = findLatestTask(meetingId, "CHAPTER"); + AiTask latestSummary = findLatestTask(meetingId, "SUMMARY"); + return new MeetingUnifiedStageContext(latestAsr, latestChapter, latestSummary, snapshot); + } + + private UnifiedMeetingStatusStage resolveStage(MeetingVO meeting, MeetingProgressSnapshot snapshot) { + if (meeting == null) { + return UnifiedMeetingStatusStage.INITIALIZING; + } + if (Integer.valueOf(3).equals(meeting.getStatus())) { + return UnifiedMeetingStatusStage.COMPLETED; + } + UnifiedMeetingStatusStage stageFromSnapshot = resolveStageFromSnapshot(snapshot); + if (stageFromSnapshot != null) { + return stageFromSnapshot; + } + + MeetingUnifiedStageContext context = buildStageContext(meeting.getId(), snapshot); + if (isAndroidOfflineMeetingWaitingUpload(meeting)) { + return UnifiedMeetingStatusStage.WAITING_UPLOAD; + } + if (isTranscribing(context)) { + return UnifiedMeetingStatusStage.TRANSCRIBING; + } + if (isSummarizing(context)) { + return UnifiedMeetingStatusStage.SUMMARIZING; + } + + return UnifiedMeetingStatusStage.INITIALIZING; + } + + private UnifiedMeetingStatusStage resolveStageFromSnapshot(MeetingProgressSnapshot snapshot) { + if (snapshot == null || snapshot.getStage() == null || snapshot.getStage().isBlank()) { + return null; + } + return switch (snapshot.getStage()) { + case "failed" -> null; + case "completed" -> UnifiedMeetingStatusStage.COMPLETED; + case "summary_running", "chapter_running" -> UnifiedMeetingStatusStage.SUMMARIZING; + case "asr_running", "asr_completed", "asr_submitted" -> UnifiedMeetingStatusStage.TRANSCRIBING; + case "queued" -> UnifiedMeetingStatusStage.INITIALIZING; + default -> null; + }; + } + + private UnifiedMeetingStatusStage resolveFailedStage(MeetingVO meeting) { + if (meeting == null || !Integer.valueOf(4).equals(meeting.getStatus())) { + return null; + } + + AiTask summaryTask = findLatestTask(meeting.getId(), "SUMMARY"); + if (isTaskFailed(summaryTask)) { + return UnifiedMeetingStatusStage.FAILED_SUMMARIZING; + } + AiTask chapterTask = findLatestTask(meeting.getId(), "CHAPTER"); + if (isTaskFailed(chapterTask)) { + return UnifiedMeetingStatusStage.FAILED_SUMMARIZING; + } + AiTask asrTask = findLatestTask(meeting.getId(), "ASR"); + if (isTaskFailed(asrTask)) { + return UnifiedMeetingStatusStage.FAILED_TRANSCRIBING; + } + return UnifiedMeetingStatusStage.FAILED_INITIALIZING; + } + + private boolean isAndroidOfflineMeetingWaitingUpload(MeetingVO meeting) { + return meeting != null + && MeetingConstants.TYPE_OFFLINE.equalsIgnoreCase(meeting.getMeetingType()) + && MeetingConstants.SOURCE_ANDROID.equalsIgnoreCase(meeting.getMeetingSource()) + && !MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED.equalsIgnoreCase(meeting.getOfflineRecordingStatus()); + } + + private boolean isSummarizing(MeetingUnifiedStageContext context) { + return isTaskRunning(context.summaryTask()) + || isTaskRunning(context.chapterTask()) + || isTaskCompleted(context.chapterTask()) + || isTaskCompleted(context.summaryTask()); + } + + private boolean isTranscribing(MeetingUnifiedStageContext context) { + if (isTaskRunningOrQueued(context.summaryTask()) || isTaskRunningOrQueued(context.chapterTask())) { + return false; + } + return isTaskRunningOrQueued(context.asrTask()) || isTaskCompleted(context.asrTask()); + } + + private Integer resolvePercent(MeetingProgressSnapshot snapshot, UnifiedMeetingStatusStage stage) { + if (snapshot != null && snapshot.getPercent() != null) { + return snapshot.getPercent(); + } + return switch (stage) { + case WAITING_UPLOAD -> 0; + case INITIALIZING -> 5; + case TRANSCRIBING -> 50; + case SUMMARIZING -> 90; + case COMPLETED -> 100; + case FAILED_INITIALIZING, FAILED_TRANSCRIBING, FAILED_SUMMARIZING -> -1; + }; + } + + private String resolveMessage(MeetingVO meeting, MeetingProgressSnapshot snapshot, UnifiedMeetingStatusStage stage) { + if (snapshot != null && snapshot.getMessage() != null && !snapshot.getMessage().isBlank() && !Objects.equals(snapshot.getMessage(), "Waiting...")) { + return snapshot.getMessage(); + } + if (stage == UnifiedMeetingStatusStage.FAILED_INITIALIZING + || stage == UnifiedMeetingStatusStage.FAILED_TRANSCRIBING + || stage == UnifiedMeetingStatusStage.FAILED_SUMMARIZING) { + return resolveFailureMessage(meeting); + } + return switch (stage) { + case WAITING_UPLOAD -> "待上传录音文件"; + case INITIALIZING -> "数据初始化"; + case TRANSCRIBING -> "转译音频"; + case SUMMARIZING -> "生成总结"; + case COMPLETED -> "处理完成"; + case FAILED_INITIALIZING -> "数据初始化失败"; + case FAILED_TRANSCRIBING -> "转译音频失败"; + case FAILED_SUMMARIZING -> "生成总结失败"; + }; + } + + private String resolveFailureMessage(MeetingVO meeting) { + if (meeting == null) { + return "处理失败"; + } + if (meeting.getLatestSummaryAttemptErrorMsg() != null && !meeting.getLatestSummaryAttemptErrorMsg().isBlank()) { + return meeting.getLatestSummaryAttemptErrorMsg(); + } + if (meeting.getLatestChapterAttemptErrorMsg() != null && !meeting.getLatestChapterAttemptErrorMsg().isBlank()) { + return meeting.getLatestChapterAttemptErrorMsg(); + } + return "处理失败"; + } + + private boolean canViewTranscript(Long meetingId) { + return meetingId != null && meetingTranscriptMapper.selectCount(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId)) > 0; + } + + private boolean canViewAiChapters(Long meetingId) { + return meetingId != null && chapterVersionMapper.selectCount(new LambdaQueryWrapper() + .eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId) + .eq(MeetingTranscriptChapterVersion::getIsCurrent, 1) + .eq(MeetingTranscriptChapterVersion::getStatus, 2)) > 0; + } + + private boolean canViewSummary(MeetingVO meeting) { + return meeting != null && meeting.getSummaryContent() != null && !meeting.getSummaryContent().isBlank(); + } + + private AiTask findLatestTask(Long meetingId, String taskType) { + return aiTaskMapper.selectOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, taskType) + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + } + + private boolean isTaskRunningOrQueued(AiTask task) { + return task != null && (Integer.valueOf(0).equals(task.getStatus()) || Integer.valueOf(1).equals(task.getStatus())); + } + + private boolean isTaskRunning(AiTask task) { + return task != null && Integer.valueOf(1).equals(task.getStatus()); + } + + private boolean isTaskCompleted(AiTask task) { + return task != null && Integer.valueOf(2).equals(task.getStatus()); + } + + private boolean isTaskFailed(AiTask task) { + return task != null && Integer.valueOf(3).equals(task.getStatus()); + } + + private MeetingVO toMeetingVO(Meeting meeting) { + MeetingVO vo = new MeetingVO(); + vo.setId(meeting.getId()); + vo.setTenantId(meeting.getTenantId()); + vo.setCreatorId(meeting.getCreatorId()); + vo.setCreatorName(meeting.getCreatorName()); + vo.setHostUserId(meeting.getHostUserId()); + vo.setHostName(meeting.getHostName()); + vo.setTitle(meeting.getTitle()); + vo.setMeetingTime(meeting.getMeetingTime()); + vo.setParticipants(meeting.getParticipants()); + vo.setTags(meeting.getTags()); + vo.setAudioUrl(meeting.getAudioUrl()); + vo.setMeetingType(meeting.getMeetingType()); + vo.setMeetingSource(meeting.getMeetingSource()); + vo.setSourceDeviceCode(meeting.getSourceDeviceCode()); + vo.setSourceDeviceMode(meeting.getSourceDeviceMode()); + vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus()); + vo.setSummaryDetailLevel(meeting.getSummaryDetailLevel()); + vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); + vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); + vo.setAccessPassword(meeting.getAccessPassword()); + vo.setEffectiveAudioDurationSeconds(meeting.getEffectiveAudioDurationSeconds()); + vo.setStatus(meeting.getStatus()); + vo.setCreatedAt(meeting.getCreatedAt()); + return vo; + } + + private record MeetingUnifiedStageContext(AiTask asrTask, + AiTask chapterTask, + AiTask summaryTask, + MeetingProgressSnapshot snapshot) { + } +} diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index 9aca51b..6251dc6 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -480,6 +480,19 @@ export interface MeetingProgress { eta?: number; queueAheadCount?: number; queuedAt?: string; + unifiedStatus?: { + meetingId: number; + statusCode: string; + statusText: string; + percent?: number; + message?: string; + eta?: number; + failedStageCode?: string; + failedStageText?: string; + canViewTranscript?: boolean; + canViewAiChapters?: boolean; + canViewSummary?: boolean; + }; } export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => { diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 57a9725..4848a9b 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +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, Radio, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd'; import { @@ -241,7 +241,7 @@ const extractSection = (markdown: string, aliases: string[]) => { const parseBulletList = (content?: string | null) => splitLines(content) - .map((line) => line.replace(/^[-*•\s]+/, '').replace(/^\d+[.)]\s*/, '').trim()) + .map((line) => line.replace(/^[-*鈥s]+/, '').replace(/^\d+[.)]\s*/, '').trim()) .filter(Boolean); const parseOverviewSection = (markdown: string) => @@ -250,7 +250,7 @@ const parseOverviewSection = (markdown: string) => const parseKeywordsSection = (markdown: string, tags: string) => { const section = extractSection(markdown, ['关键词', '关键字', '标签']); const fromSection = parseBulletList(section) - .flatMap((line) => line.split(/[,、]/)) + .flatMap((line) => line.split(/[,,、/]/)) .map((item) => item.trim()) .filter(Boolean); @@ -371,8 +371,27 @@ type MeetingProgressPhase = 'queued' | 'asr' | 'chapter' | 'summary' | 'terminal const meetingProgressTerminalRefreshCache = new Map(); const meetingProgressPhaseRefreshCache = new Map(); +const DETAIL_STAGE_STEP_ITEMS = [ + { code: 'INITIALIZING', label: '数据初始化', hint: '完成会议数据准备' }, + { code: 'TRANSCRIBING', label: '转译音频', hint: '完成语音转写' }, + { code: 'SUMMARIZING', label: '生成总结', hint: '完成 AI 内容处理' }, + { code: 'COMPLETED', label: '处理完成', hint: '已全部完成' }, +] as const; const resolveProgressPhase = (progress: MeetingProgress | null | undefined): MeetingProgressPhase => { + const unifiedStatusCode = progress?.unifiedStatus?.statusCode; + if (unifiedStatusCode?.startsWith('FAILED_') || unifiedStatusCode === 'COMPLETED') { + return 'terminal'; + } + if (unifiedStatusCode === 'SUMMARIZING') { + return 'summary'; + } + if (unifiedStatusCode === 'TRANSCRIBING') { + return 'asr'; + } + if (unifiedStatusCode === 'INITIALIZING' || unifiedStatusCode === 'WAITING_UPLOAD') { + return 'queued'; + } const percent = progress?.percent ?? 0; if (percent < 0 || percent >= 100) { return 'terminal'; @@ -467,6 +486,9 @@ const MeetingProgressDisplay: React.FC<{ const percent = progress?.percent || 0; const isError = percent < 0; + const unifiedStatusText = progress?.unifiedStatus?.statusText; + const unifiedStatusMessage = progress?.unifiedStatus?.message; + const primaryStatusText = unifiedStatusText || (isError ? '处理失败' : '处理中'); const formatEta = (seconds?: number) => { if (!seconds || seconds <= 0) return '计算中'; @@ -497,7 +519,7 @@ const MeetingProgressDisplay: React.FC<{ }} > - {progress?.message || '正在生成新版总结...'} + {unifiedStatusMessage || unifiedStatusText || progress?.message || '正在生成最新结果...'} {isError ? '失败' : `${percent}%`} @@ -543,10 +565,10 @@ const MeetingProgressDisplay: React.FC<{ />
- {progress?.message || '正在分析内容...'} + {unifiedStatusMessage || unifiedStatusText || progress?.message || '正在分析内容...'} - 预计剩余:{isError ? '--' : formatEta(progress?.eta)} + {`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
@@ -580,7 +602,7 @@ const MeetingProgressDisplay: React.FC<{ />
- {progress?.message || '正在准备计算资源...'} + {unifiedStatusMessage || unifiedStatusText || progress?.message || '正在准备计算资源...'} 分析进行中,请稍候,你可以先处理其他工作。
@@ -606,7 +628,7 @@ const MeetingProgressDisplay: React.FC<{ 任务状态 - {isError ? '已中断' : '正常'} + {isError ? '失败' : '正常'} @@ -616,6 +638,358 @@ const MeetingProgressDisplay: React.FC<{ ); }; +const DETAIL_STAGE_STEP_DISPLAY_ITEMS = [ + { code: 'INITIALIZING', label: '数据初始化', hint: '完成会议数据准备' }, + { code: 'TRANSCRIBING', label: '转译音频', hint: '完成语音转写' }, + { code: 'SUMMARIZING', label: '生成总结', hint: '生成总结与 AI 目录' }, + { code: 'COMPLETED', label: '处理完成', hint: '全部任务处理完成' }, +] as const; + +const UnifiedMeetingProgressDisplay: React.FC<{ + progress: MeetingProgress | null; + compact?: boolean; + inline?: boolean; +}> = ({ progress, compact, inline }) => { + const percent = progress?.percent || 0; + const isError = percent < 0; + const unifiedStatusCode = progress?.unifiedStatus?.statusCode; + const unifiedStatusText = progress?.unifiedStatus?.statusText; + const unifiedStatusMessage = progress?.unifiedStatus?.message; + const failedStageCode = progress?.unifiedStatus?.failedStageCode; + const primaryStatusText = unifiedStatusText || (isError ? '处理失败' : '处理中'); + + const formatEta = (seconds?: number) => { + if (!seconds || seconds <= 0) return '计算中'; + if (seconds < 60) return `${seconds} 秒`; + const minutes = Math.floor(seconds / 60); + const remainSeconds = seconds % 60; + return remainSeconds > 0 ? `${minutes} 分 ${remainSeconds} 秒` : `${minutes} 分钟`; + }; + + const resolveStageCode = () => { + if (failedStageCode) { + return failedStageCode; + } + if (unifiedStatusCode === 'WAITING_UPLOAD' || unifiedStatusCode === 'INITIALIZING') { + return 'INITIALIZING'; + } + if (unifiedStatusCode === 'TRANSCRIBING') { + return 'TRANSCRIBING'; + } + if (unifiedStatusCode === 'SUMMARIZING') { + return 'SUMMARIZING'; + } + if (unifiedStatusCode === 'COMPLETED') { + return 'COMPLETED'; + } + const phase = resolveProgressPhase(progress); + if (phase === 'queued') { + return 'INITIALIZING'; + } + if (phase === 'asr') { + return 'TRANSCRIBING'; + } + if (phase === 'chapter' || phase === 'summary') { + return 'SUMMARIZING'; + } + return 'COMPLETED'; + }; + + const currentStageCode = resolveStageCode(); + const currentStageIndex = Math.max(0, DETAIL_STAGE_STEP_DISPLAY_ITEMS.findIndex((item) => item.code === currentStageCode)); + const isFailedStage = unifiedStatusCode?.startsWith('FAILED_') || isError; + const isCompletedStage = unifiedStatusCode === 'COMPLETED' || percent === 100; + const progressText = isError ? '--' : `${Math.max(percent, 0)}%`; + const currentStageLabel = DETAIL_STAGE_STEP_DISPLAY_ITEMS[currentStageIndex]?.label || primaryStatusText; + const currentStageDisplay = isFailedStage ? `${currentStageLabel}失败` : currentStageLabel; + const helperText = unifiedStatusMessage || progress?.message || (isError ? '当前阶段执行失败,请稍后重试。' : '阶段状态已与安卓端统一。'); + + const renderStageTimeline = (options?: { + compact?: boolean; + inline?: boolean; + }) => { + const compactMode = options?.compact; + const inlineMode = options?.inline; + const containerStyle: React.CSSProperties = inlineMode + ? { + marginTop: 10, + display: 'grid', + gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', + gap: 8, + } + : compactMode + ? { + marginTop: 18, + display: 'grid', + gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', + gap: 8, + width: '100%', + maxWidth: 560, + } + : { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: 12, + }; + + return ( +
+ {DETAIL_STAGE_STEP_DISPLAY_ITEMS.map((item, index) => { + const isCurrent = index === currentStageIndex; + const isDone = isCompletedStage || (!isFailedStage && index < currentStageIndex); + const isFailed = isFailedStage && isCurrent; + const dotBg = isFailed ? '#ff4d4f' : isDone ? '#52c41a' : isCurrent ? '#4f46e5' : '#d9d9d9'; + const textColor = isFailed ? '#cf1322' : isDone || isCurrent ? '#1f1f1f' : '#8c8c8c'; + const cardPadding = inlineMode ? '8px 6px' : compactMode ? '10px 8px' : '0'; + const cardRadius = inlineMode || compactMode ? 12 : 0; + const cardBorder = inlineMode || compactMode + ? `1px solid ${isFailed ? '#ffccc7' : isCurrent ? '#c7d2fe' : '#e5e7eb'}` + : 'none'; + const cardBackground = inlineMode || compactMode + ? isFailed ? '#fff2f0' : isCurrent ? '#eef2ff' : isDone ? '#f6ffed' : '#fafafa' + : 'transparent'; + + return ( + +
+
+ {isDone ? '√' : index + 1} +
+
+ {isFailed && item.code === currentStageCode ? `${item.label}失败` : item.label} +
+ {!compactMode && !inlineMode ? ( + + {item.hint} + + ) : null} +
+ {!inlineMode && !compactMode && index < DETAIL_STAGE_STEP_DISPLAY_ITEMS.length - 1 ? ( +
+ ) : null} + + ); + })} +
+ ); + }; + + if (inline) { + return ( +
+
+ + {helperText} + + + {isError ? '失败' : `${percent}%`} + +
+ + + {`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`} + + {renderStageTimeline({ inline: true })} +
+ ); + } + + if (compact) { + return ( +
+ + {primaryStatusText} + + +
+ + {helperText} + + + {`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`} + + {renderStageTimeline({ compact: true })} +
+
+ ); + } + + return ( +
+
+ + {primaryStatusText} + + + {helperText} + + +
+ + 当前进度 + +
+ {progressText} +
+ + + {`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`} + +
+ + {renderStageTimeline()} + + + + + + 当前阶段 + + {DETAIL_STAGE_STEP_DISPLAY_ITEMS[currentStageIndex]?.label || primaryStatusText} + + + + + + 任务状态 + + {isError ? '失败' : isCompletedStage ? '已完成' : '处理中'} + + + + + + 可查看内容 + + {[ + progress?.unifiedStatus?.canViewTranscript ? '转录' : null, + progress?.unifiedStatus?.canViewAiChapters ? '目录' : null, + progress?.unifiedStatus?.canViewSummary ? '总结' : null, + ].filter(Boolean).join(' / ') || '暂无'} + + + + +
+ 处理进度仅作辅助展示,阶段状态以统一状态机为准。 +
+
+
+ ); +}; + const SpeakerEditor: React.FC<{ meetingId: number; speakerId: string; @@ -890,16 +1264,18 @@ const MeetingDetail: React.FC = () => { const fetchData = useCallback(async (meetingId: number) => { try { - const [detailRes, transcriptRes, chapterRes, shareConfigRes] = await Promise.all([ + const [detailRes, transcriptRes, chapterRes, shareConfigRes, progressRes] = await Promise.all([ getMeetingDetail(meetingId), getTranscripts(meetingId), getMeetingChapters(meetingId), getMeetingShareConfig().catch(() => null), + getMeetingProgress(meetingId, { suppressErrorToast: true }).catch(() => null), ]); setMeeting(detailRes.data.data); setTranscripts(transcriptRes.data.data || []); setMeetingChapters(chapterRes.data.data || []); setMeetingShareBaseUrl(shareConfigRes?.data?.data?.h5BaseUrl || ''); + setGenerationProgress(progressRes?.data?.data || null); } catch (error) { console.error(error); } finally { @@ -2138,12 +2514,19 @@ const MeetingDetail: React.FC = () => {
{meeting.status === 0 || meeting.status === 1 ? ( - fetchData(meeting.id)} - onRefreshNeeded={() => { void fetchData(meeting.id); }} - onProgressChange={setGenerationProgress} - /> + <> +
+ fetchData(meeting.id)} + onRefreshNeeded={() => { void fetchData(meeting.id); }} + onProgressChange={setGenerationProgress} + /> +
+ + ) : ( <> @@ -2179,13 +2562,21 @@ const MeetingDetail: React.FC = () => { )} - {meeting.status === 2 && !hasSummaryContent ? ( -
+ {(meeting.status === 1 || meeting.status === 2) && ( +
fetchData(meeting.id)} onRefreshNeeded={() => { void fetchData(meeting.id); }} onProgressChange={setGenerationProgress} + /> +
+ )} + + {meeting.status === 2 && !hasSummaryContent ? ( +
+
@@ -2201,11 +2592,8 @@ const MeetingDetail: React.FC = () => {
{summaryPanelNotice.description}
{meeting.status === 2 ? ( - fetchData(meeting.id)} - onRefreshNeeded={() => { void fetchData(meeting.id); }} - onProgressChange={setGenerationProgress} + ) : null} @@ -2551,13 +2939,13 @@ const MeetingDetail: React.FC = () => { background: rgba(95, 81, 255, 0.25); transform: translateY(-1px); } - /* 当转录行处于活动状态(紫色背景)时,调整高亮样式以保持可读性 */ + /* 当转录行处于活动状态时,调整高亮样式以保持可读性 */ .ant-list-item.transcript-row.active .highlight-text { background: rgba(255, 255, 255, 0.2); border-bottom-color: #fff; color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - animation: none; /* 活动行内不需要脉冲,避免视觉混乱 */ + animation: none; /* 活动行内不需要闪烁,避免视觉混乱 */ } .summary-keyword-link { color: #5f51ff; diff --git a/imeeting-h5/src/components/preview/MeetingPreviewView.css b/frontend/src/pages/business/MeetingPreview.css similarity index 63% rename from imeeting-h5/src/components/preview/MeetingPreviewView.css rename to frontend/src/pages/business/MeetingPreview.css index c7bb925..f3fc5cb 100644 --- a/imeeting-h5/src/components/preview/MeetingPreviewView.css +++ b/frontend/src/pages/business/MeetingPreview.css @@ -9,14 +9,22 @@ --text-main: #1a1f36; --text-secondary: #6e7695; --card-shadow: 0 10px 30px rgba(127, 139, 186, 0.08); - min-height: 100vh; + + height: 100vh; display: flex; flex-direction: column; background: var(--bg-app); color: var(--text-main); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + overflow: hidden; } +/* Glassmorphism Header - Now redundant but kept for safety if needed */ +.meeting-preview-header { + display: none; +} + +/* Main Container */ .meeting-preview-container { flex: 1; overflow-y: auto; @@ -29,6 +37,7 @@ padding: 48px 24px; } +/* Hero Section */ .meeting-preview-top-hero { display: flex; gap: 24px; @@ -75,11 +84,17 @@ font-size: 13px; font-weight: 700; } - .meeting-preview-status-tag.is-complete { background: #e6f4ea; color: #1e8e3e; } .meeting-preview-status-tag.is-processing { background: #e8f0fe; color: #1a73e8; } .meeting-preview-status-tag.is-warning { background: #fff4e5; color: #b76e00; } +.meeting-preview-hero-id { + font-size: 13px; + color: var(--text-secondary); + font-family: monospace; +} + +/* Collapsible Info */ .meeting-preview-collapsible-section { background: var(--bg-surface); border-radius: 20px; @@ -118,7 +133,7 @@ } .meeting-preview-collapsible-content.is-expanded { - max-height: 420px; + max-height: 400px; } .meeting-preview-metrics-grid { @@ -128,6 +143,18 @@ gap: 24px; } +@media (max-width: 992px) { + .meeting-preview-metrics-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .meeting-preview-metrics-grid { + grid-template-columns: 1fr; + } +} + .metric-item { display: flex; flex-direction: column; @@ -167,36 +194,7 @@ font-weight: 600; } -.meeting-preview-share-settings { - background: #ffffff; - border: 1px solid var(--border-color); - border-radius: 20px; - margin-bottom: 24px; - padding: 20px 24px; - box-shadow: var(--card-shadow); -} - -.meeting-preview-share-settings-title { - font-size: 18px; - font-weight: 800; - margin-bottom: 4px; -} - -.meeting-preview-share-settings-desc { - color: var(--text-secondary); - margin-bottom: 16px; -} - -.meeting-preview-share-settings-row { - display: flex; - gap: 12px; - align-items: center; -} - -.meeting-preview-share-settings-row .ant-input-affix-wrapper { - border-radius: 14px; -} - +/* Share Bar */ .meeting-preview-share-bar { display: flex; gap: 16px; @@ -224,6 +222,7 @@ background: white !important; } +/* Tabs and Content */ .meeting-preview-content-card { background: var(--bg-surface); border-radius: 24px; @@ -298,6 +297,7 @@ font-size: 14px; } +/* Catalog List & Timeline */ .meeting-preview-catalog-list { display: flex; flex-direction: column; @@ -415,6 +415,12 @@ visibility: visible; } +.meeting-preview-catalog-item-link:hover { + background: rgba(95, 81, 255, 0.15); + transform: translateY(-1px); +} + +/* --- Transcription Original Styles Overhaul --- */ .meeting-preview-transcript-list { display: flex; flex-direction: column; @@ -504,6 +510,7 @@ word-wrap: break-word; } +/* --- Floating Audio Player Overhaul --- */ .meeting-preview-audio-player-inline { position: fixed; bottom: 24px; @@ -511,6 +518,7 @@ transform: translateX(-50%); width: calc(100% - 32px); max-width: 720px; + height: auto; min-height: 80px; background: rgba(255, 255, 255, 0.92); backdrop-filter: blur(24px) saturate(180%); @@ -520,7 +528,9 @@ padding: 12px 24px; border-radius: 28px; z-index: 1000; - box-shadow: 0 25px 50px -12px rgba(95, 81, 255, 0.25), 0 0 0 1px rgba(95, 81, 255, 0.05); + box-shadow: + 0 25px 50px -12px rgba(95, 81, 255, 0.25), + 0 0 0 1px rgba(95, 81, 255, 0.05); } .audio-player-content { @@ -528,6 +538,7 @@ display: flex; align-items: center; gap: 16px; + flex-wrap: nowrap; } .audio-play-btn { @@ -543,6 +554,12 @@ font-size: 20px; cursor: pointer; flex-shrink: 0; + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.audio-play-btn:hover { + transform: scale(1.05); + box-shadow: 0 8px 15px rgba(95, 81, 255, 0.3); } .audio-progress-container { @@ -554,7 +571,7 @@ } .audio-time { - font-family: "JetBrains Mono", monospace; + font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; color: var(--text-secondary); @@ -590,8 +607,253 @@ cursor: pointer; color: var(--primary-blue); flex-shrink: 0; + transition: all 0.2s; } +.audio-speed-btn:hover { + background: #eef1ff; +} + +/* --- Password Gate Overhaul --- */ +.is-password-gate { + justify-content: center; + align-items: center; + overflow: hidden; + position: relative; +} + +.password-gate-background { + position: absolute; + inset: 0; + z-index: 0; + overflow: hidden; +} + +.bg-blob { + position: absolute; + filter: blur(80px); + opacity: 0.4; + border-radius: 50%; + animation: blob-float 20s infinite alternate cubic-bezier(0.45, 0.05, 0.55, 0.95); +} + +.bg-blob-1 { + width: 500px; + height: 500px; + background: #5f51ff; + top: -100px; + left: -100px; +} + +.bg-blob-2 { + width: 400px; + height: 400px; + background: #6c8cff; + bottom: -50px; + right: -50px; + animation-delay: -5s; +} + +.bg-blob-3 { + width: 300px; + height: 300px; + background: #8e84ff; + top: 40%; + left: 30%; + animation-delay: -10s; +} + +@keyframes blob-float { + 0% { transform: translate(0, 0) scale(1) rotate(0deg); } + 33% { transform: translate(30px, 50px) scale(1.1) rotate(10deg); } + 66% { transform: translate(-20px, 20px) scale(0.9) rotate(-10deg); } + 100% { transform: translate(0, 0) scale(1) rotate(0deg); } +} + +.is-password-gate .meeting-preview-shell { + width: 100%; + max-width: 480px; + padding: 24px; + z-index: 10; + margin: 0; +} + +.meeting-preview-password-card { + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(32px) saturate(200%); + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 32px; + padding: 48px 40px; + box-shadow: + 0 40px 100px -20px rgba(95, 81, 255, 0.15), + 0 0 0 1px rgba(95, 81, 255, 0.05); + display: flex; + flex-direction: column; + gap: 32px; +} + +.password-card-header { + text-align: center; +} + +.password-icon-wrapper { + width: 64px; + height: 64px; + background: var(--primary-gradient); + color: white; + font-size: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 20px; + margin: 0 auto 24px; + box-shadow: 0 12px 24px rgba(95, 81, 255, 0.25); +} + +.password-card-title { + font-size: 24px; + font-weight: 800; + color: var(--text-main); + margin-bottom: 12px; + letter-spacing: -0.02em; +} + +.password-card-subtitle { + font-size: 15px; + color: var(--text-secondary); + line-height: 1.6; + max-width: 320px; + margin: 0 auto; +} + +.password-card-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.modern-password-input { + border-radius: 16px !important; + border: 2px solid rgba(228, 232, 245, 0.8) !important; + padding: 12px 16px !important; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important; + background: white !important; +} + +.modern-password-input:focus, +.modern-password-input-focused { + border-color: var(--primary-blue) !important; + box-shadow: 0 0 0 4px rgba(95, 81, 255, 0.1) !important; +} + +.password-submit-btn { + height: 56px !important; + border-radius: 16px !important; + font-size: 16px !important; + font-weight: 700 !important; + letter-spacing: 0.02em; + background: var(--primary-gradient) !important; + border: none !important; + box-shadow: 0 12px 24px rgba(95, 81, 255, 0.2) !important; + transition: all 0.3s ease !important; +} + +.password-submit-btn:hover { + transform: translateY(-2px); + box-shadow: 0 16px 32px rgba(95, 81, 255, 0.3) !important; +} + +.password-error-message { + animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both; +} + +@keyframes shake { + 10%, 90% { transform: translate3d(-1px, 0, 0); } + 20%, 80% { transform: translate3d(2px, 0, 0); } + 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); } + 40%, 60% { transform: translate3d(4px, 0, 0); } +} + +.password-gate-footer { + position: absolute; + bottom: 32px; + z-index: 10; +} + +.footer-disclaimer { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; + opacity: 0.7; +} + +@media (max-width: 480px) { + .meeting-preview-password-card { + padding: 40px 24px; + border-radius: 24px; + } +} + +/* --- Mobile Optimizations --- */ +@media (max-width: 768px) { + .meeting-preview-transcript-list { + gap: 16px; + } + + .meeting-preview-transcript-item { + gap: 12px; + padding: 10px; + } + + .meeting-preview-transcript-avatar { + width: 36px; + height: 36px; + border-radius: 10px; + font-size: 14px; + } + + .meeting-preview-transcript-text { + font-size: 15px; + } + + /* Fix audio player deformation on mobile */ + .meeting-preview-audio-player-inline { + padding: 10px 16px; + bottom: 16px; + border-radius: 22px; + } + + .audio-player-content { + gap: 10px; + } + + .audio-play-btn { + width: 40px; + height: 40px; + border-radius: 12px; + font-size: 18px; + } + + .audio-time { + display: block; + font-size: 10px; + } + + .audio-progress-container { + gap: 0; + } + + .audio-speed-btn { + width: 38px; + height: 32px; + font-size: 11px; + } +} + +/* Utils */ .meeting-preview-footer { margin-top: 64px; padding-bottom: 64px; @@ -610,57 +872,107 @@ border-radius: 99px; } -.meeting-preview-loading, -.meeting-preview-empty { - padding-top: 32px; -} - -.meeting-preview-password-card { - max-width: 480px; - margin: 0 auto; -} - -@media (max-width: 992px) { - .meeting-preview-metrics-grid { - grid-template-columns: repeat(2, 1fr); - } -} - @media (max-width: 768px) { .meeting-preview-shell { padding: 32px 16px; } .meeting-preview-hero-title { font-size: 24px; } .meeting-preview-metrics-grid { grid-template-columns: 1fr; gap: 16px; } - .meeting-preview-share-bar, - .meeting-preview-share-settings-row { - flex-direction: column; - } - .meeting-preview-share-bar .ant-btn, - .meeting-preview-share-settings-row .ant-btn, - .meeting-preview-share-settings-row .ant-input-affix-wrapper { - width: 100%; - } .meeting-preview-tabs-container { padding: 8px 12px 0; } + .meeting-preview-tabs-container .ant-tabs-nav::before { + inset-inline: 0; + } .meeting-preview-tabs-container .ant-tabs-nav-list { width: 100%; display: grid !important; grid-template-columns: repeat(3, minmax(0, 1fr)); + transform: none !important; } .meeting-preview-tabs-container .ant-tabs-tab { margin: 0 !important; padding: 14px 8px !important; justify-content: center; text-align: center; + min-width: 0; + } + .meeting-preview-tabs-container .ant-tabs-tab .ant-tabs-tab-btn { + width: 100%; + font-size: 14px; + line-height: 1.2; + white-space: nowrap; + overflow: visible; + text-overflow: clip; + } + .meeting-preview-tabs-container .ant-tabs-nav-operations { + display: none !important; } .meeting-preview-tab-content { padding: 20px; } - .meeting-preview-transcript-list { gap: 16px; } - .meeting-preview-transcript-item { gap: 12px; padding: 10px; } - .meeting-preview-transcript-avatar { width: 36px; height: 36px; border-radius: 10px; font-size: 14px; } - .meeting-preview-transcript-text { font-size: 15px; } - .meeting-preview-audio-player-inline { padding: 10px 16px; bottom: 16px; border-radius: 22px; } - .audio-player-content { gap: 10px; } - .audio-play-btn { width: 40px; height: 40px; border-radius: 12px; font-size: 18px; } - .audio-time { font-size: 10px; } - .audio-speed-btn { width: 38px; height: 32px; font-size: 11px; } +} + +.meeting-preview-speaker-card { + background: #f8faff; + border-radius: 16px; + padding: 20px; + margin-bottom: 16px; + border: 1px solid #eef1f9; +} + +.meeting-preview-speaker-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.meeting-preview-speaker-avatar { + width: 40px; + height: 40px; + border-radius: 12px; + background: var(--primary-gradient); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; +} + +.meeting-preview-speaker-name { + font-weight: 700; + font-size: 15px; +} + +.meeting-preview-speaker-role { + font-size: 12px; + color: var(--text-secondary); +} + +.meeting-preview-keypoint { + display: flex; + gap: 16px; + padding: 16px; + border-radius: 16px; + background: #fff; + border: 1px solid var(--border-color); + margin-bottom: 12px; +} + +.meeting-preview-keypoint-index { + font-size: 18px; + font-weight: 800; + color: #dee2e6; +} + +.meeting-preview-todo { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 0; +} + +.meeting-preview-todo-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--primary-blue); + margin-top: 6px; } diff --git a/imeeting-h5/src/components/preview/MeetingPreviewView.tsx b/frontend/src/pages/business/MeetingPreview.tsx similarity index 57% rename from imeeting-h5/src/components/preview/MeetingPreviewView.tsx rename to frontend/src/pages/business/MeetingPreview.tsx index 1b3a682..65f07f3 100644 --- a/imeeting-h5/src/components/preview/MeetingPreviewView.tsx +++ b/frontend/src/pages/business/MeetingPreview.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Button, Empty, Input, Tabs, message } from "antd"; +import { Alert, Button, Empty, Input, Result, Skeleton, Tabs, message } from "antd"; +import { useParams, useSearchParams } from "react-router-dom"; import { AudioOutlined, CalendarOutlined, @@ -7,57 +8,53 @@ import { ClockCircleOutlined, CopyOutlined, FileTextOutlined, - LinkOutlined, LockOutlined, PauseOutlined, RobotOutlined, ShareAltOutlined, TeamOutlined, - UpOutlined, UserOutlined, DownOutlined, + UpOutlined, + LinkOutlined, } from "@ant-design/icons"; import dayjs from "dayjs"; import ReactMarkdown from "react-markdown"; - import { + getMeetingPreviewAccess, getPublicMeetingPreview, resolveAudioMimeType, resolveMeetingPlaybackAudioUrl, -} from "@/api/meeting"; -import { buildMeetingAnalysis } from "@/components/preview/meetingAnalysis"; -import type { MeetingChapterVO, MeetingTranscriptVO, MeetingVO } from "@/types"; -import "./MeetingPreviewView.css"; + type MeetingChapterVO, + type MeetingTranscriptVO, + type MeetingVO, +} from "../../api/business/meeting"; +import { buildMeetingAnalysis } from "./meetingAnalysis"; +import "./MeetingPreview.css"; +type AnalysisTab = "chapters" | "speakers" | "actions" | "todos"; type PreviewPageTab = "summary" | "catalog" | "transcript"; -type ChapterTranscriptLink = { - key: string; - title: string; - timeLabel: string; - transcriptIds: number[]; - firstTranscriptId: number | null; - firstTranscriptStartTime: number | null; -}; - -interface MeetingPreviewViewProps { - meeting: MeetingVO; - transcripts: MeetingTranscriptVO[]; - meetingChapters: MeetingChapterVO[]; - shareUrl: string; - editableShare?: boolean; - sharePasswordDraft?: string; - shareSaving?: boolean; - onSharePasswordDraftChange?: (value: string) => void; - onSaveSharePassword?: () => void; - onCopyShareLink?: () => void; -} - const TEXT = { statusTranscribing: "转写中", statusSummarizing: "总结中", statusCompleted: "已完成", statusPending: "待处理", + hintTranscribing: "会议内容仍在整理中,预览会持续补全。", + hintSummarizing: "AI 正在生成会议总结,已完成内容会优先展示。", + hintCompleted: "会议纪要、分析和转录内容已生成完成。", + hintPending: "当前会议尚未生成完整内容,请稍后重试。", + missingMeetingId: "未提供会议编号", + loadFailed: "会议预览加载失败", + noMeetingData: "未找到会议数据", + previewLabel: "会议预览", + untitledMeeting: "未命名会议", + meetingTime: "会议时间", + hostCreator: "主持/创建", + participantsCount: "参会人数", + tagsCount: "标签数量", + notSet: "未设置", + notFilled: "未填写", pageSummary: "AI 纪要", pageCatalog: "AI 目录", pageTranscript: "转录原文", @@ -66,30 +63,67 @@ const TEXT = { shareCopied: "预览链接已复制", shareFallbackCopied: "当前设备不支持系统分享,已为你复制链接", shareFailed: "分享失败,请先复制链接", + accessCheck: "访问校验", + passwordRequired: "该会议需要访问密码", + passwordHint: "请输入会议的 访问密码 后继续访问预览内容。", + passwordPlaceholder: "请输入 访问密码", + openPreview: "进入预览", + invalidPassword: "访问密码错误", basicInfo: "基本信息", - meetingTime: "会议时间", - hostCreator: "主持/创建", - participantsCount: "参会人数", + meetingOverview: "会议概况", + creator: "创建人", + host: "主持人", + createdAt: "创建时间", + audioStatus: "音频状态", + participants: "人", tags: "会议标签", + aiAnalysis: "AI 目录", + analysis: "会议分析", + previewExtra: "预览页仅读展示", + audioPlaybackWarning: "音频保存失败,可能影响回放。", + summaryOverview: "全文概要", + summaryEmpty: "暂无概要内容", + analysisChapters: "章节", + analysisSpeakers: "发言人", + analysisKeyPoints: "关键要点", + analysisTodos: "待办事项", + noChapterAnalysis: "暂无章节分析", + noSpeakerAnalysis: "暂无发言人分析", + noKeyPoints: "暂无关键要点", + noTodos: "暂无待办事项", + chapterFallback: "章节", + speakerFallback: "发言人", + speakerSummary: "发言概述", + keyPointFallback: "要点", + noChapterSummary: "暂无章节描述", + noSpeakerSummary: "暂无发言总结", + noKeyPointSummary: "暂无要点说明", + summarySection: "会议纪要", + fullSummary: "完整纪要", noSummary: "暂无会议纪要", - noCatalog: "暂无 AI 目录", - noTranscript: "暂无转录内容", + transcriptSection: "会议转录", + transcriptTitle: "逐段转录", noDuration: "暂无时长", audioUnavailable: "音频文件不可用,仅展示转录内容。", - transcriptTitle: "逐段转录", - keywordSection: "关键词", + noTranscript: "暂无转录内容", + unknownSpeaker: "未知发言人", + disclaimer: "智能内容由用户会议内容 + AI 模型生成,我们不对内容准确性和完整性做任何保证,亦不代表我们的观点或态度", + shareText: "我向你分享了一个会议预览链接", + audioSaved: "已保存", + audioSaveFailed: "保存失败", + audioUploaded: "已上传", + audioNotSaved: "未保存", linkToTranscript: "关联原文", - shareSettings: "分享访问设置", - shareSettingsHint: "当前登录用户可直接查看,访问密码仅对分享出去的 H5 预览链接生效。", - saveSharePassword: "保存访问密码", - passwordPlaceholder: "为空表示取消访问密码", - disclaimer: "智能内容由用户会议内容与 AI 模型生成,我们不对内容准确性和完整性做任何保证。", + noCatalog: "暂无 AI 目录", }; -const STATUS_META: Record = { - 1: { label: TEXT.statusTranscribing, className: "is-processing" }, - 2: { label: TEXT.statusSummarizing, className: "is-processing" }, - 3: { label: TEXT.statusCompleted, className: "is-complete" }, +type ChapterTranscriptLink = { + key: string; + title: string; + timeLabel: string; + transcriptIds: number[]; + firstTranscriptId: number | null; + firstTranscriptStartTime: number | null; }; function parseChapterTimeToMs(value?: string) { @@ -110,6 +144,24 @@ function parseChapterTimeToMs(value?: string) { return totalSeconds * 1000; } +const STATUS_META: Record = { + 1: { label: TEXT.statusTranscribing, className: "is-processing", hint: TEXT.hintTranscribing }, + 2: { label: TEXT.statusSummarizing, className: "is-processing", hint: TEXT.hintSummarizing }, + 3: { label: TEXT.statusCompleted, className: "is-complete", hint: TEXT.hintCompleted }, +}; + +function formatDurationRange(startTime?: number, endTime?: number) { + const format = (milliseconds?: number) => { + const safeMs = Math.max(0, milliseconds || 0); + const totalSeconds = Math.floor(safeMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + }; + + return `${format(startTime)} - ${format(endTime)}`; +} + function splitDisplayItems(value?: string) { return (value || "") .split(",") @@ -140,35 +192,24 @@ async function copyText(text: string) { document.body.removeChild(textarea); } -function formatDurationRange(startTime?: number, endTime?: number) { - const format = (milliseconds?: number) => { - const safeMs = Math.max(0, milliseconds || 0); - const totalSeconds = Math.floor(safeMs / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - }; - - return `${format(startTime)} - ${format(endTime)}`; -} - -export default function MeetingPreviewView({ - meeting, - transcripts, - meetingChapters, - shareUrl, - editableShare = false, - sharePasswordDraft = "", - shareSaving = false, - onSharePasswordDraftChange, - onSaveSharePassword, - onCopyShareLink, -}: MeetingPreviewViewProps) { +export default function MeetingPreview() { + const { id } = useParams(); + const [searchParams] = useSearchParams(); const audioRef = useRef(null); - const transcriptItemRefs = useRef>({}); const audioPlaybackErrorShownRef = useRef(null); + const transcriptItemRefs = useRef>({}); + const [meeting, setMeeting] = useState(null); + const [transcripts, setTranscripts] = useState([]); + const [meetingChapters, setMeetingChapters] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [analysisTab, setAnalysisTab] = useState("speakers"); const [pageTab, setPageTab] = useState("summary"); const [activeTranscriptId, setActiveTranscriptId] = useState(null); + const [passwordRequired, setPasswordRequired] = useState(false); + const [passwordVerified, setPasswordVerified] = useState(false); + const [accessPassword, setAccessPassword] = useState(""); + const [passwordError, setPasswordError] = useState(""); const [audioPlaying, setAudioPlaying] = useState(false); const [audioCurrentTime, setAudioCurrentTime] = useState(0); const [audioDuration, setAudioDuration] = useState(0); @@ -176,23 +217,117 @@ export default function MeetingPreviewView({ const [isMetricsExpanded, setIsMetricsExpanded] = useState(false); const [linkedTranscriptIds, setLinkedTranscriptIds] = useState([]); const [linkedChapterKey, setLinkedChapterKey] = useState(null); - const [isMobile, setIsMobile] = useState( + const [isMobile, setIsMobile] = useState(() => typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false, ); + const presetAccessPassword = useMemo(() => (searchParams.get("accessPassword") || "").trim(), [searchParams]); useEffect(() => { - if (typeof window === "undefined") return; + let mounted = true; + + const load = async () => { + if (!id) { + setError(TEXT.missingMeetingId); + setLoading(false); + return; + } + + setLoading(true); + setError(""); + setMeeting(null); + setTranscripts([]); + setMeetingChapters([]); + setPasswordRequired(false); + setPasswordVerified(false); + setAccessPassword(presetAccessPassword); + setPasswordError(""); + + try { + const meetingId = Number(id); + const accessRes = await getMeetingPreviewAccess(meetingId); + if (!mounted) { + return; + } + + const requiresPassword = !!accessRes.data.data.passwordRequired; + setPasswordRequired(requiresPassword); + if (requiresPassword) { + if (!presetAccessPassword) { + setLoading(false); + return; + } + try { + const previewRes = await getPublicMeetingPreview(meetingId, presetAccessPassword); + if (!mounted) { + return; + } + setMeeting(previewRes.data.data.meeting); + setTranscripts(previewRes.data.data.transcripts || []); + setMeetingChapters(previewRes.data.data.chapters || []); + setPasswordVerified(true); + return; + } catch (requestError: any) { + if (!mounted) { + return; + } + setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword); + setPasswordVerified(false); + setLoading(false); + return; + } + } + + const previewRes = await getPublicMeetingPreview(meetingId); + if (!mounted) { + return; + } + + setMeeting(previewRes.data.data.meeting); + setTranscripts(previewRes.data.data.transcripts || []); + setMeetingChapters(previewRes.data.data.chapters || []); + setPasswordVerified(true); + } catch (requestError: any) { + if (!mounted) { + return; + } + + setError(requestError?.response?.data?.msg || requestError?.msg || TEXT.loadFailed); + } finally { + if (mounted) { + setLoading(false); + } + } + }; + + load(); + + return () => { + mounted = false; + }; + }, [id, presetAccessPassword]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + const mediaQuery = window.matchMedia("(max-width: 767px)"); - const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches); + const handleChange = (event: MediaQueryListEvent) => { + setIsMobile(event.matches); + }; + setIsMobile(mediaQuery.matches); mediaQuery.addEventListener("change", handleChange); - return () => mediaQuery.removeEventListener("change", handleChange); + return () => { + mediaQuery.removeEventListener("change", handleChange); + }; }, []); const analysis = useMemo( () => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""), [meeting?.analysis, meeting?.summaryContent, meeting?.tags], ); + const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]); const transcriptSpeakers = useMemo(() => { const speakers = transcripts @@ -207,7 +342,9 @@ export default function MeetingPreviewView({ const statusMeta = STATUS_META[meeting?.status || 0] || { label: TEXT.statusPending, className: "is-warning", + hint: TEXT.hintPending, }; + const shareUrl = typeof window !== "undefined" ? window.location.href : ""; const participantCountValue = isMobile && transcriptSpeakers.length > 0 ? transcriptSpeakers.length : participants.length; @@ -216,11 +353,11 @@ export default function MeetingPreviewView({ const last = transcripts[transcripts.length - 1]; return last.endTime || 0; } - return meeting.duration || 0; - }, [meeting.duration, transcripts]); + return 0; + }, [transcripts]); const catalogChapterLinks = useMemo(() => { - const transcriptIdToIndex = new Map(transcripts.map((item) => [item.id, transcripts.indexOf(item)])); + const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index])); const sourceChapters: MeetingChapterVO[] = meetingChapters.length ? meetingChapters : analysis.chapters.map((item) => ({ @@ -232,13 +369,13 @@ export default function MeetingPreviewView({ let matchedTranscripts: MeetingTranscriptVO[] = []; const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds) ? chapter.sourceTranscriptIds - .map((item: number) => Number(item)) - .filter((item: number) => Number.isFinite(item) && transcriptIdToIndex.has(item)) + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item) && transcriptIdToIndex.has(item)) : []; if (sourceTranscriptIds.length) { matchedTranscripts = sourceTranscriptIds - .map((item: number) => transcripts[transcriptIdToIndex.get(item)!]) + .map((item) => transcripts[transcriptIdToIndex.get(item)!]) .filter(Boolean); } else if (chapter.startTranscriptId && chapter.endTranscriptId) { const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId)); @@ -254,12 +391,12 @@ export default function MeetingPreviewView({ .find((item): item is number => item !== null && startMs !== null && item > startMs); if (startMs !== null) { - const firstTranscriptIndex = transcripts.findIndex((item) => (item.endTime || 0) > startMs); + const firstTranscriptIndex = transcripts.findIndex((item) => item.endTime > startMs); if (firstTranscriptIndex >= 0) { const lastTranscriptIndex = nextChapterStartMs === undefined ? transcripts.length - : transcripts.findIndex((item) => (item.startTime || 0) >= nextChapterStartMs); + : transcripts.findIndex((item) => item.startTime >= nextChapterStartMs); matchedTranscripts = transcripts.slice( firstTranscriptIndex, lastTranscriptIndex >= 0 ? lastTranscriptIndex : transcripts.length, @@ -280,14 +417,24 @@ export default function MeetingPreviewView({ }, [analysis.chapters, meetingChapters, transcripts]); useEffect(() => { - if (!activeTranscriptId) return; + if (!activeTranscriptId) { + return; + } + const target = transcriptItemRefs.current[activeTranscriptId]; - if (!target) return; + if (!target) { + return; + } + + // 使用 center 模式确保当前说话段落始终位于视口中央,避免被底部的浮动控件遮挡 target.scrollIntoView({ behavior: "smooth", block: "center" }); }, [activeTranscriptId]); const handleTranscriptSeek = (item: MeetingTranscriptVO) => { - if (!audioRef.current) return; + if (!audioRef.current) { + return; + } + audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000); audioRef.current.play().catch(() => {}); }; @@ -300,9 +447,13 @@ export default function MeetingPreviewView({ setLinkedChapterKey(link.key); setActiveTranscriptId(link.firstTranscriptId); + // 自动跳转并播放音频 if (audioRef.current && link.firstTranscriptStartTime !== null) { audioRef.current.currentTime = Math.max(0, link.firstTranscriptStartTime / 1000); - audioRef.current.play().catch(() => {}); + audioRef.current.play().catch(() => { + // 部分浏览器(尤其是移动端)可能会拦截非直接交互触发的播放 + // 但由于这是由用户点击目录项触发的,通常会被允许 + }); } } }; @@ -331,17 +482,18 @@ export default function MeetingPreviewView({ }; const formatPlayerTime = (seconds: number) => { - if (!seconds || Number.isNaN(seconds)) return "00:00"; + if (!seconds || isNaN(seconds)) return '00:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); - return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; }; const handleAudioTimeUpdate = () => { if (!audioRef.current) return; const currentSeconds = audioRef.current.currentTime; setAudioCurrentTime(currentSeconds); - + + // Also update duration if it's available now if (audioRef.current.duration && audioDuration !== audioRef.current.duration) { setAudioDuration(audioRef.current.duration); } @@ -356,44 +508,55 @@ export default function MeetingPreviewView({ setActiveTranscriptId(currentItem?.id || null); }; - const handleAudioLoadedMetadata = () => { - if (!audioRef.current) return; - setAudioDuration(audioRef.current.duration || 0); + const handleAudioEnded = () => { + setAudioPlaying(false); }; const handleAudioPlay = () => setAudioPlaying(true); const handleAudioPause = () => setAudioPlaying(false); - const handleAudioEnded = () => setAudioPlaying(false); + const handleAudioLoadedMetadata = () => { + if (audioRef.current) { + setAudioDuration(audioRef.current.duration); + } + }; - const handleAudioError = async () => { + const handleAudioError = () => { const currentAudioUrl = playbackAudioUrl || ""; if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) { return; } + const normalizedUrl = currentAudioUrl.split("#")[0]?.split("?")[0]?.toLowerCase() || ""; + const isM4a = normalizedUrl.endsWith(".m4a"); + message.warning( + isM4a + ? "当前 m4a 文件在本机浏览器中无法直接播放。已确认文件与服务端响应基本正常,更可能是浏览器对该录音参数或容器实现的兼容性问题。建议优先使用 mp3、wav,或下载到本地播放。" + : TEXT.audioUnavailable, + ); audioPlaybackErrorShownRef.current = currentAudioUrl; - - try { - const retryResp = await getPublicMeetingPreview(meeting.id); - const retryUrl = resolveMeetingPlaybackAudioUrl(retryResp.data.data.meeting); - if (retryUrl && retryUrl !== currentAudioUrl && audioRef.current) { - audioRef.current.src = retryUrl; - audioRef.current.load(); - audioPlaybackErrorShownRef.current = null; - return; - } - } catch { - // ignore retry failure - } - - message.warning(meeting.audioSaveMessage || TEXT.audioUnavailable); setAudioPlaying(false); }; - const handleCopyLink = async () => { - if (onCopyShareLink) { - await onCopyShareLink(); + const handlePasswordSubmit = async () => { + if (!id) { return; } + + setLoading(true); + setPasswordError(""); + try { + const previewRes = await getPublicMeetingPreview(Number(id), accessPassword.trim()); + setMeeting(previewRes.data.data.meeting); + setTranscripts(previewRes.data.data.transcripts || []); + setMeetingChapters(previewRes.data.data.chapters || []); + setPasswordVerified(true); + } catch (requestError: any) { + setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword); + } finally { + setLoading(false); + } + }; + + const handleCopyLink = async () => { try { await copyText(shareUrl); message.success(TEXT.shareCopied); @@ -406,8 +569,8 @@ export default function MeetingPreviewView({ try { if (navigator.share) { await navigator.share({ - title: meeting?.title || "会议预览", - text: "我向你分享了一个会议预览链接", + title: meeting?.title || TEXT.previewLabel, + text: TEXT.shareText, url: shareUrl, }); return; @@ -420,26 +583,136 @@ export default function MeetingPreviewView({ } }; + if (loading && (!passwordRequired || passwordVerified)) { + return ( +
+
+
+ +
+
+ +
+
+ +
+
+
+ ); + } + + if (passwordRequired && !passwordVerified) { + return ( +
+
+
+
+
+
+ +
+
+
+
+ +
+

{TEXT.passwordRequired}

+

{TEXT.passwordHint}

+
+ +
+
+ setAccessPassword(event.target.value)} + onPressEnter={handlePasswordSubmit} + prefix={} + className="modern-password-input" + /> +
+ +
+ + {passwordError && ( +
+ +
+ )} +
+
+ +
+
+ + Secure Access • Powered by iMeeting AI +
+
+
+ ); + } + + if (error) { + return ( +
+
+
+ +
+
+
+ ); + } + + if (!meeting) { + return ( +
+
+
+ +
+
+
+ ); + } + const summaryTabContent = (
-
+
-
{TEXT.keywordSection}
-
- {keywords.length ? ( - keywords.map((item) => ( -
- #{item} -
- )) - ) : ( - 暂无关键词 - )} +
+
关键词
+
+ {keywords.length ? ( + keywords.map((item) => ( +
+ #{item} +
+ )) + ) : ( + 暂无关键词 + )} +
- {meeting.summaryContent ? {meeting.summaryContent} : } + {meeting.summaryContent ? ( + {meeting.summaryContent} + ) : ( + + )}
@@ -447,19 +720,32 @@ export default function MeetingPreviewView({ const catalogTabContent = (
-
+
+
+
+ {/*
*/} + {/* */} + {/* {TEXT.aiAnalysis}*/} + {/*
*/} +

{TEXT.pageCatalog}

+
+
+
{catalogChapterLinks.length ? ( catalogChapterLinks.map((chapter, index) => (
-
handleLocateChapterTranscript(index)}> +
handleLocateChapterTranscript(index)} + >
{chapter.timeLabel}
{chapter.title}
@@ -487,9 +773,13 @@ export default function MeetingPreviewView({ const transcriptTabContent = (
-
+
+ {/*
*/} + {/* */} + {/* {TEXT.transcriptSection}*/} + {/*
*/}

{TEXT.transcriptTitle}

@@ -499,7 +789,12 @@ export default function MeetingPreviewView({
{meeting.audioSaveStatus === "FAILED" ? ( - + ) : null}
@@ -508,6 +803,7 @@ export default function MeetingPreviewView({ const speakerKey = item.speakerName || item.speakerLabel || item.speakerId || "speaker"; const isLinked = linkedTranscriptIds.includes(item.id); const isActive = activeTranscriptId === item.id; + return (
{ handleTranscriptSeek(item); - setLinkedTranscriptIds([]); + setLinkedTranscriptIds([]); // Clear linked highlight on manual seek setLinkedChapterKey(null); }} > @@ -534,7 +830,9 @@ export default function MeetingPreviewView({ {formatDurationRange(item.startTime, item.endTime)}
-
{item.content || TEXT.noTranscript}
+
+ {item.content || TEXT.noTranscript} +
); @@ -563,101 +861,104 @@ export default function MeetingPreviewView({
+ {/* Header Title Section */}
-

{meeting.title || "未命名会议"}

+

{meeting.title || TEXT.untitledMeeting}

- {statusMeta.label} + + {statusMeta.label} + + {/*ID: {meeting.id}*/}
+ {/* Collapsible Basic Info Section */}
-
setIsMetricsExpanded(!isMetricsExpanded)}> +
setIsMetricsExpanded(!isMetricsExpanded)} + >
{TEXT.basicInfo}
-
{isMetricsExpanded ? : }
+
+ {isMetricsExpanded ? : } +
- -
+ +
{TEXT.meetingTime}
- - {meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : "未设置"} + + {meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : TEXT.notSet}
{TEXT.hostCreator}
- - {meeting.creatorName || "未设置"} + + {meeting.creatorName || TEXT.notSet}
{TEXT.participantsCount}
- - {participantCountValue} 人 + + {participantCountValue} {TEXT.participants}
会议时长
- - {meetingDuration > 0 ? formatTotalDuration(meetingDuration) : "未设置"} + + {meetingDuration > 0 ? formatTotalDuration(meetingDuration) : TEXT.notSet}
- {tags.length > 0 ? ( + {tags.length > 0 && (
{TEXT.tags}
- {tags.map((tag) => ( - - #{tag} - + {tags.map(tag => ( + #{tag} ))}
- ) : null} + )}
- {editableShare ? ( -
-
{TEXT.shareSettings}
-
{TEXT.shareSettingsHint}
-
- } - onChange={(event) => onSharePasswordDraftChange?.(event.target.value)} - /> - -
-
- ) : null} - + {/* Sharing Buttons Bar */}
- -
+ {/* Main Content Area */}
@@ -690,8 +991,12 @@ export default function MeetingPreviewView({
- {playbackAudioUrl ? ( -
+ {/* Floating Audio Player - Permanent mount, visibility controlled */} + {playbackAudioUrl && ( +
- ) : null} + )}
); } diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx index 9bd5160..53f1eb5 100644 --- a/frontend/src/pages/business/Meetings.tsx +++ b/frontend/src/pages/business/Meetings.tsx @@ -1,4 +1,4 @@ -import { +import { AppstoreOutlined, AudioOutlined, CalendarOutlined, @@ -76,11 +76,11 @@ const ALL_STATUS_FILTER = "all"; const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000; const MEETING_STATUS_FILTER_OPTIONS = [ { label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" }, - { label: "排队中", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" }, - { label: "识别中", value: "1", color: "#1890ff", bgColor: "#e6f7ff" }, - { label: "总结中", value: "2", color: "#faad14", bgColor: "#fff7e6" }, - { label: "已完成", value: "3", color: "#52c41a", bgColor: "#f6ffed" }, - { label: "失败", value: "4", color: "#ff4d4f", bgColor: "#fff1f0" }, + { label: "数据初始化", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" }, + { label: "转译音频", value: "1", color: "#1890ff", bgColor: "#e6f7ff" }, + { label: "生成总结", value: "2", color: "#faad14", bgColor: "#fff7e6" }, + { label: "处理完成", value: "3", color: "#52c41a", bgColor: "#f6ffed" }, + { label: "处理失败", value: "4", color: "#ff4d4f", bgColor: "#fff1f0" }, ] as const; const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = { offlineEnabled: true, @@ -122,12 +122,34 @@ const shouldPollMeetingCard = (item: MeetingVO) => || item.realtimeSessionStatus === "ACTIVE" || isPausedRealtimeSessionStatus(item.realtimeSessionStatus); +const getUnifiedStatusCode = (progress: MeetingProgress | null | undefined) => + progress?.unifiedStatus?.statusCode; + const getEffectiveStatus = (item: MeetingVO, progress: MeetingProgress | null) => { + const unifiedStatusCode = getUnifiedStatusCode(progress); + if (unifiedStatusCode === "WAITING_UPLOAD") { + return 8; + } + if (unifiedStatusCode === "INITIALIZING") { + return 0; + } + if (unifiedStatusCode === "TRANSCRIBING") { + return 1; + } + if (unifiedStatusCode === "SUMMARIZING") { + return 2; + } + if (unifiedStatusCode === "COMPLETED") { + return 3; + } + if (unifiedStatusCode?.startsWith("FAILED_")) { + return 4; + } if (hasLatestGenerationFailure(item)) { return 4; } const status = item.displayStatus ?? item.status; - // 如果是排队中但已有进度,则视为识别中 + // 如果处于初始化中但已经有进度,则视为转译音频中 if (status === 0 && progress && progress.percent > 0) { return 1; } @@ -176,16 +198,20 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => { const effectiveStatus = getEffectiveStatus(meeting, progress); const statusConfig: Record = { - 0: { text: "排队中", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: }, - 1: { text: "识别中", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: }, - 2: { text: "总结中", color: "#faad14", bgColor: "rgba(250, 173, 20, 0.1)", icon: }, - 3: { text: "已完成", color: "#52c41a", bgColor: "rgba(82, 196, 26, 0.1)", icon: }, - 4: { text: "失败", color: "#ff4d4f", bgColor: "rgba(255, 77, 79, 0.1)", icon: }, + 0: { text: "数据初始化", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: }, + 1: { text: "转译音频", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: }, + 2: { text: "生成总结", color: "#faad14", bgColor: "rgba(250, 173, 20, 0.1)", icon: }, + 3: { text: "处理完成", color: "#52c41a", bgColor: "rgba(82, 196, 26, 0.1)", icon: }, + 4: { text: "处理失败", color: "#ff4d4f", bgColor: "rgba(255, 77, 79, 0.1)", icon: }, 5: { text: "暂停中", color: "#d48806", bgColor: "rgba(212, 136, 6, 0.1)", icon: }, 6: { text: "进行中", color: "#5f51ff", bgColor: "rgba(95, 81, 255, 0.1)", icon: }, 7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: }, + 8: { text: "待上传录音文件", color: "#13a8a8", bgColor: "rgba(19, 168, 168, 0.1)", icon: }, }; const config = statusConfig[effectiveStatus] || statusConfig[0]; + const displayConfig = progress?.unifiedStatus?.statusText + ? { ...config, text: progress.unifiedStatus.statusText } + : config; const isProcessing = shouldTrackGenerationProgress(meeting); const percent = isProcessing ? progress?.percent || 0 : 0; @@ -198,9 +224,9 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr borderRadius: "8px", fontSize: "12px", fontWeight: 700, - color: config.color, - background: config.bgColor, - border: `1px solid ${config.color}20`, + color: displayConfig.color, + background: displayConfig.bgColor, + border: `1px solid ${displayConfig.color}20`, gap: "4px", position: "relative", overflow: "hidden" @@ -214,16 +240,16 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr bottom: 0, height: "2px", width: `${percent}%`, - background: config.color, + background: displayConfig.color, transition: "width 0.4s ease-out", - boxShadow: `0 0 8px ${config.color}` + boxShadow: `0 0 8px ${displayConfig.color}` }} /> )} - {isProcessing ? : config.icon} + {isProcessing ? : displayConfig.icon} - {config.text} + {displayConfig.text} {isProcessing && {percent}%}
); @@ -248,7 +274,7 @@ const MeetingCardItem: React.FC<{ const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS; const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS; const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item); - const crossPlatformHint = `在${getRealtimeSourceLabel(item)}继续`; + const crossPlatformHint = `在 ${getRealtimeSourceLabel(item)} 继续`; const canRetry = canRetryQueuedMeeting(item, progress); const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6"; @@ -686,7 +712,7 @@ const Meetings: React.FC = () => { return; } if (!canControlRealtimeFromCurrentPlatform(meeting)) { - message.info("该实时会议需在" + getRealtimeSourceLabel(meeting) + "继续,当前仅支持查看详情"); + message.info(`该实时会议需在${getRealtimeSourceLabel(meeting)}继续,当前仅支持查看详情`); navigate("/meetings/" + meeting.id); return; } @@ -736,7 +762,7 @@ const Meetings: React.FC = () => { render: (text: string) => {text || "未知"}, }, { - title: "来源", + title: "鏉ユ簮", dataIndex: "meetingSource", key: "meetingSource", width: 80, @@ -746,10 +772,10 @@ const Meetings: React.FC = () => { title: "参会人", dataIndex: "participants", key: "participants", - render: (text: string) => {text || "无参与人员"}, + render: (text: string) => {text || "无参会人员"}, }, { - title: "操作", + title: "鎿嶄綔", key: "action", width: 220, render: (_: unknown, record: MeetingVO) => ( @@ -779,11 +805,11 @@ const Meetings: React.FC = () => { ]; const statusConfig: Record = { - 0: { text: "排队中", color: "#8c8c8c", bgColor: "#f5f5f5" }, - 1: { text: "识别中", color: "#1890ff", bgColor: "#e6f7ff" }, - 2: { text: "总结中", color: "#faad14", bgColor: "#fff7e6" }, - 3: { text: "已完成", color: "#52c41a", bgColor: "#f6ffed" }, - 4: { text: "失败", color: "#ff4d4f", bgColor: "#fff1f0" }, + 0: { text: "数据初始化", color: "#8c8c8c", bgColor: "#f5f5f5" }, + 1: { text: "转译音频", color: "#1890ff", bgColor: "#e6f7ff" }, + 2: { text: "生成总结", color: "#faad14", bgColor: "#fff7e6" }, + 3: { text: "处理完成", color: "#52c41a", bgColor: "#f6ffed" }, + 4: { text: "处理失败", color: "#ff4d4f", bgColor: "#fff1f0" }, 5: { text: "会议暂停", color: "#d48806", bgColor: "#fff7e6" }, 6: { text: "实时进行中", color: "#1677ff", bgColor: "#e6f4ff" }, 7: { text: "待开始", color: "#595959", bgColor: "#f5f5f5" }, diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 5c705a2..ffbb53c 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -6,6 +6,7 @@ import { menuRoutes,extraRoutes } from "./routes"; const Login = lazy(() => import("@/pages/auth/login")); const ResetPassword = lazy(() => import("@/pages/auth/reset-password")); +const MeetingPreview = lazy(() => import("@/pages/business/MeetingPreview")); const PublicDeviceMeetingCreate = lazy(() => import("@/pages/business/PublicDeviceMeetingCreate")); function RouteFallback() { @@ -70,6 +71,10 @@ export default function AppRoutes() { } /> } /> + } + />