feat(UI): 添加模态框、图片灯箱和文档预览组件

Modal 组件:
- 支持 ESC 键关闭和点击遮罩关闭
- 可配置关闭按钮显示、最大宽度、全屏模式
- 添加淡入和缩放动画效果

ImageLightbox 组件:
- 支持多图片浏览和左右键导航
- 实现缩放、下载功能
- 支持触摸手势滑动切换
- 底部指示点和图片计数显示

DocumentPreview 组件:
- 支持代码文件语法高亮显示
- Markdown 文件渲染预览
- 提供复制和下载功能
This commit is contained in:
gaoziman 2025-12-20 12:13:15 +08:00
parent e99c72f02a
commit cb01e2dffb
3 changed files with 775 additions and 0 deletions

View 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>
);
}

View 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
View 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;
}
`;