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 index b02790f..f302489 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyAuthController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyAuthController.java @@ -30,8 +30,13 @@ public class LegacyAuthController { @PostMapping("/login") public LegacyApiResponse login(@Valid @RequestBody LoginRequest request) { - TokenResponse tokenResponse = authService.login(request, true); - return LegacyApiResponse.ok(new LegacyLoginResponse( + TokenResponse tokenResponse = null; + try { + tokenResponse = authService.login(request, true); + } catch (Exception e) { + return LegacyApiResponse.error("400",e.getMessage()); + } + return LegacyApiResponse.ok(new LegacyLoginResponse( tokenResponse.getAccessToken(), tokenResponse.getRefreshToken(), toLegacyUser(tokenResponse.getUser()) @@ -42,8 +47,13 @@ public class LegacyAuthController { 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())); + TokenResponse tokenResponse = null; + try { + tokenResponse = authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken)); + } catch (Exception e) { + return LegacyApiResponse.error("400",e.getMessage()); + } + return LegacyApiResponse.ok(new LegacyRefreshTokenResponse(tokenResponse.getAccessToken(),tokenResponse.getRefreshToken())); } private LegacyLoginUserResponse toLegacyUser(SysUserDTO user) { diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java index 3ab773c..ea7da83 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java @@ -44,6 +44,9 @@ public class MeetingPublicPreviewController { PublicMeetingPreviewVO data = new PublicMeetingPreviewVO(); data.setMeeting(meetingQueryService.getDetail(id)); + if (data.getMeeting() != null) { + data.getMeeting().setAccessPassword(null); + } data.setTranscripts(meetingQueryService.getTranscripts(id)); return ApiResponse.ok(data); } catch (RuntimeException ex) { 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 97cbb03..8ae496c 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -25,6 +25,7 @@ public class MeetingVO { private String audioUrl; private String audioSaveStatus; private String audioSaveMessage; + private String accessPassword; private Integer duration; private String summaryContent; private Map analysis; diff --git a/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingBasicCommand.java b/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingBasicCommand.java index 6876f96..a61a4c9 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingBasicCommand.java +++ b/backend/src/main/java/com/imeeting/dto/biz/UpdateMeetingBasicCommand.java @@ -14,4 +14,6 @@ public class UpdateMeetingBasicCommand { private LocalDateTime meetingTime; private String tags; + + private String accessPassword; } 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 3641c2c..3d833ec 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 @@ -423,7 +423,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { .eq(Meeting::getId, command.getMeetingId()) .set(command.getTitle() != null, Meeting::getTitle, command.getTitle()) .set(command.getMeetingTime() != null, Meeting::getMeetingTime, command.getMeetingTime()) - .set(command.getTags() != null, Meeting::getTags, command.getTags())); + .set(command.getTags() != null, Meeting::getTags, command.getTags()) + .set(command.getAccessPassword() != null, Meeting::getAccessPassword, normalizeAccessPassword(command.getAccessPassword()))); + } + + private String normalizeAccessPassword(String accessPassword) { + if (accessPassword == null) { + return null; + } + String normalized = accessPassword.trim(); + return normalized.isEmpty() ? null : normalized; } @Override 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 a210bd8..4752d52 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 @@ -241,6 +241,7 @@ public class MeetingDomainSupport { vo.setAudioUrl(meeting.getAudioUrl()); vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); + vo.setAccessPassword(meeting.getAccessPassword()); vo.setDuration(resolveMeetingDuration(meeting.getId())); vo.setStatus(meeting.getStatus()); vo.setCreatedAt(meeting.getCreatedAt()); diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index e9a9c10..5e1ffe0 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -16,6 +16,7 @@ export interface MeetingVO { audioUrl: string; audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED"; audioSaveMessage?: string; + accessPassword?: string; summaryContent: string; analysis?: { overview?: string; @@ -75,6 +76,7 @@ export interface UpdateMeetingBasicCommand { title?: string; meetingTime?: string; tags?: string; + accessPassword?: string | null; } export type MeetingUpdateBasicDTO = UpdateMeetingBasicCommand; diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index ec9d3e1..18d67f7 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -1,16 +1,19 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { Alert, Avatar, Breadcrumb, Button, Card, Checkbox, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, Row, Select, Skeleton, Space, Tag, Typography, App } from 'antd'; +import { Alert, Avatar, Breadcrumb, Button, Card, Checkbox, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Row, Select, Skeleton, Space, Switch, Tag, Typography, App } from 'antd'; import { AudioOutlined, CaretRightFilled, ClockCircleOutlined, + CopyOutlined, DownloadOutlined, EditOutlined, FastForwardOutlined, LeftOutlined, + LinkOutlined, LoadingOutlined, PauseOutlined, + QrcodeOutlined, RobotOutlined, SyncOutlined, UserOutlined, @@ -78,6 +81,43 @@ const ANALYSIS_EMPTY: MeetingAnalysis = { todos: [], }; +const ACCESS_PASSWORD_PATTERN = /^[A-Za-z0-9]{4}$/; +const ACCESS_PASSWORD_SOURCE = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +const copyMeetingLink = async (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); +}; + +const generateAccessPassword = () => + Array.from({ length: 4 }, () => ACCESS_PASSWORD_SOURCE[Math.floor(Math.random() * ACCESS_PASSWORD_SOURCE.length)]).join(''); + +const normalizeAccessPasswordInput = (value?: string | null) => (value || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 4); + +const buildMeetingPreviewUrl = (meetingId?: number, accessPassword?: string) => { + if (!meetingId || Number.isNaN(meetingId) || typeof window === 'undefined') { + return ''; + } + const url = new URL(`/meetings/${meetingId}/preview`, window.location.origin); + const normalizedPassword = (accessPassword || '').trim(); + if (normalizedPassword) { + url.searchParams.set('accessPassword', normalizedPassword); + } + return url.toString(); +}; + const splitLines = (value?: string | null) => (value || '') @@ -356,6 +396,7 @@ const SpeakerEditor: React.FC<{ const { message } = App.useApp(); const { items: speakerLabels } = useDict('biz_speaker_label'); + const handleSave = async (event: React.MouseEvent) => { event.stopPropagation(); setLoading(true); @@ -534,6 +575,11 @@ const MeetingDetail: React.FC = () => { const [prompts, setPrompts] = useState([]); const [, setUserList] = useState([]); const { items: speakerLabels } = useDict('biz_speaker_label'); + const [sharePopoverOpen, setSharePopoverOpen] = useState(false); + const [shareSaving, setShareSaving] = useState(false); + const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false); + const [sharePasswordDraft, setSharePasswordDraft] = useState(''); + const audioRef = useRef(null); const summaryPdfRef = useRef(null); @@ -555,6 +601,15 @@ const MeetingDetail: React.FC = () => { () => new Map(speakerLabels.map((item) => [item.itemValue, item.itemLabel])), [speakerLabels], ); + const previewAccessPassword = useMemo(() => (meeting?.accessPassword || '').trim(), [meeting?.accessPassword]); + const sharePreviewUrl = useMemo(() => { + const meetingId = meeting?.id ?? (id ? Number(id) : NaN); + return buildMeetingPreviewUrl(meetingId); + }, [meeting?.id, id]); + const meetingPreviewUrl = useMemo(() => { + const meetingId = meeting?.id ?? (id ? Number(id) : NaN); + return buildMeetingPreviewUrl(meetingId, previewAccessPassword); + }, [meeting?.id, id, previewAccessPassword]); const isOwner = useMemo(() => { if (!meeting) return false; @@ -586,6 +641,16 @@ const MeetingDetail: React.FC = () => { } }, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]); + useEffect(() => { + if (!sharePopoverOpen) { + return; + } + const normalizedPassword = normalizeAccessPasswordInput(meeting?.accessPassword); + setSharePasswordEnabled(!!normalizedPassword); + setSharePasswordDraft(normalizedPassword); + }, [sharePopoverOpen, meeting?.accessPassword]); + + useEffect(() => { const audio = audioRef.current; if (!audio) return undefined; @@ -937,6 +1002,146 @@ const MeetingDetail: React.FC = () => { } }; + const handleCopyPreviewLink = async () => { + if (!sharePreviewUrl) { + message.error('预览链接暂不可用'); + return; + } + try { + await copyMeetingLink(sharePreviewUrl); + message.success('预览链接已复制'); + } catch (error) { + console.error(error); + message.error('复制预览链接失败'); + } + }; + + const handleOpenPreview = () => { + if (!sharePreviewUrl) { + message.error('预览链接暂不可用'); + return; + } + window.open(sharePreviewUrl, '_blank', 'noopener,noreferrer'); + }; + + const handleSharePopoverOpenChange = (open: boolean) => { + setSharePopoverOpen(open); + }; + + const handleSharePasswordToggle = (checked: boolean) => { + setSharePasswordEnabled(checked); + setSharePasswordDraft((current) => { + const normalizedCurrent = normalizeAccessPasswordInput(current); + if (checked) { + return normalizedCurrent || generateAccessPassword(); + } + return ''; + }); + }; + + const handleRegenerateSharePassword = () => { + setSharePasswordEnabled(true); + setSharePasswordDraft(generateAccessPassword()); + }; + + const handleSaveShareAccess = async () => { + if (!meeting) { + return; + } + const normalizedPassword = normalizeAccessPasswordInput(sharePasswordDraft); + if (sharePasswordEnabled && !ACCESS_PASSWORD_PATTERN.test(normalizedPassword)) { + message.error('\u8bbf\u95ee\u5bc6\u7801\u9700\u4e3a 4 \u4f4d\u82f1\u6587\u6216\u6570\u5b57'); + return; + } + + setShareSaving(true); + try { + await updateMeetingBasic({ + meetingId: meeting.id, + accessPassword: sharePasswordEnabled ? normalizedPassword : '', + }); + const nextPassword = sharePasswordEnabled ? normalizedPassword : ''; + setMeeting((current) => (current ? { ...current, accessPassword: nextPassword } : current)); + message.success( + sharePasswordEnabled + ? '\u9884\u89c8\u5bc6\u7801\u5df2\u66f4\u65b0' + : '\u9884\u89c8\u5bc6\u7801\u5df2\u5173\u95ed', + ); + setSharePopoverOpen(false); + } catch (error) { + console.error(error); + message.error('\u4fdd\u5b58\u9884\u89c8\u5bc6\u7801\u5931\u8d25'); + } finally { + setShareSaving(false); + } + }; + + const shareQrContent = sharePreviewUrl ? ( +
+ + {isOwner ? ( +
+
+
+ {'\u9884\u89c8\u5bc6\u7801'} + + {sharePasswordEnabled && previewAccessPassword + ? `访问密码${previewAccessPassword}` + : '关闭后将不需要访问密码'} + +
+ +
+ {sharePasswordEnabled ? ( +
+ setSharePasswordDraft(normalizeAccessPasswordInput(event.target.value))} + /> + +
+ ) : null} + +
+ ) : null} +
+ +
+
+ {'\u4f7f\u7528\u624b\u673a\u626b\u7801\u540e\u5c06\u8df3\u8f6c\u5230\u4f1a\u8bae\u9884\u89c8\u9875\uff0c\u82e5\u5df2\u5f00\u542f\u5bc6\u7801\u9700\u624b\u52a8\u8f93\u5165\u3002'} +
+
+ + {sharePreviewUrl} +
+
+ + +
+
+ ) : null; + if (loading) { return (
@@ -1015,6 +1220,20 @@ const MeetingDetail: React.FC = () => { )} + {shareQrContent ? ( + + + + ) : null} @@ -1401,6 +1620,127 @@ const MeetingDetail: React.FC = () => { background: rgba(250, 173, 20, 0.14); color: #d48806; } + .meeting-share-popover .ant-popover-inner { + padding: 0; + overflow: hidden; + border-radius: 24px; + box-shadow: 0 24px 56px rgba(76, 61, 39, 0.18); + background: + radial-gradient(circle at top right, rgba(82, 164, 255, 0.12), transparent 32%), + radial-gradient(circle at top left, rgba(252, 208, 157, 0.18), transparent 26%), + linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(248, 241, 231, 0.98)); + } + .meeting-share-popover .ant-popover-inner-content { + padding: 0; + } + .meeting-share-card { + width: min(82vw, 300px); + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; + color: #33261c; + } + .meeting-share-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + .meeting-share-kicker { + color: rgba(72, 53, 39, 0.64); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + } + .meeting-share-title { + margin-top: 6px; + font-family: Georgia, 'Times New Roman', 'Songti SC', serif; + font-size: 22px; + line-height: 1.08; + color: #33261c; + } + .meeting-share-pill { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: rgba(49, 95, 139, 0.08); + color: #315f8b; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + } + .meeting-share-qr-wrap { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(84, 57, 31, 0.08); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); + } + .meeting-share-caption { + color: rgba(72, 53, 39, 0.76); + font-size: 13px; + line-height: 1.7; + } + .meeting-share-settings { + display: grid; + gap: 12px; + padding: 14px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.62); + border: 1px solid rgba(84, 57, 31, 0.08); + } + .meeting-share-settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + .meeting-share-settings-copy { + display: grid; + gap: 4px; + } + .meeting-share-settings-copy strong { + color: #33261c; + font-size: 14px; + } + .meeting-share-settings-copy span { + color: rgba(72, 53, 39, 0.72); + font-size: 12px; + line-height: 1.6; + } + .meeting-share-settings-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + } + .meeting-share-link-box { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(84, 57, 31, 0.08); + color: #315f8b; + font-size: 12px; + } + .meeting-share-link-box span { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .meeting-share-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } .summary-block { display: flex; flex-direction: column; @@ -1928,6 +2268,9 @@ const MeetingDetail: React.FC = () => { } } @media (max-width: 992px) { + .meeting-share-card { + width: min(86vw, 292px); + } .detail-side-column { height: auto; } @@ -1954,6 +2297,7 @@ const MeetingDetail: React.FC = () => {