From 2e20799b4b3cccfa32104931727d58e198c4cdbf Mon Sep 17 00:00:00 2001 From: chenhao Date: Fri, 5 Jun 2026 15:07:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=8D=93=E4=BC=9A=E8=AE=AE=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/imeeting/common/MeetingConstants.java | 4 + .../java/com/imeeting/common/RedisKeys.java | 4 +- .../AndroidMeetingChunkUploadController.java | 25 +- .../android/AndroidMeetingController.java | 80 +- .../AndroidPublicMeetingController.java | 62 +- .../controller/biz/MeetingController.java | 18 + .../biz/PublicDeviceMeetingController.java | 65 +- .../AndroidChunkUploadSessionState.java | 5 +- .../AndroidPublicMeetingSessionState.java | 1 + .../AndroidPublicMeetingSessionVO.java | 3 + .../legacy/LegacyMeetingCreateRequest.java | 6 + .../java/com/imeeting/dto/biz/MeetingVO.java | 2 + .../entity/biz/AndroidPushMessage.java | 2 + .../java/com/imeeting/entity/biz/Meeting.java | 3 + .../imeeting/enums/MeetingPushTypeEnum.java | 1 + .../android/AndroidChunkUploadService.java | 5 - .../android/AndroidMeetingPushService.java | 4 + .../AndroidPublicMeetingSessionService.java | 2 + .../android/AndroidPushMessageService.java | 5 + .../impl/AndroidChunkUploadServiceImpl.java | 140 +- .../impl/AndroidMeetingPushServiceImpl.java | 23 + ...ndroidPublicMeetingSessionServiceImpl.java | 29 + .../impl/AndroidPushMessageServiceImpl.java | 45 + .../legacy/LegacyMeetingAdapterService.java | 2 + .../impl/LegacyMeetingAdapterServiceImpl.java | 115 +- .../service/biz/MeetingCommandService.java | 2 + .../biz/impl/MeetingCommandServiceImpl.java | 34 + .../biz/impl/MeetingDomainSupport.java | 4 + .../redis/AndroidChunkUploadSessionCache.java | 23 +- .../task/AndroidPushMessageRetryTask.java | 12 +- .../src/main/resources/application-dev.yml | 2 + .../src/main/resources/application-prod.yml | 2 + .../src/main/resources/application-test.yml | 2 + frontend/src/api/business/meeting.ts | 12 +- frontend/src/pages/business/MeetingDetail.tsx | 22 +- .../business/PublicDeviceMeetingCreate.tsx | 358 +- frontend/src/routes/index.tsx | 5 - imeeting-h5/.gitignore | 2 + imeeting-h5/index.html | 12 + imeeting-h5/package-lock.json | 4392 +++++++++++++++++ imeeting-h5/package.json | 28 + imeeting-h5/public/logo.svg | 14 + imeeting-h5/src/App.tsx | 94 + imeeting-h5/src/api/auth.ts | 19 + imeeting-h5/src/api/http.ts | 154 + imeeting-h5/src/api/meeting.ts | 72 + imeeting-h5/src/api/platform.ts | 12 + imeeting-h5/src/api/user.ts | 18 + imeeting-h5/src/components/BottomNav.tsx | 28 + imeeting-h5/src/components/LoadingScreen.tsx | 10 + imeeting-h5/src/components/PageHeader.tsx | 32 + .../src/components/PlatformConfigProvider.tsx | 76 + .../components/preview/MeetingPreviewView.css | 450 +- .../components/preview/MeetingPreviewView.tsx | 689 +-- .../src/components/preview/meetingAnalysis.ts | 190 + imeeting-h5/src/hooks/usePageTitle.ts | 12 + imeeting-h5/src/layouts/MainLayout.tsx | 17 + imeeting-h5/src/main.tsx | 34 + imeeting-h5/src/pages/about/index.tsx | 31 + imeeting-h5/src/pages/login/index.tsx | 160 + .../src/pages/meeting-detail/index.tsx | 104 + .../src/pages/meeting-preview/index.tsx | 134 + imeeting-h5/src/pages/meetings/index.tsx | 102 + imeeting-h5/src/pages/password/index.tsx | 63 + imeeting-h5/src/pages/profile/index.tsx | 108 + imeeting-h5/src/pages/scan-confirm/index.tsx | 101 + imeeting-h5/src/routes/ProtectedRoute.tsx | 15 + imeeting-h5/src/styles/global.css | 561 +++ imeeting-h5/src/types/index.ts | 111 + imeeting-h5/src/types/platform.ts | 15 + imeeting-h5/src/utils/auth.ts | 47 + imeeting-h5/src/utils/meeting.ts | 56 + imeeting-h5/tsconfig.json | 17 + imeeting-h5/tsconfig.tsbuildinfo | 1 + imeeting-h5/vite.config.ts | 23 + 75 files changed, 7783 insertions(+), 1350 deletions(-) create mode 100644 imeeting-h5/.gitignore create mode 100644 imeeting-h5/index.html create mode 100644 imeeting-h5/package-lock.json create mode 100644 imeeting-h5/package.json create mode 100644 imeeting-h5/public/logo.svg create mode 100644 imeeting-h5/src/App.tsx create mode 100644 imeeting-h5/src/api/auth.ts create mode 100644 imeeting-h5/src/api/http.ts create mode 100644 imeeting-h5/src/api/meeting.ts create mode 100644 imeeting-h5/src/api/platform.ts create mode 100644 imeeting-h5/src/api/user.ts create mode 100644 imeeting-h5/src/components/BottomNav.tsx create mode 100644 imeeting-h5/src/components/LoadingScreen.tsx create mode 100644 imeeting-h5/src/components/PageHeader.tsx create mode 100644 imeeting-h5/src/components/PlatformConfigProvider.tsx rename frontend/src/pages/business/MeetingPreview.css => imeeting-h5/src/components/preview/MeetingPreviewView.css (63%) rename frontend/src/pages/business/MeetingPreview.tsx => imeeting-h5/src/components/preview/MeetingPreviewView.tsx (57%) create mode 100644 imeeting-h5/src/components/preview/meetingAnalysis.ts create mode 100644 imeeting-h5/src/hooks/usePageTitle.ts create mode 100644 imeeting-h5/src/layouts/MainLayout.tsx create mode 100644 imeeting-h5/src/main.tsx create mode 100644 imeeting-h5/src/pages/about/index.tsx create mode 100644 imeeting-h5/src/pages/login/index.tsx create mode 100644 imeeting-h5/src/pages/meeting-detail/index.tsx create mode 100644 imeeting-h5/src/pages/meeting-preview/index.tsx create mode 100644 imeeting-h5/src/pages/meetings/index.tsx create mode 100644 imeeting-h5/src/pages/password/index.tsx create mode 100644 imeeting-h5/src/pages/profile/index.tsx create mode 100644 imeeting-h5/src/pages/scan-confirm/index.tsx create mode 100644 imeeting-h5/src/routes/ProtectedRoute.tsx create mode 100644 imeeting-h5/src/styles/global.css create mode 100644 imeeting-h5/src/types/index.ts create mode 100644 imeeting-h5/src/types/platform.ts create mode 100644 imeeting-h5/src/utils/auth.ts create mode 100644 imeeting-h5/src/utils/meeting.ts create mode 100644 imeeting-h5/tsconfig.json create mode 100644 imeeting-h5/tsconfig.tsbuildinfo create mode 100644 imeeting-h5/vite.config.ts diff --git a/backend/src/main/java/com/imeeting/common/MeetingConstants.java b/backend/src/main/java/com/imeeting/common/MeetingConstants.java index 1e05429..23052e8 100644 --- a/backend/src/main/java/com/imeeting/common/MeetingConstants.java +++ b/backend/src/main/java/com/imeeting/common/MeetingConstants.java @@ -16,6 +16,10 @@ public final class MeetingConstants { public static final String DEVICE_DELIVERY_EXPIRED = "EXPIRED"; public static final String DEVICE_DELIVERY_CANCELLED = "CANCELLED"; + public static final String OFFLINE_RECORDING_ACTIVE = "ACTIVE"; + public static final String OFFLINE_RECORDING_PRE_END = "PRE_END"; + public static final String OFFLINE_RECORDING_UPLOAD_FINISHED = "UPLOAD_FINISHED"; + public static final String SUMMARY_DETAIL_DETAILED = "DETAILED"; public static final String SUMMARY_DETAIL_STANDARD = "STANDARD"; public static final String SUMMARY_DETAIL_BRIEF = "BRIEF"; diff --git a/backend/src/main/java/com/imeeting/common/RedisKeys.java b/backend/src/main/java/com/imeeting/common/RedisKeys.java index dc7d5d4..5bf4f8c 100644 --- a/backend/src/main/java/com/imeeting/common/RedisKeys.java +++ b/backend/src/main/java/com/imeeting/common/RedisKeys.java @@ -115,8 +115,8 @@ public final class RedisKeys { return "biz:meeting:public-session:" + sessionId; } - public static String androidChunkUploadSessionKey(String uploadSessionId) { - return "biz:meeting:android:chunk-upload:" + uploadSessionId; + public static String androidChunkUploadSessionKey(Long meetingId) { + return "biz:meeting:android:chunk-upload:" + meetingId; } public static String androidPendingMeetingDraftKey(Long meetingId) { diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java index d5af5a5..883f36e 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java @@ -43,18 +43,16 @@ public class AndroidMeetingChunkUploadController { @Anonymous public ApiResponse uploadChunk(HttpServletRequest request, @RequestParam("meeting_id") Long meetingId, - @RequestParam("upload_session_id") String uploadSessionId, @RequestParam("chunk_index") Integer chunkIndex, @RequestParam("total_chunks") Integer totalChunks, @RequestParam("chunk_file") MultipartFile chunkFile) throws IOException { AndroidRequestLogHelper.logRequest(log, "Android会议", "上传会议音频分片", "meetingId", meetingId, - "uploadSessionId", uploadSessionId, "chunkIndex", chunkIndex, "totalChunks", totalChunks, "chunkFile", chunkFile); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); - androidChunkUploadService.saveChunk(meetingId, uploadSessionId, chunkIndex, totalChunks, chunkFile, authContext); + androidChunkUploadService.saveChunk(meetingId, chunkIndex, totalChunks, chunkFile, authContext); return ApiResponse.ok(true); } @@ -69,25 +67,10 @@ public class AndroidMeetingChunkUploadController { @PostMapping("/complete") @Anonymous public ApiResponse completeUpload(HttpServletRequest request, - @RequestParam("meeting_id") Long meetingId, - @RequestParam("upload_session_id") String uploadSessionId, - @RequestParam(value = "force_replace", defaultValue = "false") boolean forceReplace, - @RequestParam(value = "prompt_id", required = false) Long promptId, - @RequestParam(value = "model_code", required = false) String modelCode) throws IOException { + @RequestParam("meeting_id") Long meetingId) throws IOException { AndroidRequestLogHelper.logRequest(log, "Android会议", "完成分片上传", - "meetingId", meetingId, - "uploadSessionId", uploadSessionId, - "forceReplace", forceReplace, - "promptId", promptId, - "modelCode", modelCode); + "meetingId", meetingId); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); - return ApiResponse.ok(androidChunkUploadService.completeUpload( - meetingId, - uploadSessionId, - forceReplace, - promptId, - modelCode, - authContext - )); + return ApiResponse.ok(androidChunkUploadService.completeUpload(meetingId, authContext)); } } 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 0a68d15..ac8a903 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -2,9 +2,13 @@ package com.imeeting.controller.android; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.imeeting.common.MeetingConstants; import com.imeeting.common.SysParamKeys; +import com.imeeting.common.exception.ExistingOfflineMeetingException; import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidMeetingConfigVo; +import com.imeeting.dto.android.AndroidOfflineMeetingConflictVO; +import com.imeeting.dto.android.AndroidOfflineMeetingFinishRequest; import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest; import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse; import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; @@ -19,9 +23,11 @@ import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.PromptTemplate; import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.android.AndroidChunkUploadService; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; import com.imeeting.support.AndroidRequestLogHelper; import com.imeeting.service.biz.*; +import com.unisbase.annotation.Anonymous; import com.unisbase.common.ApiResponse; import com.unisbase.common.annotation.Log; import com.unisbase.dto.PageResult; @@ -74,6 +80,7 @@ public class AndroidMeetingController { private static final String STAGE_COMPLETED = "completed"; private final AndroidAuthService androidAuthService; + private final AndroidChunkUploadService androidChunkUploadService; private final LegacyMeetingAdapterService legacyMeetingAdapterService; private final MeetingQueryService meetingQueryService; private final MeetingAccessService meetingAccessService; @@ -89,6 +96,7 @@ public class AndroidMeetingController { @Autowired public AndroidMeetingController(AndroidAuthService androidAuthService, + AndroidChunkUploadService androidChunkUploadService, LegacyMeetingAdapterService legacyMeetingAdapterService, MeetingQueryService meetingQueryService, MeetingAccessService meetingAccessService, @@ -102,6 +110,7 @@ public class AndroidMeetingController { SysParamService paramService, MeetingProgressService meetingProgressService) { this.androidAuthService = androidAuthService; + this.androidChunkUploadService = androidChunkUploadService; this.legacyMeetingAdapterService = legacyMeetingAdapterService; this.meetingQueryService = meetingQueryService; this.meetingAccessService = meetingAccessService; @@ -124,17 +133,22 @@ public class AndroidMeetingController { content = @Content(schema = @Schema(implementation = MeetingVO.class)) ) }) - @PostMapping + @PostMapping("/create") + @Anonymous @Log(value = "新增Android会议", type = "Android会议管理") - public ApiResponse create(HttpServletRequest request, @RequestBody LegacyMeetingCreateRequest command) { + public ApiResponse create(HttpServletRequest request, @RequestBody LegacyMeetingCreateRequest command) { AndroidRequestLogHelper.logRequest(log, "Android会议", "创建离线会议接口", "request", command); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); - LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); + LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); + try { // Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId()); // if (existingMeeting != null) { // return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId())); // } return ApiResponse.ok(legacyMeetingAdapterService.createMeeting(command, authContext, loginUser)); + } catch (ExistingOfflineMeetingException ex) { + return new ApiResponse<>("409", "有未结束会议", new AndroidOfflineMeetingConflictVO(ex.getMeetingId())); + } } @Operation(summary = "上传Android会议音频") @@ -146,6 +160,7 @@ public class AndroidMeetingController { ) }) @PostMapping("/upload-audio") + @Anonymous public ApiResponse uploadAudio(HttpServletRequest request, @RequestParam("id") Long meetingId, @RequestParam(value = "prompt_id", required = false) Long promptId, @@ -162,6 +177,8 @@ public class AndroidMeetingController { if (authContext.isAnonymous()) { return ApiResponse.ok(legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice( meetingId, + promptId, + modelCode, forceReplace, audioFile, authContext @@ -179,6 +196,32 @@ public class AndroidMeetingController { )); } + @Operation(summary = "结束 Android 离线会议录音阶段") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "结束成功返回 true", + content = @Content(schema = @Schema(implementation = Boolean.class)) + ) + }) + @PostMapping("/{meetingId}/finish") + @Anonymous + public ApiResponse finishOfflineMeeting(HttpServletRequest request, + @PathVariable Long meetingId, + @RequestBody(required = false) AndroidOfflineMeetingFinishRequest command) throws IOException { + AndroidRequestLogHelper.logRequest(log, "Android会议", "结束离线会议录音阶段", + "meetingId", meetingId, + "request", command); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); + Meeting meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser); + if (isUploadFinishedStage(command)) { + androidChunkUploadService.completeUpload(meeting.getId(), authContext); + } + meetingCommandService.finishOfflineMeeting(meeting.getId(), command == null ? null : command.getFinishStage()); + return ApiResponse.ok(true); + } + @Operation(summary = "分页查询Android会议") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -320,6 +363,37 @@ public class AndroidMeetingController { return ApiResponse.ok(resultVo); } + private Meeting requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) { + Meeting meeting = meetingService.getById(meetingId); + if (meeting == null) { + throw new RuntimeException("会议不存在"); + } + if (!MeetingConstants.TYPE_OFFLINE.equals(meeting.getMeetingType())) { + throw new RuntimeException("当前会议不是离线会议"); + } + if (authContext == null || authContext.getDeviceId() == null || authContext.getDeviceId().isBlank()) { + throw new RuntimeException("设备ID不能为空"); + } + if (meeting.getSourceDeviceCode() == null || !meeting.getSourceDeviceCode().equals(authContext.getDeviceId())) { + throw new RuntimeException("当前会议不属于该设备"); + } + if (authContext.isAnonymous()) { + if (!MeetingConstants.DEVICE_MODE_PUBLIC.equals(meeting.getSourceDeviceMode())) { + throw new RuntimeException("当前会议不是公有设备会议"); + } + return meeting; + } + if (loginUser == null || !Objects.equals(meeting.getCreatorId(), loginUser.getUserId())) { + throw new RuntimeException("仅会议创建人可操作当前会议"); + } + return meeting; + } + + private boolean isUploadFinishedStage(AndroidOfflineMeetingFinishRequest command) { + return command != null + && MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED.equalsIgnoreCase(command.getFinishStage()); + } + private LegacyMeetingPreviewResult buildPreviewResult(Long meetingId) { Meeting meeting = meetingService.getById(meetingId); if (meeting == null) { diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidPublicMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidPublicMeetingController.java index bdd646c..bffd5b2 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidPublicMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidPublicMeetingController.java @@ -1,16 +1,17 @@ package com.imeeting.controller.android; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.AndroidPushMessageVO; +import com.imeeting.dto.android.AndroidPublicMeetingSessionResultVO; import com.imeeting.dto.android.AndroidPublicMeetingSessionRequest; -import com.imeeting.dto.android.AndroidPublicMeetingSessionVO; -import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.entity.biz.AndroidPushMessage; import com.imeeting.entity.biz.Meeting; +import com.imeeting.enums.MeetingPushTypeEnum; import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.android.AndroidPublicMeetingSessionService; import com.imeeting.service.biz.MeetingCommandService; -import com.imeeting.service.biz.MeetingQueryService; import com.imeeting.service.biz.MeetingService; import com.imeeting.support.AndroidRequestLogHelper; import com.unisbase.annotation.Anonymous; @@ -23,6 +24,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -37,7 +39,7 @@ import org.springframework.web.bind.annotation.RestController; public class AndroidPublicMeetingController { private final AndroidAuthService androidAuthService; private final AndroidPublicMeetingSessionService androidPublicMeetingSessionService; - private final MeetingQueryService meetingQueryService; + private final AndroidPushMessageService androidPushMessageService; private final MeetingCommandService meetingCommandService; private final MeetingService meetingService; private final DeviceInfoMapper deviceInfoMapper; @@ -46,27 +48,48 @@ public class AndroidPublicMeetingController { @io.swagger.v3.oas.annotations.responses.ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "返回扫码会话信息,供设备展示二维码", - content = @Content(schema = @Schema(implementation = AndroidPublicMeetingSessionVO.class)) + description = "优先返回待处理扫码消息,否则返回扫码会话二维码", + content = @Content(schema = @Schema(implementation = AndroidPublicMeetingSessionResultVO.class)) ) }) @PostMapping("/session") @Anonymous - public ApiResponse createSession(HttpServletRequest request, - @RequestBody(required = false) AndroidPublicMeetingSessionRequest command) { + public ApiResponse createSession(HttpServletRequest request, + @RequestBody(required = false) AndroidPublicMeetingSessionRequest command) { AndroidRequestLogHelper.logRequest(log, "Android公有会议", "创建扫码发会会话", "request", command); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); assertPublicDevice(authContext.getDeviceId()); - Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId()); - if (existingMeeting != null) { - return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId())); + AndroidPushMessage pendingMessage = androidPushMessageService.findLatestPendingMessage( + authContext.getDeviceId(), + MeetingPushTypeEnum.PUBLIC_MEETING_LOGIN_CONFIRM.getCode() + ); + AndroidPublicMeetingSessionResultVO result = new AndroidPublicMeetingSessionResultVO(); + if (pendingMessage != null) { + result.setMode("PENDING_MESSAGE"); + result.setMessage(androidPushMessageService.toPushMessageVO(pendingMessage)); + return ApiResponse.ok(result); } - AndroidPublicMeetingSessionVO vo = androidPublicMeetingSessionService.create( + result.setMode("QR_CODE"); + result.setQrCode(androidPublicMeetingSessionService.create( authContext.getDeviceId(), command == null ? null : command.getTitle() + )); + return ApiResponse.ok(result); + } + + @Operation(summary = "主动拉取未确认扫码消息") + @GetMapping("/pending-login-message") + @Anonymous + public ApiResponse pullPendingLoginMessage(HttpServletRequest request) { + AndroidRequestLogHelper.logRequest(log, "Android公有会议", "主动拉取未确认扫码消息"); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + assertPublicDevice(authContext.getDeviceId()); + AndroidPushMessage pendingMessage = androidPushMessageService.findLatestPendingMessage( + authContext.getDeviceId(), + MeetingPushTypeEnum.PUBLIC_MEETING_LOGIN_CONFIRM.getCode() ); - return ApiResponse.ok(vo); + return ApiResponse.ok(androidPushMessageService.toPushMessageVO(pendingMessage)); } @Operation(summary = "公有设备删除未开始会议") @@ -97,17 +120,6 @@ public class AndroidPublicMeetingController { return ApiResponse.ok(true); } - private Meeting findLatestUnfinishedMeetingByDevice(String deviceId) { - if (deviceId == null || deviceId.isBlank()) { - return null; - } - return meetingService.getOne(new LambdaQueryWrapper() - .eq(Meeting::getSourceDeviceCode, deviceId) - .in(Meeting::getStatus, 0, 1, 2) - .orderByDesc(Meeting::getId) - .last("LIMIT 1")); - } - private void assertPublicDevice(String deviceId) { if (deviceId == null || deviceId.isBlank()) { throw new RuntimeException("设备ID不能为空"); 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 8115e34..e0c1f64 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -44,11 +44,13 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -86,6 +88,9 @@ public class MeetingController { private final SysParamService sysParamService; private final AiTaskService aiTaskService; + @Value("${imeeting.h5.base-url:}") + private String h5BaseUrl; + @Autowired public MeetingController(MeetingQueryService meetingQueryService, MeetingCommandService meetingCommandService, @@ -203,6 +208,19 @@ public class MeetingController { return ApiResponse.ok(vo); } + @Operation(summary = "获取会议分享配置") + @GetMapping("/share-config") + @PreAuthorize("isAuthenticated()") + public ApiResponse> getShareConfig() { + String baseUrl = StringUtils.hasText(h5BaseUrl) ? h5BaseUrl.trim() : ""; + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + Map result = new HashMap<>(); + result.put("h5BaseUrl", baseUrl); + return ApiResponse.ok(result); + } + @Operation(summary = "创建离线会议") @PostMapping @PreAuthorize("isAuthenticated()") diff --git a/backend/src/main/java/com/imeeting/controller/biz/PublicDeviceMeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/PublicDeviceMeetingController.java index a28a2f4..1745ac6 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/PublicDeviceMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/PublicDeviceMeetingController.java @@ -1,27 +1,19 @@ package com.imeeting.controller.biz; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.dto.android.AndroidPublicLoginConfirmPayload; import com.imeeting.dto.android.AndroidPublicMeetingSessionState; -import com.imeeting.dto.biz.MeetingVO; -import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand; -import com.imeeting.entity.biz.Meeting; import com.imeeting.service.android.AndroidMeetingPushService; import com.imeeting.service.android.AndroidPublicMeetingSessionService; -import com.imeeting.service.biz.MeetingCommandService; -import com.imeeting.service.biz.MeetingQueryService; -import com.imeeting.service.biz.MeetingService; import com.unisbase.common.ApiResponse; import com.unisbase.security.LoginUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -30,62 +22,35 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/biz/public-device-meetings") public class PublicDeviceMeetingController { private final AndroidPublicMeetingSessionService androidPublicMeetingSessionService; - private final MeetingCommandService meetingCommandService; - private final MeetingQueryService meetingQueryService; private final AndroidMeetingPushService androidMeetingPushService; - private final MeetingService meetingService; public PublicDeviceMeetingController(AndroidPublicMeetingSessionService androidPublicMeetingSessionService, - MeetingCommandService meetingCommandService, - MeetingQueryService meetingQueryService, - AndroidMeetingPushService androidMeetingPushService, - MeetingService meetingService) { + AndroidMeetingPushService androidMeetingPushService) { this.androidPublicMeetingSessionService = androidPublicMeetingSessionService; - this.meetingCommandService = meetingCommandService; - this.meetingQueryService = meetingQueryService; this.androidMeetingPushService = androidMeetingPushService; - this.meetingService = meetingService; } - @Operation(summary = "H5扫码为公有设备创建会议") + @Operation(summary = "H5扫码确认公有设备登录") @io.swagger.v3.oas.annotations.responses.ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "返回已创建的会议详情;若设备已有未结束会议,则返回已有会议", - content = @Content(schema = @Schema(implementation = MeetingVO.class)) + description = "确认成功返回 true,并向设备推送扫码用户信息", + content = @Content(schema = @Schema(implementation = Boolean.class)) ) }) @PostMapping("/sessions/{sessionId}/create") - public ApiResponse createBySession(@PathVariable String sessionId, - @Valid @RequestBody PublicDeviceMeetingCreateCommand command) { + public ApiResponse createBySession(@PathVariable String sessionId) { LoginUser loginUser = currentLoginUser(); AndroidPublicMeetingSessionState session = androidPublicMeetingSessionService.require(sessionId); - Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(session.getDeviceId()); - if (existingMeeting != null) { - return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId())); - } - String creatorName = loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); - MeetingVO vo = meetingCommandService.createPublicDeviceMeeting( - command, - loginUser.getTenantId(), - loginUser.getUserId(), - creatorName, - session.getDeviceId() - ); - androidMeetingPushService.pushPendingMeetingToDevice(vo.getId(), session.getDeviceId()); - androidPublicMeetingSessionService.clear(sessionId); - return ApiResponse.ok(vo); - } - - private Meeting findLatestUnfinishedMeetingByDevice(String deviceId) { - if (deviceId == null || deviceId.isBlank()) { - return null; - } - return meetingService.getOne(new LambdaQueryWrapper() - .eq(Meeting::getSourceDeviceCode, deviceId) - .in(Meeting::getStatus, 0, 1, 2) - .orderByDesc(Meeting::getId) - .last("LIMIT 1")); + AndroidPublicLoginConfirmPayload payload = new AndroidPublicLoginConfirmPayload(); + payload.setSessionId(sessionId); + payload.setTenantId(loginUser.getTenantId()); + payload.setUserId(loginUser.getUserId()); + payload.setUsername(loginUser.getUsername()); + payload.setDisplayName(loginUser.getDisplayName()); + androidMeetingPushService.pushPublicLoginConfirm(session.getDeviceId(), payload); + androidPublicMeetingSessionService.invalidate(sessionId); + return ApiResponse.ok(true); } private LoginUser currentLoginUser() { diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidChunkUploadSessionState.java b/backend/src/main/java/com/imeeting/dto/android/AndroidChunkUploadSessionState.java index 5960d5a..eebe8ee 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidChunkUploadSessionState.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidChunkUploadSessionState.java @@ -2,16 +2,19 @@ package com.imeeting.dto.android; import lombok.Data; +import java.util.Map; import java.util.Set; +import java.util.TreeMap; import java.util.TreeSet; @Data public class AndroidChunkUploadSessionState { - private String uploadSessionId; private Long meetingId; private String deviceId; private Integer totalChunks; private String fileName; private String contentType; private Set receivedChunks = new TreeSet<>(); + private Set uploadedChunkFileNames = new TreeSet<>(); + private Map chunkFileNames = new TreeMap<>(); } diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionState.java b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionState.java index f331093..7a975d7 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionState.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionState.java @@ -10,5 +10,6 @@ public class AndroidPublicMeetingSessionState { private String sessionToken; private String deviceId; private String title; + private Boolean invalidated; private LocalDateTime expireAt; } diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionVO.java index 6644219..8423c67 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionVO.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionVO.java @@ -17,6 +17,9 @@ public class AndroidPublicMeetingSessionVO { @Schema(description = "设备ID") private String deviceId; + @Schema(description = "H5 扫码确认完整地址") + private String qrUrl; + @Schema(description = "会话过期时间") private LocalDateTime expireAt; } diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingCreateRequest.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingCreateRequest.java index 002d16b..3be740a 100644 --- a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingCreateRequest.java +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingCreateRequest.java @@ -10,6 +10,12 @@ public class LegacyMeetingCreateRequest { @JsonProperty("user_id") private Long userId; + @JsonProperty("tenant_id") + private Long tenantId; + + @JsonProperty("creator_name") + private String creatorName; + private String title; @JsonProperty("meeting_time") diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java index ea1276f..836ecaf 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -47,6 +47,8 @@ public class MeetingVO { private String sourceDeviceCode; @Schema(description = "来源设备模式") private String sourceDeviceMode; + @Schema(description = "离线录音阶段:ACTIVE / PRE_END / UPLOAD_FINISHED") + private String offlineRecordingStatus; @Schema(description = "总结详细程度") private String summaryDetailLevel; @Schema(description = "音频保存状态") diff --git a/backend/src/main/java/com/imeeting/entity/biz/AndroidPushMessage.java b/backend/src/main/java/com/imeeting/entity/biz/AndroidPushMessage.java index b75c478..a4330c6 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/AndroidPushMessage.java +++ b/backend/src/main/java/com/imeeting/entity/biz/AndroidPushMessage.java @@ -24,6 +24,8 @@ public class AndroidPushMessage extends BaseEntity { private String messageType; + private String messageTitle; + private String payload; private Integer needAck; diff --git a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java index 80b142a..5a91332 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java @@ -47,6 +47,9 @@ public class Meeting extends BaseEntity { @Schema(description = "来源设备模式") private String sourceDeviceMode; + @Schema(description = "离线录音阶段:ACTIVE / PRE_END / UPLOAD_FINISHED") + private String offlineRecordingStatus; + @Schema(description = "总结详细程度") private String summaryDetailLevel; diff --git a/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java b/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java index afef3fa..723007a 100644 --- a/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java +++ b/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java @@ -4,6 +4,7 @@ import lombok.Getter; @Getter public enum MeetingPushTypeEnum { + PUBLIC_MEETING_LOGIN_CONFIRM("PUBLIC_MEETING_LOGIN_CONFIRM", "公有设备扫码登录确认消息"), MEETING_PENDING("MEETING_PENDING", "待开始会议通知"), MEETING_COMPLETED("MEETING_COMPLETED", "会议完成通知"); diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java b/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java index a2089ef..b003e43 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java @@ -8,16 +8,11 @@ import java.io.IOException; public interface AndroidChunkUploadService { void saveChunk(Long meetingId, - String uploadSessionId, Integer chunkIndex, Integer totalChunks, MultipartFile chunkFile, AndroidAuthContext authContext) throws IOException; LegacyUploadAudioResponse completeUpload(Long meetingId, - String uploadSessionId, - boolean forceReplace, - Long promptId, - String modelCode, AndroidAuthContext authContext) throws IOException; } 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 df3dd9c..72e1939 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java @@ -1,7 +1,11 @@ package com.imeeting.service.android; +import com.imeeting.dto.android.AndroidPublicLoginConfirmPayload; + public interface AndroidMeetingPushService { void pushPendingMeetingToDevice(Long meetingId, String deviceId); + void pushPublicLoginConfirm(String deviceId, AndroidPublicLoginConfirmPayload payload); + void pushMeetingCompleted(Long meetingId); } diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidPublicMeetingSessionService.java b/backend/src/main/java/com/imeeting/service/android/AndroidPublicMeetingSessionService.java index 2d0c1d9..75467f8 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidPublicMeetingSessionService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidPublicMeetingSessionService.java @@ -8,5 +8,7 @@ public interface AndroidPublicMeetingSessionService { AndroidPublicMeetingSessionState require(String sessionId); + void invalidate(String sessionId); + void clear(String sessionId); } diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidPushMessageService.java b/backend/src/main/java/com/imeeting/service/android/AndroidPushMessageService.java index aa51eed..00f00a4 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidPushMessageService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidPushMessageService.java @@ -1,5 +1,6 @@ package com.imeeting.service.android; +import com.imeeting.dto.android.AndroidPushMessageVO; import com.imeeting.entity.biz.AndroidPushMessage; import com.imeeting.grpc.push.PushMessage; @@ -12,6 +13,10 @@ public interface AndroidPushMessageService { List listPendingMeetingPushMessages(); + AndroidPushMessage findLatestPendingMessage(String deviceCode, String messageType); + + AndroidPushMessageVO toPushMessageVO(AndroidPushMessage message); + void markPushed(Long id); void markExpired(Long id); diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java index 7a94941..3d472ea 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java @@ -1,12 +1,12 @@ package com.imeeting.service.android.impl; -import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidChunkUploadSessionState; import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; import com.imeeting.service.android.AndroidChunkUploadService; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; import com.imeeting.support.redis.AndroidChunkUploadSessionCache; +import com.unisbase.security.LoginUser; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -19,10 +19,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; +import java.util.Map; import java.util.Objects; -import com.unisbase.security.LoginUser; - @Service @RequiredArgsConstructor public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService { @@ -34,13 +33,12 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService @Override public void saveChunk(Long meetingId, - String uploadSessionId, Integer chunkIndex, Integer totalChunks, MultipartFile chunkFile, AndroidAuthContext authContext) throws IOException { - if (meetingId == null || uploadSessionId == null || uploadSessionId.isBlank()) { - throw new RuntimeException("uploadSessionId不能为空"); + if (meetingId == null) { + throw new RuntimeException("meeting_id不能为空"); } if (chunkIndex == null || totalChunks == null || chunkIndex < 0 || totalChunks <= 0) { throw new RuntimeException("分片参数无效"); @@ -48,33 +46,47 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService if (chunkFile == null || chunkFile.isEmpty()) { throw new RuntimeException("chunk_file不能为空"); } - AndroidChunkUploadSessionState state = getOrCreateState(meetingId, uploadSessionId, totalChunks, chunkFile, authContext); + AndroidChunkUploadSessionState state = getOrCreateState(meetingId, totalChunks, chunkFile, authContext); if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) { throw new RuntimeException("分片上传会话与当前设备或会议不匹配"); } - Path sessionDir = sessionDir(uploadSessionId); - Files.createDirectories(sessionDir); - Files.write(sessionDir.resolve(chunkIndex + ".part"), chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + + String chunkFileName = normalizeChunkFileName(chunkFile.getOriginalFilename(), chunkIndex); + String previousFileName = state.getChunkFileNames().get(chunkIndex); + Path meetingDir = sessionDir(meetingId); + Files.createDirectories(meetingDir); + + if (previousFileName != null && !previousFileName.equals(chunkFileName)) { + deleteQuietly(meetingDir.resolve(previousFileName)); + state.getUploadedChunkFileNames().remove(previousFileName); + } + if (state.getUploadedChunkFileNames().contains(chunkFileName)) { + state.getChunkFileNames().put(chunkIndex, chunkFileName); + state.getReceivedChunks().add(chunkIndex); + saveState(meetingId, state); + return; + } + + Files.write(meetingDir.resolve(chunkFileName), chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + state.getUploadedChunkFileNames().add(chunkFileName); + state.getChunkFileNames().put(chunkIndex, chunkFileName); state.getReceivedChunks().add(chunkIndex); - saveState(uploadSessionId, state); + saveState(meetingId, state); } @Override public LegacyUploadAudioResponse completeUpload(Long meetingId, - String uploadSessionId, - boolean forceReplace, - Long promptId, - String modelCode, AndroidAuthContext authContext) throws IOException { - AndroidChunkUploadSessionState state = requireState(uploadSessionId); + AndroidChunkUploadSessionState state = requireState(meetingId); if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) { throw new RuntimeException("分片上传会话与当前设备或会议不匹配"); } for (int i = 0; i < state.getTotalChunks(); i++) { - if (!state.getReceivedChunks().contains(i)) { + if (!state.getReceivedChunks().contains(i) || !state.getChunkFileNames().containsKey(i)) { throw new RuntimeException("分片未上传完整"); } } + Path mergedFile = mergeChunks(state); try { MultipartFile mergedMultipart = new LocalMultipartFile( @@ -85,7 +97,9 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService if (authContext.isAnonymous()) { return legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice( meetingId, - forceReplace, + null, + null, + false, mergedMultipart, authContext ); @@ -93,84 +107,106 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService LoginUser loginUser = toLoginUser(authContext); return legacyMeetingAdapterService.uploadAndTriggerOfflineProcess( meetingId, - promptId, - modelCode, - forceReplace, + null, + null, + false, mergedMultipart, authContext, loginUser ); } finally { - cleanup(uploadSessionId); + cleanup(meetingId); } } private AndroidChunkUploadSessionState getOrCreateState(Long meetingId, - String uploadSessionId, Integer totalChunks, MultipartFile chunkFile, AndroidAuthContext authContext) throws IOException { - AndroidChunkUploadSessionState existing = getState(uploadSessionId); + AndroidChunkUploadSessionState existing = getState(meetingId); if (existing != null) { - return existing; + String currentFileName = chunkFile == null ? null : chunkFile.getOriginalFilename(); + boolean sameTotalChunks = Objects.equals(existing.getTotalChunks(), totalChunks); + boolean sameFileName = Objects.equals(existing.getFileName(), currentFileName); + if (sameTotalChunks && sameFileName) { + return existing; + } + cleanup(meetingId); } AndroidChunkUploadSessionState state = new AndroidChunkUploadSessionState(); - state.setUploadSessionId(uploadSessionId); state.setMeetingId(meetingId); state.setDeviceId(authContext.getDeviceId()); state.setTotalChunks(totalChunks); state.setFileName(chunkFile.getOriginalFilename()); state.setContentType(chunkFile.getContentType()); - saveState(uploadSessionId, state); + saveState(meetingId, state); return state; } private Path mergeChunks(AndroidChunkUploadSessionState state) throws IOException { - Path sessionDir = sessionDir(state.getUploadSessionId()); - Path merged = sessionDir.resolve("merged.bin"); + Path meetingDir = sessionDir(state.getMeetingId()); + Path merged = meetingDir.resolve("merged.bin"); Files.deleteIfExists(merged); Files.createFile(merged); - for (int i = 0; i < state.getTotalChunks(); i++) { - Files.write(merged, Files.readAllBytes(sessionDir.resolve(i + ".part")), StandardOpenOption.APPEND); + for (Map.Entry entry : state.getChunkFileNames().entrySet()) { + Files.write(merged, Files.readAllBytes(meetingDir.resolve(entry.getValue())), StandardOpenOption.APPEND); } return merged; } - private AndroidChunkUploadSessionState requireState(String uploadSessionId) { - AndroidChunkUploadSessionState state = getState(uploadSessionId); + private AndroidChunkUploadSessionState requireState(Long meetingId) { + AndroidChunkUploadSessionState state = getState(meetingId); if (state == null) { throw new RuntimeException("分片上传会话不存在或已过期"); } return state; } - private AndroidChunkUploadSessionState getState(String uploadSessionId) { - return sessionCache.get(uploadSessionId); + private AndroidChunkUploadSessionState getState(Long meetingId) { + return sessionCache.get(meetingId); } - private void saveState(String uploadSessionId, AndroidChunkUploadSessionState state) { - sessionCache.save(uploadSessionId, state); + private void saveState(Long meetingId, AndroidChunkUploadSessionState state) { + sessionCache.save(meetingId, state); } - private void cleanup(String uploadSessionId) throws IOException { - sessionCache.clear(uploadSessionId); - Path sessionDir = sessionDir(uploadSessionId); - if (Files.exists(sessionDir)) { - try (var paths = Files.walk(sessionDir)) { - paths.sorted((left, right) -> right.compareTo(left)).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (IOException ex) { - throw new RuntimeException(ex); - } - }); - } + private void cleanup(Long meetingId) throws IOException { + sessionCache.clear(meetingId); + Path meetingDir = sessionDir(meetingId); + if (!Files.exists(meetingDir)) { + return; + } + try (var paths = Files.walk(meetingDir)) { + paths.sorted((left, right) -> right.compareTo(left)).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + }); } } - private Path sessionDir(String uploadSessionId) { + private Path sessionDir(Long meetingId) { String normalizedBasePath = uploadPath.endsWith("/") || uploadPath.endsWith("\\") ? uploadPath : uploadPath + "/"; - return Paths.get(normalizedBasePath, "android-chunks", uploadSessionId); + return Paths.get(normalizedBasePath, "android-chunks", String.valueOf(meetingId)); + } + + private String normalizeChunkFileName(String originalFileName, Integer chunkIndex) { + String fallback = "chunk-" + (chunkIndex == null ? "unknown" : chunkIndex); + String fileName = originalFileName == null || originalFileName.isBlank() ? fallback : originalFileName.trim(); + fileName = Paths.get(fileName).getFileName().toString(); + return fileName.isBlank() ? fallback : fileName; + } + + private void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + } } private LoginUser toLoginUser(AndroidAuthContext authContext) { 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 cdfc44f..d93c083 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 @@ -1,6 +1,7 @@ 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.enums.MeetingPushTypeEnum; import com.imeeting.grpc.push.PushMessage; @@ -61,6 +62,28 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService log.info("Android pending meeting push finished, meetingId={}, deviceId={}, pushedConnections={}", meetingId, deviceId, pushed); } + @Override + public void pushPublicLoginConfirm(String deviceId, AndroidPublicLoginConfirmPayload payload) { + if (deviceId == null || deviceId.isBlank() || payload == null || payload.getUserId() == null) { + return; + } + PushMessage message = PushMessage.newBuilder() + .setMessageId("public_login_confirm:" + payload.getSessionId() + ":" + UUID.randomUUID()) + .setTimestamp(System.currentTimeMillis()) + .setType(MeetingPushTypeEnum.PUBLIC_MEETING_LOGIN_CONFIRM.getCode()) + .setTitle("扫码登录确认") + .setContent(JSONUtil.toJsonStr(payload)) + .setNeedAck(true) + .build(); + var pushEntity = androidPushMessageService.saveMeetingPushMessage(payload.getTenantId(), null, deviceId, message, pendingExpireMinutes); + int pushed = androidGatewayPushService.pushToDevice(deviceId, message); + if (pushEntity.getId() != null) { + androidPushMessageService.markPushed(pushEntity.getId()); + } + log.info("Android public login confirm push finished, deviceId={}, sessionId={}, pushedConnections={}", + deviceId, payload.getSessionId(), pushed); + } + @Override public void pushMeetingCompleted(Long meetingId) { if (meetingId == null) { diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidPublicMeetingSessionServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidPublicMeetingSessionServiceImpl.java index 3a69c86..4705042 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidPublicMeetingSessionServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidPublicMeetingSessionServiceImpl.java @@ -7,6 +7,7 @@ import com.imeeting.support.redis.AndroidPublicMeetingSessionCache; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import java.time.Duration; import java.time.LocalDateTime; @@ -20,6 +21,9 @@ public class AndroidPublicMeetingSessionServiceImpl implements AndroidPublicMeet @Value("${imeeting.public-device.session-ttl-minutes:30}") private long sessionTtlMinutes; + @Value("${imeeting.h5.base-url:}") + private String h5BaseUrl; + @Override public AndroidPublicMeetingSessionVO create(String deviceId, String title) { String sessionId = UUID.randomUUID().toString().replace("-", ""); @@ -35,6 +39,7 @@ public class AndroidPublicMeetingSessionServiceImpl implements AndroidPublicMeet vo.setSessionId(sessionId); vo.setSessionToken(sessionId); vo.setDeviceId(deviceId); + vo.setQrUrl(buildQrUrl(sessionId)); vo.setExpireAt(expireAt); return vo; } @@ -45,11 +50,35 @@ public class AndroidPublicMeetingSessionServiceImpl implements AndroidPublicMeet if (state == null) { throw new RuntimeException("公有设备发会会话不存在或已过期"); } + if (Boolean.TRUE.equals(state.getInvalidated())) { + throw new RuntimeException("二维码已失效"); + } return state; } + @Override + public void invalidate(String sessionId) { + AndroidPublicMeetingSessionState state = sessionCache.get(sessionId); + if (state == null) { + return; + } + state.setInvalidated(true); + sessionCache.save(sessionId, state, Duration.ofMinutes(Math.max(sessionTtlMinutes, 1))); + } + @Override public void clear(String sessionId) { sessionCache.clear(sessionId); } + + private String buildQrUrl(String sessionId) { + String baseUrl = StringUtils.hasText(h5BaseUrl) ? h5BaseUrl.trim() : ""; + if (!StringUtils.hasText(baseUrl)) { + throw new RuntimeException("未配置 imeeting.h5.base-url,无法生成 H5 扫码确认地址"); + } + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + return baseUrl + "/scan-confirm/" + sessionId; + } } diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidPushMessageServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidPushMessageServiceImpl.java index 1788a3b..f434314 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidPushMessageServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidPushMessageServiceImpl.java @@ -3,6 +3,7 @@ package com.imeeting.service.android.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.imeeting.common.MeetingConstants; +import com.imeeting.dto.android.AndroidPushMessageVO; import com.imeeting.entity.biz.AndroidPushMessage; import com.imeeting.grpc.push.PushMessage; import com.imeeting.mapper.biz.AndroidPushMessageMapper; @@ -30,6 +31,7 @@ public class AndroidPushMessageServiceImpl implements AndroidPushMessageService entity.setDeviceCode(deviceCode); entity.setMessageId(pushMessage.getMessageId()); entity.setMessageType(pushMessage.getType()); + entity.setMessageTitle(pushMessage.getTitle()); entity.setPayload(pushMessage.getContent()); entity.setNeedAck(pushMessage.getNeedAck() ? 1 : 0); entity.setAcked(0); @@ -64,6 +66,39 @@ public class AndroidPushMessageServiceImpl implements AndroidPushMessageService .eq(AndroidPushMessage::getIsDeleted, 0)); } + @Override + public AndroidPushMessage findLatestPendingMessage(String deviceCode, String messageType) { + if (deviceCode == null || deviceCode.isBlank() || messageType == null || messageType.isBlank()) { + return null; + } + return androidPushMessageMapper.selectOne(new LambdaQueryWrapper() + .eq(AndroidPushMessage::getDeviceCode, deviceCode.trim()) + .eq(AndroidPushMessage::getMessageType, messageType.trim()) + .eq(AndroidPushMessage::getNeedAck, 1) + .eq(AndroidPushMessage::getAcked, 0) + .eq(AndroidPushMessage::getPushStatus, MeetingConstants.DEVICE_DELIVERY_PENDING) + .and(wrapper -> wrapper.isNull(AndroidPushMessage::getExpireAt).or().gt(AndroidPushMessage::getExpireAt, LocalDateTime.now())) + .eq(AndroidPushMessage::getIsDeleted, 0) + .orderByDesc(AndroidPushMessage::getCreatedAt) + .orderByDesc(AndroidPushMessage::getId) + .last("LIMIT 1")); + } + + @Override + public AndroidPushMessageVO toPushMessageVO(AndroidPushMessage message) { + if (message == null) { + return null; + } + AndroidPushMessageVO vo = new AndroidPushMessageVO(); + vo.setMessageId(message.getMessageId()); + vo.setTimestamp(resolveTimestamp(message)); + vo.setType(message.getMessageType()); + vo.setTitle(message.getMessageTitle()); + vo.setContent(message.getPayload()); + vo.setNeedAck(Integer.valueOf(1).equals(message.getNeedAck())); + return vo; + } + @Override public void markPushed(Long id) { androidPushMessageMapper.update(null, new LambdaUpdateWrapper() @@ -92,4 +127,14 @@ public class AndroidPushMessageServiceImpl implements AndroidPushMessageService .eq(AndroidPushMessage::getIsDeleted, 0) .set(AndroidPushMessage::getPushStatus, MeetingConstants.DEVICE_DELIVERY_CANCELLED)); } + + private long resolveTimestamp(AndroidPushMessage message) { + if (message.getCreatedAt() != null) { + return message.getCreatedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli(); + } + if (message.getLastPushAt() != null) { + return message.getLastPushAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli(); + } + return System.currentTimeMillis(); + } } diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterService.java b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterService.java index 68f5276..9e27ee1 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterService.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterService.java @@ -21,6 +21,8 @@ public interface LegacyMeetingAdapterService { LoginUser loginUser) throws IOException; LegacyUploadAudioResponse uploadAndTriggerOfflineProcessForPublicDevice(Long meetingId, + Long promptId, + String modelCode, boolean forceReplace, MultipartFile audioFile, AndroidAuthContext authContext) throws IOException; diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java index 816ce51..0261cbf 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java @@ -2,22 +2,19 @@ package com.imeeting.service.android.legacy.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.imeeting.common.MeetingConstants; +import com.imeeting.common.exception.ExistingOfflineMeetingException; import com.imeeting.dto.android.AndroidAuthContext; -import com.imeeting.dto.android.AndroidPendingMeetingDraft; import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; import com.imeeting.dto.biz.MeetingVO; -import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand; import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.LlmModel; import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.MeetingTranscript; -import com.imeeting.entity.biz.PromptTemplate; import com.imeeting.mapper.biz.LlmModelMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; -import com.imeeting.service.android.AndroidPendingMeetingDraftService; import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingRuntimeProfileResolver; @@ -60,7 +57,6 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ private final MeetingTranscriptMapper transcriptMapper; private final LlmModelMapper llmModelMapper; private final MeetingAudioUploadSupport meetingAudioUploadSupport; - private final AndroidPendingMeetingDraftService androidPendingMeetingDraftService; @Override @Transactional(rollbackFor = Exception.class) @@ -73,6 +69,33 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ throw new RuntimeException("会议时间不能为空"); } + Long creatorUserId; + Long tenantId; + String creatorName; + String sourceDeviceMode; + if (authContext != null && authContext.isAnonymous()) { + if (request.getUserId() == null || request.getTenantId() == null) { + throw new RuntimeException("公有设备创建会议缺少用户或租户信息"); + } + creatorUserId = request.getUserId(); + tenantId = request.getTenantId(); + creatorName = normalizeCreatorName(request.getCreatorName(), creatorUserId); + sourceDeviceMode = MeetingConstants.DEVICE_MODE_PUBLIC; + } else { + if (loginUser == null || loginUser.getUserId() == null || loginUser.getTenantId() == null) { + throw new RuntimeException("安卓用户未登录或认证无效"); + } + creatorUserId = loginUser.getUserId(); + tenantId = loginUser.getTenantId(); + creatorName = resolveCreatorName(loginUser); + sourceDeviceMode = MeetingConstants.DEVICE_MODE_PRIVATE; + } + + Meeting existingMeeting = findLatestBlockingOfflineMeeting(authContext == null ? null : authContext.getDeviceId(), creatorUserId); + if (existingMeeting != null) { + throw new ExistingOfflineMeetingException(existingMeeting.getId()); + } + Meeting meeting = meetingDomainSupport.initMeeting( request.getTitle().trim(), meetingTime, @@ -81,15 +104,15 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ null, MeetingConstants.TYPE_OFFLINE, MeetingConstants.SOURCE_ANDROID, - loginUser.getTenantId(), - loginUser.getUserId(), - resolveCreatorName(loginUser), - loginUser.getUserId(), - resolveCreatorName(loginUser), + tenantId, + creatorUserId, + creatorName, + creatorUserId, + creatorName, MeetingConstants.SUMMARY_DETAIL_STANDARD, 0, authContext == null ? null : authContext.getDeviceId(), - MeetingConstants.DEVICE_MODE_PRIVATE + sourceDeviceMode ); meetingService.save(meeting); @@ -156,6 +179,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ meeting.setAudioUrl(relocatedUrl); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); meeting.setAudioSaveMessage(null); + meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED); meeting.setStatus(1); meetingService.updateById(meeting); @@ -170,6 +194,8 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ @Override @Transactional(rollbackFor = Exception.class) public LegacyUploadAudioResponse uploadAndTriggerOfflineProcessForPublicDevice(Long meetingId, + Long promptId, + String modelCode, boolean forceReplace, MultipartFile audioFile, AndroidAuthContext authContext) throws IOException { @@ -187,46 +213,48 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ if (!forceReplace && meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) { throw new RuntimeException("当前会议已存在音频,如需替换请设置 force_replace=true"); } - AndroidPendingMeetingDraft draft = androidPendingMeetingDraftService.get(meetingId); - if (draft == null || draft.getCommand() == null) { - throw new RuntimeException("未找到公有设备会议配置草稿"); - } long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper() .eq(MeetingTranscript::getMeetingId, meetingId)); if (transcriptCount > 0) { throw new RuntimeException("当前会议已存在转录内容,不支持替换已生成的转录"); } - PublicDeviceMeetingCreateCommand command = draft.getCommand(); + LoginUser loginUser = toMeetingOwnerLoginUser(meeting); + if (promptId != null && !promptTemplateService.isTemplateEnabledForUser( + promptId, + loginUser.getTenantId(), + loginUser.getUserId(), + Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()), + Boolean.TRUE.equals(loginUser.getIsTenantAdmin()))) { + throw new RuntimeException("总结模板不可用"); + } RealtimeMeetingRuntimeProfile profile = runtimeProfileResolver.resolve( meeting.getTenantId(), - command.getAsrModelId(), - command.getSummaryModelId(), - command.getPromptId(), + null, + resolveSummaryModelId(modelCode, meeting.getTenantId()), + promptId, null, null, - command.getUseSpkId(), null, null, - command.getEnableTextRefine(), null, - command.getHotWordGroupId(), - command.getHotWords() + null, + null, + null, + List.of() ); String stagingUrl = storeStagingAudio(audioFile); String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl); meeting.setAudioUrl(relocatedUrl); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); meeting.setAudioSaveMessage(null); + meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED); meeting.setStatus(1); meetingService.updateById(meeting); resetOrCreateAsrTask(meetingId, profile); - Long summaryModelId = profile.getResolvedSummaryModelId(); - Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : summaryModelId; - resetOrCreateChapterTask(meetingId, summaryModelId, chapterModelId, profile.getResolvedPromptId(), command.getUserPrompt(), command.getSummaryDetailLevel()); - resetOrCreateSummaryTask(meetingId, summaryModelId, chapterModelId, profile.getResolvedPromptId(), command.getUserPrompt(), command.getSummaryDetailLevel()); + resetOrCreateChapterTask(meetingId, profile); + resetOrCreateSummaryTask(meetingId, profile); dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId()); - androidPendingMeetingDraftService.clear(meetingId); return new LegacyUploadAudioResponse(meetingId, relocatedUrl); } @@ -431,6 +459,13 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); } + private String normalizeCreatorName(String creatorName, Long creatorUserId) { + if (creatorName != null && !creatorName.isBlank()) { + return creatorName.trim(); + } + return creatorUserId == null ? "android" : String.valueOf(creatorUserId); + } + private void assertDeviceOwnsMeeting(Meeting meeting, AndroidAuthContext authContext) { if (meeting == null || authContext == null || authContext.getDeviceId() == null) { return; @@ -440,4 +475,28 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ throw new RuntimeException("当前会议不属于该设备"); } } + + private Meeting findLatestBlockingOfflineMeeting(String deviceId, Long creatorUserId) { + if (deviceId == null || deviceId.isBlank() || creatorUserId == null) { + return null; + } + return meetingService.getOne(new LambdaQueryWrapper() + .eq(Meeting::getMeetingType, MeetingConstants.TYPE_OFFLINE) + .eq(Meeting::getSourceDeviceCode, deviceId) + .eq(Meeting::getCreatorId, creatorUserId) + .and(wrapper -> wrapper + .eq(Meeting::getOfflineRecordingStatus, MeetingConstants.OFFLINE_RECORDING_ACTIVE) + .or() + .isNull(Meeting::getOfflineRecordingStatus)) + .orderByDesc(Meeting::getId) + .last("LIMIT 1")); + } + + private LoginUser toMeetingOwnerLoginUser(Meeting meeting) { + LoginUser loginUser = new LoginUser(); + loginUser.setUserId(meeting.getCreatorId()); + loginUser.setTenantId(meeting.getTenantId()); + loginUser.setDisplayName(meeting.getCreatorName()); + return loginUser; + } } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java index 80313e8..124b3c3 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java @@ -34,6 +34,8 @@ public interface MeetingCommandService { void completeRealtimeMeeting(Long meetingId, String audioUrl, boolean overwriteAudio); + void finishOfflineMeeting(Long meetingId, String finishStage); + void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label); void updateMeetingTranscript(UpdateMeetingTranscriptCommand command); 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 8d7f0c0..976627d 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 @@ -493,6 +493,28 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { aiTaskService.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); } + @Override + @Transactional(rollbackFor = Exception.class) + public void finishOfflineMeeting(Long meetingId, String finishStage) { + Meeting meeting = meetingService.getById(meetingId); + if (meeting == null) { + throw new RuntimeException("会议不存在"); + } + if (!MeetingConstants.TYPE_OFFLINE.equals(meeting.getMeetingType())) { + throw new RuntimeException("当前会议不是离线会议"); + } + String normalizedStage = normalizeOfflineFinishStage(finishStage); + String currentStage = meeting.getOfflineRecordingStatus(); + if (MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED.equals(currentStage)) { + return; + } + if (Objects.equals(currentStage, normalizedStage)) { + return; + } + meeting.setOfflineRecordingStatus(normalizedStage); + meetingService.updateById(meeting); + } + private void applyRealtimeAudioFinalizeResult(Meeting meeting, RealtimeMeetingAudioStorageService.FinalizeResult result) { if (result == null) { markAudioSaveFailure(meeting, RealtimeMeetingAudioStorageService.DEFAULT_FAILURE_MESSAGE); @@ -520,6 +542,18 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { : message); } + private String normalizeOfflineFinishStage(String finishStage) { + if (finishStage == null || finishStage.isBlank()) { + throw new RuntimeException("结束阶段不能为空"); + } + String normalized = finishStage.trim().toUpperCase(); + if (MeetingConstants.OFFLINE_RECORDING_PRE_END.equals(normalized) + || MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED.equals(normalized)) { + return normalized; + } + throw new RuntimeException("结束阶段无效"); + } + private void prepareOfflineReprocessTasks(Long meetingId, RealtimeMeetingSessionStatusVO currentStatus) { RealtimeMeetingResumeConfig resumeConfig = currentStatus == null ? null : currentStatus.getResumeConfig(); if (resumeConfig == null || resumeConfig.getAsrModelId() == null) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java index 4b1ddaf..385c1f3 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -86,6 +86,9 @@ public class MeetingDomainSupport { meeting.setSourceDeviceMode(sourceDeviceMode); meeting.setSummaryDetailLevel(normalizeSummaryDetailLevel(summaryDetailLevel)); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_NONE); + if (MeetingConstants.TYPE_OFFLINE.equalsIgnoreCase(meetingType)) { + meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_ACTIVE); + } meeting.setStatus(status); return meeting; } @@ -396,6 +399,7 @@ public class MeetingDomainSupport { vo.setMeetingSource(meeting.getMeetingSource()); vo.setSourceDeviceCode(meeting.getSourceDeviceCode()); vo.setSourceDeviceMode(meeting.getSourceDeviceMode()); + vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus()); vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel())); vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); diff --git a/backend/src/main/java/com/imeeting/support/redis/AndroidChunkUploadSessionCache.java b/backend/src/main/java/com/imeeting/support/redis/AndroidChunkUploadSessionCache.java index d934ac0..fefa33f 100644 --- a/backend/src/main/java/com/imeeting/support/redis/AndroidChunkUploadSessionCache.java +++ b/backend/src/main/java/com/imeeting/support/redis/AndroidChunkUploadSessionCache.java @@ -16,21 +16,24 @@ public class AndroidChunkUploadSessionCache { private final RedisSupport redisSupport; - public AndroidChunkUploadSessionState get(String uploadSessionId) { - if (uploadSessionId == null || uploadSessionId.isBlank()) { + public AndroidChunkUploadSessionState get(Long meetingId) { + if (meetingId == null) { return null; } - return redisSupport.getJsonQuietly(RedisKeys.androidChunkUploadSessionKey(uploadSessionId), AndroidChunkUploadSessionState.class); + return redisSupport.getJsonQuietly(RedisKeys.androidChunkUploadSessionKey(meetingId), AndroidChunkUploadSessionState.class); } - public void save(String uploadSessionId, AndroidChunkUploadSessionState state) { - redisSupport.setJson(RedisKeys.androidChunkUploadSessionKey(uploadSessionId), state, SESSION_TTL); - } - - public void clear(String uploadSessionId) { - if (uploadSessionId == null || uploadSessionId.isBlank()) { + public void save(Long meetingId, AndroidChunkUploadSessionState state) { + if (meetingId == null) { return; } - redisSupport.deleteQuietly(RedisKeys.androidChunkUploadSessionKey(uploadSessionId)); + redisSupport.setJson(RedisKeys.androidChunkUploadSessionKey(meetingId), state, SESSION_TTL); + } + + public void clear(Long meetingId) { + if (meetingId == null) { + return; + } + redisSupport.deleteQuietly(RedisKeys.androidChunkUploadSessionKey(meetingId)); } } diff --git a/backend/src/main/java/com/imeeting/task/AndroidPushMessageRetryTask.java b/backend/src/main/java/com/imeeting/task/AndroidPushMessageRetryTask.java index 9137912..2a12a45 100644 --- a/backend/src/main/java/com/imeeting/task/AndroidPushMessageRetryTask.java +++ b/backend/src/main/java/com/imeeting/task/AndroidPushMessageRetryTask.java @@ -31,15 +31,23 @@ public class AndroidPushMessageRetryTask { .setMessageId(message.getMessageId()) .setTimestamp(System.currentTimeMillis()) .setType(message.getMessageType()) - .setTitle("待开始会议") + .setTitle(resolveMessageTitle(message)) .setContent(message.getPayload() == null ? "" : message.getPayload()) .setNeedAck(true) .build(); int pushed = androidGatewayPushService.pushToDevice(message.getDeviceCode(), pushMessage); if (pushed > 0) { androidPushMessageService.markPushed(message.getId()); - log.info("Retried android push message, messageId={}, deviceCode={}, pushCountIncreased=true", message.getMessageId(), message.getDeviceCode()); + log.info("Retried android push message, messageId={}, deviceCode={}, pushCountIncreased=true", + message.getMessageId(), message.getDeviceCode()); } } } + + private String resolveMessageTitle(AndroidPushMessage message) { + if (message == null || message.getMessageTitle() == null || message.getMessageTitle().isBlank()) { + return "待处理消息"; + } + return message.getMessageTitle(); + } } diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index e6e723c..3053b45 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -34,5 +34,7 @@ unisbase: server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}} upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting/uploads/} imeeting: + h5: + base-url: ${IMEETING_H5_BASE_URL:http://127.0.0.1:3000} audio: ffmpeg-path: D:\tools\exe\ffmpeg-master-latest-win64-gpl-shared\bin\ffmpeg diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index f84c5e6..56a4429 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -22,5 +22,7 @@ unisbase: server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}} upload-path: ${APP_UPLOAD_PATH:/data/imeeting/uploads/} imeeting: + h5: + base-url: ${IMEETING_H5_BASE_URL} audio: ffmpeg-path: ${IMEETING_AUDIO_FFMPEG_PATH:ffmpeg} diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml index bc69d9e..326ac1c 100644 --- a/backend/src/main/resources/application-test.yml +++ b/backend/src/main/resources/application-test.yml @@ -26,5 +26,7 @@ unisbase: server-base-url: ${APP_SERVER_BASE_URL:http://10.100.53.199:${server.port}} upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting-test/uploads/} imeeting: + h5: + base-url: ${IMEETING_H5_BASE_URL:http://127.0.0.1:3000} audio: ffmpeg-path: ${IMEETING_AUDIO_FFMPEG_PATH:ffmpeg} diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index 7d2609b..9aca51b 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -184,10 +184,16 @@ export const createMeeting = (data: CreateMeetingCommand) => { ); }; -export const createPublicDeviceMeetingBySession = (sessionId: string, data: PublicDeviceMeetingCreateCommand) => { - return http.post<{ code: string; data: MeetingVO; msg: string }>( +export const createPublicDeviceMeetingBySession = (sessionId: string) => { + return http.post<{ code: string; data: boolean; msg: string }>( `/api/biz/public-device-meetings/sessions/${sessionId}/create`, - data + {} + ); +}; + +export const getMeetingShareConfig = () => { + return http.get<{ code: string; data: { h5BaseUrl?: string }; msg: string }>( + "/api/biz/meeting/share-config" ); }; diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index c4265b4..57a9725 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -31,6 +31,7 @@ import { getMeetingDetail, getMeetingChapters, getMeetingProgress, + getMeetingShareConfig, getTranscripts, MeetingChapterVO, MeetingProgress, @@ -159,11 +160,13 @@ const getMeetingAudioDownloadName = (meeting?: Pick { - if (!meetingId || Number.isNaN(meetingId) || typeof window === 'undefined') { +const buildMeetingPreviewUrl = (baseUrl?: string, meetingId?: number, accessPassword?: string) => { + const normalizedBaseUrl = (baseUrl || '').trim(); + if (!normalizedBaseUrl || !meetingId || Number.isNaN(meetingId)) { return ''; } - const url = new URL(`/meetings/${meetingId}/preview`, window.location.origin); + const safeBaseUrl = normalizedBaseUrl.endsWith('/') ? normalizedBaseUrl.slice(0, -1) : normalizedBaseUrl; + const url = new URL(`${safeBaseUrl}/meetings/${meetingId}/preview`); const normalizedPassword = (accessPassword || '').trim(); if (normalizedPassword) { url.searchParams.set('accessPassword', normalizedPassword); @@ -864,6 +867,7 @@ const MeetingDetail: React.FC = () => { const [highlightKeyword, setHighlightKeyword] = useState(''); const [linkedTranscriptIds, setLinkedTranscriptIds] = useState([]); const [linkedChapterKey, setLinkedChapterKey] = useState(null); + const [meetingShareBaseUrl, setMeetingShareBaseUrl] = useState(''); const audioRef = useRef(null); const [audioCurrentTime, setAudioCurrentTime] = useState(0); @@ -886,14 +890,16 @@ const MeetingDetail: React.FC = () => { const fetchData = useCallback(async (meetingId: number) => { try { - const [detailRes, transcriptRes, chapterRes] = await Promise.all([ + const [detailRes, transcriptRes, chapterRes, shareConfigRes] = await Promise.all([ getMeetingDetail(meetingId), getTranscripts(meetingId), getMeetingChapters(meetingId), + getMeetingShareConfig().catch(() => null), ]); setMeeting(detailRes.data.data); setTranscripts(transcriptRes.data.data || []); setMeetingChapters(chapterRes.data.data || []); + setMeetingShareBaseUrl(shareConfigRes?.data?.data?.h5BaseUrl || ''); } catch (error) { console.error(error); } finally { @@ -1003,12 +1009,12 @@ const MeetingDetail: React.FC = () => { }, [analysis.chapters, meetingChapters, transcripts]); const sharePreviewUrl = useMemo(() => { const meetingId = meeting?.id ?? (id ? Number(id) : NaN); - return buildMeetingPreviewUrl(meetingId); - }, [meeting?.id, id]); + return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId); + }, [meetingShareBaseUrl, meeting?.id, id]); const meetingPreviewUrl = useMemo(() => { const meetingId = meeting?.id ?? (id ? Number(id) : NaN); - return buildMeetingPreviewUrl(meetingId, previewAccessPassword); - }, [meeting?.id, id, previewAccessPassword]); + return buildMeetingPreviewUrl(meetingShareBaseUrl, meetingId, previewAccessPassword); + }, [meetingShareBaseUrl, meeting?.id, id, previewAccessPassword]); const isOwner = useMemo(() => { if (!meeting) return false; diff --git a/frontend/src/pages/business/PublicDeviceMeetingCreate.tsx b/frontend/src/pages/business/PublicDeviceMeetingCreate.tsx index 5934844..2ffeae5 100644 --- a/frontend/src/pages/business/PublicDeviceMeetingCreate.tsx +++ b/frontend/src/pages/business/PublicDeviceMeetingCreate.tsx @@ -1,62 +1,19 @@ -import { App, Button, Card, Col, DatePicker, Form, Input, Radio, Row, Select, Space, Spin, Typography } from "antd"; -import { AudioOutlined, CheckCircleOutlined, QrcodeOutlined } from "@ant-design/icons"; -import dayjs from "dayjs"; -import { useEffect, useMemo, useState } from "react"; +import { App, Button, Card, Result, Space, Spin, Typography } from "antd"; +import { CheckCircleOutlined, QrcodeOutlined } from "@ant-design/icons"; +import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { listUsers } from "@/api"; -import { getAiModelDefault, getAiModelPage, type AiModelVO } from "@/api/business/aimodel"; -import { getHotWordPage, type HotWordVO } from "@/api/business/hotword"; -import { getHotWordGroupOptions, type HotWordGroupVO } from "@/api/business/hotwordGroup"; -import { - createPublicDeviceMeetingBySession, - type PublicDeviceMeetingCreateCommand, - type SummaryDetailLevel, -} from "@/api/business/meeting"; -import { getPromptPage, type PromptTemplateVO } from "@/api/business/prompt"; -import type { SysUser } from "@/types"; +import { createPublicDeviceMeetingBySession } from "@/api/business/meeting"; const { Title, Text } = Typography; -const { Option } = Select; - -type FormValues = { - title: string; - meetingTime: dayjs.Dayjs; - participants?: number[]; - tags?: string[]; - hostUserId?: number; - asrModelId: number; - summaryModelId: number; - promptId: number; - hotWordGroupId?: number; - summaryDetailLevel: SummaryDetailLevel; - useSpkId?: boolean; - enableTextRefine?: boolean; - userPrompt?: string; - accessPassword?: string; -}; export default function PublicDeviceMeetingCreate() { const { message } = App.useApp(); const navigate = useNavigate(); const { sessionId } = useParams<{ sessionId: string }>(); - const [form] = Form.useForm(); - const [loading, setLoading] = useState(true); + const [ready, setReady] = useState(false); const [submitting, setSubmitting] = useState(false); - const [asrModels, setAsrModels] = useState([]); - const [llmModels, setLlmModels] = useState([]); - const [prompts, setPrompts] = useState([]); - const [hotwordList, setHotwordList] = useState([]); - const [hotWordGroups, setHotWordGroups] = useState([]); - const [userList, setUserList] = useState([]); - - const watchedPromptId = Form.useWatch("promptId", form); - const watchedHotWordGroupId = Form.useWatch("hotWordGroupId", form); - - const selectedPrompt = useMemo( - () => prompts.find((item) => item.id === watchedPromptId) || null, - [prompts, watchedPromptId] - ); + const [confirmed, setConfirmed] = useState(false); useEffect(() => { const token = localStorage.getItem("accessToken"); @@ -65,239 +22,126 @@ export default function PublicDeviceMeetingCreate() { navigate(`/login?redirect=${redirect}`, { replace: true }); return; } - void loadInitialData(); + setReady(true); }, [navigate]); - const loadInitialData = async () => { - setLoading(true); - try { - const [asrRes, llmRes, promptRes, hotwordRes, hotWordGroupRes, users, defaultAsr, defaultLlm] = await Promise.all([ - getAiModelPage({ current: 1, size: 100, type: "ASR" }), - getAiModelPage({ current: 1, size: 100, type: "LLM" }), - getPromptPage({ current: 1, size: 100 }), - getHotWordPage({ current: 1, size: 1000 }), - getHotWordGroupOptions(), - listUsers(), - getAiModelDefault("ASR"), - getAiModelDefault("LLM"), - ]); - const activePrompts = promptRes.data.data.records.filter((item: PromptTemplateVO) => item.status === 1); - const defaultPrompt = activePrompts[0]; - setAsrModels(asrRes.data.data.records.filter((item: AiModelVO) => item.status === 1)); - setLlmModels(llmRes.data.data.records.filter((item: AiModelVO) => item.status === 1)); - setPrompts(activePrompts); - setHotwordList(hotwordRes.data.data.records.filter((item: HotWordVO) => item.status === 1)); - setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1)); - setUserList(users || []); - form.setFieldsValue({ - title: `设备会议 ${dayjs().format("MM-DD HH:mm")}`, - meetingTime: dayjs(), - asrModelId: defaultAsr.data.data?.id, - summaryModelId: defaultLlm.data.data?.id, - promptId: defaultPrompt?.id, - hotWordGroupId: defaultPrompt?.hotWordGroupId ?? 0, - summaryDetailLevel: "STANDARD", - useSpkId: true, - enableTextRefine: false, - }); - } catch { - message.error("加载建会配置失败"); - } finally { - setLoading(false); - } - }; - - const handleSubmit = async () => { + const handleConfirm = async () => { if (!sessionId) { message.error("扫码会话不存在"); return; } - const values = await form.validateFields(); setSubmitting(true); try { - const selectedHotWords = values.hotWordGroupId == null || values.hotWordGroupId === 0 - ? undefined - : hotwordList - .filter((item) => item.hotWordGroupId === values.hotWordGroupId) - .map((item) => item.word) - .filter((word) => !!word?.trim()); - - const payload: PublicDeviceMeetingCreateCommand = { - title: values.title, - meetingTime: values.meetingTime.format("YYYY-MM-DD HH:mm:ss"), - participants: values.participants?.join(",") || "", - tags: values.tags?.join(",") || "", - hostUserId: values.hostUserId, - asrModelId: values.asrModelId, - summaryModelId: values.summaryModelId, - promptId: values.promptId, - hotWordGroupId: values.hotWordGroupId, - summaryDetailLevel: values.summaryDetailLevel, - useSpkId: values.useSpkId ? 1 : 0, - enableTextRefine: !!values.enableTextRefine, - userPrompt: values.userPrompt, - hotWords: selectedHotWords, - accessPassword: values.accessPassword?.trim() || undefined, - }; - await createPublicDeviceMeetingBySession(sessionId, payload); - message.success("会议已创建,已推送到设备"); - form.resetFields(); - navigate("/meetings"); + await createPublicDeviceMeetingBySession(sessionId); + setConfirmed(true); + message.success("登录确认已发送到设备"); } catch { - message.error("创建设备会议失败"); + message.error("登录确认失败"); } finally { setSubmitting(false); } }; return ( -
-
- - - -
- -
-
- 扫码设备发起会议 - 登录后填写会议基础信息,系统会把待开始会议推送到设备端。 -
-
- - 设备录音仍在设备端执行 - 创建成功后会自动推送到扫码设备 - -
- - {loading ? ( +
+
+ + {!ready ? (
+ ) : !sessionId ? ( + navigate("/meetings")}> + 返回会议列表 + + } + /> + ) : confirmed ? ( + navigate("/meetings")}> + 返回会议列表 + + } + /> ) : ( -
- - - - - - - - - - - - + + +
+ +
+
+ + 公有设备扫码登录确认 + + + 当前页面只做登录确认,不在 H5 端创建会议。确认后会通过消息通知安卓设备。 + +
+
- - - - - - - - - - - - + + + + + 设备端收到确认消息后,才允许后续离线发会创建会议。 + + + 如果这个二维码已经被确认过,再次访问会直接提示二维码失效。 + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - } placeholder="请输入用户名或手机号" /> + + + + } placeholder="请输入密码" /> + + + {captchaEnabled ? ( + <> + + + } onClick={() => void loadCaptcha()}> + 刷新 + + } + /> + + {captcha?.imageBase64 ? ( +
+ 验证码 +
+ ) : null} + + ) : null} + + + + + {platformConfig?.icpInfo || platformConfig?.copyrightInfo ? ( +
+ {platformConfig?.icpInfo ?
{platformConfig.icpInfo}
: null} + {platformConfig?.copyrightInfo ?
{platformConfig.copyrightInfo}
: null} +
+ ) : null} +
+
+ ); +} diff --git a/imeeting-h5/src/pages/meeting-detail/index.tsx b/imeeting-h5/src/pages/meeting-detail/index.tsx new file mode 100644 index 0000000..bae51c8 --- /dev/null +++ b/imeeting-h5/src/pages/meeting-detail/index.tsx @@ -0,0 +1,104 @@ +import { App } from "antd"; +import { useEffect, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; + +import { getMeetingChapters, getMeetingDetail, getMeetingTranscripts, updateMeetingBasic } from "@/api/meeting"; +import LoadingScreen from "@/components/LoadingScreen"; +import PageHeader from "@/components/PageHeader"; +import MeetingPreviewView from "@/components/preview/MeetingPreviewView"; +import usePageTitle from "@/hooks/usePageTitle"; +import type { MeetingChapterVO, MeetingTranscriptVO, MeetingVO } from "@/types"; +import { buildMeetingPreviewUrl } from "@/utils/meeting"; + +export default function MeetingDetailPage() { + const { message } = App.useApp(); + const { id } = useParams<{ id: string }>(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [meeting, setMeeting] = useState(null); + const [transcripts, setTranscripts] = useState([]); + const [chapters, setChapters] = useState([]); + const [sharePasswordDraft, setSharePasswordDraft] = useState(""); + + const meetingId = useMemo(() => Number(id), [id]); + usePageTitle(meeting?.title || "会议详情"); + + const loadDetail = async () => { + if (!meetingId || Number.isNaN(meetingId)) { + message.error("会议编号无效"); + return; + } + + setLoading(true); + try { + const [detailResp, transcriptResp, chapterResp] = await Promise.all([ + getMeetingDetail(meetingId), + getMeetingTranscripts(meetingId), + getMeetingChapters(meetingId), + ]); + const detail = detailResp.data.data; + setMeeting(detail); + setTranscripts(transcriptResp.data.data || []); + setChapters(chapterResp.data.data || []); + setSharePasswordDraft((detail.accessPassword || "").trim()); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadDetail(); + }, [meetingId]); + + const handleSaveSharePassword = async () => { + if (!meeting) { + return; + } + setSaving(true); + try { + await updateMeetingBasic({ + meetingId: meeting.id, + accessPassword: sharePasswordDraft.trim(), + }); + setMeeting({ ...meeting, accessPassword: sharePasswordDraft.trim() }); + message.success(sharePasswordDraft.trim() ? "访问密码已更新" : "访问密码已取消"); + } finally { + setSaving(false); + } + }; + + const handleCopyShareLink = async () => { + if (!meeting) { + return; + } + const url = buildMeetingPreviewUrl(meeting.id); + try { + await navigator.clipboard.writeText(url); + message.success("分享链接已复制"); + } catch { + message.error("复制分享链接失败"); + } + }; + + return ( +
+ + {loading || !meeting ? ( + + ) : ( + + )} +
+ ); +} diff --git a/imeeting-h5/src/pages/meeting-preview/index.tsx b/imeeting-h5/src/pages/meeting-preview/index.tsx new file mode 100644 index 0000000..8af19c0 --- /dev/null +++ b/imeeting-h5/src/pages/meeting-preview/index.tsx @@ -0,0 +1,134 @@ +import { App, Button, Card, Input, Space, Typography } from "antd"; +import { useEffect, useMemo, useState } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; + +import { getMeetingPreviewAccess, getPublicMeetingPreview } from "@/api/meeting"; +import LoadingScreen from "@/components/LoadingScreen"; +import MeetingPreviewView from "@/components/preview/MeetingPreviewView"; +import usePageTitle from "@/hooks/usePageTitle"; +import type { MeetingChapterVO, MeetingTranscriptVO, MeetingVO } from "@/types"; +import { buildMeetingPreviewUrl } from "@/utils/meeting"; + +const { Paragraph, Title } = Typography; + +export default function MeetingPreviewPage() { + const { message } = App.useApp(); + const { id } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); + const [loading, setLoading] = useState(true); + const [meeting, setMeeting] = useState(null); + const [transcripts, setTranscripts] = useState([]); + const [chapters, setChapters] = useState([]); + const [passwordRequired, setPasswordRequired] = useState(false); + const [passwordVerified, setPasswordVerified] = useState(false); + const [accessPassword, setAccessPassword] = useState(""); + + const meetingId = useMemo(() => Number(id), [id]); + const presetAccessPassword = useMemo(() => (searchParams.get("accessPassword") || "").trim(), [searchParams]); + usePageTitle(meeting?.title || "会议预览"); + + const loadPreview = async (password?: string) => { + const previewResp = await getPublicMeetingPreview(meetingId, password); + setMeeting(previewResp.data.data.meeting); + setTranscripts(previewResp.data.data.transcripts || []); + setChapters(previewResp.data.data.chapters || []); + setPasswordVerified(true); + }; + + useEffect(() => { + const run = async () => { + if (!meetingId || Number.isNaN(meetingId)) { + message.error("会议编号无效"); + setLoading(false); + return; + } + + setLoading(true); + try { + const accessResp = await getMeetingPreviewAccess(meetingId); + const required = !!accessResp.data.data.passwordRequired; + setPasswordRequired(required); + + if (!required) { + await loadPreview(); + return; + } + + if (presetAccessPassword) { + try { + await loadPreview(presetAccessPassword); + setAccessPassword(presetAccessPassword); + return; + } catch { + setPasswordVerified(false); + } + } + } finally { + setLoading(false); + } + }; + + void run(); + }, [meetingId, presetAccessPassword]); + + const handleSubmitPassword = async () => { + if (!accessPassword.trim()) { + return; + } + setLoading(true); + try { + await loadPreview(accessPassword.trim()); + message.success("访问校验通过"); + } catch { + message.error("访问密码错误"); + } finally { + setLoading(false); + } + }; + + if (loading && !meeting && !passwordRequired) { + return ; + } + + if (passwordRequired && !passwordVerified) { + return ( +
+ + 会议预览 + 该会议已设置访问密码,请输入密码后继续访问分享内容。 + + setAccessPassword(event.target.value)} + onPressEnter={() => void handleSubmitPassword()} + /> + + + +
+ ); + } + + if (!meeting) { + return ; + } + + return ( +
+
+
+ iMeeting 分享预览 +
+ +
+
+ ); +} diff --git a/imeeting-h5/src/pages/meetings/index.tsx b/imeeting-h5/src/pages/meetings/index.tsx new file mode 100644 index 0000000..84ac80f --- /dev/null +++ b/imeeting-h5/src/pages/meetings/index.tsx @@ -0,0 +1,102 @@ +import { ReloadOutlined } from "@ant-design/icons"; +import { App, Button, Card, Empty, Pagination, Space, Typography } from "antd"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { getMeetingPage } from "@/api/meeting"; +import LoadingScreen from "@/components/LoadingScreen"; +import PageHeader from "@/components/PageHeader"; +import usePageTitle from "@/hooks/usePageTitle"; +import type { MeetingVO } from "@/types"; +import { formatMeetingDate, formatMeetingTimeRange, getSummarySnippet, splitParticipants } from "@/utils/meeting"; + +const { Paragraph, Text } = Typography; + +export default function MeetingsPage() { + const { message } = App.useApp(); + const navigate = useNavigate(); + usePageTitle("我的会议"); + const [loading, setLoading] = useState(true); + const [current, setCurrent] = useState(1); + const [size, setSize] = useState(10); + const [total, setTotal] = useState(0); + const [meetings, setMeetings] = useState([]); + + const loadData = async (page = current, pageSize = size) => { + setLoading(true); + try { + const resp = await getMeetingPage({ + current: page, + size: pageSize, + viewType: "all", + }); + setMeetings(resp.data.data.records || []); + setTotal(resp.data.data.total || 0); + setCurrent(page); + setSize(pageSize); + } catch { + message.error("会议列表加载失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadData(1, size); + }, []); + + return ( +
+ } onClick={() => void loadData(current, size)} /> + } + /> + + + {loading ? ( + + ) : meetings.length ? ( +
+ {meetings.map((meeting) => { + const participantCount = splitParticipants(meeting).length; + return ( + + ); + })} + +
+ void loadData(page, pageSize)} + /> +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/imeeting-h5/src/pages/password/index.tsx b/imeeting-h5/src/pages/password/index.tsx new file mode 100644 index 0000000..9ef05e3 --- /dev/null +++ b/imeeting-h5/src/pages/password/index.tsx @@ -0,0 +1,63 @@ +import { App, Button, Card, Form, Input } from "antd"; +import { useState } from "react"; + +import { updateMyPassword } from "@/api/user"; +import PageHeader from "@/components/PageHeader"; +import usePageTitle from "@/hooks/usePageTitle"; + +export default function PasswordPage() { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + usePageTitle("修改密码"); + + const handleFinish = async (values: { oldPassword: string; newPassword: string; confirmPassword: string }) => { + setSaving(true); + try { + await updateMyPassword(values); + message.success("密码修改成功"); + form.resetFields(); + } catch { + return; + } finally { + setSaving(false); + } + }; + + return ( +
+ + +
+ + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue("newPassword") === value) { + return Promise.resolve(); + } + return Promise.reject(new Error("两次输入的新密码不一致")); + }, + }), + ]} + > + + + +
+
+
+ ); +} diff --git a/imeeting-h5/src/pages/profile/index.tsx b/imeeting-h5/src/pages/profile/index.tsx new file mode 100644 index 0000000..0a85dec --- /dev/null +++ b/imeeting-h5/src/pages/profile/index.tsx @@ -0,0 +1,108 @@ +import { App, Avatar, Button, Card, Space, Typography } from "antd"; +import { InfoCircleOutlined, LockOutlined, LogoutOutlined, RightOutlined } from "@ant-design/icons"; +import { useEffect, useState, type ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; + +import { getCurrentUser } from "@/api/user"; +import LoadingScreen from "@/components/LoadingScreen"; +import PageHeader from "@/components/PageHeader"; +import usePageTitle from "@/hooks/usePageTitle"; +import type { UserProfile } from "@/types"; +import { clearAuth, saveProfile } from "@/utils/auth"; +import { getDisplayName, getProfileOrgLabel } from "@/utils/meeting"; + +const { Paragraph, Text, Title } = Typography; + +function ProfileAction({ + icon, + label, + onClick, + danger = false, +}: { + icon: ReactNode; + label: string; + onClick: () => void; + danger?: boolean; +}) { + return ( + + ); +} + +export default function ProfilePage() { + const { message } = App.useApp(); + const navigate = useNavigate(); + usePageTitle("个人中心"); + const [loading, setLoading] = useState(true); + const [profile, setProfile] = useState(null); + + useEffect(() => { + const loadProfile = async () => { + setLoading(true); + try { + const data = await getCurrentUser(); + setProfile(data); + saveProfile(data); + } finally { + setLoading(false); + } + }; + + void loadProfile(); + }, []); + + const handleLogout = () => { + clearAuth(); + message.success("已退出当前账号"); + navigate("/login", { replace: true }); + }; + + if (loading || !profile) { + return ( +
+ + +
+ ); + } + + const displayName = getDisplayName(profile.displayName, profile.username); + + return ( +
+ + +
+ + {displayName.slice(0, 1)} + + + {displayName} + + {profile.email || "未设置邮箱"} +
+
{getProfileOrgLabel(profile)}
+
{profile.phone || "未设置手机号"}
+
+
+ + + + } label="个人设置" onClick={() => navigate("/profile/password")} /> + } label="关于我们" onClick={() => navigate("/about")} /> + } label="退出当前账号" onClick={handleLogout} danger /> + + + + + 登录用户可直接查看自己的会议详情。分享给外部访问者时,可在会议详情中单独设置访问密码。 + +
+ ); +} diff --git a/imeeting-h5/src/pages/scan-confirm/index.tsx b/imeeting-h5/src/pages/scan-confirm/index.tsx new file mode 100644 index 0000000..ff2a27a --- /dev/null +++ b/imeeting-h5/src/pages/scan-confirm/index.tsx @@ -0,0 +1,101 @@ +import { App, Button, Card, Result, Space, Typography } from "antd"; +import { CheckCircleOutlined, QrcodeOutlined } from "@ant-design/icons"; +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { createPublicDeviceMeetingBySession } from "@/api/meeting"; +import PageHeader from "@/components/PageHeader"; +import usePageTitle from "@/hooks/usePageTitle"; + +const { Paragraph, Title } = Typography; + +export default function ScanConfirmPage() { + const { message } = App.useApp(); + const navigate = useNavigate(); + const { sessionId } = useParams<{ sessionId: string }>(); + const [submitting, setSubmitting] = useState(false); + const [confirmed, setConfirmed] = useState(false); + usePageTitle("扫码确认"); + + const handleConfirm = async () => { + if (!sessionId) { + message.error("扫码会话不存在"); + return; + } + setSubmitting(true); + try { + await createPublicDeviceMeetingBySession(sessionId); + setConfirmed(true); + message.success("登录确认已发送到安卓设备"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ + + + {!sessionId ? ( + navigate("/meetings")}> + 返回我的会议 + + } + /> + ) : confirmed ? ( + navigate("/meetings")}> + 返回我的会议 + + } + /> + ) : ( + +
+
+ +
+
+ + 公有设备扫码登录确认 + + + 当前页面只做登录确认,不在 H5 端直接创建会议。确认后会通过消息通知安卓设备。 + +
+
+ + + +
+ + 设备端收到确认消息后,才允许继续后续离线发会流程。 +
+
+ 如果该二维码已被确认,再次访问通常会提示会话失效。 +
+
+
+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/imeeting-h5/src/routes/ProtectedRoute.tsx b/imeeting-h5/src/routes/ProtectedRoute.tsx new file mode 100644 index 0000000..c8562a5 --- /dev/null +++ b/imeeting-h5/src/routes/ProtectedRoute.tsx @@ -0,0 +1,15 @@ +import type { PropsWithChildren } from "react"; +import { Navigate, useLocation } from "react-router-dom"; + +import { hasAccessToken } from "@/utils/auth"; + +export default function ProtectedRoute({ children }: PropsWithChildren) { + const location = useLocation(); + + if (!hasAccessToken()) { + const redirect = `${location.pathname}${location.search}`; + return ; + } + + return children; +} diff --git a/imeeting-h5/src/styles/global.css b/imeeting-h5/src/styles/global.css new file mode 100644 index 0000000..f336c45 --- /dev/null +++ b/imeeting-h5/src/styles/global.css @@ -0,0 +1,561 @@ +:root { + color-scheme: light; + --h5-max-width: 430px; + --h5-page-padding: 18px; + --h5-nav-height: 82px; + --h5-text-main: #162033; + --h5-text-subtle: #6b7a90; + --h5-line: rgba(15, 36, 66, 0.08); + --h5-primary: #1f6bff; + --h5-primary-soft: #e9f1ff; + --h5-surface: rgba(255, 255, 255, 0.94); + --h5-surface-strong: #ffffff; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; + margin: 0; +} + +body { + background: + radial-gradient(circle at top, rgba(50, 122, 255, 0.12), transparent 34%), + linear-gradient(180deg, #fbfdff 0%, #f2f6fb 100%); + color: var(--h5-text-main); + font-family: "HarmonyOS Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font: inherit; +} + +.h5-app-shell { + position: relative; + min-height: 100vh; + padding: 0 0 calc(env(safe-area-inset-bottom) + 12px); +} + +.h5-app-shell__bg { + position: fixed; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at 20% 10%, rgba(31, 107, 255, 0.08), transparent 25%), + radial-gradient(circle at 80% 0%, rgba(59, 130, 246, 0.08), transparent 20%); +} + +.h5-app-shell__content { + position: relative; + max-width: var(--h5-max-width); + min-height: 100vh; + margin: 0 auto; + padding: 20px var(--h5-page-padding) calc(var(--h5-nav-height) + 18px); +} + +.page-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.surface-card { + border: 1px solid var(--h5-line) !important; + background: var(--h5-surface) !important; + box-shadow: 0 18px 48px rgba(16, 40, 78, 0.08) !important; +} + +.surface-card--hero { + overflow: hidden; +} + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 44px; +} + +.page-header__left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.page-header__title { + margin: 0; + font-size: 28px; + font-weight: 800; + letter-spacing: -0.03em; +} + +.page-header__back { + background: rgba(255, 255, 255, 0.8); +} + +.h5-bottom-nav { + position: fixed; + left: 50%; + bottom: 0; + z-index: 5; + display: grid; + grid-template-columns: repeat(2, 1fr); + width: min(var(--h5-max-width), 100vw); + transform: translateX(-50%); + padding: 10px 18px calc(env(safe-area-inset-bottom) + 10px); + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(14px); + border-top: 1px solid rgba(15, 36, 66, 0.08); +} + +.h5-bottom-nav__item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + color: #8794a8; + font-size: 12px; + font-weight: 600; +} + +.h5-bottom-nav__item.is-active { + color: var(--h5-primary); +} + +.h5-bottom-nav__icon { + font-size: 20px; +} + +.state-panel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 220px; + gap: 12px; +} + +.state-panel__text { + color: var(--h5-text-subtle); +} + +.login-page { + max-width: var(--h5-max-width); + margin: 0 auto; + min-height: 100vh; + padding: 32px var(--h5-page-padding); + display: flex; + flex-direction: column; + justify-content: center; + gap: 24px; +} + +.login-page__hero { + padding: 8px 4px; +} + +.login-page__badge { + display: inline-flex; + align-items: center; + padding: 6px 12px; + border-radius: 999px; + background: var(--h5-primary-soft); + color: var(--h5-primary); + font-size: 12px; + font-weight: 700; +} + +.login-card .ant-card-body { + padding: 24px; +} + +.login-card__brand { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; +} + +.login-card__logo { + width: 48px; + height: 48px; + border-radius: 14px; + object-fit: cover; + background: #f1f5fb; +} + +.login-card__brand-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.login-card__brand-name { + font-size: 18px; + font-weight: 800; +} + +.login-card__brand-desc { + color: var(--h5-text-subtle); + font-size: 12px; +} + +.captcha-preview { + margin: -4px 0 16px; + padding: 10px 14px; + border-radius: 14px; + background: #f7faff; + text-align: center; +} + +.captcha-preview img { + max-width: 100%; +} + +.login-card__footer { + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid rgba(15, 36, 66, 0.08); + color: var(--h5-text-subtle); + font-size: 12px; + line-height: 1.8; +} + +.meeting-card-list { + display: flex; + flex-direction: column; + gap: 14px; +} + +.meeting-list-card { + width: 100%; + border: 0; + border-radius: 18px; + background: #fff; + padding: 18px 16px; + text-align: left; + box-shadow: inset 0 0 0 1px rgba(15, 36, 66, 0.06); +} + +.meeting-list-card__title { + font-size: 18px; + font-weight: 800; + line-height: 1.4; +} + +.meeting-list-card__meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 14px; + margin-top: 12px; + color: var(--h5-primary); + font-size: 13px; +} + +.meeting-list-card__summary { + margin: 14px 0 0 !important; + color: var(--h5-text-subtle) !important; +} + +.meeting-pagination { + display: flex; + justify-content: center; + padding-top: 4px; +} + +.meeting-hero { + display: flex; + flex-direction: column; + gap: 16px; +} + +.meeting-hero__chips { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.meeting-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-radius: 999px; + background: var(--h5-primary-soft); + color: var(--h5-primary); + font-size: 13px; + font-weight: 700; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 6px; +} + +.info-item__label { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--h5-text-subtle); + font-size: 12px; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.participant-tag { + margin: 0 !important; + padding: 6px 12px !important; + border-radius: 999px !important; + border: 0 !important; + background: #f3f7fc !important; +} + +.inline-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.inline-actions--right { + justify-content: flex-end; +} + +.meeting-audio { + width: 100%; +} + +.markdown-body { + color: var(--h5-text-main); + line-height: 1.8; +} + +.markdown-body p:first-child { + margin-top: 0; +} + +.chapter-list, +.transcript-list { + display: flex; + flex-direction: column; + gap: 14px; +} + +.chapter-item, +.transcript-item { + display: flex; + gap: 12px; + padding: 14px; + border-radius: 16px; + background: #f8fbff; + border: 1px solid rgba(15, 36, 66, 0.06); +} + +.chapter-item__index { + flex-shrink: 0; + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: var(--h5-primary); + color: #fff; + font-size: 12px; + font-weight: 700; +} + +.chapter-item__body, +.transcript-item__content { + flex: 1; + min-width: 0; +} + +.chapter-item__title { + display: flex; + flex-direction: column; + gap: 6px; + font-weight: 700; +} + +.chapter-item__time, +.transcript-item__meta { + color: var(--h5-text-subtle); + font-size: 12px; +} + +.chapter-item__summary { + margin-top: 8px; + color: var(--h5-text-subtle); + line-height: 1.7; +} + +.transcript-item { + flex-direction: column; +} + +.transcript-item__meta { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.preview-page { + min-height: 100vh; + padding: 20px 16px 32px; +} + +.preview-page__inner { + max-width: 860px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +.preview-page__header { + display: flex; + justify-content: center; + padding-top: 6px; +} + +.preview-password-card { + max-width: 420px; + margin: 80px auto 0; +} + +.profile-hero { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0 8px; + text-align: center; +} + +.profile-hero__avatar { + border: 4px solid rgba(31, 107, 255, 0.12); + background: linear-gradient(180deg, #f7fbff 0%, #eef5ff 100%); + color: var(--h5-primary); + font-size: 28px; + font-weight: 800; +} + +.profile-hero__meta { + margin-top: 14px; + color: var(--h5-text-subtle); + display: grid; + gap: 6px; +} + +.profile-action { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 14px; + border: 1px solid rgba(15, 36, 66, 0.08); + border-radius: 16px; + background: #fff; + color: var(--h5-text-main); +} + +.profile-action.is-danger { + justify-content: center; + color: #ff4d4f; +} + +.profile-action__left { + display: inline-flex; + align-items: center; + gap: 12px; + font-weight: 700; +} + +.profile-action__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 10px; + background: #edf4ff; + color: var(--h5-primary); +} + +.profile-footer { + text-align: center; + padding: 0 8px; +} + +.scan-confirm__hero { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.scan-confirm__icon { + width: 52px; + height: 52px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + background: var(--h5-primary-soft); + color: var(--h5-primary); + font-size: 22px; +} + +.scan-confirm__tip-card { + background: #f7fbff !important; +} + +.scan-confirm__tip-line { + display: flex; + gap: 8px; + color: var(--h5-text-main); +} + +.scan-confirm__tip-line.is-muted { + color: var(--h5-text-subtle); +} + +@media (max-width: 520px) { + .page-header__title { + font-size: 24px; + } + + .info-grid, + .meeting-list-card__meta-grid { + grid-template-columns: 1fr 1fr; + } + + .inline-actions { + flex-direction: column; + } + + .inline-actions .ant-btn { + width: 100%; + } +} diff --git a/imeeting-h5/src/types/index.ts b/imeeting-h5/src/types/index.ts new file mode 100644 index 0000000..d9b6464 --- /dev/null +++ b/imeeting-h5/src/types/index.ts @@ -0,0 +1,111 @@ +export interface UserProfile { + userId: number; + username?: string; + displayName?: string; + email?: string; + phone?: string; + deptName?: string; + orgName?: string; + companyName?: string; + avatarUrl?: string; + isPlatformAdmin?: boolean; + isTenantAdmin?: boolean; + pwdResetRequired?: number; +} + +export interface CaptchaResponse { + captchaId: string; + imageBase64: string; +} + +export interface TokenResponse { + accessToken: string; + refreshToken: string; + accessExpiresInMinutes: number; + refreshExpiresInDays: number; +} + +export interface MeetingVO { + id: number; + tenantId: number; + creatorId: number; + creatorName?: string; + hostUserId?: number; + hostName?: string; + title: string; + meetingTime: string; + participants: string; + participantIds?: number[]; + tags: string; + audioUrl: string; + playbackAudioUrl?: string; + duration?: number; + meetingType?: "OFFLINE" | "REALTIME"; + meetingSource?: "WEB" | "ANDROID"; + sourceDeviceCode?: string; + sourceDeviceMode?: "PUBLIC" | "PRIVATE"; + audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED"; + audioSaveMessage?: string; + accessPassword?: string | null; + summaryContent: string; + analysis?: { + overview?: string; + keywords?: string[]; + chapters?: Array<{ time?: string; title?: string; summary?: string }>; + speakerSummaries?: Array<{ speaker?: string; summary?: string }>; + keyPoints?: Array<{ title?: string; summary?: string; speaker?: string; time?: string }>; + todos?: string[]; + }; + status: number; + createdAt: string; +} + +export interface MeetingTranscriptVO { + id: number; + speakerId: string; + speakerName: string; + speakerLabel?: string; + content: string; + startTime?: number; + endTime?: number; + startTimeText?: string; + endTimeText?: string; + createdAt?: string; +} + +export interface MeetingChapterVO { + chapterNo?: number; + time?: string; + title?: string; + summary?: string; + startTime?: number; + endTime?: number; + startTimeText?: string; + endTimeText?: string; + startTranscriptId?: number; + endTranscriptId?: number; + sourceTranscriptIds?: number[]; +} + +export interface PublicMeetingPreviewVO { + meeting: MeetingVO; + transcripts: MeetingTranscriptVO[]; + chapters?: MeetingChapterVO[]; +} + +export interface MeetingPreviewAccessVO { + passwordRequired: boolean; +} + +export interface UpdateMeetingBasicCommand { + meetingId: number; + title?: string; + meetingTime?: string; + tags?: string; + accessPassword?: string | null; +} + +export interface MeetingPageResult { + records: MeetingVO[]; + total: number; +} diff --git a/imeeting-h5/src/types/platform.ts b/imeeting-h5/src/types/platform.ts new file mode 100644 index 0000000..99aca83 --- /dev/null +++ b/imeeting-h5/src/types/platform.ts @@ -0,0 +1,15 @@ +export interface SysPlatformConfig { + projectName: string; + logoUrl?: string; + iconUrl?: string; + loginBgUrl?: string; + icpInfo?: string; + copyrightInfo?: string; + systemDescription?: string; +} + +export interface PlatformConfigState { + platformConfig: SysPlatformConfig | null; + captchaEnabled: boolean; + loaded: boolean; +} diff --git a/imeeting-h5/src/utils/auth.ts b/imeeting-h5/src/utils/auth.ts new file mode 100644 index 0000000..c2d7dbd --- /dev/null +++ b/imeeting-h5/src/utils/auth.ts @@ -0,0 +1,47 @@ +const ACCESS_TOKEN_KEY = "accessToken"; +const REFRESH_TOKEN_KEY = "refreshToken"; +const PROFILE_KEY = "userProfile"; + +export function hasAccessToken() { + return !!localStorage.getItem(ACCESS_TOKEN_KEY); +} + +export function getAccessToken() { + return localStorage.getItem(ACCESS_TOKEN_KEY); +} + +export function getRefreshToken() { + return localStorage.getItem(REFRESH_TOKEN_KEY); +} + +export function saveTokens(accessToken: string, refreshToken: string) { + localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); +} + +export function clearAuth() { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + sessionStorage.removeItem(PROFILE_KEY); +} + +export function saveProfile(profile: unknown) { + sessionStorage.setItem(PROFILE_KEY, JSON.stringify(profile)); +} + +export function getStoredProfile() { + const raw = sessionStorage.getItem(PROFILE_KEY); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export function buildLoginRedirect(pathname?: string) { + const target = pathname || `${window.location.pathname}${window.location.search}`; + return `/login?redirect=${encodeURIComponent(target)}`; +} diff --git a/imeeting-h5/src/utils/meeting.ts b/imeeting-h5/src/utils/meeting.ts new file mode 100644 index 0000000..a7e3090 --- /dev/null +++ b/imeeting-h5/src/utils/meeting.ts @@ -0,0 +1,56 @@ +import dayjs from "dayjs"; + +import type { MeetingVO } from "@/types"; + +export function getDisplayName(name?: string, username?: string) { + return name || username || "未命名用户"; +} + +export function getProfileOrgLabel(profile: { companyName?: string; orgName?: string; deptName?: string }) { + return profile.companyName || profile.orgName || profile.deptName || "未设置组织信息"; +} + +export function formatMeetingDate(value?: string) { + if (!value) { + return "--"; + } + return dayjs(value).format("YYYY/MM/DD"); +} + +export function formatMeetingTimeRange(value?: string) { + if (!value) { + return "--"; + } + const start = dayjs(value); + return `${start.format("HH:mm")} - ${start.add(90, "minute").format("HH:mm")}`; +} + +export function splitParticipants(meeting?: Pick | null) { + if (!meeting) { + return []; + } + if (Array.isArray(meeting.participantIds) && meeting.participantIds.length > 0) { + return meeting.participantIds.map((id) => String(id)); + } + return (meeting.participants || "") + .split(/[,\n,、;;]/) + .map((item) => item.trim()) + .filter(Boolean); +} + +export function getSummarySnippet(meeting: Pick) { + return ( + meeting.analysis?.overview?.trim() || + meeting.summaryContent?.replace(/[#>*`-]/g, " ").replace(/\s+/g, " ").trim() || + "暂无会议摘要" + ); +} + +export function buildMeetingPreviewUrl(meetingId: number, accessPassword?: string) { + const url = new URL(`/meetings/${meetingId}/preview`, window.location.origin); + const normalizedPassword = (accessPassword || "").trim(); + if (normalizedPassword) { + url.searchParams.set("accessPassword", normalizedPassword); + } + return url.toString(); +} diff --git a/imeeting-h5/tsconfig.json b/imeeting-h5/tsconfig.json new file mode 100644 index 0000000..d29e058 --- /dev/null +++ b/imeeting-h5/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/imeeting-h5/tsconfig.tsbuildinfo b/imeeting-h5/tsconfig.tsbuildinfo new file mode 100644 index 0000000..1af707c --- /dev/null +++ b/imeeting-h5/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/http.ts","./src/api/meeting.ts","./src/api/platform.ts","./src/api/user.ts","./src/components/bottomnav.tsx","./src/components/loadingscreen.tsx","./src/components/meetingcontent.tsx","./src/components/pageheader.tsx","./src/components/platformconfigprovider.tsx","./src/components/preview/meetingpreviewview.tsx","./src/components/preview/meetinganalysis.ts","./src/hooks/usepagetitle.ts","./src/layouts/mainlayout.tsx","./src/pages/about/index.tsx","./src/pages/login/index.tsx","./src/pages/meeting-detail/index.tsx","./src/pages/meeting-preview/index.tsx","./src/pages/meetings/index.tsx","./src/pages/password/index.tsx","./src/pages/profile/index.tsx","./src/pages/scan-confirm/index.tsx","./src/routes/protectedroute.tsx","./src/types/index.ts","./src/types/platform.ts","./src/utils/auth.ts","./src/utils/meeting.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/imeeting-h5/vite.config.ts b/imeeting-h5/vite.config.ts new file mode 100644 index 0000000..ea2dbab --- /dev/null +++ b/imeeting-h5/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { fileURLToPath, URL } from "node:url"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + server: { + proxy: { + "/auth": "http://localhost:8080", + "/sys": "http://localhost:8080", + "/api": "http://localhost:8080", + "/ws": { + target: "ws://localhost:8080", + ws: true, + }, + }, + }, +});