diff --git a/backend/app/models/models.py b/backend/app/models/models.py
index 3007da0..8fb2bc2 100644
--- a/backend/app/models/models.py
+++ b/backend/app/models/models.py
@@ -87,6 +87,7 @@ class Meeting(BaseModel):
overall_status: Optional[str] = None
overall_progress: Optional[int] = None
current_stage: Optional[str] = None
+ access_password: Optional[str] = None
class TranscriptSegment(BaseModel):
segment_id: int
diff --git a/frontend/src/components/QRCodeModal.jsx b/frontend/src/components/QRCodeModal.jsx
index eb747a3..aa9cb55 100644
--- a/frontend/src/components/QRCodeModal.jsx
+++ b/frontend/src/components/QRCodeModal.jsx
@@ -5,7 +5,7 @@ import { QRCodeSVG } from 'qrcode.react';
const { Text, Paragraph } = Typography;
-const QRCodeModal = ({ open, onClose, url, title = "扫描二维码分享" }) => {
+const QRCodeModal = ({ open, onClose, url, title = "扫描二维码分享", children }) => {
const { message } = App.useApp();
const handleCopy = () => {
@@ -43,6 +43,11 @@ const QRCodeModal = ({ open, onClose, url, title = "扫描二维码分享" }) =>
{url}
+ {children ? (
+
+ {children}
+
+ ) : null}
);
diff --git a/frontend/src/config/api.js b/frontend/src/config/api.js
index dfc8abd..190b6a1 100644
--- a/frontend/src/config/api.js
+++ b/frontend/src/config/api.js
@@ -36,6 +36,7 @@ const API_CONFIG = {
UPLOAD_IMAGE: (meetingId) => `/api/meetings/${meetingId}/upload-image`,
REGENERATE_SUMMARY: (meetingId) => `/api/meetings/${meetingId}/regenerate-summary`,
NAVIGATION: (meetingId) => `/api/meetings/${meetingId}/navigation`,
+ ACCESS_PASSWORD: (meetingId) => `/api/meetings/${meetingId}/access-password`,
PREVIEW_DATA: (meetingId) => `/api/meetings/${meetingId}/preview-data`,
LLM_MODELS: '/api/llm-models/active'
},
diff --git a/frontend/src/pages/MeetingDetails.jsx b/frontend/src/pages/MeetingDetails.jsx
index d72a6ce..4643d10 100644
--- a/frontend/src/pages/MeetingDetails.jsx
+++ b/frontend/src/pages/MeetingDetails.jsx
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Row, Col, Button, Space, Typography, Tag, Avatar,
Tooltip, Progress, Spin, App, Dropdown,
- Divider, List, Timeline, Tabs, Input, Upload, Empty, Drawer, Select
+ Divider, List, Timeline, Tabs, Input, Upload, Empty, Drawer, Select, Switch
} from 'antd';
import {
ClockCircleOutlined, UserOutlined,
@@ -75,6 +75,9 @@ const MeetingDetails = ({ user }) => {
const [showQRModal, setShowQRModal] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [playbackRate, setPlaybackRate] = useState(1);
+ const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
+ const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
+ const [savingAccessPassword, setSavingAccessPassword] = useState(false);
// 转录编辑 Drawer
const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false);
@@ -87,6 +90,7 @@ const MeetingDetails = ({ user }) => {
const audioRef = useRef(null);
const transcriptRefs = useRef([]);
+ const isMeetingOwner = user?.user_id === meeting?.creator_id;
/* ══════════════════ 数据获取 ══════════════════ */
@@ -105,6 +109,8 @@ const MeetingDetails = ({ user }) => {
setLoading(true);
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)));
setMeeting(response.data);
+ setAccessPasswordEnabled(Boolean(response.data.access_password));
+ setAccessPasswordDraft(response.data.access_password || '');
if (response.data.transcription_status) {
const ts = response.data.transcription_status;
@@ -220,6 +226,40 @@ const MeetingDetails = ({ user }) => {
finally { setIsUploading(false); }
};
+ const saveAccessPassword = async () => {
+ const nextPassword = accessPasswordEnabled ? accessPasswordDraft.trim() : null;
+ if (accessPasswordEnabled && !nextPassword) {
+ message.warning('开启访问密码后,请先输入密码');
+ return;
+ }
+
+ setSavingAccessPassword(true);
+ try {
+ const res = await apiClient.put(
+ buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meeting_id)),
+ { password: nextPassword }
+ );
+ const savedPassword = res.data?.password || null;
+ setMeeting((prev) => (prev ? { ...prev, access_password: savedPassword } : prev));
+ setAccessPasswordEnabled(Boolean(savedPassword));
+ setAccessPasswordDraft(savedPassword || '');
+ message.success(res.message || '访问密码已更新');
+ } catch (error) {
+ message.error(error?.response?.data?.message || '访问密码更新失败');
+ } finally {
+ setSavingAccessPassword(false);
+ }
+ };
+
+ const copyAccessPassword = async () => {
+ if (!accessPasswordDraft) {
+ message.warning('当前没有可复制的访问密码');
+ return;
+ }
+ await navigator.clipboard.writeText(accessPasswordDraft);
+ message.success('访问密码已复制');
+ };
+
const openAudioUploadPicker = () => {
document.getElementById('audio-upload-input')?.click();
};
@@ -864,7 +904,48 @@ const MeetingDetails = ({ user }) => {
)}
- setShowQRModal(false)} url={`${window.location.origin}/meetings/preview/${meeting_id}`} />
+ setShowQRModal(false)} url={`${window.location.origin}/meetings/preview/${meeting_id}`}>
+ {isMeetingOwner ? (
+ <>
+
+
+ 访问密码保护
+
+
+ {accessPasswordEnabled ? (
+
+ setAccessPasswordDraft(e.target.value)}
+ placeholder="请输入访问密码"
+ />
+
+ 开启后,访客打开分享链接时需要输入这个密码
+
+
+
+
+
+
+ ) : (
+
+ 关闭后,任何拿到链接的人都可以直接查看预览页
+
+
+ )}
+ >
+ ) : meeting?.access_password ? (
+ <>
+
+ 该分享链接已启用访问密码,密码由会议创建人管理。
+ >
+ ) : null}
+
setEditDrawerOpen(false)}