diff --git a/client/src/App.tsx b/client/src/App.tsx index a38be2f..d256074 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react' import { Routes, Route, Navigate } from 'react-router-dom' -import { ConfigProvider, theme } from 'antd' +import { ConfigProvider, theme, App as AntdApp } from 'antd' import { useAuthStore } from '@/stores/authStore' import { useThemeStore } from '@/stores/themeStore' import Layout from '@/components/Layout' @@ -97,44 +97,46 @@ function App() { }, }} > -
- - {/* 公共路由 */} - - - - } - /> + +
+ + {/* 公共路由 */} + + + + } + /> + + {/* 受保护的路由 */} + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + - {/* 受保护的路由 */} - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - } - /> - - - {/* 全局通知容器 */} - -
+ {/* 全局通知容器 */} + +
+ ) } diff --git a/client/src/components/ImagePreview.tsx b/client/src/components/ImagePreview.tsx new file mode 100644 index 0000000..bf8cd3c --- /dev/null +++ b/client/src/components/ImagePreview.tsx @@ -0,0 +1,265 @@ +import React from 'react' +import { Modal, Button } from 'antd' +import { Download, X, ZoomIn, ZoomOut, RotateCw } from 'lucide-react' +import { fileApiClient } from '@/utils/fileApi' + +interface ImagePreviewProps { + isOpen: boolean + onClose: () => void + imagePath: string + fileName: string +} + +export const ImagePreview: React.FC = ({ + isOpen, + onClose, + imagePath, + fileName +}) => { + const [zoom, setZoom] = React.useState(1) + const [rotation, setRotation] = React.useState(0) + const [imageLoaded, setImageLoaded] = React.useState(false) + const [imageError, setImageError] = React.useState(false) + const [imageUrl, setImageUrl] = React.useState('') + const [position, setPosition] = React.useState({ x: 0, y: 0 }) + const [isDragging, setIsDragging] = React.useState(false) + const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 }) + const [lastPosition, setLastPosition] = React.useState({ x: 0, y: 0 }) + + // 加载图片数据 + React.useEffect(() => { + if (!imagePath || !isOpen) { + setImageUrl('') + return + } + + const loadImage = async () => { + try { + const token = localStorage.getItem('gsm3_token') + const url = fileApiClient.getImagePreviewUrl(imagePath) + + const response = await fetch(url, { + headers: { + 'Authorization': token ? `Bearer ${token}` : '' + } + }) + + if (!response.ok) { + throw new Error(`Failed to load image: ${response.status} ${response.statusText}`) + } + + const blob = await response.blob() + + if (blob.size === 0) { + throw new Error('Empty response received') + } + + const objectUrl = URL.createObjectURL(blob) + setImageUrl(objectUrl) + setImageError(false) + } catch (error) { + console.error('Error loading image:', error) + setImageError(true) + } + } + + loadImage() + + // 清理函数 + return () => { + if (imageUrl && imageUrl.startsWith('blob:')) { + URL.revokeObjectURL(imageUrl) + } + } + }, [imagePath, isOpen]) + + const handleDownload = () => { + fileApiClient.downloadFile(imagePath) + } + + const handleZoomIn = () => { + setZoom(prev => Math.min(prev * 1.2, 5)) + } + + const handleZoomOut = () => { + setZoom(prev => Math.max(prev / 1.2, 0.1)) + } + + const handleRotate = () => { + setRotation(prev => (prev + 90) % 360) + } + + const resetTransform = () => { + setZoom(1) + setRotation(0) + setPosition({ x: 0, y: 0 }) + } + + // 鼠标按下开始拖拽 + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault() + setIsDragging(true) + setDragStart({ x: e.clientX, y: e.clientY }) + setLastPosition(position) + } + + // 鼠标移动拖拽 + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging) return + e.preventDefault() + + const deltaX = e.clientX - dragStart.x + const deltaY = e.clientY - dragStart.y + + setPosition({ + x: lastPosition.x + deltaX, + y: lastPosition.y + deltaY + }) + } + + // 鼠标抬起结束拖拽 + const handleMouseUp = () => { + setIsDragging(false) + } + + // 滚轮缩放 + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault() + const delta = e.deltaY > 0 ? 0.9 : 1.1 + setZoom(prev => Math.min(Math.max(prev * delta, 0.1), 5)) + } + + // 全局鼠标事件监听 + React.useEffect(() => { + const handleGlobalMouseMove = (e: MouseEvent) => { + if (!isDragging) return + + const deltaX = e.clientX - dragStart.x + const deltaY = e.clientY - dragStart.y + + setPosition({ + x: lastPosition.x + deltaX, + y: lastPosition.y + deltaY + }) + } + + const handleGlobalMouseUp = () => { + setIsDragging(false) + } + + if (isDragging) { + document.addEventListener('mousemove', handleGlobalMouseMove) + document.addEventListener('mouseup', handleGlobalMouseUp) + } + + return () => { + document.removeEventListener('mousemove', handleGlobalMouseMove) + document.removeEventListener('mouseup', handleGlobalMouseUp) + } + }, [isDragging, dragStart, lastPosition]) + + // 重置状态当对话框关闭时 + React.useEffect(() => { + if (!isOpen) { + setZoom(1) + setRotation(0) + setPosition({ x: 0, y: 0 }) + setIsDragging(false) + setImageLoaded(false) + setImageError(false) + // 清理blob URL + if (imageUrl && imageUrl.startsWith('blob:')) { + URL.revokeObjectURL(imageUrl) + } + setImageUrl('') + } + }, [isOpen, imageUrl]) + + return ( + + {fileName} +
+ +
+ + } + open={isOpen} + onCancel={onClose} + footer={null} + width="90%" + style={{ top: 20 }} + styles={{ body: { height: '80vh', padding: 0, backgroundColor: '#f5f5f5' } }} + closeIcon={} + > +
+ {imageError ? ( +
+
无法加载图片
+
图片格式不支持或文件已损坏
+
+ ) : ( +
+ {fileName} setImageLoaded(true)} + onError={() => setImageError(true)} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + draggable={false} + /> + {!imageLoaded && !imageError && ( +
+
加载中...
+
+ )} +
+ )} +
+
+ ) +} + +export default ImagePreview \ No newline at end of file diff --git a/client/src/pages/FileManagerPage.tsx b/client/src/pages/FileManagerPage.tsx index 5e79f8b..b54e236 100644 --- a/client/src/pages/FileManagerPage.tsx +++ b/client/src/pages/FileManagerPage.tsx @@ -51,9 +51,10 @@ import { } from '@/components/FileDialogs' import { CompressDialog } from '@/components/CompressDialog' import { MonacoEditor } from '@/components/MonacoEditor' +import { ImagePreview } from '@/components/ImagePreview' import { FileItem } from '@/types/file' import { fileApiClient } from '@/utils/fileApi' -import { isTextFile } from '@/utils/format' +import { isTextFile, isImageFile } from '@/utils/format' import { normalizePath, getDirectoryPath, getBasename } from '@/utils/pathUtils' const { TabPane } = Tabs @@ -136,6 +137,11 @@ const FileManagerPage: React.FC = () => { // 编辑器模态框 const [editorModalVisible, setEditorModalVisible] = useState(false) + // 图片预览模态框 + const [imagePreviewVisible, setImagePreviewVisible] = useState(false) + const [previewImagePath, setPreviewImagePath] = useState('') + const [previewImageName, setPreviewImageName] = useState('') + // 任务状态抽屉 const [taskDrawerVisible, setTaskDrawerVisible] = useState(false) @@ -323,6 +329,10 @@ const FileManagerPage: React.FC = () => { } else if (isTextFile(file.name)) { openFile(file.path) setEditorModalVisible(true) + } else if (isImageFile(file.name)) { + setPreviewImagePath(file.path) + setPreviewImageName(file.name) + setImagePreviewVisible(true) } else { // 非文本文件,提示下载 message.info('该文件类型不支持在线编辑,请下载查看') @@ -401,6 +411,10 @@ const FileManagerPage: React.FC = () => { if (isTextFile(file.name)) { openFile(file.path) setEditorModalVisible(true) + } else if (isImageFile(file.name)) { + setPreviewImagePath(file.path) + setPreviewImageName(file.name) + setImagePreviewVisible(true) } else { message.info('该文件类型不支持预览') } @@ -937,6 +951,14 @@ const FileManagerPage: React.FC = () => { )} + {/* 图片预览模态框 */} + setImagePreviewVisible(false)} + imagePath={previewImagePath} + fileName={previewImageName} + /> + {/* 任务状态抽屉 */} encodeURIComponent(segment)).join('/') + // 确保返回完整的URL路径 + return `${window.location.origin}${API_BASE}/preview?path=${encodedPath}` + } + // 保存文件内容 async saveFile(path: string, content: string, encoding: string = 'utf-8'): Promise { const response = await this.client.post(`${API_BASE}/save`, { diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 99bab10..3cd1b38 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -77,7 +77,10 @@ const isValidPath = (filePath: string): boolean => { return false } - const normalizedPath = path.normalize(filePath) + // 先解码URL编码的路径 + const decodedPath = decodeURIComponent(filePath) + + const normalizedPath = path.normalize(decodedPath) // 检查是否包含危险的路径遍历 if (normalizedPath.includes('..')) { @@ -136,10 +139,16 @@ router.get('/list', authenticateToken, async (req: Request, res: Response) => { data: files }) } catch (error: any) { - res.status(500).json({ - status: 'error', - message: error.message - }) + console.error('Preview request - Error occurred:', error) + console.error('Preview request - Error stack:', error.stack) + + // 确保响应还没有发送 + if (!res.headersSent) { + res.status(500).json({ + status: 'error', + message: error.message || '文件预览失败' + }) + } } }) @@ -182,6 +191,111 @@ router.get('/read', authenticateToken, async (req: Request, res: Response) => { } }) +// 预览图片文件 +router.get('/preview', authenticateToken, async (req: Request, res: Response) => { + try { + const { path: filePath } = req.query + + console.log('Preview request - Original path:', filePath) + + if (!filePath) { + return res.status(400).json({ + status: 'error', + message: '缺少文件路径' + }) + } + + const decodedFilePath = decodeURIComponent(filePath as string) + console.log('Preview request - Decoded path:', decodedFilePath) + + // 处理相对路径,转换为基于工作目录的绝对路径 + let absoluteFilePath: string + if (path.isAbsolute(decodedFilePath)) { + absoluteFilePath = decodedFilePath + } else { + // 相对路径,基于工作目录解析 + absoluteFilePath = path.resolve(process.cwd(), decodedFilePath) + } + + console.log('Preview request - Absolute path:', absoluteFilePath) + + // 基本安全检查:确保路径不包含危险的遍历 + const normalizedPath = path.normalize(absoluteFilePath) + if (normalizedPath.includes('..')) { + console.log('Preview request - Path contains dangerous traversal') + return res.status(400).json({ + status: 'error', + message: '无效的路径' + }) + } + + console.log('Preview request - Checking file stats for:', absoluteFilePath) + + // 检查文件是否存在 + try { + await fs.access(absoluteFilePath) + } catch (accessError) { + return res.status(404).json({ + status: 'error', + message: '文件不存在或无法访问' + }) + } + + const stats = await fs.stat(absoluteFilePath) + + if (!stats.isFile()) { + return res.status(400).json({ + status: 'error', + message: '指定路径不是文件' + }) + } + + // 获取文件扩展名并设置对应的Content-Type + const ext = path.extname(absoluteFilePath).toLowerCase() + const mimeTypes: { [key: string]: string } = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon' + } + + const contentType = mimeTypes[ext] || 'application/octet-stream' + + console.log('Preview request - Reading file:', absoluteFilePath) + console.log('Preview request - Content type:', contentType) + console.log('Preview request - File extension:', ext) + + // 读取文件为Buffer + const fileBuffer = await fs.readFile(absoluteFilePath) + + console.log('Preview request - File buffer length:', fileBuffer.length) + console.log('Preview request - Buffer first 10 bytes:', fileBuffer.slice(0, 10)) + + // 设置响应头 + res.setHeader('Content-Type', contentType) + res.setHeader('Content-Length', fileBuffer.length) + res.setHeader('Cache-Control', 'public, max-age=3600') // 缓存1小时 + + console.log('Preview request - Sending response with headers:', { + 'Content-Type': contentType, + 'Content-Length': fileBuffer.length + }) + + // 发送文件数据 + res.send(fileBuffer) + console.log('Preview request - Response sent successfully') + } catch (error: any) { + res.status(500).json({ + status: 'error', + message: error.message + }) + } +}) + // 创建文件 router.post('/create', authenticateToken, async (req: Request, res: Response) => { try {