feat: 添加会议访问密码功能和相关UI组件
- 在 `MeetingCommandServiceImpl` 中添加 `normalizeAccessPassword` 方法,处理访问密码 - 在 `MeetingVO` 和 `UpdateMeetingBasicCommand` 中添加 `accessPassword` 字段 - 在前端 `MeetingPreview.tsx` 和 `MeetingDetail.tsx` 中添加访问密码输入和预览链接生成逻辑 - 在 `MeetingPublicPreviewController` 中移除预览响应中的访问密码 - 在 `LegacyAuthController` 中添加异常处理,返回错误信息 - 更新相关前端API和组件,支持访问密码功能dev_na
parent
caf2e22df0
commit
712d31d911
|
|
@ -30,8 +30,13 @@ public class LegacyAuthController {
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public LegacyApiResponse<LegacyLoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
public LegacyApiResponse<LegacyLoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||||
TokenResponse tokenResponse = authService.login(request, true);
|
TokenResponse tokenResponse = null;
|
||||||
return LegacyApiResponse.ok(new LegacyLoginResponse(
|
try {
|
||||||
|
tokenResponse = authService.login(request, true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return LegacyApiResponse.error("400",e.getMessage());
|
||||||
|
}
|
||||||
|
return LegacyApiResponse.ok(new LegacyLoginResponse(
|
||||||
tokenResponse.getAccessToken(),
|
tokenResponse.getAccessToken(),
|
||||||
tokenResponse.getRefreshToken(),
|
tokenResponse.getRefreshToken(),
|
||||||
toLegacyUser(tokenResponse.getUser())
|
toLegacyUser(tokenResponse.getUser())
|
||||||
|
|
@ -42,8 +47,13 @@ public class LegacyAuthController {
|
||||||
public LegacyApiResponse<LegacyRefreshTokenResponse> refresh(@RequestBody(required = false) RefreshRequest request,
|
public LegacyApiResponse<LegacyRefreshTokenResponse> refresh(@RequestBody(required = false) RefreshRequest request,
|
||||||
@RequestHeader(value = "Authorization", required = false) String authorization,
|
@RequestHeader(value = "Authorization", required = false) String authorization,
|
||||||
@RequestHeader(value = "X-Android-Access-Token", required = false) String androidAccessToken) {
|
@RequestHeader(value = "X-Android-Access-Token", required = false) String androidAccessToken) {
|
||||||
TokenResponse tokenResponse = authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken));
|
TokenResponse tokenResponse = null;
|
||||||
return LegacyApiResponse.ok(new LegacyRefreshTokenResponse(tokenResponse.getAccessToken(),tokenResponse.getRefreshToken()));
|
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) {
|
private LegacyLoginUserResponse toLegacyUser(SysUserDTO user) {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ public class MeetingPublicPreviewController {
|
||||||
|
|
||||||
PublicMeetingPreviewVO data = new PublicMeetingPreviewVO();
|
PublicMeetingPreviewVO data = new PublicMeetingPreviewVO();
|
||||||
data.setMeeting(meetingQueryService.getDetail(id));
|
data.setMeeting(meetingQueryService.getDetail(id));
|
||||||
|
if (data.getMeeting() != null) {
|
||||||
|
data.getMeeting().setAccessPassword(null);
|
||||||
|
}
|
||||||
data.setTranscripts(meetingQueryService.getTranscripts(id));
|
data.setTranscripts(meetingQueryService.getTranscripts(id));
|
||||||
return ApiResponse.ok(data);
|
return ApiResponse.ok(data);
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ public class MeetingVO {
|
||||||
private String audioUrl;
|
private String audioUrl;
|
||||||
private String audioSaveStatus;
|
private String audioSaveStatus;
|
||||||
private String audioSaveMessage;
|
private String audioSaveMessage;
|
||||||
|
private String accessPassword;
|
||||||
private Integer duration;
|
private Integer duration;
|
||||||
private String summaryContent;
|
private String summaryContent;
|
||||||
private Map<String, Object> analysis;
|
private Map<String, Object> analysis;
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,6 @@ public class UpdateMeetingBasicCommand {
|
||||||
private LocalDateTime meetingTime;
|
private LocalDateTime meetingTime;
|
||||||
|
|
||||||
private String tags;
|
private String tags;
|
||||||
|
|
||||||
|
private String accessPassword;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,16 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
.eq(Meeting::getId, command.getMeetingId())
|
.eq(Meeting::getId, command.getMeetingId())
|
||||||
.set(command.getTitle() != null, Meeting::getTitle, command.getTitle())
|
.set(command.getTitle() != null, Meeting::getTitle, command.getTitle())
|
||||||
.set(command.getMeetingTime() != null, Meeting::getMeetingTime, command.getMeetingTime())
|
.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
|
@Override
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,7 @@ public class MeetingDomainSupport {
|
||||||
vo.setAudioUrl(meeting.getAudioUrl());
|
vo.setAudioUrl(meeting.getAudioUrl());
|
||||||
vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
|
vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
|
||||||
vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
|
vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
|
||||||
|
vo.setAccessPassword(meeting.getAccessPassword());
|
||||||
vo.setDuration(resolveMeetingDuration(meeting.getId()));
|
vo.setDuration(resolveMeetingDuration(meeting.getId()));
|
||||||
vo.setStatus(meeting.getStatus());
|
vo.setStatus(meeting.getStatus());
|
||||||
vo.setCreatedAt(meeting.getCreatedAt());
|
vo.setCreatedAt(meeting.getCreatedAt());
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface MeetingVO {
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
|
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
|
||||||
audioSaveMessage?: string;
|
audioSaveMessage?: string;
|
||||||
|
accessPassword?: string;
|
||||||
summaryContent: string;
|
summaryContent: string;
|
||||||
analysis?: {
|
analysis?: {
|
||||||
overview?: string;
|
overview?: string;
|
||||||
|
|
@ -75,6 +76,7 @@ export interface UpdateMeetingBasicCommand {
|
||||||
title?: string;
|
title?: string;
|
||||||
meetingTime?: string;
|
meetingTime?: string;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
|
accessPassword?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MeetingUpdateBasicDTO = UpdateMeetingBasicCommand;
|
export type MeetingUpdateBasicDTO = UpdateMeetingBasicCommand;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { 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 {
|
import {
|
||||||
AudioOutlined,
|
AudioOutlined,
|
||||||
CaretRightFilled,
|
CaretRightFilled,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
|
CopyOutlined,
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
FastForwardOutlined,
|
FastForwardOutlined,
|
||||||
LeftOutlined,
|
LeftOutlined,
|
||||||
|
LinkOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
PauseOutlined,
|
PauseOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
|
@ -78,6 +81,43 @@ const ANALYSIS_EMPTY: MeetingAnalysis = {
|
||||||
todos: [],
|
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) =>
|
const splitLines = (value?: string | null) =>
|
||||||
(value || '')
|
(value || '')
|
||||||
|
|
@ -356,6 +396,7 @@ const SpeakerEditor: React.FC<{
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const { items: speakerLabels } = useDict('biz_speaker_label');
|
const { items: speakerLabels } = useDict('biz_speaker_label');
|
||||||
|
|
||||||
|
|
||||||
const handleSave = async (event: React.MouseEvent) => {
|
const handleSave = async (event: React.MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -534,6 +575,11 @@ const MeetingDetail: React.FC = () => {
|
||||||
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
||||||
const [, setUserList] = useState<SysUser[]>([]);
|
const [, setUserList] = useState<SysUser[]>([]);
|
||||||
const { items: speakerLabels } = useDict('biz_speaker_label');
|
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<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const summaryPdfRef = useRef<HTMLDivElement>(null);
|
const summaryPdfRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -555,6 +601,15 @@ const MeetingDetail: React.FC = () => {
|
||||||
() => new Map(speakerLabels.map((item) => [item.itemValue, item.itemLabel])),
|
() => new Map(speakerLabels.map((item) => [item.itemValue, item.itemLabel])),
|
||||||
[speakerLabels],
|
[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(() => {
|
const isOwner = useMemo(() => {
|
||||||
if (!meeting) return false;
|
if (!meeting) return false;
|
||||||
|
|
@ -586,6 +641,16 @@ const MeetingDetail: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]);
|
}, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sharePopoverOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedPassword = normalizeAccessPasswordInput(meeting?.accessPassword);
|
||||||
|
setSharePasswordEnabled(!!normalizedPassword);
|
||||||
|
setSharePasswordDraft(normalizedPassword);
|
||||||
|
}, [sharePopoverOpen, meeting?.accessPassword]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return undefined;
|
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 ? (
|
||||||
|
<div className="meeting-share-card">
|
||||||
|
|
||||||
|
{isOwner ? (
|
||||||
|
<div className="meeting-share-settings">
|
||||||
|
<div className="meeting-share-settings-row">
|
||||||
|
<div className="meeting-share-settings-copy">
|
||||||
|
<strong>{'\u9884\u89c8\u5bc6\u7801'}</strong>
|
||||||
|
<span>
|
||||||
|
{sharePasswordEnabled && previewAccessPassword
|
||||||
|
? `访问密码${previewAccessPassword}`
|
||||||
|
: '关闭后将不需要访问密码'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={sharePasswordEnabled}
|
||||||
|
checkedChildren={'\u5f00\u542f'}
|
||||||
|
unCheckedChildren={'\u5173\u95ed'}
|
||||||
|
onChange={handleSharePasswordToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{sharePasswordEnabled ? (
|
||||||
|
<div className="meeting-share-settings-actions">
|
||||||
|
<Input
|
||||||
|
value={sharePasswordDraft}
|
||||||
|
maxLength={4}
|
||||||
|
placeholder={'\u4f8b\u5982 A7K2'}
|
||||||
|
onChange={(event) => setSharePasswordDraft(normalizeAccessPasswordInput(event.target.value))}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleRegenerateSharePassword}>{'\u91cd\u7f6e\u9ed8\u8ba4'}</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" loading={shareSaving} onClick={handleSaveShareAccess}>
|
||||||
|
{'\u4fdd\u5b58\u5bc6\u7801\u8bbe\u7f6e'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="meeting-share-qr-wrap">
|
||||||
|
<QRCode
|
||||||
|
value={sharePreviewUrl}
|
||||||
|
type="svg"
|
||||||
|
size={172}
|
||||||
|
color="#315f8b"
|
||||||
|
bgColor="#fffaf4"
|
||||||
|
errorLevel="H"
|
||||||
|
bordered={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="meeting-share-caption">
|
||||||
|
{'\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'}
|
||||||
|
</div>
|
||||||
|
<div className="meeting-share-link-box">
|
||||||
|
<LinkOutlined />
|
||||||
|
<span title={sharePreviewUrl}>{sharePreviewUrl}</span>
|
||||||
|
</div>
|
||||||
|
<div className="meeting-share-actions">
|
||||||
|
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyPreviewLink}>
|
||||||
|
{'\u590d\u5236\u94fe\u63a5'}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="primary" ghost onClick={handleOpenPreview}>
|
||||||
|
{'\u6253\u5f00\u9884\u89c8'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
|
|
@ -1015,6 +1220,20 @@ const MeetingDetail: React.FC = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{shareQrContent ? (
|
||||||
|
<Popover
|
||||||
|
content={shareQrContent}
|
||||||
|
trigger="click"
|
||||||
|
open={sharePopoverOpen}
|
||||||
|
onOpenChange={handleSharePopoverOpenChange}
|
||||||
|
placement="bottomRight"
|
||||||
|
overlayClassName="meeting-share-popover"
|
||||||
|
>
|
||||||
|
<Button size="small" icon={<QrcodeOutlined />}>
|
||||||
|
二维码
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
) : null}
|
||||||
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
|
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
|
||||||
返回列表
|
返回列表
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1401,6 +1620,127 @@ const MeetingDetail: React.FC = () => {
|
||||||
background: rgba(250, 173, 20, 0.14);
|
background: rgba(250, 173, 20, 0.14);
|
||||||
color: #d48806;
|
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 {
|
.summary-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -1928,6 +2268,9 @@ const MeetingDetail: React.FC = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
|
.meeting-share-card {
|
||||||
|
width: min(86vw, 292px);
|
||||||
|
}
|
||||||
.detail-side-column {
|
.detail-side-column {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
@ -1954,6 +2297,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
<Select mode="tags" placeholder="输入标签后回车" />
|
<Select mode="tags" placeholder="输入标签后回车" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Text type="warning">参会人员 ID 当前不支持在这里直接修改,如需调整请联系管理员。</Text>
|
<Text type="warning">参会人员 ID 当前不支持在这里直接修改,如需调整请联系管理员。</Text>
|
||||||
|
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, Button, Empty, Input, Result, Segmented, Skeleton, Tabs, Tag, message } from "antd";
|
import { Alert, Button, Empty, Input, Result, Segmented, Skeleton, Tabs, Tag, message } from "antd";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AudioOutlined,
|
AudioOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
|
|
@ -156,6 +156,7 @@ async function copyText(text: string) {
|
||||||
|
|
||||||
export default function MeetingPreview() {
|
export default function MeetingPreview() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||||
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
|
||||||
|
|
@ -172,6 +173,7 @@ export default function MeetingPreview() {
|
||||||
const [isMobile, setIsMobile] = useState(() =>
|
const [isMobile, setIsMobile] = useState(() =>
|
||||||
typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false,
|
typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false,
|
||||||
);
|
);
|
||||||
|
const presetAccessPassword = useMemo(() => (searchParams.get("accessPassword") || "").trim(), [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
@ -185,28 +187,48 @@ export default function MeetingPreview() {
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
setMeeting(null);
|
setMeeting(null);
|
||||||
setTranscripts([]);
|
setTranscripts([]);
|
||||||
setPasswordRequired(false);
|
setPasswordRequired(false);
|
||||||
setPasswordVerified(false);
|
setPasswordVerified(false);
|
||||||
setAccessPassword("");
|
setAccessPassword(presetAccessPassword);
|
||||||
setPasswordError("");
|
setPasswordError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const meetingId = Number(id);
|
const meetingId = Number(id);
|
||||||
const accessRes = await getMeetingPreviewAccess(meetingId);
|
const accessRes = await getMeetingPreviewAccess(meetingId);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiresPassword = !!accessRes.data.data.passwordRequired;
|
const requiresPassword = !!accessRes.data.data.passwordRequired;
|
||||||
setPasswordRequired(requiresPassword);
|
setPasswordRequired(requiresPassword);
|
||||||
if (requiresPassword) {
|
if (requiresPassword) {
|
||||||
setLoading(false);
|
if (!presetAccessPassword) {
|
||||||
return;
|
setLoading(false);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const previewRes = await getPublicMeetingPreview(meetingId, presetAccessPassword);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMeeting(previewRes.data.data.meeting);
|
||||||
|
setTranscripts(previewRes.data.data.transcripts || []);
|
||||||
|
setPasswordVerified(true);
|
||||||
|
return;
|
||||||
|
} catch (requestError: any) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword);
|
||||||
|
setPasswordVerified(false);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const previewRes = await getPublicMeetingPreview(meetingId);
|
const previewRes = await getPublicMeetingPreview(meetingId);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -232,7 +254,7 @@ export default function MeetingPreview() {
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [id]);
|
}, [id, presetAccessPassword]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue