From cb01e2dffb50f0dcdef83e6531f4daa296af85af Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Sat, 20 Dec 2025 12:13:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(UI):=20=E6=B7=BB=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E6=80=81=E6=A1=86=E3=80=81=E5=9B=BE=E7=89=87=E7=81=AF=E7=AE=B1?= =?UTF-8?q?=E5=92=8C=E6=96=87=E6=A1=A3=E9=A2=84=E8=A7=88=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modal 组件: - 支持 ESC 键关闭和点击遮罩关闭 - 可配置关闭按钮显示、最大宽度、全屏模式 - 添加淡入和缩放动画效果 ImageLightbox 组件: - 支持多图片浏览和左右键导航 - 实现缩放、下载功能 - 支持触摸手势滑动切换 - 底部指示点和图片计数显示 DocumentPreview 组件: - 支持代码文件语法高亮显示 - Markdown 文件渲染预览 - 提供复制和下载功能 --- src/components/ui/DocumentPreview.tsx | 332 ++++++++++++++++++++++++++ src/components/ui/ImageLightbox.tsx | 297 +++++++++++++++++++++++ src/components/ui/Modal.tsx | 146 +++++++++++ 3 files changed, 775 insertions(+) create mode 100644 src/components/ui/DocumentPreview.tsx create mode 100644 src/components/ui/ImageLightbox.tsx create mode 100644 src/components/ui/Modal.tsx diff --git a/src/components/ui/DocumentPreview.tsx b/src/components/ui/DocumentPreview.tsx new file mode 100644 index 0000000..c334c63 --- /dev/null +++ b/src/components/ui/DocumentPreview.tsx @@ -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 = { + // 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 ( +
+ {/* 内容区域 */} +
e.stopPropagation()} + > + {/* 头部 */} +
+ {/* 文件信息 */} +
+
+ +
+
+

+ {documentData.name} +

+

+ {formatFileSize(documentData.size)} · {language.toUpperCase()} +

+
+
+ + {/* 操作按钮 */} +
+ {/* 复制按钮(PDF 不支持复制) */} + {fileType !== 'pdf' && ( + + )} + + {/* 下载按钮 */} + + + {/* 关闭按钮 */} + +
+
+ + {/* 内容区域 */} +
+ {fileType === 'code' && ( +
+ +
+ )} + + {fileType === 'markdown' && ( +
+ +
+ )} + + {fileType === 'text' && ( +
+
+                {documentData.content}
+              
+
+ )} + + {fileType === 'pdf' && ( +
+
+ +

+ PDF 预览暂不支持 +

+

+ 请点击下载按钮查看文件 +

+
+
+ )} +
+ + {/* 底部提示 */} +
+

+ ESC 关闭 · 点击外部区域关闭 +

+
+
+
+ ); +} diff --git a/src/components/ui/ImageLightbox.tsx b/src/components/ui/ImageLightbox.tsx new file mode 100644 index 0000000..518fe03 --- /dev/null +++ b/src/components/ui/ImageLightbox.tsx @@ -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(null); + + // 触摸手势相关 + const touchStartX = useRef(0); + const touchEndX = useRef(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 ( +
+ {/* 顶部工具栏 */} +
+ {/* 左侧:图片计数 */} +
+ {hasMultipleImages && ( + {currentIndex + 1} / {images.length} + )} +
+ + {/* 右侧:操作按钮 */} +
+ {/* 缩放按钮 */} + + + {/* 下载按钮 */} + + + {/* 关闭按钮 */} + +
+
+ + {/* 图片容器 */} +
{ + if (isZoomed) { + e.stopPropagation(); + setIsZoomed(false); + } + }} + > + {`图片 { + e.stopPropagation(); + toggleZoom(); + }} + draggable={false} + /> +
+ + {/* 左侧导航按钮 */} + {hasMultipleImages && ( + + )} + + {/* 右侧导航按钮 */} + {hasMultipleImages && ( + + )} + + {/* 底部指示点 */} + {hasMultipleImages && ( +
+ {images.map((_, index) => ( +
+ )} + + {/* 底部提示 */} +
+ {hasMultipleImages ? '← → 切换图片 · ESC 关闭 · 点击图片缩放' : 'ESC 关闭 · 点击图片缩放'} +
+
+ ); +} diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 0000000..172f8fc --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -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(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 ( +
+ {/* 内容区域 */} +
e.stopPropagation()} + > + {/* 关闭按钮 */} + {showCloseButton && ( + + )} + + {children} +
+
+ ); +} + +// 添加动画样式到 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; +} +`;