feat(UI): 添加模态框、图片灯箱和文档预览组件
Modal 组件: - 支持 ESC 键关闭和点击遮罩关闭 - 可配置关闭按钮显示、最大宽度、全屏模式 - 添加淡入和缩放动画效果 ImageLightbox 组件: - 支持多图片浏览和左右键导航 - 实现缩放、下载功能 - 支持触摸手势滑动切换 - 底部指示点和图片计数显示 DocumentPreview 组件: - 支持代码文件语法高亮显示 - Markdown 文件渲染预览 - 提供复制和下载功能
This commit is contained in:
parent
e99c72f02a
commit
cb01e2dffb
332
src/components/ui/DocumentPreview.tsx
Normal file
332
src/components/ui/DocumentPreview.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { X, Copy, Download, Check, FileText, FileCode, File } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CodeBlock } from '@/components/markdown/CodeBlock';
|
||||
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
||||
|
||||
export interface DocumentData {
|
||||
name: string;
|
||||
content: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface DocumentPreviewProps {
|
||||
documentData: DocumentData | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 根据文件扩展名获取语言
|
||||
function getLanguageFromFileName(fileName: string): string {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
const languageMap: Record<string, string> = {
|
||||
// JavaScript/TypeScript
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
mjs: 'javascript',
|
||||
cjs: 'javascript',
|
||||
// Python
|
||||
py: 'python',
|
||||
pyw: 'python',
|
||||
// Web
|
||||
html: 'markup',
|
||||
htm: 'markup',
|
||||
xml: 'markup',
|
||||
svg: 'markup',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
sass: 'scss',
|
||||
less: 'css',
|
||||
// Data
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
toml: 'yaml',
|
||||
// Shell
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
zsh: 'bash',
|
||||
fish: 'bash',
|
||||
// C family
|
||||
c: 'c',
|
||||
h: 'c',
|
||||
cpp: 'cpp',
|
||||
cc: 'cpp',
|
||||
cxx: 'cpp',
|
||||
hpp: 'cpp',
|
||||
cs: 'csharp',
|
||||
// Others
|
||||
java: 'java',
|
||||
go: 'go',
|
||||
rs: 'rust',
|
||||
rb: 'ruby',
|
||||
php: 'php',
|
||||
sql: 'sql',
|
||||
md: 'markdown',
|
||||
markdown: 'markdown',
|
||||
// Config
|
||||
env: 'bash',
|
||||
ini: 'ini',
|
||||
conf: 'ini',
|
||||
// Plain text
|
||||
txt: 'text',
|
||||
log: 'text',
|
||||
csv: 'text',
|
||||
};
|
||||
return languageMap[ext] || 'text';
|
||||
}
|
||||
|
||||
// 判断文件类型
|
||||
function getFileType(fileName: string): 'code' | 'markdown' | 'pdf' | 'text' {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
if (ext === 'pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
if (ext === 'md' || ext === 'markdown') {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
const codeExtensions = [
|
||||
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs',
|
||||
'py', 'pyw',
|
||||
'html', 'htm', 'xml', 'svg', 'css', 'scss', 'sass', 'less',
|
||||
'json', 'yaml', 'yml', 'toml',
|
||||
'sh', 'bash', 'zsh', 'fish',
|
||||
'c', 'h', 'cpp', 'cc', 'cxx', 'hpp', 'cs',
|
||||
'java', 'go', 'rs', 'rb', 'php', 'sql',
|
||||
'env', 'ini', 'conf',
|
||||
];
|
||||
|
||||
if (codeExtensions.includes(ext)) {
|
||||
return 'code';
|
||||
}
|
||||
|
||||
return 'text';
|
||||
}
|
||||
|
||||
// 根据文件类型获取图标
|
||||
function getFileIcon(type: 'code' | 'markdown' | 'pdf' | 'text') {
|
||||
switch (type) {
|
||||
case 'code':
|
||||
return FileCode;
|
||||
case 'markdown':
|
||||
case 'text':
|
||||
return FileText;
|
||||
case 'pdf':
|
||||
return File;
|
||||
default:
|
||||
return FileText;
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function DocumentPreview({ documentData, isOpen, onClose }: DocumentPreviewProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.document.addEventListener('keydown', handleKeyDown);
|
||||
window.document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
window.document.removeEventListener('keydown', handleKeyDown);
|
||||
window.document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 复制内容
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!documentData) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(documentData.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
}
|
||||
}, [documentData]);
|
||||
|
||||
// 下载文件
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!documentData) return;
|
||||
|
||||
const blob = new Blob([documentData.content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = window.document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = documentData.name;
|
||||
window.document.body.appendChild(link);
|
||||
link.click();
|
||||
window.document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [documentData]);
|
||||
|
||||
if (!isOpen || !documentData) return null;
|
||||
|
||||
const fileType = getFileType(documentData.name);
|
||||
const language = getLanguageFromFileName(documentData.name);
|
||||
const Icon = getFileIcon(fileType);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-fade-in-fast"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* 内容区域 */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative bg-[var(--color-bg-primary)] rounded-xl shadow-2xl',
|
||||
'animate-scale-in-fast',
|
||||
'w-[90vw] max-w-4xl max-h-[90vh] flex flex-col overflow-hidden'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border)]">
|
||||
{/* 文件信息 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 flex items-center justify-center rounded-lg bg-[var(--color-primary-light)]">
|
||||
<Icon size={20} className="text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-medium text-[var(--color-text-primary)] truncate max-w-[300px]">
|
||||
{documentData.name}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{formatFileSize(documentData.size)} · {language.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 复制按钮(PDF 不支持复制) */}
|
||||
{fileType !== 'pdf' && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[var(--color-bg-secondary)] hover:bg-[var(--color-bg-hover)]',
|
||||
'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={16} className="text-green-500" />
|
||||
<span className="text-green-500">已复制</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={16} />
|
||||
<span>复制</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 下载按钮 */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[var(--color-bg-secondary)] hover:bg-[var(--color-bg-hover)]',
|
||||
'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
>
|
||||
<Download size={16} />
|
||||
<span>下载</span>
|
||||
</button>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'w-8 h-8 flex items-center justify-center rounded-lg',
|
||||
'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]',
|
||||
'hover:bg-[var(--color-bg-hover)] transition-colors duration-150'
|
||||
)}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{fileType === 'code' && (
|
||||
<div className="p-0">
|
||||
<CodeBlock
|
||||
code={documentData.content}
|
||||
language={language}
|
||||
showLineNumbers={true}
|
||||
className="my-0 rounded-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileType === 'markdown' && (
|
||||
<div className="p-6">
|
||||
<MarkdownRenderer content={documentData.content} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileType === 'text' && (
|
||||
<div className="p-6">
|
||||
<pre className="text-sm text-[var(--color-text-primary)] whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{documentData.content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileType === 'pdf' && (
|
||||
<div className="h-full min-h-[60vh] flex items-center justify-center bg-[var(--color-bg-secondary)]">
|
||||
<div className="text-center p-8">
|
||||
<File size={48} className="mx-auto mb-4 text-[var(--color-text-tertiary)]" />
|
||||
<p className="text-[var(--color-text-secondary)] mb-2">
|
||||
PDF 预览暂不支持
|
||||
</p>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
请点击下载按钮查看文件
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="px-5 py-3 border-t border-[var(--color-border)] bg-[var(--color-bg-secondary)]">
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] text-center">
|
||||
ESC 关闭 · 点击外部区域关闭
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
297
src/components/ui/ImageLightbox.tsx
Normal file
297
src/components/ui/ImageLightbox.tsx
Normal file
@ -0,0 +1,297 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ImageLightboxProps {
|
||||
images: string[];
|
||||
initialIndex?: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImageLightbox({
|
||||
images,
|
||||
initialIndex = 0,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ImageLightboxProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 触摸手势相关
|
||||
const touchStartX = useRef<number>(0);
|
||||
const touchEndX = useRef<number>(0);
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
// 重置索引
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCurrentIndex(initialIndex);
|
||||
setIsZoomed(false);
|
||||
}
|
||||
}, [isOpen, initialIndex]);
|
||||
|
||||
// 上一张
|
||||
const goToPrevious = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
|
||||
setIsZoomed(false);
|
||||
}, [images.length]);
|
||||
|
||||
// 下一张
|
||||
const goToNext = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
|
||||
setIsZoomed(false);
|
||||
}, [images.length]);
|
||||
|
||||
// 键盘导航
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
goToPrevious();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
goToNext();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose, goToPrevious, goToNext]);
|
||||
|
||||
// 触摸事件处理
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
touchEndX.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
const distance = touchStartX.current - touchEndX.current;
|
||||
|
||||
if (Math.abs(distance) > minSwipeDistance) {
|
||||
if (distance > 0) {
|
||||
// 向左滑动 -> 下一张
|
||||
goToNext();
|
||||
} else {
|
||||
// 向右滑动 -> 上一张
|
||||
goToPrevious();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 下载图片
|
||||
const handleDownload = async () => {
|
||||
const imageUrl = images[currentIndex];
|
||||
try {
|
||||
// 处理 base64 或 URL
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
// Base64 图片
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
link.download = `image-${currentIndex + 1}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else {
|
||||
// URL 图片
|
||||
const response = await fetch(imageUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `image-${currentIndex + 1}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载图片失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换缩放
|
||||
const toggleZoom = () => {
|
||||
setIsZoomed(!isZoomed);
|
||||
};
|
||||
|
||||
if (!isOpen || images.length === 0) return null;
|
||||
|
||||
const currentImage = images[currentIndex];
|
||||
const hasMultipleImages = images.length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in-fast"
|
||||
onClick={onClose}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4 bg-gradient-to-b from-black/50 to-transparent">
|
||||
{/* 左侧:图片计数 */}
|
||||
<div className="text-white/90 text-sm font-medium">
|
||||
{hasMultipleImages && (
|
||||
<span>{currentIndex + 1} / {images.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 缩放按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleZoom();
|
||||
}}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-black/30 text-white/90 hover:bg-black/50 hover:text-white transition-colors"
|
||||
title={isZoomed ? '缩小' : '放大'}
|
||||
>
|
||||
{isZoomed ? <ZoomOut size={20} /> : <ZoomIn size={20} />}
|
||||
</button>
|
||||
|
||||
{/* 下载按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload();
|
||||
}}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-black/30 text-white/90 hover:bg-black/50 hover:text-white transition-colors"
|
||||
title="下载图片"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-black/30 text-white/90 hover:bg-black/50 hover:text-white transition-colors"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片容器 */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-full h-full px-16',
|
||||
'transition-transform duration-200',
|
||||
isZoomed && 'cursor-zoom-out'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isZoomed) {
|
||||
e.stopPropagation();
|
||||
setIsZoomed(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={currentImage}
|
||||
alt={`图片 ${currentIndex + 1}`}
|
||||
className={cn(
|
||||
'max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl',
|
||||
'transition-transform duration-200 animate-scale-in-fast',
|
||||
isZoomed ? 'scale-150 cursor-zoom-out' : 'cursor-zoom-in'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleZoom();
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 左侧导航按钮 */}
|
||||
{hasMultipleImages && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToPrevious();
|
||||
}}
|
||||
className={cn(
|
||||
'absolute left-4 top-1/2 -translate-y-1/2 z-10',
|
||||
'w-12 h-12 flex items-center justify-center rounded-full',
|
||||
'bg-black/30 text-white/90 hover:bg-black/50 hover:text-white',
|
||||
'transition-all duration-150',
|
||||
'hover:scale-110'
|
||||
)}
|
||||
title="上一张 (←)"
|
||||
>
|
||||
<ChevronLeft size={28} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 右侧导航按钮 */}
|
||||
{hasMultipleImages && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToNext();
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-4 top-1/2 -translate-y-1/2 z-10',
|
||||
'w-12 h-12 flex items-center justify-center rounded-full',
|
||||
'bg-black/30 text-white/90 hover:bg-black/50 hover:text-white',
|
||||
'transition-all duration-150',
|
||||
'hover:scale-110'
|
||||
)}
|
||||
title="下一张 (→)"
|
||||
>
|
||||
<ChevronRight size={28} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 底部指示点 */}
|
||||
{hasMultipleImages && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 z-10">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentIndex(index);
|
||||
setIsZoomed(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-all duration-150',
|
||||
index === currentIndex
|
||||
? 'bg-white w-6'
|
||||
: 'bg-white/50 hover:bg-white/80'
|
||||
)}
|
||||
title={`第 ${index + 1} 张`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="absolute bottom-14 left-1/2 -translate-x-1/2 text-white/60 text-xs">
|
||||
{hasMultipleImages ? '← → 切换图片 · ESC 关闭 · 点击图片缩放' : 'ESC 关闭 · 点击图片缩放'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/components/ui/Modal.tsx
Normal file
146
src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
/** 是否显示关闭按钮 */
|
||||
showCloseButton?: boolean;
|
||||
/** 点击遮罩是否关闭 */
|
||||
closeOnBackdrop?: boolean;
|
||||
/** 按 ESC 是否关闭 */
|
||||
closeOnEsc?: boolean;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 遮罩类名 */
|
||||
backdropClassName?: string;
|
||||
/** 内容区域最大宽度 */
|
||||
maxWidth?: string;
|
||||
/** 是否全屏 */
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
closeOnBackdrop = true,
|
||||
closeOnEsc = true,
|
||||
className,
|
||||
backdropClassName,
|
||||
maxWidth = '800px',
|
||||
fullScreen = false,
|
||||
}: ModalProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ESC 关闭
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (closeOnEsc && e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[closeOnEsc, onClose]
|
||||
);
|
||||
|
||||
// 绑定键盘事件
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
// 禁止背景滚动
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
// 点击遮罩关闭
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (closeOnBackdrop && e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 flex items-center justify-center',
|
||||
'bg-black/60 backdrop-blur-sm',
|
||||
'animate-fade-in-fast',
|
||||
backdropClassName
|
||||
)}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* 内容区域 */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
'relative bg-[var(--color-bg-primary)] rounded-xl shadow-2xl',
|
||||
'animate-scale-in-fast',
|
||||
fullScreen ? 'w-full h-full rounded-none' : 'max-h-[90vh]',
|
||||
className
|
||||
)}
|
||||
style={!fullScreen ? { maxWidth } : undefined}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'absolute top-3 right-3 z-10',
|
||||
'w-8 h-8 flex items-center justify-center rounded-full',
|
||||
'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]',
|
||||
'hover:bg-[var(--color-bg-hover)] transition-colors duration-150'
|
||||
)}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 添加动画样式到 globals.css
|
||||
// 这里导出需要添加的 CSS
|
||||
export const modalAnimationStyles = `
|
||||
@keyframes fadeInFast {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scaleInFast {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-fast {
|
||||
animation: fadeInFast 0.15s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in-fast {
|
||||
animation: scaleInFast 0.15s ease-out;
|
||||
}
|
||||
`;
|
||||
Loading…
Reference in New Issue
Block a user