新增图片预览

This commit is contained in:
小朱
2025-07-10 23:08:08 +08:00
parent e8b6b696c5
commit e38a69a59a
5 changed files with 457 additions and 44 deletions

View File

@@ -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() {
},
}}
>
<div className="min-h-screen bg-game-gradient">
<Routes>
{/* 公共路由 */}
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<AntdApp>
<div className="min-h-screen bg-game-gradient">
<Routes>
{/* 公共路由 */}
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
{/* 受保护的路由 */}
<Route
path="/*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<PageTransition><HomePage /></PageTransition>} />
<Route path="/terminal" element={<PageTransition><TerminalPage /></PageTransition>} />
<Route path="/instances" element={<PageTransition><InstanceManagerPage /></PageTransition>} />
<Route path="/game-deployment" element={<PageTransition><GameDeploymentPage /></PageTransition>} />
<Route path="/scheduled-tasks" element={<PageTransition><ScheduledTasksPage /></PageTransition>} />
<Route path="/files" element={<PageTransition><FileManagerPage /></PageTransition>} />
<Route path="/settings" element={<PageTransition><SettingsPage /></PageTransition>} />
<Route path="/about" element={<PageTransition><AboutProjectPage /></PageTransition>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
{/* 受保护的路由 */}
<Route
path="/*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<PageTransition><HomePage /></PageTransition>} />
<Route path="/terminal" element={<PageTransition><TerminalPage /></PageTransition>} />
<Route path="/instances" element={<PageTransition><InstanceManagerPage /></PageTransition>} />
<Route path="/game-deployment" element={<PageTransition><GameDeploymentPage /></PageTransition>} />
<Route path="/scheduled-tasks" element={<PageTransition><ScheduledTasksPage /></PageTransition>} />
<Route path="/files" element={<PageTransition><FileManagerPage /></PageTransition>} />
<Route path="/settings" element={<PageTransition><SettingsPage /></PageTransition>} />
<Route path="/about" element={<PageTransition><AboutProjectPage /></PageTransition>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
{/* 全局通知容器 */}
<NotificationContainer />
</div>
{/* 全局通知容器 */}
<NotificationContainer />
</div>
</AntdApp>
</ConfigProvider>
)
}

View File

@@ -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<ImagePreviewProps> = ({
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 (
<Modal
title={
<div className="flex items-center justify-between">
<span className="text-lg font-semibold truncate">{fileName}</span>
<div className="flex items-center gap-2">
<Button
size="small"
onClick={handleZoomOut}
disabled={zoom <= 0.1}
icon={<ZoomOut className="h-4 w-4" />}
/>
<span className="text-sm text-gray-500 min-w-[60px] text-center">
{Math.round(zoom * 100)}%
</span>
<Button
size="small"
onClick={handleZoomIn}
disabled={zoom >= 5}
icon={<ZoomIn className="h-4 w-4" />}
/>
<Button
size="small"
onClick={handleRotate}
icon={<RotateCw className="h-4 w-4" />}
/>
<Button
size="small"
onClick={resetTransform}
>
</Button>
<Button
size="small"
onClick={handleDownload}
icon={<Download className="h-4 w-4" />}
/>
</div>
</div>
}
open={isOpen}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
styles={{ body: { height: '80vh', padding: 0, backgroundColor: '#f5f5f5' } }}
closeIcon={<X className="h-4 w-4" />}
>
<div className="h-full flex items-center justify-center p-4">
{imageError ? (
<div className="text-center text-gray-500">
<div className="text-lg mb-2"></div>
<div className="text-sm"></div>
</div>
) : (
<div
className="relative overflow-hidden max-w-full max-h-full cursor-grab"
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
onWheel={handleWheel}
>
<img
src={imageUrl}
alt={fileName}
className="max-w-none transition-transform duration-200 ease-in-out select-none"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom}) rotate(${rotation}deg)`,
transformOrigin: 'center'
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
draggable={false}
/>
{!imageLoaded && !imageError && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
<div className="text-gray-500">...</div>
</div>
)}
</div>
)}
</div>
</Modal>
)
}
export default ImagePreview

View File

@@ -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 = () => {
)}
</Modal>
{/* 图片预览模态框 */}
<ImagePreview
isOpen={imagePreviewVisible}
onClose={() => setImagePreviewVisible(false)}
imagePath={previewImagePath}
fileName={previewImageName}
/>
{/* 任务状态抽屉 */}
<Drawer
title="任务状态"

View File

@@ -45,6 +45,16 @@ export class FileApiClient {
return response.data.data
}
// 获取图片预览URL
getImagePreviewUrl(path: string): string {
// 将Windows路径转换为Unix风格然后对路径进行编码
// 但不要对整个路径进行encodeURIComponent因为这会编码斜杠
const normalizedPath = path.replace(/\\/g, '/')
const encodedPath = normalizedPath.split('/').map(segment => encodeURIComponent(segment)).join('/')
// 确保返回完整的URL路径
return `${window.location.origin}${API_BASE}/preview?path=${encodedPath}`
}
// 保存文件内容
async saveFile(path: string, content: string, encoding: string = 'utf-8'): Promise<FileOperationResult> {
const response = await this.client.post(`${API_BASE}/save`, {

View File

@@ -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 {