diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyAuthController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyAuthController.java new file mode 100644 index 0000000..b02790f --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyAuthController.java @@ -0,0 +1,96 @@ +package com.imeeting.controller.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyApiResponse; +import com.imeeting.dto.android.legacy.LegacyLoginResponse; +import com.imeeting.dto.android.legacy.LegacyLoginUserResponse; +import com.imeeting.dto.android.legacy.LegacyRefreshTokenResponse; +import com.unisbase.dto.LoginRequest; +import com.unisbase.dto.RefreshRequest; +import com.unisbase.dto.SysRoleDTO; +import com.unisbase.dto.SysUserDTO; +import com.unisbase.dto.TokenResponse; +import com.unisbase.service.AuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.util.StringUtils; + +import java.util.List; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class LegacyAuthController { + + private final AuthService authService; + + @PostMapping("/login") + public LegacyApiResponse login(@Valid @RequestBody LoginRequest request) { + TokenResponse tokenResponse = authService.login(request, true); + return LegacyApiResponse.ok(new LegacyLoginResponse( + tokenResponse.getAccessToken(), + tokenResponse.getRefreshToken(), + toLegacyUser(tokenResponse.getUser()) + )); + } + + @PostMapping("/refresh") + public LegacyApiResponse refresh(@RequestBody(required = false) RefreshRequest request, + @RequestHeader(value = "Authorization", required = false) String authorization, + @RequestHeader(value = "X-Android-Access-Token", required = false) String androidAccessToken) { + TokenResponse tokenResponse = authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken)); + return LegacyApiResponse.ok(new LegacyRefreshTokenResponse(tokenResponse.getAccessToken(),tokenResponse.getRefreshToken())); + } + + private LegacyLoginUserResponse toLegacyUser(SysUserDTO user) { + if (user == null) { + return null; + } + SysRoleDTO primaryRole = resolvePrimaryRole(user); + return new LegacyLoginUserResponse( + user.getUserId(), + user.getUsername(), + user.getDisplayName(), + user.getAvatarUrl(), + user.getEmail(), + primaryRole == null ? null : primaryRole.getRoleId(), + primaryRole == null ? null : primaryRole.getRoleName(), + user.getCreatedAt() + ); + } + + private SysRoleDTO resolvePrimaryRole(SysUserDTO user) { + List roles = user.getRoles(); + if (roles != null && !roles.isEmpty()) { + return roles.get(0); + } + List roleIds = user.getRoleIds(); + if (roleIds != null && !roleIds.isEmpty()) { + SysRoleDTO role = new SysRoleDTO(); + role.setRoleId(roleIds.get(0)); + return role; + } + return null; + } + + private String resolveRefreshToken(RefreshRequest request, String authorization, String androidAccessToken) { + if (request != null && StringUtils.hasText(request.getRefreshToken())) { + return request.getRefreshToken().trim(); + } + if (StringUtils.hasText(androidAccessToken)) { + return androidAccessToken.trim(); + } + if (StringUtils.hasText(authorization)) { + String value = authorization.trim(); + if (value.startsWith("Bearer ")) { + return value.substring(7).trim(); + } + return value; + } + throw new IllegalArgumentException("refreshToken不能为空"); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java new file mode 100644 index 0000000..3ab773c --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java @@ -0,0 +1,53 @@ +package com.imeeting.controller.biz; + +import com.imeeting.dto.biz.MeetingPreviewAccessVO; +import com.imeeting.dto.biz.PublicMeetingPreviewVO; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.service.biz.MeetingAccessService; +import com.imeeting.service.biz.MeetingQueryService; +import com.unisbase.common.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/public/meetings") +public class MeetingPublicPreviewController { + + private final MeetingQueryService meetingQueryService; + private final MeetingAccessService meetingAccessService; + + public MeetingPublicPreviewController(MeetingQueryService meetingQueryService, + MeetingAccessService meetingAccessService) { + this.meetingQueryService = meetingQueryService; + this.meetingAccessService = meetingAccessService; + } + + @GetMapping("/{id}/preview/access") + public ApiResponse getPreviewAccess(@PathVariable Long id) { + try { + Meeting meeting = meetingAccessService.requireMeeting(id); + return ApiResponse.ok(new MeetingPreviewAccessVO(meetingAccessService.isPreviewPasswordRequired(meeting))); + } catch (RuntimeException ex) { + return ApiResponse.error(ex.getMessage()); + } + } + + @GetMapping("/{id}/preview") + public ApiResponse getPreview(@PathVariable Long id, + @RequestParam(required = false) String accessPassword) { + try { + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanPreviewMeeting(meeting, accessPassword); + + PublicMeetingPreviewVO data = new PublicMeetingPreviewVO(); + data.setMeeting(meetingQueryService.getDetail(id)); + data.setTranscripts(meetingQueryService.getTranscripts(id)); + return ApiResponse.ok(data); + } catch (RuntimeException ex) { + return ApiResponse.error(ex.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLoginResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLoginResponse.java new file mode 100644 index 0000000..7263bad --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLoginResponse.java @@ -0,0 +1,14 @@ +package com.imeeting.dto.android.legacy; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LegacyLoginResponse { + private String token; + private String refreshToken; + private LegacyLoginUserResponse user; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLoginUserResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLoginUserResponse.java new file mode 100644 index 0000000..50c541c --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLoginUserResponse.java @@ -0,0 +1,21 @@ +package com.imeeting.dto.android.legacy; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LegacyLoginUserResponse { + private Long user_id; + private String username; + private String caption; + private String avatar_url; + private String email; + private Long role_id; + private String role_name; + private LocalDateTime created_at; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingTagResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingTagResponse.java new file mode 100644 index 0000000..261b8d8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingTagResponse.java @@ -0,0 +1,13 @@ +package com.imeeting.dto.android.legacy; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LegacyMeetingTagResponse { + private Long id; + private String name; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyRefreshTokenResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyRefreshTokenResponse.java new file mode 100644 index 0000000..34fc764 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyRefreshTokenResponse.java @@ -0,0 +1,13 @@ +package com.imeeting.dto.android.legacy; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LegacyRefreshTokenResponse { + private String token; + private String refreshToken; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingPreviewAccessVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingPreviewAccessVO.java new file mode 100644 index 0000000..91844df --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingPreviewAccessVO.java @@ -0,0 +1,10 @@ +package com.imeeting.dto.biz; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class MeetingPreviewAccessVO { + private boolean passwordRequired; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/PublicMeetingPreviewVO.java b/backend/src/main/java/com/imeeting/dto/biz/PublicMeetingPreviewVO.java new file mode 100644 index 0000000..6e4e09e --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/PublicMeetingPreviewVO.java @@ -0,0 +1,11 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +import java.util.List; + +@Data +public class PublicMeetingPreviewVO { + private MeetingVO meeting; + private List transcripts; +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java index 1a89d17..73f61f6 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAccessServiceImpl.java @@ -17,7 +17,7 @@ public class MeetingAccessServiceImpl implements MeetingAccessService { public Meeting requireMeeting(Long meetingId) { Meeting meeting = meetingMapper.selectById(meetingId); if (meeting == null) { - throw new RuntimeException("会议不存在"); + throw new RuntimeException("Meeting not found"); } return meeting; } @@ -35,10 +35,10 @@ public class MeetingAccessServiceImpl implements MeetingAccessService { } String providedPassword = normalizePreviewPassword(accessPassword); if (providedPassword == null) { - throw new RuntimeException("该会议需要访问密码"); + throw new RuntimeException("Access password is required"); } if (!expectedPassword.equals(providedPassword)) { - throw new RuntimeException("访问密码错误"); + throw new RuntimeException("Access password is incorrect"); } } @@ -48,7 +48,7 @@ public class MeetingAccessServiceImpl implements MeetingAccessService { return; } if (!isSameTenant(meeting, loginUser)) { - throw new RuntimeException("无权查看此会议"); + throw new RuntimeException("No permission to view this meeting"); } if (isTenantAdmin(loginUser)) { return; @@ -56,7 +56,7 @@ public class MeetingAccessServiceImpl implements MeetingAccessService { if (isCreator(meeting, loginUser) || isParticipant(meeting, loginUser)) { return; } - throw new RuntimeException("无权查看此会议"); + throw new RuntimeException("No permission to view this meeting"); } @Override @@ -65,12 +65,12 @@ public class MeetingAccessServiceImpl implements MeetingAccessService { return; } if (!isSameTenant(meeting, loginUser)) { - throw new RuntimeException("无权修改此会议"); + throw new RuntimeException("No permission to edit this meeting"); } if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser)) { return; } - throw new RuntimeException("无权修改此会议"); + throw new RuntimeException("No permission to edit this meeting"); } @Override @@ -79,12 +79,12 @@ public class MeetingAccessServiceImpl implements MeetingAccessService { return; } if (!isSameTenant(meeting, loginUser)) { - throw new RuntimeException("无权操作此实时会议"); + throw new RuntimeException("No permission to manage this realtime meeting"); } if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser)) { return; } - throw new RuntimeException("无权操作此实时会议"); + throw new RuntimeException("No permission to manage this realtime meeting"); } @Override @@ -93,12 +93,12 @@ public class MeetingAccessServiceImpl implements MeetingAccessService { return; } if (!isSameTenant(meeting, loginUser)) { - throw new RuntimeException("无权导出此会议"); + throw new RuntimeException("No permission to export this meeting"); } if (isTenantAdmin(loginUser) || isCreator(meeting, loginUser) || isParticipant(meeting, loginUser)) { return; } - throw new RuntimeException("无权导出此会议"); + throw new RuntimeException("No permission to export this meeting"); } private boolean isPlatformAdmin(LoginUser loginUser) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8984c6e..d051127 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -50,6 +50,7 @@ unisbase: token-prefix: "Bearer " permit-all-urls: - /actuator/health + - /api/auth/** - /api/static/** - /api/public/meetings/** - /ws/** diff --git a/frontend/src/pages/business/MeetingPreview.css b/frontend/src/pages/business/MeetingPreview.css index 5aa7f19..245e140 100644 --- a/frontend/src/pages/business/MeetingPreview.css +++ b/frontend/src/pages/business/MeetingPreview.css @@ -151,6 +151,23 @@ letter-spacing: -0.04em; } +.meeting-preview-title-number { + font-family: "Segoe UI", "SF Pro Display", "Helvetica Neue", sans-serif; + font-variant-numeric: lining-nums proportional-nums; + letter-spacing: -0.02em; +} + +.meeting-preview-hero-toolbar { + display: grid; + gap: 18px; +} + +.meeting-preview-hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + .meeting-preview-subtitle { margin: 14px 0 0; color: var(--preview-muted); @@ -193,6 +210,19 @@ margin-top: 18px; } +.meeting-preview-page-tabs .ant-tabs-nav { + margin-bottom: 18px; +} + +.meeting-preview-page-tabs .ant-tabs-tab { + font-weight: 600; +} + +.meeting-preview-tab-panel { + display: grid; + gap: 18px; +} + .meeting-preview-section { padding: 22px 18px; } @@ -501,10 +531,17 @@ } .meeting-preview-transcript-item.is-active { + transform: translateY(-2px); border-color: rgba(184, 100, 50, 0.28); + box-shadow: 0 12px 26px rgba(105, 72, 40, 0.08); background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 244, 235, 0.92)); } +.meeting-preview-transcript-item.is-active .meeting-preview-transcript-copy { + color: #5a3113; + font-weight: 600; +} + .meeting-preview-transcript-head { display: flex; align-items: center; @@ -589,3 +626,10 @@ gap: 22px; } } + +@media (max-width: 767px) { + .meeting-preview-hero-actions { + display: grid; + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/pages/business/MeetingPreview.tsx b/frontend/src/pages/business/MeetingPreview.tsx index 7def8fb..e564169 100644 --- a/frontend/src/pages/business/MeetingPreview.tsx +++ b/frontend/src/pages/business/MeetingPreview.tsx @@ -1,13 +1,15 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Button, Empty, Input, Result, Segmented, Skeleton, Tabs, Tag, message } from "antd"; import { useParams } from "react-router-dom"; -import { Alert, Button, Empty, Input, Result, Segmented, Skeleton, Tag } from "antd"; import { AudioOutlined, CalendarOutlined, ClockCircleOutlined, + CopyOutlined, FileTextOutlined, LockOutlined, RobotOutlined, + ShareAltOutlined, TeamOutlined, UserOutlined, } from "@ant-design/icons"; @@ -23,11 +25,91 @@ import { buildMeetingAnalysis } from "./meetingAnalysis"; import "./MeetingPreview.css"; type AnalysisTab = "chapters" | "speakers" | "actions" | "todos"; +type PreviewPageTab = "summary" | "transcript"; + +const TEXT = { + statusTranscribing: "\u8f6c\u5199\u4e2d", + statusSummarizing: "\u603b\u7ed3\u4e2d", + statusCompleted: "\u5df2\u5b8c\u6210", + statusPending: "\u5f85\u5904\u7406", + hintTranscribing: "\u4f1a\u8bae\u5185\u5bb9\u4ecd\u5728\u6574\u7406\u4e2d\uff0c\u9884\u89c8\u4f1a\u6301\u7eed\u8865\u5168\u3002", + hintSummarizing: "AI \u6b63\u5728\u751f\u6210\u4f1a\u8bae\u603b\u7ed3\uff0c\u5df2\u5b8c\u6210\u5185\u5bb9\u4f1a\u4f18\u5148\u5c55\u793a\u3002", + hintCompleted: "\u4f1a\u8bae\u7eaa\u8981\u3001\u5206\u6790\u548c\u8f6c\u5f55\u5185\u5bb9\u5df2\u751f\u6210\u5b8c\u6210\u3002", + hintPending: "\u5f53\u524d\u4f1a\u8bae\u5c1a\u672a\u751f\u6210\u5b8c\u6574\u5185\u5bb9\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002", + missingMeetingId: "\u672a\u63d0\u4f9b\u4f1a\u8bae\u7f16\u53f7", + loadFailed: "\u4f1a\u8bae\u9884\u89c8\u52a0\u8f7d\u5931\u8d25", + noMeetingData: "\u672a\u627e\u5230\u4f1a\u8bae\u6570\u636e", + previewLabel: "\u4f1a\u8bae\u9884\u89c8", + untitledMeeting: "\u672a\u547d\u540d\u4f1a\u8bae", + meetingTime: "\u4f1a\u8bae\u65f6\u95f4", + hostCreator: "\u4e3b\u6301/\u521b\u5efa", + participantsCount: "\u53c2\u4f1a\u4eba\u6570", + tagsCount: "\u6807\u7b7e\u6570\u91cf", + notSet: "\u672a\u8bbe\u7f6e", + notFilled: "\u672a\u586b\u5199", + pageSummary: "\u603b\u7ed3\u4e0e\u5206\u6790", + pageTranscript: "\u8f6c\u5f55\u4e0e\u97f3\u9891", + copyLink: "\u590d\u5236\u94fe\u63a5", + shareNow: "\u7acb\u5373\u5206\u4eab", + shareCopied: "\u9884\u89c8\u94fe\u63a5\u5df2\u590d\u5236", + shareFallbackCopied: "\u5f53\u524d\u8bbe\u5907\u4e0d\u652f\u6301\u7cfb\u7edf\u5206\u4eab\uff0c\u5df2\u4e3a\u4f60\u590d\u5236\u94fe\u63a5", + shareFailed: "\u5206\u4eab\u5931\u8d25\uff0c\u8bf7\u5148\u590d\u5236\u94fe\u63a5", + accessCheck: "\u8bbf\u95ee\u6821\u9a8c", + passwordRequired: "\u8be5\u4f1a\u8bae\u9700\u8981\u8bbf\u95ee\u5bc6\u7801", + passwordHint: "\u8bf7\u8f93\u5165\u4f1a\u8bae\u7684 access_password \u540e\u7ee7\u7eed\u8bbf\u95ee\u9884\u89c8\u5185\u5bb9\u3002", + passwordPlaceholder: "\u8bf7\u8f93\u5165 access_password", + openPreview: "\u8fdb\u5165\u9884\u89c8", + invalidPassword: "\u8bbf\u95ee\u5bc6\u7801\u9519\u8bef", + basicInfo: "\u57fa\u672c\u4fe1\u606f", + meetingOverview: "\u4f1a\u8bae\u6982\u51b5", + creator: "\u521b\u5efa\u4eba", + host: "\u4e3b\u6301\u4eba", + createdAt: "\u521b\u5efa\u65f6\u95f4", + audioStatus: "\u97f3\u9891\u72b6\u6001", + participants: "\u53c2\u4f1a\u4eba\u5458", + tags: "\u4f1a\u8bae\u6807\u7b7e", + aiAnalysis: "AI \u5206\u6790", + analysis: "\u4f1a\u8bae\u5206\u6790", + previewExtra: "\u9884\u89c8\u9875\u4ec5\u8bfb\u5c55\u793a", + audioPlaybackWarning: "\u97f3\u9891\u4fdd\u5b58\u5931\u8d25\uff0c\u53ef\u80fd\u5f71\u54cd\u56de\u653e\u3002", + summaryOverview: "\u5168\u6587\u6982\u8981", + summaryEmpty: "\u6682\u65e0\u6982\u8981\u5185\u5bb9", + analysisChapters: "\u7ae0\u8282", + analysisSpeakers: "\u53d1\u8a00\u4eba", + analysisKeyPoints: "\u5173\u952e\u8981\u70b9", + analysisTodos: "\u5f85\u529e\u4e8b\u9879", + noChapterAnalysis: "\u6682\u65e0\u7ae0\u8282\u5206\u6790", + noSpeakerAnalysis: "\u6682\u65e0\u53d1\u8a00\u4eba\u5206\u6790", + noKeyPoints: "\u6682\u65e0\u5173\u952e\u8981\u70b9", + noTodos: "\u6682\u65e0\u5f85\u529e\u4e8b\u9879", + chapterFallback: "\u7ae0\u8282", + speakerFallback: "\u53d1\u8a00\u4eba", + speakerSummary: "\u53d1\u8a00\u6982\u8ff0", + keyPointFallback: "\u8981\u70b9", + noChapterSummary: "\u6682\u65e0\u7ae0\u8282\u63cf\u8ff0", + noSpeakerSummary: "\u6682\u65e0\u53d1\u8a00\u603b\u7ed3", + noKeyPointSummary: "\u6682\u65e0\u8981\u70b9\u8bf4\u660e", + summarySection: "\u4f1a\u8bae\u7eaa\u8981", + fullSummary: "\u5b8c\u6574\u7eaa\u8981", + noSummary: "\u6682\u65e0\u4f1a\u8bae\u7eaa\u8981", + transcriptSection: "\u4f1a\u8bae\u8f6c\u5f55", + transcriptTitle: "\u9010\u6bb5\u8f6c\u5f55", + noDuration: "\u6682\u65e0\u65f6\u957f", + audioUnavailable: "\u97f3\u9891\u6587\u4ef6\u4e0d\u53ef\u7528\uff0c\u4ec5\u5c55\u793a\u8f6c\u5f55\u5185\u5bb9\u3002", + noTranscript: "\u6682\u65e0\u8f6c\u5f55\u5185\u5bb9", + unknownSpeaker: "\u672a\u77e5\u53d1\u8a00\u4eba", + disclaimer: "\u8be5\u9875\u9762\u4e3a\u4f1a\u8bae\u5206\u4eab\u9884\u89c8\u9875\uff0c\u672a\u5f00\u653e\u7f16\u8f91\u3001\u5bfc\u51fa\u548c\u5176\u4ed6\u53d7\u4fdd\u62a4\u64cd\u4f5c\u3002", + shareText: "\u6211\u5411\u4f60\u5206\u4eab\u4e86\u4e00\u4e2a\u4f1a\u8bae\u9884\u89c8\u94fe\u63a5", + audioSaved: "\u5df2\u4fdd\u5b58", + audioSaveFailed: "\u4fdd\u5b58\u5931\u8d25", + audioUploaded: "\u5df2\u4e0a\u4f20", + audioNotSaved: "\u672a\u4fdd\u5b58", +}; const STATUS_META: Record = { - 1: { label: "閺夌儐鍓欓崯鎾寸▔?, className: "is-processing", hint: "濞村吋淇洪鍛村礃閸涱収鍟囧ù鐘茬Т濠€顏堝极鐎靛憡鍊炲☉鎿冨弿缁辨繃锛愰崟顕呮綌闁轰胶澧楀畵浣瑰濮橆厼鐦紓渚囧弨钘熼柛蹇嬪妸閳? }, - 2: { label: "闁诡剝宕电划銊︾▔?, className: "is-processing", hint: "AI 婵繐绲藉﹢顏堟偨閻旂鐏囬柟顒冨吹缁劑鏁嶅畝鍕垫殨閻熸瑥鐗撻妴澶愭椤厾绐楅柡鍕⒔閵囨艾顔忛幓鎺旀殮闁瑰瓨鍔楀▓鎴﹀礃閸涱収鍟囬柕? }, - 3: { label: "鐎瑰憡褰冮悾顒勫箣?, className: "is-complete", hint: "濞村吋淇洪鍛棯椤忓浂娲i柕鍡曠閸ㄥ酣寮搁幇顒佸闁告鍠愰弸鍐啅閼碱剚鏅搁柟瀛樺姇閻n剟骞嬮幇鈹惧亾? }, + 1: { label: TEXT.statusTranscribing, className: "is-processing", hint: TEXT.hintTranscribing }, + 2: { label: TEXT.statusSummarizing, className: "is-processing", hint: TEXT.hintSummarizing }, + 3: { label: TEXT.statusCompleted, className: "is-complete", hint: TEXT.hintCompleted }, }; function formatDurationRange(startTime?: number, endTime?: number) { @@ -44,7 +126,7 @@ function formatDurationRange(startTime?: number, endTime?: number) { function splitDisplayItems(value?: string) { return (value || "") - .split(/[闁?闁靛棔绠?) + .split(",") .map((item) => item.trim()) .filter(Boolean); } @@ -55,26 +137,48 @@ function transcriptColorSeed(speakerKey: string) { return palette[score % palette.length]; } +async function copyText(text: string) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); +} + export default function MeetingPreview() { const { id } = useParams(); const audioRef = useRef(null); + const transcriptItemRefs = useRef>({}); const [meeting, setMeeting] = useState(null); const [transcripts, setTranscripts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [analysisTab, setAnalysisTab] = useState("chapters"); + const [pageTab, setPageTab] = useState("summary"); const [activeTranscriptId, setActiveTranscriptId] = useState(null); const [passwordRequired, setPasswordRequired] = useState(false); const [passwordVerified, setPasswordVerified] = useState(false); const [accessPassword, setAccessPassword] = useState(""); const [passwordError, setPasswordError] = useState(""); + const [isMobile, setIsMobile] = useState(() => + typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false, + ); useEffect(() => { let mounted = true; - const fetchData = async () => { + const load = async () => { if (!id) { - setError("闁哄牜浜濊ぐ浣圭瑹濞戞绐楅悹渚囧枤缁鳖亪宕?); + setError(TEXT.missingMeetingId); setLoading(false); return; } @@ -91,7 +195,6 @@ export default function MeetingPreview() { try { const meetingId = Number(id); const accessRes = await getMeetingPreviewAccess(meetingId); - if (!mounted) { return; } @@ -116,7 +219,7 @@ export default function MeetingPreview() { return; } - setError(requestError?.response?.data?.msg || "濞村吋淇洪鍛紣閸曨噮娼旈柛鏃傚Ь濞村洦寰勬潏顐バ?); + setError(requestError?.response?.data?.msg || requestError?.msg || TEXT.loadFailed); } finally { if (mounted) { setLoading(false); @@ -124,25 +227,78 @@ export default function MeetingPreview() { } }; - fetchData(); + load(); return () => { mounted = false; }; }, [id]); + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const mediaQuery = window.matchMedia("(max-width: 767px)"); + const handleChange = (event: MediaQueryListEvent) => { + setIsMobile(event.matches); + }; + + setIsMobile(mediaQuery.matches); + mediaQuery.addEventListener("change", handleChange); + return () => { + mediaQuery.removeEventListener("change", handleChange); + }; + }, []); + const analysis = useMemo( () => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""), [meeting?.analysis, meeting?.summaryContent, meeting?.tags], ); const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]); + const transcriptSpeakers = useMemo(() => { + const speakers = transcripts + .map((item) => item.speakerName || item.speakerLabel || item.speakerId || "") + .map((item) => item.trim()) + .filter(Boolean); + return Array.from(new Set(speakers)); + }, [transcripts]); const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]); + const keywords = useMemo(() => analysis.keywords || [], [analysis.keywords]); const statusMeta = STATUS_META[meeting?.status || 0] || { - label: "鐎垫澘鎳庨ˇ鈺呮偠?, + label: TEXT.statusPending, className: "is-warning", - hint: "鐟滅増鎸告晶鐘冲濮樻剚鍞村ù鐘茬У濠€顓㈡偨閻旂鐏囬悗鐟版湰閺嗭綁宕橀崨顓у晣闁挎稑鐭侀顒傜矙瀹ュ懏鍊甸柛鎰Х閻︻垶濡?, + hint: TEXT.hintPending, }; + const audioStatusLabel = useMemo(() => { + if (meeting?.audioSaveStatus === "SUCCESS") { + return TEXT.audioSaved; + } + if (meeting?.audioSaveStatus === "FAILED") { + return TEXT.audioSaveFailed; + } + if (meeting?.audioUrl) { + return TEXT.audioUploaded; + } + return TEXT.audioNotSaved; + }, [meeting?.audioSaveStatus, meeting?.audioUrl]); + const shareUrl = typeof window !== "undefined" ? window.location.href : ""; + const participantCountValue = + isMobile && transcriptSpeakers.length > 0 ? transcriptSpeakers.length : participants.length; + + useEffect(() => { + if (!activeTranscriptId) { + return; + } + + const target = transcriptItemRefs.current[activeTranscriptId]; + if (!target) { + return; + } + + target.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }, [activeTranscriptId]); const handleTranscriptSeek = (item: MeetingTranscriptVO) => { if (!audioRef.current) { @@ -166,6 +322,19 @@ export default function MeetingPreview() { setActiveTranscriptId(currentItem?.id || null); }; + const renderMeetingTitle = (title?: string) => { + const safeTitle = title || TEXT.untitledMeeting; + return safeTitle.split(/(\d+)/).map((part, index) => + /\d+/.test(part) ? ( + + {part} + + ) : ( + {part} + ), + ); + }; + const handlePasswordSubmit = async () => { if (!id) { return; @@ -179,12 +348,39 @@ export default function MeetingPreview() { setTranscripts(previewRes.data.data.transcripts || []); setPasswordVerified(true); } catch (requestError: any) { - setPasswordError(requestError?.response?.data?.msg || requestError?.msg || "閻犱礁娼″Λ鍓佲偓闈涙閻栨粓鏌ㄥ▎鎺濆殩"); + setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword); } finally { setLoading(false); } }; + const handleCopyLink = async () => { + try { + await copyText(shareUrl); + message.success(TEXT.shareCopied); + } catch { + message.error(TEXT.shareFailed); + } + }; + + const handleShareNow = async () => { + try { + if (navigator.share) { + await navigator.share({ + title: meeting?.title || TEXT.previewLabel, + text: TEXT.shareText, + url: shareUrl, + }); + return; + } + + await copyText(shareUrl); + message.success(TEXT.shareFallbackCopied); + } catch { + message.error(TEXT.shareFailed); + } + }; + if (loading && (!passwordRequired || passwordVerified)) { return (
@@ -212,25 +408,23 @@ export default function MeetingPreview() {
- Access Check + {TEXT.accessCheck}
-

Password Required

+

{TEXT.passwordRequired}

-

- Enter access_password to view this meeting preview. -

+

{TEXT.passwordHint}

setAccessPassword(event.target.value)} onPressEnter={handlePasswordSubmit} />
@@ -246,7 +440,7 @@ export default function MeetingPreview() {
-
@@ -258,13 +452,294 @@ export default function MeetingPreview() {
- +
+
+
+
+ + {TEXT.basicInfo} +
+

{TEXT.meetingOverview}

+
+
+ +
+
+ {TEXT.creator} + {meeting.creatorName || TEXT.notSet} +
+
+ {TEXT.host} + {meeting.hostName || TEXT.notSet} +
+
+ {TEXT.createdAt} + + {meeting.createdAt ? dayjs(meeting.createdAt).format("YYYY.MM.DD HH:mm") : TEXT.notSet} + +
+
+ {TEXT.audioStatus} + {audioStatusLabel} +
+
+ + {participants.length > 0 ? ( +
+
{TEXT.participants}
+
+ {participants.map((item) => ( + + + {item} + + ))} +
+
+ ) : null} + + {tags.length > 0 ? ( +
+
{TEXT.tags}
+
+ {tags.map((item) => ( + + {item} + + ))} +
+
+ ) : null} +
+ +
+
+
+
+ + {TEXT.aiAnalysis} +
+

{TEXT.analysis}

+
+
{TEXT.previewExtra}
+
+ + {meeting.audioSaveStatus === "FAILED" ? ( + + ) : null} + + {keywords.length > 0 ? ( +
+ {keywords.map((item) => ( + + {item} + + ))} +
+ ) : null} + +
+
{TEXT.summaryOverview}
+

{analysis.overview || TEXT.summaryEmpty}

+
+ +
+ setAnalysisTab(value as AnalysisTab)} + options={[ + { label: TEXT.analysisChapters, value: "chapters" }, + { label: TEXT.analysisSpeakers, value: "speakers" }, + { label: TEXT.analysisKeyPoints, value: "actions" }, + { label: TEXT.analysisTodos, value: "todos" }, + ]} + /> +
+ +
+ {analysisTab === "chapters" ? ( + analysis.chapters.length > 0 ? ( + analysis.chapters.map((item, index) => ( +
+
{item.time || "--:--"}
+
+ {item.title || `${TEXT.chapterFallback} ${index + 1}`} + {item.summary || TEXT.noChapterSummary} +
+
+ )) + ) : ( +
{TEXT.noChapterAnalysis}
+ ) + ) : null} + + {analysisTab === "speakers" ? ( + analysis.speakerSummaries.length > 0 ? ( + analysis.speakerSummaries.map((item, index) => ( +
+
+
{(item.speaker || "S").slice(0, 1)}
+
+
{item.speaker || `${TEXT.speakerFallback} ${index + 1}`}
+
{TEXT.speakerSummary}
+
+
+
{item.summary || TEXT.noSpeakerSummary}
+
+ )) + ) : ( +
{TEXT.noSpeakerAnalysis}
+ ) + ) : null} + + {analysisTab === "actions" ? ( + analysis.keyPoints.length > 0 ? ( + analysis.keyPoints.map((item, index) => ( +
+
{String(index + 1).padStart(2, "0")}
+
+ {item.title || `${TEXT.keyPointFallback} ${index + 1}`} + {item.summary || TEXT.noKeyPointSummary} + {(item.speaker || item.time) ? ( +
+ {item.speaker ? {item.speaker} : null} + {item.time ? {item.time} : null} +
+ ) : null} +
+
+ )) + ) : ( +
{TEXT.noKeyPoints}
+ ) + ) : null} + + {analysisTab === "todos" ? ( + analysis.todos.length > 0 ? ( + analysis.todos.map((item, index) => ( +
+ + {item} +
+ )) + ) : ( +
{TEXT.noTodos}
+ ) + ) : null} +
+
+ +
+
+
+
+ + {TEXT.summarySection} +
+

{TEXT.fullSummary}

+
+
+ +
+ {meeting.summaryContent ? ( + {meeting.summaryContent} + ) : ( + + )} +
+
+
+ ); + + const transcriptTab = ( +
+
+
+
+
+ + {TEXT.transcriptSection} +
+

{TEXT.transcriptTitle}

+
+
+ + {meeting.duration ? formatDurationRange(0, meeting.duration) : TEXT.noDuration} +
+
+ + {meeting.audioSaveStatus === "FAILED" ? ( + + ) : null} + + {meeting.audioUrl ? ( +
+
+ ); + return (
@@ -272,322 +747,65 @@ export default function MeetingPreview() {
- 濞村吋淇洪鍛紣閸曨噮娼? + {TEXT.previewLabel}
{statusMeta.label}
-

{meeting.title || "闁哄牜浜滈幊锟犲触瀹ュ嫮绐楅悹?}

-

{statusMeta.hint}

+
+
+

{renderMeetingTitle(meeting.title)}

+

{statusMeta.hint}

+
+
+ + +
+
- 濞村吋淇洪鍛村籍閸洘锛?/span> + {TEXT.meetingTime} - {meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY.MM.DD HH:mm") : "闁哄牜浜i鏇犵磾?} + {meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY.MM.DD HH:mm") : TEXT.notSet}
- 濞戞挾绮€?闁告帗绋戠紓?/span> - {meeting.hostName || meeting.creatorName || "闁哄牜浜i鏇犵磾?} + {TEXT.hostCreator} + {meeting.hostName || meeting.creatorName || TEXT.notSet}
- 闁告瑥鍊风槐鐗堢閻戞ɑ娈?/span> - {participants.length || "闁哄牜浜滈敐鐐哄礃?} + {TEXT.participantsCount} + {participantCountValue || TEXT.notFilled}
- 闁哄秴娲ㄩ鐑藉极娴兼潙娅?/span> - {tags.length || "闁哄牜浜i鏇犵磾?} + {TEXT.tagsCount} + {tags.length || TEXT.notSet}
-
-
-
- - 闁糕晝鍎ゅ﹢鐗堢┍閳╁啩绱? -
-

濞村吋淇洪鍛潡閸屾艾鏋?/h2> -

-
- -
-
- 闁告帗绋戠紓鎾寸?/span> - {meeting.creatorName || "闁哄牜浜i鏇犵磾?} -
-
- 濞戞挾绮€垫梹绂?/span> - {meeting.hostName || "闁哄牜浜i鏇犵磾?} -
-
- 闁告帗绋戠紓鎾诲籍閸洘锛?/span> - - {meeting.createdAt ? dayjs(meeting.createdAt).format("YYYY.MM.DD HH:mm") : "闁哄牜浜i鏇犵磾?} - -
-
- 闂傚﹥濞婇。鍫曟偐閼哥鍋?/span> - {meeting.audioSaveStatus || "NONE"} -
-
- - {participants.length > 0 ? ( -
-
闁告瑥鍊风槐鐗堢閸濆嫭鍠?/div> -
- {participants.map((item) => ( - - - {item} - - ))} -
-
- ) : null} - - {tags.length > 0 ? ( -
-
濞村吋淇洪鍛村冀閸モ晩鍔?/div> -
- {tags.map((item) => ( - - {item} - - ))} -
-
- ) : null} -
- -
-
-
-
- - 闁哄懘缂氶崗姗€鏌呴悢娲绘綌 -
-

濞村吋淇洪鍛村礆閸℃鈧?/h2> -

-
濞戞挸绨肩槐鎵媼椤旀鍤婇柟顖氭噸缁绘岸骞愭担鍛婂€遍柛娆欑到缁?/div> -
- - {meeting.status < 3 ? ( - - ) : null} - - {analysis.keywords.length > 0 ? ( -
- {analysis.keywords.map((item) => ( - - {item} - - ))} -
- ) : null} - -
-
闁稿繈鍔嶉弸鍐潡閸屾繍娲?/div> -

{analysis.overview || "闁哄棗鍊瑰Λ銈咁潡閸屾繍娲i柛鎰噹椤?}

-
- -
- - block - value={analysisTab} - onChange={(value) => setAnalysisTab(value)} - options={[ - { label: "缂佹梻濮炬俊?, value: "chapters" }, - { label: "闁告瑦鍨奸埢?, value: "speakers" }, - { label: "閻熸洑鑳堕崑?, value: "actions" }, - { label: "鐎垫澘鎳庢慨?, value: "todos" }, - ]} - /> -
- -
- {analysisTab === "chapters" && - (analysis.chapters.length ? ( - analysis.chapters.map((item, index) => ( -
-
{item.time || "--:--"}
-
- {item.title || `缂佹梻濮炬俊?${index + 1}`} - {item.summary || "闁哄棗鍊瑰Λ銈囩博閻樺搫螡闁硅绻楅崼?} -
-
- )) - ) : ( -
- ( -
-
-
{(item.speaker || "闁?).slice(0, 1)}
-
-
{item.speaker || `闁告瑦鍨奸埢鍫熺?${index + 1}`}
-
闁告瑦鍨奸埢鍫濐潡閸屾繂鐗?/div> -
-
-
{item.summary || "闁哄棗鍊瑰Λ銈夊矗閹达絺鏋呴柟顒冨吹缁?}
-
- )) - ) : ( -
- ( -
-
{String(index + 1).padStart(2, "0")}
-
- {item.title || `閻熸洑鑳堕崑?${index + 1}`} - {item.summary || "闁哄棗鍊瑰Λ銈囨啺娴e搫浠悹鍥х摠濡?} - {(item.speaker || item.time) && ( -
- {item.speaker ? {item.speaker} : null} - {item.time ? {item.time} : null} -
- )} -
-
- )) - ) : ( -
- ( -
- - {item} -
- )) - ) : ( -
- -
-
-
- - AI 闁诡剝宕电划? -
-

閻庣懓鏈弳锝囩棯椤忓浂娲?/h2> -

-
- -
- {meeting.summaryContent ? ( -
- {meeting.summaryContent} -
- ) : ( - -
-
-
- - 闁告鍠愰弸鍐媼閺夎法绉? -
-

濞村吋淇洪鍛姜椤掆偓缂?/h2> -

-
- - 闁绘劗鎳撻崵顔尖枔娴e啯鍎伴柛娆樺灥閻戯附娼鍕従濡? -
-
- - {meeting.audioSaveStatus === "FAILED" ? ( - - ) : null} - - {meeting.audioUrl ? ( -
- 闁哄懘缂氶崗姗€宕橀崨顓у晣闁?AI 婵☆垪鈧磭鈧兘鎮介悢绋跨亣闁挎稑濂旂划搴ㄦ偨閵娿倗鑹惧ù鍏间亢椤斿懏绌遍埄鍐х礀濡澘瀚~宥夋晬瀹€鍐惧殲缂備焦鎸搁幃搴ㄥ储閻斿娼楀ù鍏间亢椤斿懐鎷犻鐑嗘殧閻庣櫢绻濆Σ鍕椽瀹€鈧垾妯兼媼閵堝啠鍋? + + {TEXT.disclaimer}