From e38a69a59a2049bb25c007679b630a538c232baf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=B0=8F=E6=9C=B1?=
<10714957+xiao-zhu245@user.noreply.gitee.com>
Date: Thu, 10 Jul 2025 23:08:08 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9B=BE=E7=89=87=E9=A2=84?=
=?UTF-8?q?=E8=A7=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
client/src/App.tsx | 78 ++++----
client/src/components/ImagePreview.tsx | 265 +++++++++++++++++++++++++
client/src/pages/FileManagerPage.tsx | 24 ++-
client/src/utils/fileApi.ts | 10 +
server/src/routes/files.ts | 124 +++++++++++-
5 files changed, 457 insertions(+), 44 deletions(-)
create mode 100644 client/src/components/ImagePreview.tsx
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}
+
+ }
+ />
+
+ {Math.round(zoom * 100)}%
+
+
+
+ }
+ open={isOpen}
+ onCancel={onClose}
+ footer={null}
+ width="90%"
+ style={{ top: 20 }}
+ styles={{ body: { height: '80vh', padding: 0, backgroundColor: '#f5f5f5' } }}
+ closeIcon={}
+ >
+
+ {imageError ? (
+
+
无法加载图片
+
图片格式不支持或文件已损坏
+
+ ) : (
+
+

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 {