增加了会议访问密码

main
mula.liu 2025-12-16 18:56:28 +08:00
parent 3bb66c8e81
commit a708031347
7 changed files with 822 additions and 26 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -171,7 +171,7 @@
}
.hero-subtitle {
font-size: 1.25rem;
font-size: 1.5rem;
margin-bottom: 3rem;
color: rgba(255, 255, 255, 0.85);
max-width: 650px;

View File

@ -90,9 +90,9 @@ const HomePage = ({ onLogin }) => {
{/* Hero Section */}
<section className="hero">
<div className="hero-content">
<h1 className="hero-title">iMeeting 让会议更智能</h1>
<h1 className="hero-title">iMeeting - (Líng Shū)</h1>
<p className="hero-subtitle">
通过AI将会议音频转录并自动总结对齐团队目标构建企业知识库
让每一次谈话都产生价值
</p>
<button
className="cta-button"

View File

@ -1517,14 +1517,17 @@
width: 90%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.summary-modal-content {
padding: 0 24px 24px;
max-height: calc(90vh - 120px);
flex: 1;
overflow-y: auto;
min-height: 0; /* 关键:允许 flex 子元素缩小 */
}
.summary-input-section {
@ -1832,14 +1835,17 @@
width: 90%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
}
.transcript-edit-content {
padding: 20px;
max-height: calc(90vh - 120px);
flex: 1;
overflow-y: auto;
min-height: 0; /* 关键:允许 flex 子元素缩小 */
}
.modal-description {
@ -2225,4 +2231,374 @@
background: linear-gradient(135deg, #5a67d8 0%, #6b46c1 100%);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(102, 126, 234, 0.4);
}
/* 访问密码管理样式 */
.access-password-section {
background: #fff;
border-left: 4px solid #f59e0b;
}
.access-password-section h2 {
color: #1e293b;
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.password-management {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* 创建人密码控制 */
.creator-password-controls {
display: flex;
flex-direction: column;
gap: 1rem;
}
.password-toggle-area {
display: flex;
flex-direction: column;
gap: 1rem;
}
.password-toggle-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
background: white;
color: #64748b;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
max-width: 160px;
}
.password-toggle-btn.enabled {
border-color: #10b981;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
}
.password-toggle-btn.disabled {
border-color: #f59e0b;
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
}
.password-toggle-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.password-toggle-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.password-display {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 8px;
}
.password-label {
font-weight: 600;
color: #475569;
font-size: 0.9rem;
}
.password-value {
font-family: 'Monaco', 'Courier New', monospace;
font-size: 1.1rem;
font-weight: 700;
color: #1e293b;
letter-spacing: 0.2em;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #cbd5e1;
border-radius: 6px;
min-width: 80px;
text-align: center;
}
.password-action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: white;
border: 1px solid #cbd5e1;
border-radius: 6px;
color: #64748b;
cursor: pointer;
transition: all 0.2s ease;
}
.password-action-btn:hover {
background: #f1f5f9;
border-color: #94a3b8;
color: #475569;
}
.password-action-btn.copied {
background: #10b981;
border-color: #10b981;
color: white;
}
.password-hint {
font-size: 0.875rem;
color: #64748b;
line-height: 1.6;
margin: 0;
padding: 0.75rem 1rem;
background: #f8fafc;
border-left: 3px solid #f59e0b;
border-radius: 4px;
}
/* 非创建人查看密码样式 */
.viewer-password-display {
display: flex;
flex-direction: column;
gap: 1rem;
}
.password-info-card {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.5rem;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border: 2px solid #e2e8f0;
border-radius: 12px;
}
.password-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
border-radius: 12px;
flex-shrink: 0;
}
.password-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.password-content h4 {
margin: 0;
color: #1e293b;
font-size: 1rem;
font-weight: 600;
}
.password-display-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.password-display-row .password-label {
font-size: 0.875rem;
}
.password-display-row .password-value {
padding: 0.25rem 0.75rem;
font-size: 1rem;
min-width: 60px;
}
.no-password-message {
padding: 1.5rem;
text-align: center;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 8px;
color: #64748b;
}
.no-password-message p {
margin: 0;
font-size: 0.9rem;
}
/* 访问密码 - 行内样式 */
.meta-item-password {
display: flex;
align-items: center;
gap: 0.75rem;
color: #334155;
}
.password-control-inline {
display: flex;
align-items: center;
gap: 0.5rem;
}
.password-toggle-inline {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.8rem;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: white;
color: #64748b;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.password-toggle-inline.enabled {
border-color: #10b981;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
}
.password-toggle-inline.disabled {
border-color: #cbd5e1;
background: white;
color: #64748b;
}
.password-toggle-inline.enabled:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
}
.password-toggle-inline.disabled:hover:not(:disabled) {
background: #f9fafb;
border-color: #94a3b8;
}
.password-toggle-inline:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.password-text {
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.15em;
color: white;
min-width: 45px;
text-align: center;
}
.password-view-inline {
display: flex;
align-items: center;
gap: 0.5rem;
}
.password-view-inline .password-text {
color: #1e293b;
padding: 0.3rem 0.6rem;
background: #f8fafc;
border: 1px solid #cbd5e1;
border-radius: 4px;
}
.password-icon-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.35rem;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.password-toggle-inline.disabled + .password-icon-btn,
.password-view-inline .password-icon-btn {
background: white;
border-color: #cbd5e1;
color: #64748b;
}
.password-icon-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
}
.password-toggle-inline.disabled + .password-icon-btn:hover,
.password-view-inline .password-icon-btn:hover {
background: #f1f5f9;
border-color: #94a3b8;
color: #475569;
}
.no-password-text {
font-size: 0.85rem;
color: #94a3b8;
font-style: italic;
}
/* 参会人员 - 行内样式 */
.meta-item-attendees {
display: flex;
align-items: center;
gap: 0.75rem;
color: #334155;
grid-column: 1 / -1;
}
.attendees-inline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
color: #475569;
}
.attendee-name {
color: #475569;
}
.attendee-count {
color: #64748b;
font-size: 0.85rem;
margin-left: 0.25rem;
}
/* 旋转动画 */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spin {
animation: spin 1s linear infinite;
}

View File

@ -3,7 +3,7 @@ import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import configService from '../utils/configService';
import tools from '../utils/tools';
import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image, QrCode, MoreVertical, Upload, ChevronLeft, ChevronRight, Loader } from 'lucide-react';
import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image, QrCode, MoreVertical, Upload, ChevronLeft, ChevronRight, Loader, Lock, Unlock, Eye, EyeOff, Copy, Check } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
@ -69,6 +69,13 @@ const MeetingDetails = ({ user }) => {
const [maxFileSize, setMaxFileSize] = useState(100 * 1024 * 1024); // 100MB
const [uploadError, setUploadError] = useState('');
// 访
const [accessPassword, setAccessPassword] = useState(null);
const [passwordEnabled, setPasswordEnabled] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [passwordCopied, setPasswordCopied] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
//
const [audioLoading, setAudioLoading] = useState(true); //
const [audioCanPlay, setAudioCanPlay] = useState(false); //
@ -242,7 +249,16 @@ const MeetingDetails = ({ user }) => {
const response = await apiClient.get(`${baseUrl}${detailEndpoint}`);
setMeeting(response.data);
// 访
if (response.data.access_password) {
setAccessPassword(response.data.access_password);
setPasswordEnabled(true);
} else {
setAccessPassword(null);
setPasswordEnabled(false);
}
// Handle transcription status from meeting details
if (response.data.transcription_status) {
const newStatus = response.data.transcription_status;
@ -1081,6 +1097,58 @@ const MeetingDetails = ({ user }) => {
}
};
// 访
const generatePassword = () => {
// 4+
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
let password = '';
for (let i = 0; i < 4; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
};
const handleTogglePassword = async () => {
try {
setPasswordLoading(true);
if (passwordEnabled) {
//
await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/access-password`), {
password: null
});
setAccessPassword(null);
setPasswordEnabled(false);
setShowPassword(false);
showToast('访问密码已关闭', 'success');
} else {
//
const newPassword = generatePassword();
await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/access-password`), {
password: newPassword
});
setAccessPassword(newPassword);
setPasswordEnabled(true);
setShowPassword(true);
showToast('访问密码已生成', 'success');
}
} catch (err) {
console.error('密码操作失败:', err);
showToast(err.response?.data?.message || '密码操作失败,请重试', 'error');
} finally {
setPasswordLoading(false);
}
};
const handleCopyPassword = () => {
if (accessPassword) {
navigator.clipboard.writeText(accessPassword);
setPasswordCopied(true);
showToast('密码已复制到剪贴板', 'success');
setTimeout(() => setPasswordCopied(false), 2000);
}
};
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
if (loading) {
@ -1181,12 +1249,12 @@ const MeetingDetails = ({ user }) => {
<div className="meta-grid">
<div className="meta-item">
<Calendar size={18} />
<strong>会议日期:</strong>
<strong>日期:</strong>
<span>{tools.formatDateTime(meeting.meeting_time).split(' ')[0].slice(2)}</span>
</div>
<div className="meta-item">
<Clock size={18} />
<strong>会议时间:</strong>
<strong>时间:</strong>
<span>{tools.formatDateTime(meeting.meeting_time).split(' ')[1]}</span>
</div>
<div className="meta-item">
@ -1194,26 +1262,73 @@ const MeetingDetails = ({ user }) => {
<strong>创建人:</strong>
<span>{meeting.creator_username}</span>
</div>
<div className="meta-item">
<div className="meta-item meta-item-password">
<Lock size={18} />
<strong>访问密码:</strong>
{isCreator ? (
// /
<div className="password-control-inline">
<button
className={`password-toggle-inline ${passwordEnabled ? 'enabled' : 'disabled'}`}
onClick={handleTogglePassword}
disabled={passwordLoading}
title={passwordEnabled ? '点击关闭密码' : '点击开启密码'}
>
{passwordLoading ? (
<Loader size={14} className="spin" />
) : passwordEnabled ? (
<>
<span className="password-text">{showPassword ? accessPassword : '••••'}</span>
</>
) : (
<>
<span>开启</span>
</>
)}
</button>
{passwordEnabled && accessPassword && (
<button
className="password-icon-btn"
onClick={() => setShowPassword(!showPassword)}
title={showPassword ? '隐藏密码' : '显示密码'}
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
)}
</div>
) : (
// ""
passwordEnabled && accessPassword ? (
<div className="password-view-inline">
<span className="password-text">{showPassword ? accessPassword : '••••'}</span>
<button
className="password-icon-btn"
onClick={() => setShowPassword(!showPassword)}
title={showPassword ? '隐藏密码' : '显示密码'}
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
) : (
<span className="no-password-text">未设置</span>
)
)}
</div>
<div className="meta-item meta-item-attendees">
<Users size={18} />
<strong>参会人数:</strong>
<span>{meeting.attendees.length}</span>
<strong>参会人员<span className="attendee-count">{meeting.attendees.length}</span>:</strong>
<span className="attendees-inline">
{meeting.attendees.map((attendee, index) => (
<span key={index} className="attendee-name">
{typeof attendee === 'string' ? attendee : attendee.caption}
{index < meeting.attendees.length - 1 && '、'}
</span>
))}
</span>
</div>
</div>
</header>
<section className="card-section">
<h2><Users size={20} /> 参会人员</h2>
<div className="attendees-list">
{meeting.attendees.map((attendee, index) => (
<div key={index} className="attendee-chip">
<User size={16} />
<span>{typeof attendee === 'string' ? attendee : attendee.caption}</span>
</div>
))}
</div>
</section>
{/* Audio Player Section */}
<section className="card-section audio-section">
<div className="section-header-with-menu">
@ -1718,7 +1833,7 @@ const MeetingDetails = ({ user }) => {
<div className="summary-modal-overlay" onClick={closeSummaryModal}>
<div className="summary-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3><Brain size={20} /> AI会议总结</h3>
<h3><Brain size={20} /> AI总结</h3>
<button
className="close-btn"
onClick={closeSummaryModal}

View File

@ -543,3 +543,180 @@
color: #666;
}
}
/* 密码保护界面样式 */
.password-protection-modal {
display: flex;
align-items: center;
justify-content: center;
min-height: 80vh;
}
.password-modal-content {
background: white;
border-radius: 16px;
padding: 48px 40px;
max-width: 450px;
width: 100%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12);
text-align: center;
border: 1px solid #e5e7eb;
}
.password-icon-large {
width: 80px;
height: 80px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
box-shadow: 0 4px 16px rgba(245, 158, 11, 0.3);
}
.password-modal-content h2 {
font-size: 24px;
font-weight: 600;
color: #1e293b;
margin-bottom: 12px;
}
.password-modal-content > p {
font-size: 16px;
color: #64748b;
margin-bottom: 32px;
}
.password-input-group {
position: relative;
margin-bottom: 16px;
}
.password-input {
width: 100%;
padding: 14px 50px 14px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 16px;
font-family: 'Monaco', 'Courier New', monospace;
letter-spacing: 0.3em;
text-align: center;
transition: all 0.2s ease;
box-sizing: border-box;
}
.password-input:focus {
outline: none;
border-color: #f59e0b;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1);
}
.password-input.error {
border-color: #ef4444;
}
.password-input:disabled {
background: #f8fafc;
cursor: not-allowed;
}
.password-toggle-btn {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #64748b;
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
}
.password-toggle-btn:hover {
background: #f1f5f9;
color: #475569;
}
.password-error-message {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #dc2626;
font-size: 14px;
margin-bottom: 16px;
}
.password-verify-btn {
width: 100%;
padding: 14px 24px;
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
}
.password-verify-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #d97706 0%, #b45309 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
}
.password-verify-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(245, 158, 11, 0.3);
}
.password-verify-btn:disabled {
background: #cbd5e1;
cursor: not-allowed;
box-shadow: none;
}
/* 移动端密码界面优化 */
@media (max-width: 768px) {
.password-modal-content {
padding: 32px 24px;
margin: 0 15px;
}
.password-icon-large {
width: 64px;
height: 64px;
}
.password-modal-content h2 {
font-size: 20px;
}
.password-modal-content > p {
font-size: 14px;
margin-bottom: 24px;
}
.password-input {
padding: 12px 45px 12px 12px;
font-size: 14px;
}
.password-verify-btn {
padding: 12px 20px;
font-size: 15px;
}
}

View File

@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { Tabs } from 'antd';
import { Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
import MindMap from '../components/MindMap';
import './MeetingPreview.css';
@ -17,7 +18,21 @@ const MeetingPreview = () => {
const [error, setError] = useState(null);
const [errorType, setErrorType] = useState(''); // 'not_found', 'no_summary', 'network'
//
const [isPasswordProtected, setIsPasswordProtected] = useState(false);
const [passwordVerified, setPasswordVerified] = useState(false);
const [passwordInput, setPasswordInput] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [passwordError, setPasswordError] = useState('');
const [verifyingPassword, setVerifyingPassword] = useState(false);
useEffect(() => {
// 使 sessionStorage
const verifiedKey = `meeting_${meeting_id}_verified`;
const isVerified = sessionStorage.getItem(verifiedKey) === 'true';
if (isVerified) {
setPasswordVerified(true);
}
fetchMeetingPreviewData();
}, [meeting_id]);
@ -29,7 +44,17 @@ const MeetingPreview = () => {
const result = await response.json();
if (result.code === "200") {
setMeetingData(result.data);
// - sessionStorage
const verifiedKey = `meeting_${meeting_id}_verified`;
const isVerified = sessionStorage.getItem(verifiedKey) === 'true';
if (result.data.has_password && !isVerified && !passwordVerified) {
setIsPasswordProtected(true);
setMeetingData(null);
} else {
setMeetingData(result.data);
setIsPasswordProtected(false);
}
setError(null);
setErrorType('');
} else if (result.code === "404") {
@ -51,6 +76,56 @@ const MeetingPreview = () => {
}
};
const handlePasswordVerify = async () => {
if (!passwordInput.trim()) {
setPasswordError('请输入访问密码');
return;
}
setVerifyingPassword(true);
setPasswordError('');
try {
const response = await fetch(`/api/meetings/${meeting_id}/verify-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password: passwordInput }),
});
const result = await response.json();
if (result.code === "200" && result.data.verified) {
//
setPasswordVerified(true);
setIsPasswordProtected(false);
setPasswordInput('');
setPasswordError('');
// sessionStorage
const verifiedKey = `meeting_${meeting_id}_verified`;
sessionStorage.setItem(verifiedKey, 'true');
//
fetchMeetingPreviewData();
} else {
setPasswordError('密码错误,请重试');
}
} catch (err) {
console.error('验证密码失败:', err);
setPasswordError('验证失败,请重试');
} finally {
setVerifyingPassword(false);
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
handlePasswordVerify();
}
};
const formatDateTime = (dateTime) => {
if (!dateTime) return '';
const date = new Date(dateTime);
@ -76,6 +151,59 @@ const MeetingPreview = () => {
);
}
//
if (isPasswordProtected && !passwordVerified) {
return (
<div className="preview-container">
<div className="password-protection-modal">
<div className="password-modal-content">
<div className="password-icon-large">
<Lock size={48} />
</div>
<h2>此会议需要访问密码</h2>
<p>请输入密码以查看会议总结</p>
<div className="password-input-group">
<input
type={showPassword ? "text" : "password"}
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="请输入4位访问密码"
className={`password-input ${passwordError ? 'error' : ''}`}
disabled={verifyingPassword}
maxLength={4}
autoFocus
/>
<button
className="password-toggle-btn"
onClick={() => setShowPassword(!showPassword)}
type="button"
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
{passwordError && (
<div className="password-error-message">
<AlertCircle size={16} />
<span>{passwordError}</span>
</div>
)}
<button
className="password-verify-btn"
onClick={handlePasswordVerify}
disabled={verifyingPassword || !passwordInput.trim()}
>
{verifyingPassword ? '验证中...' : '验证密码'}
</button>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="preview-container">