mirror of
https://github.com/GSManagerXZ/GameServerManager.git
synced 2026-07-01 00:54:19 +08:00
新增图片预览
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
265
client/src/components/ImagePreview.tsx
Normal file
265
client/src/components/ImagePreview.tsx
Normal 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
|
||||
@@ -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="任务状态"
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user