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