图片加载提示,info半透明

This commit is contained in:
2025-11-18 20:00:39 +08:00
parent 5a97bc2a8d
commit b5929613ba
3 changed files with 2337 additions and 226 deletions
+106 -75
View File
@@ -50,7 +50,9 @@ interface Password {
}
interface Credential {
_id: string;
// 后端有的可能返回 _id,有的可能返回 id,这里都兼容
_id?: string;
id?: string;
hostname: string;
username: string;
browser: string;
@@ -127,6 +129,7 @@ export default function HostDetail() {
const [autoPlaySpeed, setAutoPlaySpeed] = useState(100);
const [imagesLoadedCount, setImagesLoadedCount] = useState(0);
const [imageAspectRatio, setImageAspectRatio] = useState(16 / 9);
const [loadingImageIds, setLoadingImageIds] = useState<Set<string>>(new Set());
const autoPlayTimer = useRef<NodeJS.Timeout | null>(null);
const wheelDeltaAccumulator = useRef(0);
@@ -590,6 +593,19 @@ export default function HostDetail() {
if (imgEl.naturalHeight !== 0) {
setImageAspectRatio(imgEl.naturalWidth / imgEl.naturalHeight);
}
setLoadingImageIds(prev => {
const next = new Set(prev);
next.delete(fileId);
return next;
});
};
const onImageError = (fileId: string) => {
setLoadingImageIds(prev => {
const next = new Set(prev);
next.delete(fileId);
return next;
});
};
// 键盘快捷键处理
@@ -709,6 +725,14 @@ export default function HostDetail() {
}
}, [selectedRecord, records, autoPlay, startAutoPlayTimer]);
useEffect(() => {
if (selectedRecord) {
setLoadingImageIds(new Set(selectedRecord.screenshots.map(s => s.fileId)));
} else {
setLoadingImageIds(new Set());
}
}, [selectedRecord]);
useEffect(() => {
if (autoPlay) {
stopAutoPlayTimer();
@@ -879,11 +903,17 @@ export default function HostDetail() {
alt={screenshot.monitorName}
className="absolute top-0 left-0 w-full h-full object-contain shadow-sm hover:shadow-md transition-shadow"
onLoad={(e) => onImageLoad(e, screenshot.fileId)}
onError={() => onImageError(screenshot.fileId)}
/>
{loadingImageIds.has(screenshot.fileId) && (
<div className="absolute top-2 right-2 bg-black/40 rounded-full p-2">
<Loader2 className="h-5 w-5 text-white animate-spin" />
</div>
)}
</div>
{/* 图片说明 */}
<div className="absolute bottom-4 left-4 bg-black dark:bg-gray-900 bg-opacity-60 dark:bg-opacity-80 text-white px-2 py-1 rounded">
<div className="absolute bottom-4 left-4 bg-black/40 dark:bg-gray-900/40 text-white px-2 py-1 rounded">
<div className="text-sm">{screenshot.monitorName}</div>
<div className="text-xs">
{new Date(selectedRecord.timestamp).toLocaleString()}
@@ -1203,84 +1233,85 @@ export default function HostDetail() {
{/* 浏览器凭据列表 */}
{expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) && (
<div className="pl-6 space-y-3 mt-2">
{browser.credentials.map((cred) => (
<div
key={`${userGroup.username}-${browser.name}-${cred._id}`}
className="border border-gray-200 dark:border-gray-600 rounded-md overflow-hidden"
>
{/* 凭据网站头部 */}
{browser.credentials.map((cred) => {
// 统一计算凭据唯一 ID(兼容 _id / id),若都不存在,用组合键兜底
const credentialId = cred._id || cred.id || `${userGroup.username}-${browser.name}-${cred.url}-${cred.login}`;
const isExpanded = expandedCredentials.includes(credentialId);
return (
<div
className="bg-gray-50 dark:bg-gray-700 px-3 py-2 flex items-center justify-between cursor-pointer"
onClick={() => toggleCredentialExpanded(cred._id)}
key={credentialId}
className="border border-gray-200 dark:border-gray-600 rounded-md overflow-hidden"
>
<div className="flex items-center">
<Link className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-2" />
<div className="text-sm font-medium text-gray-900 dark:text-white truncate max-w-md">
{cred.url}
{/* 凭据网站头部 */}
<div
className="bg-gray-50 dark:bg-gray-700 px-3 py-2 flex items-center justify-between cursor-pointer"
onClick={() => toggleCredentialExpanded(credentialId)}
>
<div className="flex items-center">
<Link className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-2" />
<div className="text-sm font-medium text-gray-900 dark:text-white truncate max-w-md">
{cred.url}
</div>
</div>
<ChevronDown
className={`h-4 w-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
/>
</div>
<ChevronDown
className={`h-4 w-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${expandedCredentials.includes(cred._id) ? 'rotate-180' : ''
}`}
/>
{/* 凭据详情 */}
{isExpanded && (
<div className="bg-white dark:bg-gray-800 px-3 py-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="flex items-center">
<span className="text-sm text-gray-500 dark:text-gray-400 w-16">用户名:</span>
<span className="text-sm font-medium ml-2 text-gray-900 dark:text-white">{cred.login}</span>
</div>
<div className="flex flex-col">
<div className="flex items-center mb-1">
<span className="text-sm text-gray-500 dark:text-gray-400 w-16">密码历史:</span>
<span className="text-xs bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded px-1">
{cred.passwords.length} 条记录
</span>
</div>
<div className="pl-6 space-y-2 mt-1">
{cred.passwords.map((pwd, pwdIndex) => {
const pwdKey = `${credentialId}-${pwdIndex}`;
const revealed = revealedPasswords.includes(pwdKey);
return (
<div
key={pwdIndex}
className="flex items-center group relative"
>
<span className="text-xs text-gray-400 dark:text-gray-500 w-24 flex-shrink-0">
{formatDate(pwd.timestamp, 'short')}
</span>
<div className="flex-1 flex items-center">
<span
className="text-sm font-mono bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white px-2 py-0.5 rounded flex-1 cursor-pointer"
onClick={() => revealed ? null : revealPassword(pwdKey)}
>
{revealed ? pwd.value : '••••••••'}
</span>
<button
onClick={() => copyToClipboard(pwd.value)}
className="ml-2 opacity-0 group-hover:opacity-100 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<Clipboard className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
)}
</div>
{/* 凭据详情 */}
{expandedCredentials.includes(cred._id) && (
<div className="bg-white dark:bg-gray-800 px-3 py-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="flex items-center">
<span className="text-sm text-gray-500 dark:text-gray-400 w-16">用户名:</span>
<span className="text-sm font-medium ml-2 text-gray-900 dark:text-white">{cred.login}</span>
</div>
<div className="flex flex-col">
<div className="flex items-center mb-1">
<span className="text-sm text-gray-500 dark:text-gray-400 w-16">密码历史:</span>
<span className="text-xs bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded px-1">
{cred.passwords.length} 条记录
</span>
</div>
<div className="pl-6 space-y-2 mt-1">
{cred.passwords.map((pwd, pwdIndex) => (
<div
key={pwdIndex}
className="flex items-center group relative"
>
<span className="text-xs text-gray-400 dark:text-gray-500 w-24 flex-shrink-0">
{formatDate(pwd.timestamp, 'short')}
</span>
<div className="flex-1 flex items-center">
<span
className="text-sm font-mono bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white px-2 py-0.5 rounded flex-1 cursor-pointer"
onClick={() =>
revealedPasswords.includes(`${cred._id}-${pwdIndex}`)
? null
: revealPassword(`${cred._id}-${pwdIndex}`)
}
>
{revealedPasswords.includes(`${cred._id}-${pwdIndex}`)
? pwd.value
: '••••••••'
}
</span>
<button
onClick={() => copyToClipboard(pwd.value)}
className="ml-2 opacity-0 group-hover:opacity-100 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<Clipboard className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
))}
);
})}
</div>
)}
</div>