mirror of
https://github.com/GSManagerXZ/GameServerManager.git
synced 2026-05-31 09:59:40 +08:00
初始
This commit is contained in:
125
.gitignore
vendored
Normal file
125
.gitignore
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
panel/public/
|
||||
*.code-workspace
|
||||
data/
|
||||
dist/
|
||||
out/
|
||||
lib/
|
||||
production/
|
||||
.DS_Store
|
||||
production-code/
|
||||
test.js
|
||||
.idea/
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
error
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
workspace.code-workspace
|
||||
src/public/1.png
|
||||
|
||||
# IntelliJ Idea project files
|
||||
/.idea/*
|
||||
*/.idea/*
|
||||
|
||||
.turbo
|
||||
参考文件/
|
||||
12
README.md
Normal file
12
README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
使用React框架写一个游戏面板,后端为node.js 目前只需要三个页面即可 首页 终端(需要实现功能) 设置 要同时兼容Windows和Linux
|
||||
目前后端已经存在了,你需要写一个美观漂亮并且符合游戏风格的面板
|
||||
要支持全局深色和浅色模式更改
|
||||
# 配置文件
|
||||
统一保存在 server/data 目录下
|
||||
|
||||
# 终端
|
||||
需要做成拓展性很强,因为在后续功能需要调用此终端 需要做到灵活调用,并且终端需要在刷新网页时仍然为刷新网页前的状态和所有命令记录,由于后端pty已经是一个编译好的模块可以直接通过进程获取具体信息 具体你可以查看后端代码
|
||||
要支持终端的所有交互
|
||||
|
||||
# 登录
|
||||
需要实现用户登录,jwt密钥不要采用硬编 而是通过每次启动后随机生成到配置文件
|
||||
102
client/index.html
Normal file
102
client/index.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GSM3 游戏面板</title>
|
||||
<meta name="description" content="GSM3 游戏服务器管理面板 - 支持Steam等游戏一键部署" />
|
||||
|
||||
<!-- 游戏风格字体 -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Exo+2:wght@300;400;500;600;700&family=Rajdhani:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
/* 防止页面闪烁 */
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Exo 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.dark .loading {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5121
client/package-lock.json
generated
Normal file
5121
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
client/package.json
Normal file
43
client/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "gsm3-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"zustand": "^4.4.7",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwind-merge": "^2.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
103
client/src/App.tsx
Normal file
103
client/src/App.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import Layout from '@/components/Layout'
|
||||
import LoginPage from '@/pages/LoginPage'
|
||||
import HomePage from '@/pages/HomePage'
|
||||
import TerminalPage from '@/pages/TerminalPage'
|
||||
import SettingsPage from '@/pages/SettingsPage'
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
import NotificationContainer from '@/components/NotificationContainer'
|
||||
|
||||
// 受保护的路由组件
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useAuthStore()
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// 公共路由组件(已登录用户重定向到首页)
|
||||
const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useAuthStore()
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { verifyToken, setLoading } = useAuthStore()
|
||||
const { initTheme } = useThemeStore()
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化主题
|
||||
initTheme()
|
||||
|
||||
// 验证token
|
||||
const initAuth = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await verifyToken()
|
||||
} catch (error) {
|
||||
console.error('Token验证失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
initAuth()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<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={<HomePage />} />
|
||||
<Route path="/terminal" element={<TerminalPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
|
||||
{/* 全局通知容器 */}
|
||||
<NotificationContainer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
173
client/src/components/Layout.tsx
Normal file
173
client/src/components/Layout.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import {
|
||||
Home,
|
||||
Terminal,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Sun,
|
||||
Moon,
|
||||
User,
|
||||
Gamepad2
|
||||
} from 'lucide-react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { theme, toggleTheme } = useThemeStore()
|
||||
|
||||
const navigation = [
|
||||
{ name: '首页', href: '/', icon: Home },
|
||||
{ name: '终端', href: '/terminal', icon: Terminal },
|
||||
{ name: '设置', href: '/settings', icon: Settings },
|
||||
]
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-game-gradient">
|
||||
{/* 移动端侧边栏遮罩 */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 w-64 transform transition-transform duration-300 ease-in-out lg:translate-x-0
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<div className="flex h-full flex-col glass border-r border-white/20 dark:border-gray-700/30">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between px-6 border-b border-white/20 dark:border-gray-700/30">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Gamepad2 className="w-8 h-8 text-blue-500" />
|
||||
<span className="text-xl font-bold font-game neon-text">
|
||||
GSM3
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="lg:hidden text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<nav className="flex-1 px-4 py-6 space-y-2">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.href
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`
|
||||
flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200
|
||||
${isActive
|
||||
? 'bg-blue-600/20 text-blue-400 border border-blue-500/30 shadow-lg'
|
||||
: 'text-gray-300 hover:bg-white/10 hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.name}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 用户信息和操作 */}
|
||||
<div className="border-t border-white/20 dark:border-gray-700/30 p-4 space-y-4">
|
||||
{/* 主题切换 */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center space-x-3 w-full px-4 py-2 text-gray-300 hover:bg-white/10 hover:text-white rounded-lg transition-all duration-200"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{theme === 'dark' ? '浅色模式' : '深色模式'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 用户信息 */}
|
||||
<div className="flex items-center space-x-3 px-4 py-2 bg-white/5 rounded-lg">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{user?.username}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{user?.role === 'admin' ? '管理员' : '用户'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-3 w-full px-4 py-2 text-red-400 hover:bg-red-500/10 hover:text-red-300 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="font-medium">登出</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className="lg:pl-64">
|
||||
{/* 顶部栏 */}
|
||||
<div className="sticky top-0 z-30 flex h-16 items-center justify-between px-6 glass border-b border-white/20 dark:border-gray-700/30">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="lg:hidden text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-xl font-semibold text-white font-display">
|
||||
{navigation.find(item => item.href === location.pathname)?.name || 'GSM3 游戏面板'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 连接状态指示器 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-gray-300">已连接</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页面内容 */}
|
||||
<main className="p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
33
client/src/components/LoadingSpinner.tsx
Normal file
33
client/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
text?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'md',
|
||||
text = '加载中...',
|
||||
className = ''
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center space-y-4 ${className}`}>
|
||||
<Loader2 className={`${sizeClasses[size]} animate-spin text-blue-500`} />
|
||||
{text && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 font-medium">
|
||||
{text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingSpinner
|
||||
75
client/src/components/NotificationContainer.tsx
Normal file
75
client/src/components/NotificationContainer.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
import { useNotificationStore } from '@/stores/notificationStore'
|
||||
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
|
||||
const NotificationContainer: React.FC = () => {
|
||||
const { notifications, removeNotification } = useNotificationStore()
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-5 h-5 text-green-500" />
|
||||
case 'error':
|
||||
return <AlertCircle className="w-5 h-5 text-red-500" />
|
||||
case 'warning':
|
||||
return <AlertTriangle className="w-5 h-5 text-yellow-500" />
|
||||
case 'info':
|
||||
default:
|
||||
return <Info className="w-5 h-5 text-blue-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getBackgroundColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
case 'error':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
|
||||
case 'info':
|
||||
default:
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
}
|
||||
}
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2 max-w-sm">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`
|
||||
${getBackgroundColor(notification.type)}
|
||||
border rounded-lg p-4 shadow-lg backdrop-blur-sm
|
||||
transform transition-all duration-300 ease-in-out
|
||||
hover:scale-105
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
{getIcon(notification.type)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{notification.title}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationContainer
|
||||
171
client/src/index.css
Normal file
171
client/src/index.css
Normal file
@@ -0,0 +1,171 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 游戏风格自定义样式 */
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-gray-200 dark:border-gray-800;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* 游戏风格按钮 */
|
||||
.btn-game {
|
||||
@apply relative overflow-hidden px-6 py-3 font-semibold text-white transition-all duration-300;
|
||||
@apply bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700;
|
||||
@apply border border-blue-500/30 rounded-lg shadow-lg hover:shadow-xl;
|
||||
@apply before:absolute before:inset-0 before:bg-gradient-to-r before:from-white/20 before:to-transparent;
|
||||
@apply before:translate-x-[-100%] hover:before:translate-x-[100%] before:transition-transform before:duration-700;
|
||||
}
|
||||
|
||||
.btn-game:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
/* 游戏风格卡片 */
|
||||
.card-game {
|
||||
@apply bg-white/10 dark:bg-gray-800/50 backdrop-blur-sm border border-white/20 dark:border-gray-700/50;
|
||||
@apply rounded-xl shadow-xl hover:shadow-2xl transition-all duration-300;
|
||||
@apply hover:bg-white/15 dark:hover:bg-gray-800/70;
|
||||
}
|
||||
|
||||
/* 霓虹效果 */
|
||||
.neon-text {
|
||||
@apply text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500;
|
||||
text-shadow: 0 0 10px rgba(6, 182, 212, 0.5);
|
||||
}
|
||||
|
||||
.neon-border {
|
||||
@apply border border-cyan-400/50 shadow-[0_0_15px_rgba(6,182,212,0.3)];
|
||||
}
|
||||
|
||||
/* 终端样式 */
|
||||
.terminal-container {
|
||||
@apply bg-gray-900 border border-gray-700 rounded-lg overflow-hidden;
|
||||
@apply shadow-2xl shadow-black/50;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
@apply bg-gray-800 px-4 py-2 flex items-center justify-between;
|
||||
@apply border-b border-gray-700;
|
||||
}
|
||||
|
||||
.terminal-dots {
|
||||
@apply flex space-x-2;
|
||||
}
|
||||
|
||||
.terminal-dot {
|
||||
@apply w-3 h-3 rounded-full;
|
||||
}
|
||||
|
||||
.terminal-dot.red {
|
||||
@apply bg-red-500;
|
||||
}
|
||||
|
||||
.terminal-dot.yellow {
|
||||
@apply bg-yellow-500;
|
||||
}
|
||||
|
||||
.terminal-dot.green {
|
||||
@apply bg-green-500;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
@apply animate-spin rounded-full border-2 border-gray-300 border-t-blue-600;
|
||||
}
|
||||
|
||||
/* 状态指示器 */
|
||||
.status-indicator {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.status-indicator.online {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400;
|
||||
}
|
||||
|
||||
.status-indicator.offline {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400;
|
||||
}
|
||||
|
||||
.status-indicator.warning {
|
||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400;
|
||||
}
|
||||
|
||||
/* 渐变背景 */
|
||||
.bg-game-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.dark .bg-game-gradient {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
}
|
||||
|
||||
/* 玻璃态效果 */
|
||||
.glass {
|
||||
@apply bg-white/10 dark:bg-gray-900/20 backdrop-blur-md border border-white/20 dark:border-gray-700/30;
|
||||
}
|
||||
|
||||
/* 悬浮效果 */
|
||||
.hover-lift {
|
||||
@apply transition-transform duration-300 hover:-translate-y-1 hover:shadow-xl;
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义动画 */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.8), 0 0 30px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 终端字体 */
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
/* 游戏字体 */
|
||||
.font-game {
|
||||
font-family: 'Orbitron', 'Exo 2', sans-serif;
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: 'Rajdhani', 'Exo 2', sans-serif;
|
||||
}
|
||||
18
client/src/main.tsx
Normal file
18
client/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
// 初始化主题
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
const { initTheme } = useThemeStore.getState()
|
||||
initTheme()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
287
client/src/pages/HomePage.tsx
Normal file
287
client/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { SystemStats, SystemInfo } from '@/types'
|
||||
import socketClient from '@/utils/socket'
|
||||
import apiClient from '@/utils/api'
|
||||
import {
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
Network,
|
||||
Clock,
|
||||
Server,
|
||||
Activity,
|
||||
Zap,
|
||||
Users,
|
||||
Terminal as TerminalIcon
|
||||
} from 'lucide-react'
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const { user } = useAuthStore()
|
||||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null)
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||
const [connected, setConnected] = useState(socketClient.isConnected())
|
||||
|
||||
useEffect(() => {
|
||||
// 获取系统信息
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await apiClient.getSystemInfo()
|
||||
if (response.success) {
|
||||
setSystemInfo(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSystemInfo()
|
||||
|
||||
// 设置初始连接状态
|
||||
setConnected(socketClient.isConnected())
|
||||
|
||||
// 监听Socket连接状态
|
||||
socketClient.on('connection-status', ({ connected }) => {
|
||||
setConnected(connected)
|
||||
})
|
||||
|
||||
// 监听系统状态更新
|
||||
socketClient.on('system-stats', (stats: SystemStats) => {
|
||||
setSystemStats(stats)
|
||||
})
|
||||
|
||||
// 订阅系统状态
|
||||
socketClient.subscribeSystemStats()
|
||||
|
||||
return () => {
|
||||
socketClient.off('connection-status')
|
||||
socketClient.off('system-stats')
|
||||
socketClient.emit('unsubscribe-system-stats')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours}小时 ${minutes}分钟`
|
||||
} else if (hours > 0) {
|
||||
return `${hours}小时 ${minutes}分钟`
|
||||
} else {
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
}
|
||||
|
||||
const getUsageColor = (usage: number) => {
|
||||
if (usage >= 90) return 'text-red-500'
|
||||
if (usage >= 70) return 'text-yellow-500'
|
||||
return 'text-green-500'
|
||||
}
|
||||
|
||||
const getUsageBgColor = (usage: number) => {
|
||||
if (usage >= 90) return 'bg-red-500'
|
||||
if (usage >= 70) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 欢迎信息 */}
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white font-display mb-2">
|
||||
欢迎回来,{user?.username}!
|
||||
</h2>
|
||||
<p className="text-gray-300">
|
||||
GSM3 游戏服务器管理面板 - 让游戏服务器管理变得简单
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-3 h-3 rounded-full ${connected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></div>
|
||||
<span className="text-sm text-gray-300">
|
||||
{connected ? '已连接' : '连接中断'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统信息卡片 */}
|
||||
{systemInfo && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Server className="w-8 h-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">操作系统</p>
|
||||
<p className="text-lg font-semibold text-white">{systemInfo.platform}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Cpu className="w-8 h-8 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">架构</p>
|
||||
<p className="text-lg font-semibold text-white">{systemInfo.arch}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Network className="w-8 h-8 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">主机名</p>
|
||||
<p className="text-lg font-semibold text-white">{systemInfo.hostname}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Zap className="w-8 h-8 text-yellow-500" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Node.js</p>
|
||||
<p className="text-lg font-semibold text-white">{systemInfo.nodeVersion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 系统状态 */}
|
||||
{systemStats && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* CPU使用率 */}
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Cpu className="w-6 h-6 text-blue-500" />
|
||||
<h3 className="text-lg font-semibold text-white">CPU使用率</h3>
|
||||
</div>
|
||||
<span className={`text-2xl font-bold ${getUsageColor(systemStats.cpu.usage)}`}>
|
||||
{systemStats.cpu.usage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-400">
|
||||
<span>核心数: {systemStats.cpu.cores}</span>
|
||||
<span>型号: {systemStats.cpu.model}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getUsageBgColor(systemStats.cpu.usage)}`}
|
||||
style={{ width: `${systemStats.cpu.usage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内存使用率 */}
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<MemoryStick className="w-6 h-6 text-green-500" />
|
||||
<h3 className="text-lg font-semibold text-white">内存使用率</h3>
|
||||
</div>
|
||||
<span className={`text-2xl font-bold ${getUsageColor(systemStats.memory.usage)}`}>
|
||||
{systemStats.memory.usage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-400">
|
||||
<span>已用: {formatBytes(systemStats.memory.used)}</span>
|
||||
<span>总计: {formatBytes(systemStats.memory.total)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getUsageBgColor(systemStats.memory.usage)}`}
|
||||
style={{ width: `${systemStats.memory.usage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 磁盘使用率 */}
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<HardDrive className="w-6 h-6 text-purple-500" />
|
||||
<h3 className="text-lg font-semibold text-white">磁盘使用率</h3>
|
||||
</div>
|
||||
<span className={`text-2xl font-bold ${getUsageColor(systemStats.disk.usage)}`}>
|
||||
{systemStats.disk.usage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-400">
|
||||
<span>已用: {formatBytes(systemStats.disk.used)}</span>
|
||||
<span>总计: {formatBytes(systemStats.disk.total)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getUsageBgColor(systemStats.disk.usage)}`}
|
||||
style={{ width: `${systemStats.disk.usage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统运行时间 */}
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Clock className="w-6 h-6 text-yellow-500" />
|
||||
<h3 className="text-lg font-semibold text-white">系统运行时间</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{formatUptime(systemStats.uptime)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
最后更新: {new Date(systemStats.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 快速操作 */}
|
||||
<div className="card-game p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center space-x-2">
|
||||
<Activity className="w-5 h-5" />
|
||||
<span>快速操作</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button className="btn-game flex items-center justify-center space-x-2 py-4">
|
||||
<TerminalIcon className="w-5 h-5" />
|
||||
<span>打开终端</span>
|
||||
</button>
|
||||
<button className="btn-game flex items-center justify-center space-x-2 py-4">
|
||||
<Server className="w-5 h-5" />
|
||||
<span>服务器状态</span>
|
||||
</button>
|
||||
<button className="btn-game flex items-center justify-center space-x-2 py-4">
|
||||
<Users className="w-5 h-5" />
|
||||
<span>用户管理</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
165
client/src/pages/LoginPage.tsx
Normal file
165
client/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useNotificationStore } from '@/stores/notificationStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { Eye, EyeOff, Gamepad2, Sun, Moon, Loader2 } from 'lucide-react'
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const { login, loading, error } = useAuthStore()
|
||||
const { addNotification } = useNotificationStore()
|
||||
const { theme, toggleTheme } = useThemeStore()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!username.trim() || !password.trim()) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '输入错误',
|
||||
message: '请输入用户名和密码'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await login({ username: username.trim(), password })
|
||||
|
||||
if (result.success) {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '登录成功',
|
||||
message: '欢迎回来!'
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '登录失败',
|
||||
message: result.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-game-gradient p-4">
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="fixed top-4 right-4 p-3 glass rounded-full text-white hover:bg-white/20 transition-all duration-200"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo和标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-4 glass rounded-full">
|
||||
<Gamepad2 className="w-12 h-12 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-game neon-text mb-2">
|
||||
GSM3
|
||||
</h1>
|
||||
<p className="text-gray-300 font-display">
|
||||
游戏服务器管理面板
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<div className="card-game p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 用户名输入 */}
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-200 mb-2">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="
|
||||
w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg
|
||||
text-white placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
transition-all duration-200
|
||||
"
|
||||
placeholder="请输入用户名"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 密码输入 */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-200 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="
|
||||
w-full px-4 py-3 pr-12 bg-white/10 border border-white/20 rounded-lg
|
||||
text-white placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
transition-all duration-200
|
||||
"
|
||||
placeholder="请输入密码"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="
|
||||
w-full btn-game py-3 font-semibold
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center justify-center space-x-2
|
||||
"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>登录中...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>登录</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-400">
|
||||
GSM3 游戏服务器管理面板 v1.0.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
486
client/src/pages/SettingsPage.tsx
Normal file
486
client/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useNotificationStore } from '@/stores/notificationStore'
|
||||
import {
|
||||
Settings,
|
||||
Palette,
|
||||
Bell,
|
||||
Terminal,
|
||||
Shield,
|
||||
User,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Check
|
||||
} from 'lucide-react'
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { theme, toggleTheme } = useThemeStore()
|
||||
const { user, changePassword } = useAuthStore()
|
||||
const { addNotification } = useNotificationStore()
|
||||
|
||||
// 密码修改状态
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
showOldPassword: false,
|
||||
showNewPassword: false,
|
||||
showConfirmPassword: false
|
||||
})
|
||||
const [passwordLoading, setPasswordLoading] = useState(false)
|
||||
|
||||
// 终端设置
|
||||
const [terminalSettings, setTerminalSettings] = useState({
|
||||
fontSize: 14,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
theme: 'dark',
|
||||
cursorBlink: true,
|
||||
scrollback: 1000
|
||||
})
|
||||
|
||||
// 通知设置
|
||||
const [notificationSettings, setNotificationSettings] = useState({
|
||||
desktop: true,
|
||||
sound: true,
|
||||
system: true,
|
||||
games: true
|
||||
})
|
||||
|
||||
// 处理密码修改
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '密码不匹配',
|
||||
message: '新密码和确认密码不一致'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.newPassword.length < 6) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '密码太短',
|
||||
message: '新密码至少需要6个字符'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setPasswordLoading(true)
|
||||
|
||||
try {
|
||||
const result = await changePassword(passwordForm.oldPassword, passwordForm.newPassword)
|
||||
|
||||
if (result.success) {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '密码修改成功',
|
||||
message: '您的密码已成功更新'
|
||||
})
|
||||
|
||||
setPasswordForm({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
showOldPassword: false,
|
||||
showNewPassword: false,
|
||||
showConfirmPassword: false
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '密码修改失败',
|
||||
message: result.message
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '修改失败',
|
||||
message: '网络错误,请稍后重试'
|
||||
})
|
||||
} finally {
|
||||
setPasswordLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
const saveSettings = () => {
|
||||
// 这里可以调用API保存设置到后端
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '设置已保存',
|
||||
message: '您的设置已成功保存'
|
||||
})
|
||||
}
|
||||
|
||||
// 重置设置
|
||||
const resetSettings = () => {
|
||||
setTerminalSettings({
|
||||
fontSize: 14,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
theme: 'dark',
|
||||
cursorBlink: true,
|
||||
scrollback: 1000
|
||||
})
|
||||
|
||||
setNotificationSettings({
|
||||
desktop: true,
|
||||
sound: true,
|
||||
system: true,
|
||||
games: true
|
||||
})
|
||||
|
||||
addNotification({
|
||||
type: 'info',
|
||||
title: '设置已重置',
|
||||
message: '所有设置已恢复为默认值'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Settings className="w-6 h-6 text-blue-500" />
|
||||
<h1 className="text-2xl font-bold text-white font-display">
|
||||
系统设置
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-gray-300 mt-2">
|
||||
自定义您的GSM3游戏面板体验
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 外观设置 */}
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<Palette className="w-5 h-5 text-purple-500" />
|
||||
<h2 className="text-lg font-semibold text-white">外观设置</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-200">主题模式</label>
|
||||
<p className="text-xs text-gray-400">选择浅色或深色主题</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
|
||||
${theme === 'dark' ? 'bg-blue-600' : 'bg-gray-300'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
|
||||
${theme === 'dark' ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-700">
|
||||
<p className="text-sm text-gray-300">
|
||||
当前主题: <span className="font-semibold">{theme === 'dark' ? '深色模式' : '浅色模式'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 通知设置 */}
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<Bell className="w-5 h-5 text-yellow-500" />
|
||||
<h2 className="text-lg font-semibold text-white">通知设置</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(notificationSettings).map(([key, value]) => {
|
||||
const labels = {
|
||||
desktop: '桌面通知',
|
||||
sound: '声音提醒',
|
||||
system: '系统通知',
|
||||
games: '游戏通知'
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-200">
|
||||
{labels[key as keyof typeof labels]}
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setNotificationSettings(prev => ({
|
||||
...prev,
|
||||
[key]: !value
|
||||
}))}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
|
||||
${value ? 'bg-green-600' : 'bg-gray-600'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
|
||||
${value ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 终端设置 */}
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<Terminal className="w-5 h-5 text-green-500" />
|
||||
<h2 className="text-lg font-semibold text-white">终端设置</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||
字体大小
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="20"
|
||||
value={terminalSettings.fontSize}
|
||||
onChange={(e) => setTerminalSettings(prev => ({
|
||||
...prev,
|
||||
fontSize: parseInt(e.target.value)
|
||||
}))}
|
||||
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>10px</span>
|
||||
<span>{terminalSettings.fontSize}px</span>
|
||||
<span>20px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||
字体系列
|
||||
</label>
|
||||
<select
|
||||
value={terminalSettings.fontFamily}
|
||||
onChange={(e) => setTerminalSettings(prev => ({
|
||||
...prev,
|
||||
fontFamily: e.target.value
|
||||
}))}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="JetBrains Mono">JetBrains Mono</option>
|
||||
<option value="Fira Code">Fira Code</option>
|
||||
<option value="Consolas">Consolas</option>
|
||||
<option value="Monaco">Monaco</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||
滚动缓冲区大小
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="100"
|
||||
max="10000"
|
||||
step="100"
|
||||
value={terminalSettings.scrollback}
|
||||
onChange={(e) => setTerminalSettings(prev => ({
|
||||
...prev,
|
||||
scrollback: parseInt(e.target.value)
|
||||
}))}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-200">
|
||||
光标闪烁
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setTerminalSettings(prev => ({
|
||||
...prev,
|
||||
cursorBlink: !prev.cursorBlink
|
||||
}))}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
|
||||
${terminalSettings.cursorBlink ? 'bg-blue-600' : 'bg-gray-600'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
|
||||
${terminalSettings.cursorBlink ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账户安全 */}
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<Shield className="w-5 h-5 text-red-500" />
|
||||
<h2 className="text-lg font-semibold text-white">账户安全</h2>
|
||||
</div>
|
||||
|
||||
{/* 用户信息 */}
|
||||
<div className="mb-6 p-4 bg-white/5 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<User className="w-8 h-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-white font-medium">{user?.username}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{user?.role === 'admin' ? '管理员' : '普通用户'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 修改密码表单 */}
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||
当前密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={passwordForm.showOldPassword ? 'text' : 'password'}
|
||||
value={passwordForm.oldPassword}
|
||||
onChange={(e) => setPasswordForm(prev => ({
|
||||
...prev,
|
||||
oldPassword: e.target.value
|
||||
}))}
|
||||
className="w-full px-3 py-2 pr-10 bg-white/10 border border-white/20 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="请输入当前密码"
|
||||
disabled={passwordLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasswordForm(prev => ({
|
||||
...prev,
|
||||
showOldPassword: !prev.showOldPassword
|
||||
}))}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white"
|
||||
>
|
||||
{passwordForm.showOldPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||
新密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={passwordForm.showNewPassword ? 'text' : 'password'}
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm(prev => ({
|
||||
...prev,
|
||||
newPassword: e.target.value
|
||||
}))}
|
||||
className="w-full px-3 py-2 pr-10 bg-white/10 border border-white/20 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="请输入新密码"
|
||||
disabled={passwordLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasswordForm(prev => ({
|
||||
...prev,
|
||||
showNewPassword: !prev.showNewPassword
|
||||
}))}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white"
|
||||
>
|
||||
{passwordForm.showNewPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||
确认新密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={passwordForm.showConfirmPassword ? 'text' : 'password'}
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) => setPasswordForm(prev => ({
|
||||
...prev,
|
||||
confirmPassword: e.target.value
|
||||
}))}
|
||||
className="w-full px-3 py-2 pr-10 bg-white/10 border border-white/20 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="请再次输入新密码"
|
||||
disabled={passwordLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasswordForm(prev => ({
|
||||
...prev,
|
||||
showConfirmPassword: !prev.showConfirmPassword
|
||||
}))}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white"
|
||||
>
|
||||
{passwordForm.showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={passwordLoading || !passwordForm.oldPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
|
||||
className="w-full btn-game py-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{passwordLoading ? '修改中...' : '修改密码'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="card-game p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">设置操作</h3>
|
||||
<p className="text-sm text-gray-400">保存或重置您的设置</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span>重置设置</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={saveSettings}
|
||||
className="btn-game px-4 py-2 flex items-center space-x-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>保存设置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
389
client/src/pages/TerminalPage.tsx
Normal file
389
client/src/pages/TerminalPage.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import socketClient from '@/utils/socket'
|
||||
import { useNotificationStore } from '@/stores/notificationStore'
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Terminal as TerminalIcon
|
||||
} from 'lucide-react'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
interface TerminalSession {
|
||||
id: string
|
||||
name: string
|
||||
terminal: Terminal
|
||||
fitAddon: FitAddon
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const TerminalPage: React.FC = () => {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([])
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { addNotification } = useNotificationStore()
|
||||
|
||||
// 创建新的终端会话
|
||||
const createTerminalSession = () => {
|
||||
const sessionId = `terminal-${Date.now()}`
|
||||
const sessionName = `终端 ${sessions.length + 1}`
|
||||
|
||||
const terminal = new Terminal({
|
||||
theme: {
|
||||
background: '#1a1a1a',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#ffffff',
|
||||
selection: '#ffffff30',
|
||||
black: '#000000',
|
||||
red: '#ff6b6b',
|
||||
green: '#51cf66',
|
||||
yellow: '#ffd43b',
|
||||
blue: '#74c0fc',
|
||||
magenta: '#f06292',
|
||||
cyan: '#4dd0e1',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#ff8a80',
|
||||
brightGreen: '#69f0ae',
|
||||
brightYellow: '#ffff8d',
|
||||
brightBlue: '#82b1ff',
|
||||
brightMagenta: '#ff80ab',
|
||||
brightCyan: '#84ffff',
|
||||
brightWhite: '#ffffff'
|
||||
},
|
||||
fontFamily: 'JetBrains Mono, Fira Code, Consolas, Monaco, monospace',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
scrollback: 1000,
|
||||
tabStopWidth: 4,
|
||||
allowTransparency: true
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(webLinksAddon)
|
||||
|
||||
// 监听终端输入
|
||||
terminal.onData((data) => {
|
||||
socketClient.sendTerminalInput(sessionId, data)
|
||||
})
|
||||
|
||||
// 监听终端大小变化
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
socketClient.resizeTerminal(sessionId, cols, rows)
|
||||
})
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
name: sessionName,
|
||||
terminal,
|
||||
fitAddon,
|
||||
active: true
|
||||
}
|
||||
|
||||
setSessions(prev => {
|
||||
const updated = prev.map(s => ({ ...s, active: false }))
|
||||
return [...updated, newSession]
|
||||
})
|
||||
|
||||
setActiveSessionId(sessionId)
|
||||
|
||||
// 请求创建PTY
|
||||
socketClient.createTerminal({
|
||||
sessionId: sessionId,
|
||||
name: sessionName,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '终端创建成功',
|
||||
message: `已创建新的终端会话: ${sessionName}`
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭终端会话
|
||||
const closeTerminalSession = (sessionId: string) => {
|
||||
const session = sessions.find(s => s.id === sessionId)
|
||||
if (!session) return
|
||||
|
||||
// 清理终端
|
||||
session.terminal.dispose()
|
||||
|
||||
// 通知后端关闭PTY
|
||||
socketClient.closeTerminal(sessionId)
|
||||
|
||||
setSessions(prev => {
|
||||
const filtered = prev.filter(s => s.id !== sessionId)
|
||||
|
||||
// 如果关闭的是当前活动会话,切换到其他会话
|
||||
if (sessionId === activeSessionId) {
|
||||
if (filtered.length > 0) {
|
||||
const newActive = filtered[filtered.length - 1]
|
||||
newActive.active = true
|
||||
setActiveSessionId(newActive.id)
|
||||
} else {
|
||||
setActiveSessionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
addNotification({
|
||||
type: 'info',
|
||||
title: '终端已关闭',
|
||||
message: `终端会话 ${session.name} 已关闭`
|
||||
})
|
||||
}
|
||||
|
||||
// 切换终端会话
|
||||
const switchTerminalSession = (sessionId: string) => {
|
||||
setSessions(prev => prev.map(s => ({
|
||||
...s,
|
||||
active: s.id === sessionId
|
||||
})))
|
||||
setActiveSessionId(sessionId)
|
||||
}
|
||||
|
||||
// 重置终端
|
||||
const resetTerminal = () => {
|
||||
const activeSession = sessions.find(s => s.id === activeSessionId)
|
||||
if (activeSession) {
|
||||
activeSession.terminal.reset()
|
||||
addNotification({
|
||||
type: 'info',
|
||||
title: '终端已重置',
|
||||
message: '终端内容已清空'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 切换全屏模式
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen)
|
||||
|
||||
// 延迟调整终端大小
|
||||
setTimeout(() => {
|
||||
const activeSession = sessions.find(s => s.id === activeSessionId)
|
||||
if (activeSession) {
|
||||
activeSession.fitAddon.fit()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 监听终端输出
|
||||
socketClient.on('terminal-output', ({ sessionId, data }) => {
|
||||
const session = sessions.find(s => s.id === sessionId)
|
||||
if (session) {
|
||||
session.terminal.write(data)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听终端创建成功
|
||||
socketClient.on('terminal-created', ({ sessionId, name }) => {
|
||||
console.log(`终端创建成功: ${sessionId} - ${name}`)
|
||||
})
|
||||
|
||||
// 监听终端关闭
|
||||
socketClient.on('terminal-closed', ({ sessionId }) => {
|
||||
console.log(`终端已关闭: ${sessionId}`)
|
||||
})
|
||||
|
||||
return () => {
|
||||
socketClient.off('terminal-output')
|
||||
socketClient.off('terminal-created')
|
||||
socketClient.off('terminal-closed')
|
||||
}
|
||||
}, [sessions])
|
||||
|
||||
useEffect(() => {
|
||||
// 当活动会话改变时,挂载终端到DOM
|
||||
const activeSession = sessions.find(s => s.id === activeSessionId)
|
||||
if (activeSession && terminalContainerRef.current) {
|
||||
const container = terminalContainerRef.current
|
||||
|
||||
// 清空容器
|
||||
container.innerHTML = ''
|
||||
|
||||
// 挂载终端
|
||||
activeSession.terminal.open(container)
|
||||
|
||||
// 调整大小
|
||||
setTimeout(() => {
|
||||
activeSession.fitAddon.fit()
|
||||
}, 100)
|
||||
}
|
||||
}, [activeSessionId, sessions])
|
||||
|
||||
useEffect(() => {
|
||||
// 窗口大小变化时调整终端大小
|
||||
const handleResize = () => {
|
||||
const activeSession = sessions.find(s => s.id === activeSessionId)
|
||||
if (activeSession) {
|
||||
setTimeout(() => {
|
||||
activeSession.fitAddon.fit()
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [activeSessionId, sessions])
|
||||
|
||||
return (
|
||||
<div className={`${isFullscreen ? 'fixed inset-0 z-50 bg-gray-900 flex flex-col' : ''}`}>
|
||||
<div className={`${isFullscreen ? 'flex flex-col h-full' : 'space-y-4'}`}>
|
||||
{/* 终端标签栏 */}
|
||||
<div className={`card-game p-4 ${isFullscreen ? 'flex-shrink-0' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<TerminalIcon className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-white font-display">
|
||||
终端管理
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={createTerminalSession}
|
||||
className="btn-game px-4 py-2 text-sm flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>新建终端</span>
|
||||
</button>
|
||||
|
||||
{activeSessionId && (
|
||||
<>
|
||||
<button
|
||||
onClick={resetTerminal}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
title="重置终端"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
title={isFullscreen ? '退出全屏' : '全屏模式'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
title="终端设置"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 终端标签 */}
|
||||
{sessions.length > 0 && (
|
||||
<div className="flex space-x-2 overflow-x-auto">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`
|
||||
flex items-center space-x-2 px-3 py-2 rounded-lg cursor-pointer transition-all
|
||||
${session.active
|
||||
? 'bg-blue-600/20 text-blue-400 border border-blue-500/30'
|
||||
: 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white'
|
||||
}
|
||||
`}
|
||||
onClick={() => switchTerminalSession(session.id)}
|
||||
>
|
||||
<span className="text-sm font-medium whitespace-nowrap">
|
||||
{session.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTerminalSession(session.id)
|
||||
}}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 终端容器 */}
|
||||
<div className={`terminal-container ${isFullscreen ? 'flex-1 min-h-0 flex flex-col' : 'h-96'}`}>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<TerminalIcon className="w-16 h-16 text-gray-500 mx-auto mb-4" />
|
||||
<p className="text-gray-400 mb-4">暂无终端会话</p>
|
||||
<button
|
||||
onClick={createTerminalSession}
|
||||
className="btn-game px-6 py-3"
|
||||
>
|
||||
创建第一个终端
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 终端头部 */}
|
||||
<div className={`terminal-header ${isFullscreen ? 'flex-shrink-0' : ''}`}>
|
||||
<div className="terminal-dots">
|
||||
<div className="terminal-dot red"></div>
|
||||
<div className="terminal-dot yellow"></div>
|
||||
<div className="terminal-dot green"></div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{sessions.find(s => s.active)?.name || '终端'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{activeSessionId}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 终端内容 */}
|
||||
<div
|
||||
ref={terminalContainerRef}
|
||||
className={`${isFullscreen ? 'flex-1 min-h-0' : 'h-80'} bg-gray-900`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 终端使用说明 */}
|
||||
{!isFullscreen && (
|
||||
<div className="card-game p-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-2">使用说明</h3>
|
||||
<div className="text-xs text-gray-400 space-y-1">
|
||||
<p>• 支持多终端会话,可以同时运行多个终端</p>
|
||||
<p>• 终端会话在页面刷新后会自动恢复</p>
|
||||
<p>• 支持全屏模式,提供更好的操作体验</p>
|
||||
<p>• 支持所有标准终端操作和快捷键</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TerminalPage
|
||||
185
client/src/stores/authStore.ts
Normal file
185
client/src/stores/authStore.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { AuthState, User, LoginRequest } from '@/types'
|
||||
import apiClient from '@/utils/api'
|
||||
import socketClient from '@/utils/socket'
|
||||
|
||||
interface AuthStore extends AuthState {
|
||||
login: (credentials: LoginRequest) => Promise<{ success: boolean; message: string }>
|
||||
logout: () => Promise<void>
|
||||
verifyToken: () => Promise<boolean>
|
||||
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; message: string }>
|
||||
clearError: () => void
|
||||
setLoading: (loading: boolean) => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
login: async (credentials: LoginRequest) => {
|
||||
set({ loading: true, error: null })
|
||||
|
||||
try {
|
||||
const response = await apiClient.login(credentials)
|
||||
|
||||
if (response.success && response.token && response.user) {
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
user: response.user,
|
||||
token: response.token,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
// 更新Socket认证
|
||||
socketClient.updateAuth(response.token)
|
||||
|
||||
return { success: true, message: response.message }
|
||||
} else {
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: response.message,
|
||||
})
|
||||
|
||||
return { success: false, message: response.message }
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || '登录失败,请稍后重试'
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
})
|
||||
|
||||
return { success: false, message: errorMessage }
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
set({ loading: true })
|
||||
|
||||
try {
|
||||
await apiClient.logout()
|
||||
} catch (error) {
|
||||
console.error('登出请求失败:', error)
|
||||
} finally {
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
// 断开Socket连接
|
||||
socketClient.disconnect()
|
||||
|
||||
// 重定向到登录页
|
||||
window.location.href = '/login'
|
||||
}
|
||||
},
|
||||
|
||||
verifyToken: async () => {
|
||||
const { token } = get()
|
||||
|
||||
if (!token) {
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
set({ loading: true })
|
||||
|
||||
try {
|
||||
const response = await apiClient.verifyToken()
|
||||
|
||||
if (response.success && response.user) {
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
user: response.user,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
return true
|
||||
} else {
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: response.message,
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
} catch (error: any) {
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: error.message || 'Token验证失败',
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
changePassword: async (oldPassword: string, newPassword: string) => {
|
||||
set({ loading: true, error: null })
|
||||
|
||||
try {
|
||||
const response = await apiClient.changePassword(oldPassword, newPassword)
|
||||
|
||||
set({ loading: false })
|
||||
|
||||
if (!response.success) {
|
||||
set({ error: response.message })
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || '修改密码失败'
|
||||
set({
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
})
|
||||
|
||||
return { success: false, message: errorMessage }
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null })
|
||||
},
|
||||
|
||||
setLoading: (loading: boolean) => {
|
||||
set({ loading })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'gsm3-auth',
|
||||
partialize: (state) => ({
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
user: state.user,
|
||||
token: state.token,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
37
client/src/stores/notificationStore.ts
Normal file
37
client/src/stores/notificationStore.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { create } from 'zustand'
|
||||
import { NotificationState, Notification } from '@/types'
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set, get) => ({
|
||||
notifications: [],
|
||||
|
||||
addNotification: (notification) => {
|
||||
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||||
const newNotification: Notification = {
|
||||
...notification,
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
notifications: [...state.notifications, newNotification]
|
||||
}))
|
||||
|
||||
// 自动移除通知
|
||||
if (notification.duration !== 0) {
|
||||
const duration = notification.duration || 5000
|
||||
setTimeout(() => {
|
||||
get().removeNotification(id)
|
||||
}, duration)
|
||||
}
|
||||
},
|
||||
|
||||
removeNotification: (id) => {
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter(n => n.id !== id)
|
||||
}))
|
||||
},
|
||||
|
||||
clearNotifications: () => {
|
||||
set({ notifications: [] })
|
||||
},
|
||||
}))
|
||||
72
client/src/stores/themeStore.ts
Normal file
72
client/src/stores/themeStore.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { ThemeState, Theme } from '@/types'
|
||||
|
||||
interface ThemeStore extends ThemeState {
|
||||
setTheme: (theme: Theme) => void
|
||||
initTheme: () => void
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: 'dark',
|
||||
|
||||
toggleTheme: () => {
|
||||
const { theme } = get()
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light'
|
||||
set({ theme: newTheme })
|
||||
applyTheme(newTheme)
|
||||
},
|
||||
|
||||
setTheme: (theme: Theme) => {
|
||||
set({ theme })
|
||||
applyTheme(theme)
|
||||
},
|
||||
|
||||
initTheme: () => {
|
||||
const { theme } = get()
|
||||
applyTheme(theme)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'gsm3-theme',
|
||||
partialize: (state) => ({ theme: state.theme }),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 应用主题到DOM
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement
|
||||
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
|
||||
// 更新meta标签
|
||||
const metaThemeColor = document.querySelector('meta[name="theme-color"]')
|
||||
if (metaThemeColor) {
|
||||
metaThemeColor.setAttribute(
|
||||
'content',
|
||||
theme === 'dark' ? '#1a1a2e' : '#667eea'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
if (typeof window !== 'undefined') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
mediaQuery.addEventListener('change', (e) => {
|
||||
const { theme } = useThemeStore.getState()
|
||||
// 如果用户没有手动设置过主题,跟随系统主题
|
||||
const hasUserPreference = localStorage.getItem('gsm3-theme')
|
||||
if (!hasUserPreference) {
|
||||
const systemTheme = e.matches ? 'dark' : 'light'
|
||||
useThemeStore.getState().setTheme(systemTheme)
|
||||
}
|
||||
})
|
||||
}
|
||||
200
client/src/types/index.ts
Normal file
200
client/src/types/index.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
// 用户相关类型
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
role: 'admin' | 'user'
|
||||
createdAt: string
|
||||
lastLogin?: string
|
||||
loginAttempts: number
|
||||
lockedUntil?: string
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
token?: string
|
||||
user?: User
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
user: User | null
|
||||
token: string | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// 主题相关类型
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
export interface ThemeState {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
// 终端相关类型
|
||||
export interface TerminalSession {
|
||||
id: string
|
||||
name: string
|
||||
active: boolean
|
||||
createdAt: string
|
||||
lastActivity: string
|
||||
}
|
||||
|
||||
export interface TerminalState {
|
||||
sessions: TerminalSession[]
|
||||
activeSessionId: string | null
|
||||
connected: boolean
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
// 系统信息类型
|
||||
export interface SystemStats {
|
||||
cpu: {
|
||||
usage: number
|
||||
cores: number
|
||||
model: string
|
||||
}
|
||||
memory: {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
usage: number
|
||||
}
|
||||
disk: {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
usage: number
|
||||
}
|
||||
network: {
|
||||
rx: number
|
||||
tx: number
|
||||
}
|
||||
uptime: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
platform: string
|
||||
arch: string
|
||||
hostname: string
|
||||
version: string
|
||||
nodeVersion: string
|
||||
}
|
||||
|
||||
// 游戏相关类型
|
||||
export interface GameServer {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
status: 'running' | 'stopped' | 'starting' | 'stopping' | 'error'
|
||||
port: number
|
||||
players: {
|
||||
current: number
|
||||
max: number
|
||||
}
|
||||
uptime: number
|
||||
lastUpdate: string
|
||||
}
|
||||
|
||||
export interface GameConfig {
|
||||
name: string
|
||||
type: string
|
||||
port: number
|
||||
maxPlayers: number
|
||||
autoStart: boolean
|
||||
restartOnCrash: boolean
|
||||
customArgs: string[]
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Socket事件类型
|
||||
export interface SocketEvents {
|
||||
// 终端事件
|
||||
'terminal-output': (data: { sessionId: string; data: string }) => void
|
||||
'terminal-created': (data: { sessionId: string; name: string }) => void
|
||||
'terminal-closed': (data: { sessionId: string }) => void
|
||||
|
||||
// 系统监控事件
|
||||
'system-stats': (data: SystemStats) => void
|
||||
'system-alert': (data: { type: string; message: string; level: 'info' | 'warning' | 'error' }) => void
|
||||
|
||||
// 游戏服务器事件
|
||||
'game-status': (data: { gameId: string; status: GameServer['status'] }) => void
|
||||
'game-players': (data: { gameId: string; players: { current: number; max: number } }) => void
|
||||
'game-output': (data: { gameId: string; output: string }) => void
|
||||
}
|
||||
|
||||
// 导航相关类型
|
||||
export interface NavItem {
|
||||
id: string
|
||||
label: string
|
||||
icon: React.ComponentType<any>
|
||||
path: string
|
||||
requireAuth?: boolean
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
// 通知类型
|
||||
export interface Notification {
|
||||
id: string
|
||||
type: 'success' | 'error' | 'warning' | 'info'
|
||||
title: string
|
||||
message: string
|
||||
duration?: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface NotificationState {
|
||||
notifications: Notification[]
|
||||
addNotification: (notification: Omit<Notification, 'id' | 'timestamp'>) => void
|
||||
removeNotification: (id: string) => void
|
||||
clearNotifications: () => void
|
||||
}
|
||||
|
||||
// 设置相关类型
|
||||
export interface AppSettings {
|
||||
theme: Theme
|
||||
language: 'zh-CN' | 'en-US'
|
||||
autoSave: boolean
|
||||
notifications: {
|
||||
desktop: boolean
|
||||
sound: boolean
|
||||
system: boolean
|
||||
games: boolean
|
||||
}
|
||||
terminal: {
|
||||
fontSize: number
|
||||
fontFamily: string
|
||||
theme: 'dark' | 'light'
|
||||
cursorBlink: boolean
|
||||
scrollback: number
|
||||
}
|
||||
dashboard: {
|
||||
refreshInterval: number
|
||||
showSystemStats: boolean
|
||||
showGameServers: boolean
|
||||
compactMode: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
settings: AppSettings
|
||||
updateSettings: (updates: Partial<AppSettings>) => void
|
||||
resetSettings: () => void
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
276
client/src/utils/api.ts
Normal file
276
client/src/utils/api.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { LoginRequest, LoginResponse, ApiResponse, User } from '@/types'
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance
|
||||
private token: string | null = null
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: 'http://localhost:3001/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (this.token) {
|
||||
config.headers.Authorization = `Bearer ${this.token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
this.client.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token过期或无效,清除本地存储的token
|
||||
this.clearToken()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 从localStorage恢复token
|
||||
this.loadToken()
|
||||
}
|
||||
|
||||
private loadToken() {
|
||||
const token = localStorage.getItem('gsm3_token')
|
||||
if (token) {
|
||||
this.setToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
setToken(token: string) {
|
||||
this.token = token
|
||||
localStorage.setItem('gsm3_token', token)
|
||||
}
|
||||
|
||||
clearToken() {
|
||||
this.token = null
|
||||
localStorage.removeItem('gsm3_token')
|
||||
localStorage.removeItem('gsm3_user')
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return this.token
|
||||
}
|
||||
|
||||
// 通用请求方法
|
||||
private async request<T = any>(
|
||||
config: AxiosRequestConfig
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await this.client.request<ApiResponse<T>>(config)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
if (error.response?.data) {
|
||||
throw error.response.data
|
||||
}
|
||||
throw {
|
||||
success: false,
|
||||
error: '网络错误',
|
||||
message: error.message || '请求失败,请检查网络连接',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET请求
|
||||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({ ...config, method: 'GET', url })
|
||||
}
|
||||
|
||||
// POST请求
|
||||
async post<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({ ...config, method: 'POST', url, data })
|
||||
}
|
||||
|
||||
// PUT请求
|
||||
async put<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({ ...config, method: 'PUT', url, data })
|
||||
}
|
||||
|
||||
// DELETE请求
|
||||
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({ ...config, method: 'DELETE', url })
|
||||
}
|
||||
|
||||
// 认证相关API
|
||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await this.client.post<LoginResponse>('/auth/login', credentials)
|
||||
const result = response.data
|
||||
|
||||
if (result.success && result.token) {
|
||||
this.setToken(result.token)
|
||||
if (result.user) {
|
||||
localStorage.setItem('gsm3_user', JSON.stringify(result.user))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error: any) {
|
||||
if (error.response?.data) {
|
||||
return error.response.data
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '登录失败,请检查网络连接',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async verifyToken(): Promise<{ success: boolean; user?: User; message?: string }> {
|
||||
try {
|
||||
const response = await this.client.get('/auth/verify')
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
this.clearToken()
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || 'Token验证失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await this.client.post('/auth/logout')
|
||||
this.clearToken()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
this.clearToken()
|
||||
return {
|
||||
success: true,
|
||||
message: '已登出',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await this.client.post('/auth/change-password', {
|
||||
oldPassword,
|
||||
newPassword,
|
||||
})
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
if (error.response?.data) {
|
||||
return error.response.data
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '修改密码失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 系统相关API
|
||||
async getSystemStats() {
|
||||
return this.get('/system/stats')
|
||||
}
|
||||
|
||||
async getSystemInfo() {
|
||||
return this.get('/system/info')
|
||||
}
|
||||
|
||||
// 终端相关API
|
||||
async getTerminalSessions() {
|
||||
return this.get('/terminal/sessions')
|
||||
}
|
||||
|
||||
async createTerminalSession(name?: string) {
|
||||
return this.post('/terminal/create', { name })
|
||||
}
|
||||
|
||||
async closeTerminalSession(sessionId: string) {
|
||||
return this.post('/terminal/close', { sessionId })
|
||||
}
|
||||
|
||||
// 游戏相关API
|
||||
async getGameServers() {
|
||||
return this.get('/game/servers')
|
||||
}
|
||||
|
||||
async createGameServer(config: any) {
|
||||
return this.post('/game/create', config)
|
||||
}
|
||||
|
||||
async startGameServer(gameId: string) {
|
||||
return this.post(`/game/${gameId}/start`)
|
||||
}
|
||||
|
||||
async stopGameServer(gameId: string) {
|
||||
return this.post(`/game/${gameId}/stop`)
|
||||
}
|
||||
|
||||
async deleteGameServer(gameId: string) {
|
||||
return this.delete(`/game/${gameId}`)
|
||||
}
|
||||
|
||||
// 文件管理API
|
||||
async getFiles(path: string) {
|
||||
return this.get('/files', { params: { path } })
|
||||
}
|
||||
|
||||
async uploadFile(file: File, path: string) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('path', path)
|
||||
|
||||
return this.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async downloadFile(path: string) {
|
||||
try {
|
||||
const response = await this.client.get('/files/download', {
|
||||
params: { path },
|
||||
responseType: 'blob',
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(path: string) {
|
||||
return this.delete('/files', { params: { path } })
|
||||
}
|
||||
|
||||
async createFolder(path: string, name: string) {
|
||||
return this.post('/files/folder', { path, name })
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const apiClient = new ApiClient()
|
||||
|
||||
export default apiClient
|
||||
export { ApiClient }
|
||||
233
client/src/utils/socket.ts
Normal file
233
client/src/utils/socket.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { SocketEvents } from '@/types'
|
||||
|
||||
class SocketClient {
|
||||
private socket: Socket | null = null
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
private reconnectDelay = 1000
|
||||
private listeners: Map<string, Function[]> = new Map()
|
||||
|
||||
constructor() {
|
||||
this.connect()
|
||||
}
|
||||
|
||||
private connect() {
|
||||
const token = localStorage.getItem('gsm3_token')
|
||||
|
||||
this.socket = io('http://localhost:3001', {
|
||||
auth: {
|
||||
token,
|
||||
},
|
||||
transports: ['websocket', 'polling'],
|
||||
timeout: 20000,
|
||||
forceNew: true,
|
||||
})
|
||||
|
||||
this.setupEventListeners()
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
if (!this.socket) return
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Socket连接成功:', this.socket?.id)
|
||||
this.reconnectAttempts = 0
|
||||
this.emit('connection-status', { connected: true })
|
||||
})
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('Socket断开连接:', reason)
|
||||
this.emit('connection-status', { connected: false, reason })
|
||||
|
||||
if (reason === 'io server disconnect') {
|
||||
// 服务器主动断开,需要重新连接
|
||||
this.reconnect()
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('Socket连接错误:', error)
|
||||
this.emit('connection-error', { error: error.message })
|
||||
this.reconnect()
|
||||
})
|
||||
|
||||
this.socket.on('error', (error) => {
|
||||
console.error('Socket错误:', error)
|
||||
this.emit('socket-error', { error })
|
||||
})
|
||||
|
||||
// 认证错误处理
|
||||
this.socket.on('auth-error', (error) => {
|
||||
console.error('Socket认证错误:', error)
|
||||
this.emit('auth-error', { error })
|
||||
// 清除token并重定向到登录页
|
||||
localStorage.removeItem('gsm3_token')
|
||||
window.location.href = '/login'
|
||||
})
|
||||
}
|
||||
|
||||
private reconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('达到最大重连次数,停止重连')
|
||||
this.emit('max-reconnect-attempts', {})
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
|
||||
|
||||
console.log(`${delay}ms后尝试第${this.reconnectAttempts}次重连...`)
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.socket) {
|
||||
this.socket.connect()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// 发送事件
|
||||
emit(event: string, data?: any) {
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit(event, data)
|
||||
} else {
|
||||
console.warn('Socket未连接,无法发送事件:', event)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听事件
|
||||
on<K extends keyof SocketEvents>(event: K, callback: SocketEvents[K]): void
|
||||
on(event: string, callback: Function): void
|
||||
on(event: string, callback: Function) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, [])
|
||||
}
|
||||
|
||||
this.listeners.get(event)!.push(callback)
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.on(event, callback as any)
|
||||
}
|
||||
}
|
||||
|
||||
// 取消监听事件
|
||||
off(event: string, callback?: Function) {
|
||||
if (callback) {
|
||||
const listeners = this.listeners.get(event)
|
||||
if (listeners) {
|
||||
const index = listeners.indexOf(callback)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.off(event, callback as any)
|
||||
}
|
||||
} else {
|
||||
// 移除所有监听器
|
||||
this.listeners.delete(event)
|
||||
if (this.socket) {
|
||||
this.socket.off(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 一次性监听
|
||||
once(event: string, callback: Function) {
|
||||
if (this.socket) {
|
||||
this.socket.once(event, callback as any)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取连接状态
|
||||
isConnected(): boolean {
|
||||
return this.socket?.connected || false
|
||||
}
|
||||
|
||||
// 获取Socket ID
|
||||
getId(): string | undefined {
|
||||
return this.socket?.id
|
||||
}
|
||||
|
||||
// 手动重连
|
||||
reconnectManually() {
|
||||
this.reconnectAttempts = 0
|
||||
if (this.socket) {
|
||||
this.socket.connect()
|
||||
} else {
|
||||
this.connect()
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect()
|
||||
this.socket = null
|
||||
}
|
||||
this.listeners.clear()
|
||||
}
|
||||
|
||||
// 更新认证token
|
||||
updateAuth(token: string) {
|
||||
if (this.socket) {
|
||||
this.socket.auth = { token }
|
||||
this.socket.disconnect().connect()
|
||||
}
|
||||
}
|
||||
|
||||
// 终端相关方法
|
||||
createTerminal(data: { sessionId: string; name?: string; cols?: number; rows?: number }) {
|
||||
this.emit('create-pty', data)
|
||||
}
|
||||
|
||||
sendTerminalInput(sessionId: string, data: string) {
|
||||
this.emit('terminal-input', { sessionId, data })
|
||||
}
|
||||
|
||||
resizeTerminal(sessionId: string, cols: number, rows: number) {
|
||||
this.emit('terminal-resize', { sessionId, cols, rows })
|
||||
}
|
||||
|
||||
closeTerminal(sessionId: string) {
|
||||
this.emit('close-pty', { sessionId })
|
||||
}
|
||||
|
||||
// 系统监控相关方法
|
||||
subscribeSystemStats() {
|
||||
this.emit('subscribe-system-stats')
|
||||
}
|
||||
|
||||
unsubscribeSystemStats() {
|
||||
this.emit('unsubscribe-system-stats')
|
||||
}
|
||||
|
||||
// 游戏服务器相关方法
|
||||
startGame(gameId: string) {
|
||||
this.emit('game-start', { gameId })
|
||||
}
|
||||
|
||||
stopGame(gameId: string) {
|
||||
this.emit('game-stop', { gameId })
|
||||
}
|
||||
|
||||
sendGameCommand(gameId: string, command: string) {
|
||||
this.emit('game-command', { gameId, command })
|
||||
}
|
||||
|
||||
// 订阅游戏服务器状态
|
||||
subscribeGameStatus(gameId: string) {
|
||||
this.emit('subscribe-game-status', { gameId })
|
||||
}
|
||||
|
||||
unsubscribeGameStatus(gameId: string) {
|
||||
this.emit('unsubscribe-game-status', { gameId })
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const socketClient = new SocketClient()
|
||||
|
||||
export default socketClient
|
||||
export { SocketClient }
|
||||
128
client/tailwind.config.js
Normal file
128
client/tailwind.config.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// 游戏风格的配色方案
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
},
|
||||
secondary: {
|
||||
50: '#fdf4ff',
|
||||
100: '#fae8ff',
|
||||
200: '#f5d0fe',
|
||||
300: '#f0abfc',
|
||||
400: '#e879f9',
|
||||
500: '#d946ef',
|
||||
600: '#c026d3',
|
||||
700: '#a21caf',
|
||||
800: '#86198f',
|
||||
900: '#701a75',
|
||||
},
|
||||
accent: {
|
||||
50: '#fff7ed',
|
||||
100: '#ffedd5',
|
||||
200: '#fed7aa',
|
||||
300: '#fdba74',
|
||||
400: '#fb923c',
|
||||
500: '#f97316',
|
||||
600: '#ea580c',
|
||||
700: '#c2410c',
|
||||
800: '#9a3412',
|
||||
900: '#7c2d12',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
dark: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
'mono': ['JetBrains Mono', 'Fira Code', 'Consolas', 'monospace'],
|
||||
'game': ['Orbitron', 'Exo 2', 'Rajdhani', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'bounce-slow': 'bounce 2s infinite',
|
||||
'glow': 'glow 2s ease-in-out infinite alternate',
|
||||
'terminal-blink': 'terminal-blink 1s infinite',
|
||||
},
|
||||
keyframes: {
|
||||
glow: {
|
||||
'0%': { boxShadow: '0 0 5px rgba(59, 130, 246, 0.5)' },
|
||||
'100%': { boxShadow: '0 0 20px rgba(59, 130, 246, 0.8)' },
|
||||
},
|
||||
'terminal-blink': {
|
||||
'0%, 50%': { opacity: '1' },
|
||||
'51%, 100%': { opacity: '0' },
|
||||
},
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
},
|
||||
boxShadow: {
|
||||
'glow': '0 0 20px rgba(59, 130, 246, 0.3)',
|
||||
'glow-lg': '0 0 40px rgba(59, 130, 246, 0.4)',
|
||||
'terminal': '0 0 30px rgba(0, 255, 0, 0.2)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
31
client/tsconfig.json
Normal file
31
client/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
client/tsconfig.node.json
Normal file
10
client/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
31
client/vite.config.ts
Normal file
31
client/vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
373
package-lock.json
generated
Normal file
373
package-lock.json
generated
Normal file
@@ -0,0 +1,373 @@
|
||||
{
|
||||
"name": "gsm3-game-panel",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gsm3-game-panel",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
||||
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"lodash": "^4.17.21",
|
||||
"rxjs": "^7.8.1",
|
||||
"shell-quote": "^1.8.1",
|
||||
"spawn-command": "0.0.2",
|
||||
"supports-color": "^8.1.1",
|
||||
"tree-kill": "^1.2.2",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.13.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/date-fns"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/spawn-command": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
|
||||
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "gsm3-game-panel",
|
||||
"version": "1.0.0",
|
||||
"description": "游戏面板 - 支持Steam等游戏一键部署",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"",
|
||||
"dev:client": "cd client && npm run dev",
|
||||
"dev:server": "cd server && npm run dev",
|
||||
"build": "npm run build:client && npm run build:server",
|
||||
"build:client": "cd client && npm run build",
|
||||
"build:server": "cd server && npm run build",
|
||||
"install:all": "npm install && cd client && npm install && cd ../server && npm install"
|
||||
},
|
||||
"keywords": ["game", "panel", "steam", "deployment", "terminal"],
|
||||
"author": "GSM3 Team",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
62
server/.env.example
Normal file
62
server/.env.example
Normal file
@@ -0,0 +1,62 @@
|
||||
# 服务器配置
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=info
|
||||
|
||||
# CORS配置
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# Socket.IO配置
|
||||
SOCKET_CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# 数据目录
|
||||
DATA_DIR=./data
|
||||
|
||||
# 日志目录
|
||||
LOG_DIR=./logs
|
||||
|
||||
# PTY配置
|
||||
PTY_TIMEOUT=1800000
|
||||
PTY_MAX_SESSIONS=10
|
||||
|
||||
# 游戏服务器配置
|
||||
GAME_MAX_INSTANCES=5
|
||||
GAME_DATA_DIR=./data/games
|
||||
|
||||
# 系统监控配置
|
||||
SYSTEM_MONITOR_INTERVAL=5000
|
||||
SYSTEM_STATS_HISTORY_SIZE=720
|
||||
|
||||
# 告警配置
|
||||
ALERT_CPU_WARNING=70
|
||||
ALERT_CPU_CRITICAL=90
|
||||
ALERT_MEMORY_WARNING=80
|
||||
ALERT_MEMORY_CRITICAL=95
|
||||
ALERT_DISK_WARNING=85
|
||||
ALERT_DISK_CRITICAL=95
|
||||
|
||||
# Java配置(用于Minecraft服务器)
|
||||
JAVA_HOME=
|
||||
JAVA_OPTS=-Xmx2G -Xms1G
|
||||
|
||||
# 安全配置
|
||||
SESSION_SECRET=your-secret-key-here
|
||||
JWT_SECRET=your-jwt-secret-here
|
||||
|
||||
# 数据库配置(如果需要)
|
||||
# DATABASE_URL=sqlite:./data/database.sqlite
|
||||
|
||||
# 备份配置
|
||||
BACKUP_ENABLED=true
|
||||
BACKUP_INTERVAL=86400000
|
||||
BACKUP_RETENTION=7
|
||||
|
||||
# 网络配置
|
||||
MAX_UPLOAD_SIZE=100mb
|
||||
REQUEST_TIMEOUT=30000
|
||||
|
||||
# 开发配置
|
||||
DEV_AUTO_RELOAD=true
|
||||
DEV_MOCK_DATA=false
|
||||
BIN
server/PTY/pty_linux_x64
Normal file
BIN
server/PTY/pty_linux_x64
Normal file
Binary file not shown.
BIN
server/PTY/pty_win32_x64.exe
Normal file
BIN
server/PTY/pty_win32_x64.exe
Normal file
Binary file not shown.
67
server/PTY/介绍.md
Normal file
67
server/PTY/介绍.md
Normal file
@@ -0,0 +1,67 @@
|
||||
Pseudo-teletype App
|
||||
-- -- --
|
||||
|
||||
仿真终端应用程序,支持运行所有 Linux/Windows 程序,可以为您的更高层应用带来完全终端控制能力。
|
||||
|
||||
中文 | English
|
||||
|
||||
terminal image
|
||||
|
||||
图片中表示的是,使用仿真终端运行 Minecraft 服务器,并且按下 Tab 键来选取提示。
|
||||
|
||||
|
||||
什么是 PTY/TTY?
|
||||
tty = "teletype",pty = "pseudo-teletype"
|
||||
|
||||
众所周知,程序拥有输入与输出流,但是数据流与显示器之间有一个区别,那便是缺少行和高的排列维度。简而言之,PTY 的中文意义就是伪装设备终端,让我们的程序伪装成一个拥有固定高宽的显示器,接受来自程序的输出内容。
|
||||
|
||||
|
||||
使用
|
||||
开一个 PTY 并执行命令,设置固定窗口大小,IO 流直接转发。
|
||||
|
||||
注意:-cmd 接收的是一个数组, 命令的参数以数组的形式传递,且需要序列化,如:[\"java\",\"-jar\",\"ser.jar\",\"nogui\"]
|
||||
go build
|
||||
./pty -dir "." -cmd [\"bash\"] -size 50,50
|
||||
接下来您会得到一个设置好大小宽度的窗口,并且您可以像 SSH 终端一样,进行任何交互。
|
||||
|
||||
ping google.com
|
||||
top
|
||||
htop
|
||||
|
||||
参数:
|
||||
-cmd string
|
||||
command
|
||||
-coder string
|
||||
Coder (default "UTF-8")
|
||||
-dir string
|
||||
command work path (default ".")
|
||||
-size string
|
||||
Initialize pty size, stdin will be forwarded directly (default "50,50")
|
||||
-test
|
||||
Test whether the system environment is pty compatible
|
||||
|
||||
兼容性
|
||||
支持所有现代主流版本 Linux 系统。
|
||||
支持 Windows 7 到 Windows 11 所有版本系统,包括 Server 系列。
|
||||
支持 windows amd64 / linux amd64 & arm64。
|
||||
|
||||
MCSManager
|
||||
MCSManager 是一款开源,分布式,开箱即用,支持 Minecraft 和其他控制台应用的程序管理面板。
|
||||
|
||||
这个程序是专门为了 MCSManager 而设计,您也可以尝试嵌入到您自己的程序中。
|
||||
|
||||
More info: https://github.com/mcsmanager
|
||||
|
||||
|
||||
贡献
|
||||
此程序属于 MCSManager 的最重要的核心功能之一,非必要不新增功能。
|
||||
|
||||
如果您想为这个项目提供新功能,那您必须开一个 issue 说明此功能,并提供编程思路,我们一起经过讨论后再决定是否开发
|
||||
|
||||
如果您是修复 BUG,可以直接提交 PR 并说明情况
|
||||
|
||||
|
||||
MIT license
|
||||
遵循 MIT License 开源协议。
|
||||
|
||||
版权所有 zijiren233 和贡献者们。
|
||||
6493
server/package-lock.json
generated
Normal file
6493
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
server/package.json
Normal file
42
server/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "gsm3-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"helmet": "^7.1.0",
|
||||
"joi": "^17.11.0",
|
||||
"socket.io": "^4.7.4",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0",
|
||||
"archiver": "^6.0.1",
|
||||
"unzipper": "^0.10.14",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"express-rate-limit": "^7.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.8",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"jest": "^29.7.0",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
261
server/src/index.ts
Normal file
261
server/src/index.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import express from 'express'
|
||||
import { createServer } from 'http'
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import winston from 'winston'
|
||||
|
||||
import { TerminalManager } from './modules/terminal/TerminalManager'
|
||||
import { GameManager } from './modules/game/GameManager'
|
||||
import { SystemManager } from './modules/system/SystemManager'
|
||||
import { ConfigManager } from './modules/config/ConfigManager'
|
||||
import { AuthManager } from './modules/auth/AuthManager'
|
||||
import { setupTerminalRoutes } from './routes/terminal'
|
||||
import { setupGameRoutes } from './routes/games'
|
||||
import { setupSystemRoutes } from './routes/system'
|
||||
import { setupAuthRoutes } from './routes/auth'
|
||||
import { setAuthManager } from './middleware/auth'
|
||||
|
||||
// 获取当前文件目录
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config()
|
||||
|
||||
// 配置日志
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: { service: 'gsm3-server' },
|
||||
transports: [
|
||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
||||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
// 创建Express应用
|
||||
const app = express()
|
||||
const server = createServer(app)
|
||||
const io = new SocketIOServer(server, {
|
||||
cors: {
|
||||
origin: process.env.CLIENT_URL || 'http://localhost:3000',
|
||||
methods: ['GET', 'POST']
|
||||
},
|
||||
transports: ['websocket', 'polling']
|
||||
})
|
||||
|
||||
// 中间件配置
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false // 开发环境下禁用CSP
|
||||
}))
|
||||
|
||||
app.use(cors({
|
||||
origin: process.env.CLIENT_URL || 'http://localhost:3000',
|
||||
credentials: true
|
||||
}))
|
||||
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
||||
|
||||
// 静态文件服务
|
||||
app.use('/static', express.static(path.join(__dirname, '../public')))
|
||||
|
||||
// 管理器变量声明
|
||||
let configManager: ConfigManager
|
||||
let authManager: AuthManager
|
||||
let terminalManager: TerminalManager
|
||||
let gameManager: GameManager
|
||||
let systemManager: SystemManager
|
||||
|
||||
// 健康检查端点
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
version: process.env.npm_package_version || '1.0.0'
|
||||
})
|
||||
})
|
||||
|
||||
// 根路径
|
||||
app.get('/', (req, res) => {
|
||||
res.json({
|
||||
name: 'GSM3 Server',
|
||||
version: '1.0.0',
|
||||
description: '游戏面板后端服务',
|
||||
endpoints: {
|
||||
health: '/api/health',
|
||||
terminal: '/api/terminal',
|
||||
game: '/api/game',
|
||||
system: '/api/system'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Socket.IO 连接处理将在startServer函数中设置
|
||||
|
||||
// 全局错误处理
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('未处理的错误:', err)
|
||||
res.status(500).json({
|
||||
error: '服务器内部错误',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : '请稍后重试'
|
||||
})
|
||||
})
|
||||
|
||||
// 404处理将在startServer函数中设置
|
||||
|
||||
// 优雅关闭处理
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('收到SIGTERM信号,开始优雅关闭...')
|
||||
server.close(() => {
|
||||
logger.info('HTTP服务器已关闭')
|
||||
terminalManager.cleanup()
|
||||
gameManager.cleanup()
|
||||
systemManager.cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('收到SIGINT信号,开始优雅关闭...')
|
||||
server.close(() => {
|
||||
logger.info('HTTP服务器已关闭')
|
||||
terminalManager.cleanup()
|
||||
gameManager.cleanup()
|
||||
systemManager.cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
|
||||
// 未捕获异常处理
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('未捕获的异常:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('未处理的Promise拒绝:', reason)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
// 启动服务器
|
||||
async function startServer() {
|
||||
try {
|
||||
// 初始化管理器
|
||||
configManager = new ConfigManager(logger)
|
||||
authManager = new AuthManager(configManager, logger)
|
||||
terminalManager = new TerminalManager(io, logger)
|
||||
gameManager = new GameManager(io, logger)
|
||||
systemManager = new SystemManager(io, logger)
|
||||
|
||||
// 初始化配置和认证
|
||||
await configManager.initialize()
|
||||
await authManager.initialize()
|
||||
setAuthManager(authManager)
|
||||
|
||||
// 设置路由
|
||||
app.use('/api/auth', setupAuthRoutes(authManager))
|
||||
app.use('/api/terminal', setupTerminalRoutes(terminalManager))
|
||||
app.use('/api/game', setupGameRoutes(gameManager))
|
||||
app.use('/api/system', setupSystemRoutes(systemManager))
|
||||
|
||||
// 404处理(必须在所有路由之后)
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({
|
||||
error: '接口不存在',
|
||||
path: req.originalUrl
|
||||
})
|
||||
})
|
||||
|
||||
// Socket.IO 连接处理
|
||||
io.on('connection', (socket) => {
|
||||
logger.info(`客户端连接: ${socket.id}`)
|
||||
|
||||
// 终端相关事件
|
||||
socket.on('create-pty', (data) => {
|
||||
terminalManager.createPty(socket, data)
|
||||
})
|
||||
|
||||
socket.on('terminal-input', (data) => {
|
||||
terminalManager.handleInput(socket, data)
|
||||
})
|
||||
|
||||
socket.on('terminal-resize', (data) => {
|
||||
terminalManager.resizeTerminal(socket, data)
|
||||
})
|
||||
|
||||
socket.on('close-pty', (data) => {
|
||||
terminalManager.closePty(socket, data)
|
||||
})
|
||||
|
||||
// 游戏管理事件
|
||||
socket.on('game-start', (data) => {
|
||||
gameManager.startGame(socket, data)
|
||||
})
|
||||
|
||||
socket.on('game-stop', (data) => {
|
||||
gameManager.stopGame(socket, data)
|
||||
})
|
||||
|
||||
socket.on('game-command', (data) => {
|
||||
gameManager.sendCommand(socket, data.gameId, data.command)
|
||||
})
|
||||
|
||||
// 系统监控事件
|
||||
socket.on('subscribe-system-stats', () => {
|
||||
socket.join('system-stats')
|
||||
logger.info(`客户端 ${socket.id} 开始订阅系统状态`)
|
||||
})
|
||||
|
||||
socket.on('unsubscribe-system-stats', () => {
|
||||
socket.leave('system-stats')
|
||||
logger.info(`客户端 ${socket.id} 取消订阅系统状态`)
|
||||
})
|
||||
|
||||
// 断开连接处理
|
||||
socket.on('disconnect', (reason) => {
|
||||
logger.info(`客户端断开连接: ${socket.id}, 原因: ${reason}`)
|
||||
terminalManager.handleDisconnect(socket)
|
||||
socket.leave('system-stats')
|
||||
})
|
||||
|
||||
// 错误处理
|
||||
socket.on('error', (error) => {
|
||||
logger.error(`Socket错误 ${socket.id}:`, error)
|
||||
})
|
||||
})
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10)
|
||||
const HOST = process.env.HOST || '0.0.0.0'
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
logger.info(`GSM3服务器启动成功!`)
|
||||
logger.info(`地址: http://${HOST}:${PORT}`)
|
||||
logger.info(`环境: ${process.env.NODE_ENV || 'development'}`)
|
||||
logger.info(`进程ID: ${process.pid}`)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('服务器启动失败:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
startServer()
|
||||
|
||||
export { app, server, io, logger }
|
||||
78
server/src/middleware/auth.ts
Normal file
78
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { AuthManager } from '../modules/auth/AuthManager'
|
||||
import logger from '../utils/logger'
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
}
|
||||
|
||||
let authManager: AuthManager
|
||||
|
||||
export function setAuthManager(manager: AuthManager) {
|
||||
authManager = manager
|
||||
}
|
||||
|
||||
export function authenticateToken(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization']
|
||||
const token = authHeader && authHeader.split(' ')[1] // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
error: '访问被拒绝',
|
||||
message: '需要提供访问令牌'
|
||||
})
|
||||
}
|
||||
|
||||
if (!authManager) {
|
||||
logger.error('认证管理器未初始化')
|
||||
return res.status(500).json({
|
||||
error: '服务器错误',
|
||||
message: '认证服务不可用'
|
||||
})
|
||||
}
|
||||
|
||||
const decoded = authManager.verifyToken(token)
|
||||
|
||||
if (!decoded) {
|
||||
return res.status(403).json({
|
||||
error: '访问被拒绝',
|
||||
message: '无效或过期的访问令牌'
|
||||
})
|
||||
}
|
||||
|
||||
req.user = {
|
||||
userId: decoded.userId,
|
||||
username: decoded.username,
|
||||
role: decoded.role
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
export function requireRole(role: string) {
|
||||
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: '访问被拒绝',
|
||||
message: '需要身份验证'
|
||||
})
|
||||
}
|
||||
|
||||
if (req.user.role !== role && req.user.role !== 'admin') {
|
||||
return res.status(403).json({
|
||||
error: '访问被拒绝',
|
||||
message: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
return requireRole('admin')(req, res, next)
|
||||
}
|
||||
323
server/src/modules/auth/AuthManager.ts
Normal file
323
server/src/modules/auth/AuthManager.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import winston from 'winston'
|
||||
import { ConfigManager } from '../config/ConfigManager'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
password: string
|
||||
role: 'admin' | 'user'
|
||||
createdAt: string
|
||||
lastLogin?: string
|
||||
loginAttempts: number
|
||||
lockedUntil?: string
|
||||
}
|
||||
|
||||
export interface LoginAttempt {
|
||||
username: string
|
||||
ip: string
|
||||
timestamp: string
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean
|
||||
token?: string
|
||||
user?: Omit<User, 'password'>
|
||||
message: string
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private users: Map<string, User> = new Map()
|
||||
private loginAttempts: LoginAttempt[] = []
|
||||
private usersFilePath: string
|
||||
private attemptsFilePath: string
|
||||
private logger: winston.Logger
|
||||
private configManager: ConfigManager
|
||||
|
||||
constructor(configManager: ConfigManager, logger: winston.Logger) {
|
||||
this.configManager = configManager
|
||||
this.logger = logger
|
||||
this.usersFilePath = path.join(process.cwd(), 'data', 'users.json')
|
||||
this.attemptsFilePath = path.join(process.cwd(), 'data', 'login_attempts.json')
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
// 确保data目录存在
|
||||
const dataDir = path.dirname(this.usersFilePath)
|
||||
await fs.mkdir(dataDir, { recursive: true })
|
||||
|
||||
// 加载用户数据
|
||||
await this.loadUsers()
|
||||
await this.loadLoginAttempts()
|
||||
|
||||
// 如果没有用户,创建默认管理员账户
|
||||
if (this.users.size === 0) {
|
||||
await this.createDefaultAdmin()
|
||||
}
|
||||
|
||||
this.logger.info('认证管理器初始化完成')
|
||||
} catch (error) {
|
||||
this.logger.error('认证管理器初始化失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async loadUsers(): Promise<void> {
|
||||
try {
|
||||
const usersData = await fs.readFile(this.usersFilePath, 'utf-8')
|
||||
const users = JSON.parse(usersData) as User[]
|
||||
|
||||
this.users.clear()
|
||||
users.forEach(user => {
|
||||
this.users.set(user.username, user)
|
||||
})
|
||||
|
||||
this.logger.info(`加载了 ${users.length} 个用户`)
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
this.logger.info('用户文件不存在,将创建新文件')
|
||||
} else {
|
||||
this.logger.error('加载用户文件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadLoginAttempts(): Promise<void> {
|
||||
try {
|
||||
const attemptsData = await fs.readFile(this.attemptsFilePath, 'utf-8')
|
||||
this.loginAttempts = JSON.parse(attemptsData) as LoginAttempt[]
|
||||
|
||||
// 清理过期的登录尝试记录(保留最近24小时)
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
this.loginAttempts = this.loginAttempts.filter(
|
||||
attempt => new Date(attempt.timestamp) > oneDayAgo
|
||||
)
|
||||
|
||||
this.logger.info(`加载了 ${this.loginAttempts.length} 条登录尝试记录`)
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
this.logger.info('登录尝试文件不存在,将创建新文件')
|
||||
this.loginAttempts = []
|
||||
} else {
|
||||
this.logger.error('加载登录尝试文件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async saveUsers(): Promise<void> {
|
||||
try {
|
||||
const users = Array.from(this.users.values())
|
||||
await fs.writeFile(this.usersFilePath, JSON.stringify(users, null, 2), 'utf-8')
|
||||
} catch (error) {
|
||||
this.logger.error('保存用户文件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async saveLoginAttempts(): Promise<void> {
|
||||
try {
|
||||
await fs.writeFile(this.attemptsFilePath, JSON.stringify(this.loginAttempts, null, 2), 'utf-8')
|
||||
} catch (error) {
|
||||
this.logger.error('保存登录尝试文件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async createDefaultAdmin(): Promise<void> {
|
||||
const defaultPassword = 'admin123'
|
||||
const hashedPassword = await bcrypt.hash(defaultPassword, 12)
|
||||
|
||||
const adminUser: User = {
|
||||
id: 'admin',
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
role: 'admin',
|
||||
createdAt: new Date().toISOString(),
|
||||
loginAttempts: 0
|
||||
}
|
||||
|
||||
this.users.set('admin', adminUser)
|
||||
await this.saveUsers()
|
||||
|
||||
this.logger.warn(`创建了默认管理员账户: admin / ${defaultPassword}`)
|
||||
this.logger.warn('请立即登录并修改默认密码!')
|
||||
}
|
||||
|
||||
async login(username: string, password: string, ip: string): Promise<AuthResult> {
|
||||
const user = this.users.get(username)
|
||||
|
||||
// 记录登录尝试
|
||||
const attempt: LoginAttempt = {
|
||||
username,
|
||||
ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
success: false
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
this.loginAttempts.push(attempt)
|
||||
await this.saveLoginAttempts()
|
||||
return {
|
||||
success: false,
|
||||
message: '用户名或密码错误'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查账户是否被锁定
|
||||
if (this.isAccountLocked(user)) {
|
||||
this.loginAttempts.push(attempt)
|
||||
await this.saveLoginAttempts()
|
||||
return {
|
||||
success: false,
|
||||
message: '账户已被锁定,请稍后再试'
|
||||
}
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isValidPassword = await bcrypt.compare(password, user.password)
|
||||
|
||||
if (!isValidPassword) {
|
||||
// 增加失败尝试次数
|
||||
user.loginAttempts += 1
|
||||
|
||||
const authConfig = this.configManager.getAuthConfig()
|
||||
if (user.loginAttempts >= authConfig.maxLoginAttempts) {
|
||||
user.lockedUntil = new Date(Date.now() + authConfig.lockoutDuration).toISOString()
|
||||
this.logger.warn(`用户 ${username} 因多次登录失败被锁定`)
|
||||
}
|
||||
|
||||
await this.saveUsers()
|
||||
this.loginAttempts.push(attempt)
|
||||
await this.saveLoginAttempts()
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '用户名或密码错误'
|
||||
}
|
||||
}
|
||||
|
||||
// 登录成功,重置失败次数
|
||||
user.loginAttempts = 0
|
||||
user.lockedUntil = undefined
|
||||
user.lastLogin = new Date().toISOString()
|
||||
await this.saveUsers()
|
||||
|
||||
// 记录成功的登录尝试
|
||||
attempt.success = true
|
||||
this.loginAttempts.push(attempt)
|
||||
await this.saveLoginAttempts()
|
||||
|
||||
// 生成JWT token
|
||||
const jwtConfig = this.configManager.getJWTConfig()
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
},
|
||||
jwtConfig.secret,
|
||||
{ expiresIn: jwtConfig.expiresIn }
|
||||
)
|
||||
|
||||
this.logger.info(`用户 ${username} 登录成功`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
lastLogin: user.lastLogin,
|
||||
loginAttempts: user.loginAttempts
|
||||
},
|
||||
message: '登录成功'
|
||||
}
|
||||
}
|
||||
|
||||
private isAccountLocked(user: User): boolean {
|
||||
if (!user.lockedUntil) return false
|
||||
|
||||
const lockoutTime = new Date(user.lockedUntil)
|
||||
const now = new Date()
|
||||
|
||||
if (now < lockoutTime) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 锁定时间已过,清除锁定状态
|
||||
user.lockedUntil = undefined
|
||||
user.loginAttempts = 0
|
||||
this.saveUsers() // 异步保存,不等待
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
verifyToken(token: string): any {
|
||||
try {
|
||||
const jwtSecret = this.configManager.getJWTSecret()
|
||||
return jwt.verify(token, jwtSecret)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(username: string, oldPassword: string, newPassword: string): Promise<{ success: boolean; message: string }> {
|
||||
const user = this.users.get(username)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
}
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
const isValidOldPassword = await bcrypt.compare(oldPassword, user.password)
|
||||
if (!isValidOldPassword) {
|
||||
return {
|
||||
success: false,
|
||||
message: '原密码错误'
|
||||
}
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
const hashedNewPassword = await bcrypt.hash(newPassword, 12)
|
||||
user.password = hashedNewPassword
|
||||
|
||||
await this.saveUsers()
|
||||
|
||||
this.logger.info(`用户 ${username} 修改密码成功`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '密码修改成功'
|
||||
}
|
||||
}
|
||||
|
||||
getUsers(): Omit<User, 'password'>[] {
|
||||
return Array.from(this.users.values()).map(user => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
lastLogin: user.lastLogin,
|
||||
loginAttempts: user.loginAttempts,
|
||||
lockedUntil: user.lockedUntil
|
||||
}))
|
||||
}
|
||||
|
||||
getLoginAttempts(limit: number = 100): LoginAttempt[] {
|
||||
return this.loginAttempts
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, limit)
|
||||
}
|
||||
}
|
||||
152
server/src/modules/config/ConfigManager.ts
Normal file
152
server/src/modules/config/ConfigManager.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import winston from 'winston'
|
||||
|
||||
export interface AppConfig {
|
||||
jwt: {
|
||||
secret: string
|
||||
expiresIn: string
|
||||
}
|
||||
auth: {
|
||||
maxLoginAttempts: number
|
||||
lockoutDuration: number
|
||||
sessionTimeout: number
|
||||
}
|
||||
server: {
|
||||
port: number
|
||||
host: string
|
||||
corsOrigin: string
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
private config: AppConfig
|
||||
private configPath: string
|
||||
private logger: winston.Logger
|
||||
|
||||
constructor(logger: winston.Logger) {
|
||||
this.logger = logger
|
||||
this.configPath = path.join(process.cwd(), 'data', 'config.json')
|
||||
this.config = this.getDefaultConfig()
|
||||
}
|
||||
|
||||
private getDefaultConfig(): AppConfig {
|
||||
return {
|
||||
jwt: {
|
||||
secret: this.generateJWTSecret(),
|
||||
expiresIn: '24h'
|
||||
},
|
||||
auth: {
|
||||
maxLoginAttempts: 5,
|
||||
lockoutDuration: 15 * 60 * 1000, // 15分钟
|
||||
sessionTimeout: 24 * 60 * 60 * 1000 // 24小时
|
||||
},
|
||||
server: {
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
host: process.env.HOST || '0.0.0.0',
|
||||
corsOrigin: process.env.CLIENT_URL || 'http://localhost:3000'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private generateJWTSecret(): string {
|
||||
return crypto.randomBytes(64).toString('hex')
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
// 确保data目录存在
|
||||
const dataDir = path.dirname(this.configPath)
|
||||
await fs.mkdir(dataDir, { recursive: true })
|
||||
|
||||
// 尝试加载现有配置
|
||||
await this.loadConfig()
|
||||
|
||||
this.logger.info('配置管理器初始化完成')
|
||||
} catch (error) {
|
||||
this.logger.error('配置管理器初始化失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async loadConfig(): Promise<void> {
|
||||
try {
|
||||
const configData = await fs.readFile(this.configPath, 'utf-8')
|
||||
const savedConfig = JSON.parse(configData) as Partial<AppConfig>
|
||||
|
||||
// 合并默认配置和保存的配置
|
||||
this.config = this.mergeConfig(this.getDefaultConfig(), savedConfig)
|
||||
|
||||
this.logger.info('配置文件加载成功')
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// 配置文件不存在,创建新的
|
||||
this.logger.info('配置文件不存在,创建新的配置文件')
|
||||
await this.saveConfig()
|
||||
} else {
|
||||
this.logger.error('加载配置文件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mergeConfig(defaultConfig: AppConfig, savedConfig: Partial<AppConfig>): AppConfig {
|
||||
return {
|
||||
jwt: {
|
||||
...defaultConfig.jwt,
|
||||
...savedConfig.jwt
|
||||
},
|
||||
auth: {
|
||||
...defaultConfig.auth,
|
||||
...savedConfig.auth
|
||||
},
|
||||
server: {
|
||||
...defaultConfig.server,
|
||||
...savedConfig.server
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveConfig(): Promise<void> {
|
||||
try {
|
||||
await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), 'utf-8')
|
||||
this.logger.info('配置文件保存成功')
|
||||
} catch (error) {
|
||||
this.logger.error('保存配置文件失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(): AppConfig {
|
||||
return { ...this.config }
|
||||
}
|
||||
|
||||
async updateConfig(updates: Partial<AppConfig>): Promise<void> {
|
||||
this.config = this.mergeConfig(this.config, updates)
|
||||
await this.saveConfig()
|
||||
}
|
||||
|
||||
// 重新生成JWT密钥
|
||||
async regenerateJWTSecret(): Promise<void> {
|
||||
this.config.jwt.secret = this.generateJWTSecret()
|
||||
await this.saveConfig()
|
||||
this.logger.info('JWT密钥已重新生成')
|
||||
}
|
||||
|
||||
getJWTSecret(): string {
|
||||
return this.config.jwt.secret
|
||||
}
|
||||
|
||||
getJWTConfig() {
|
||||
return this.config.jwt
|
||||
}
|
||||
|
||||
getAuthConfig() {
|
||||
return this.config.auth
|
||||
}
|
||||
|
||||
getServerConfig() {
|
||||
return this.config.server
|
||||
}
|
||||
}
|
||||
765
server/src/modules/game/GameManager.ts
Normal file
765
server/src/modules/game/GameManager.ts
Normal file
@@ -0,0 +1,765 @@
|
||||
import { spawn, ChildProcess } from 'child_process'
|
||||
import { Server as SocketIOServer, Socket } from 'socket.io'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import winston from 'winston'
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
interface GameConfig {
|
||||
id: string
|
||||
name: string
|
||||
type: 'minecraft' | 'terraria' | 'custom'
|
||||
executable: string
|
||||
args: string[]
|
||||
workingDirectory: string
|
||||
autoStart: boolean
|
||||
autoRestart: boolean
|
||||
maxMemory?: string
|
||||
minMemory?: string
|
||||
javaPath?: string
|
||||
port?: number
|
||||
maxPlayers?: number
|
||||
description?: string
|
||||
icon?: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
interface GameInstance {
|
||||
id: string
|
||||
config: GameConfig
|
||||
process?: ChildProcess
|
||||
status: 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed'
|
||||
startTime?: Date
|
||||
stopTime?: Date
|
||||
players: GamePlayer[]
|
||||
stats: GameStats
|
||||
logs: GameLog[]
|
||||
}
|
||||
|
||||
interface GamePlayer {
|
||||
name: string
|
||||
uuid?: string
|
||||
joinTime: Date
|
||||
ip?: string
|
||||
}
|
||||
|
||||
interface GameStats {
|
||||
uptime: number
|
||||
playerCount: number
|
||||
maxPlayerCount: number
|
||||
cpuUsage: number
|
||||
memoryUsage: number
|
||||
networkIn: number
|
||||
networkOut: number
|
||||
}
|
||||
|
||||
interface GameLog {
|
||||
timestamp: Date
|
||||
level: 'info' | 'warn' | 'error' | 'debug'
|
||||
message: string
|
||||
source: 'stdout' | 'stderr' | 'system'
|
||||
}
|
||||
|
||||
interface GameTemplate {
|
||||
id: string
|
||||
name: string
|
||||
type: 'minecraft' | 'terraria' | 'custom'
|
||||
description: string
|
||||
icon: string
|
||||
defaultConfig: Partial<GameConfig>
|
||||
setupSteps: string[]
|
||||
}
|
||||
|
||||
export class GameManager extends EventEmitter {
|
||||
private games: Map<string, GameInstance> = new Map()
|
||||
private io: SocketIOServer
|
||||
private logger: winston.Logger
|
||||
private configPath: string
|
||||
private templates: GameTemplate[]
|
||||
|
||||
constructor(io: SocketIOServer, logger: winston.Logger) {
|
||||
super()
|
||||
this.io = io
|
||||
this.logger = logger
|
||||
this.configPath = path.resolve(process.cwd(), 'data', 'games')
|
||||
|
||||
// 初始化游戏模板
|
||||
this.templates = this.initializeTemplates()
|
||||
|
||||
this.logger.info('游戏管理器初始化完成')
|
||||
|
||||
// 定期更新游戏统计信息
|
||||
setInterval(() => {
|
||||
this.updateGameStats()
|
||||
}, 5000) // 每5秒更新一次
|
||||
|
||||
// 初始化时加载已保存的游戏配置
|
||||
this.loadGameConfigs()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化游戏模板
|
||||
*/
|
||||
private initializeTemplates(): GameTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'minecraft-vanilla',
|
||||
name: 'Minecraft 原版服务器',
|
||||
type: 'minecraft',
|
||||
description: 'Minecraft 官方原版服务器',
|
||||
icon: '🎮',
|
||||
defaultConfig: {
|
||||
type: 'minecraft',
|
||||
args: ['-Xmx2G', '-Xms1G', '-jar', 'server.jar', 'nogui'],
|
||||
maxMemory: '2G',
|
||||
minMemory: '1G',
|
||||
port: 25565,
|
||||
maxPlayers: 20
|
||||
},
|
||||
setupSteps: [
|
||||
'下载 Minecraft 服务器 JAR 文件',
|
||||
'配置 server.properties',
|
||||
'同意 EULA',
|
||||
'配置内存分配'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'minecraft-forge',
|
||||
name: 'Minecraft Forge 服务器',
|
||||
type: 'minecraft',
|
||||
description: 'Minecraft Forge 模组服务器',
|
||||
icon: '⚒️',
|
||||
defaultConfig: {
|
||||
type: 'minecraft',
|
||||
args: ['-Xmx4G', '-Xms2G', '-jar', 'forge-server.jar', 'nogui'],
|
||||
maxMemory: '4G',
|
||||
minMemory: '2G',
|
||||
port: 25565,
|
||||
maxPlayers: 20
|
||||
},
|
||||
setupSteps: [
|
||||
'下载 Minecraft Forge 安装器',
|
||||
'运行安装器安装服务器',
|
||||
'配置 server.properties',
|
||||
'同意 EULA',
|
||||
'安装模组到 mods 文件夹'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'terraria',
|
||||
name: 'Terraria 服务器',
|
||||
type: 'terraria',
|
||||
description: 'Terraria 专用服务器',
|
||||
icon: '🌍',
|
||||
defaultConfig: {
|
||||
type: 'terraria',
|
||||
args: ['-server', '-world', 'world.wld'],
|
||||
port: 7777,
|
||||
maxPlayers: 8
|
||||
},
|
||||
setupSteps: [
|
||||
'下载 Terraria 专用服务器',
|
||||
'创建或导入世界文件',
|
||||
'配置服务器设置'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: '自定义游戏服务器',
|
||||
type: 'custom',
|
||||
description: '自定义配置的游戏服务器',
|
||||
icon: '🔧',
|
||||
defaultConfig: {
|
||||
type: 'custom',
|
||||
args: []
|
||||
},
|
||||
setupSteps: [
|
||||
'指定可执行文件路径',
|
||||
'配置启动参数',
|
||||
'设置工作目录'
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取游戏模板列表
|
||||
*/
|
||||
public getTemplates(): GameTemplate[] {
|
||||
return this.templates
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新游戏
|
||||
*/
|
||||
public async createGame(socket: Socket, config: Omit<GameConfig, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> {
|
||||
try {
|
||||
const gameId = uuidv4()
|
||||
const gameConfig: GameConfig = {
|
||||
...config,
|
||||
id: gameId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
// 创建游戏实例
|
||||
const gameInstance: GameInstance = {
|
||||
id: gameId,
|
||||
config: gameConfig,
|
||||
status: 'stopped',
|
||||
players: [],
|
||||
stats: {
|
||||
uptime: 0,
|
||||
playerCount: 0,
|
||||
maxPlayerCount: 0,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
networkIn: 0,
|
||||
networkOut: 0
|
||||
},
|
||||
logs: []
|
||||
}
|
||||
|
||||
this.games.set(gameId, gameInstance)
|
||||
|
||||
// 保存配置到文件
|
||||
await this.saveGameConfig(gameConfig)
|
||||
|
||||
// 通知客户端
|
||||
this.io.emit('game-created', {
|
||||
game: this.getGameInfo(gameInstance)
|
||||
})
|
||||
|
||||
this.logger.info(`游戏创建成功: ${gameConfig.name} (${gameId})`)
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('创建游戏失败:', error)
|
||||
socket.emit('game-error', {
|
||||
error: error instanceof Error ? error.message : '创建游戏失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动游戏
|
||||
*/
|
||||
public async startGame(socket: Socket, gameId: string): Promise<void> {
|
||||
try {
|
||||
const game = this.games.get(gameId)
|
||||
if (!game) {
|
||||
socket.emit('game-error', { error: '游戏不存在' })
|
||||
return
|
||||
}
|
||||
|
||||
if (game.status !== 'stopped' && game.status !== 'crashed') {
|
||||
socket.emit('game-error', { error: '游戏已在运行或正在启动' })
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.info(`启动游戏: ${game.config.name} (${gameId})`)
|
||||
|
||||
// 更新状态
|
||||
game.status = 'starting'
|
||||
game.startTime = new Date()
|
||||
game.logs = []
|
||||
|
||||
this.io.emit('game-status-changed', {
|
||||
gameId,
|
||||
status: game.status,
|
||||
startTime: game.startTime
|
||||
})
|
||||
|
||||
// 确保工作目录存在
|
||||
await fs.mkdir(game.config.workingDirectory, { recursive: true })
|
||||
|
||||
// 启动游戏进程
|
||||
const gameProcess = spawn(game.config.executable, game.config.args, {
|
||||
cwd: game.config.workingDirectory,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
JAVA_HOME: game.config.javaPath || process.env.JAVA_HOME
|
||||
}
|
||||
})
|
||||
|
||||
game.process = gameProcess
|
||||
|
||||
// 处理游戏输出
|
||||
gameProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const message = data.toString()
|
||||
this.addGameLog(game, 'info', message, 'stdout')
|
||||
this.parseGameOutput(game, message)
|
||||
|
||||
socket.emit('game-output', {
|
||||
gameId,
|
||||
data: message
|
||||
})
|
||||
})
|
||||
|
||||
// 处理游戏错误输出
|
||||
gameProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const message = data.toString()
|
||||
this.addGameLog(game, 'error', message, 'stderr')
|
||||
|
||||
socket.emit('game-output', {
|
||||
gameId,
|
||||
data: message
|
||||
})
|
||||
})
|
||||
|
||||
// 处理进程退出
|
||||
gameProcess.on('exit', (code, signal) => {
|
||||
this.logger.info(`游戏进程退出: ${game.config.name}, 退出码: ${code}, 信号: ${signal}`)
|
||||
|
||||
game.status = code === 0 ? 'stopped' : 'crashed'
|
||||
game.stopTime = new Date()
|
||||
game.process = undefined
|
||||
game.players = []
|
||||
|
||||
this.addGameLog(game, 'info', `游戏进程退出,退出码: ${code}`, 'system')
|
||||
|
||||
this.io.emit('game-status-changed', {
|
||||
gameId,
|
||||
status: game.status,
|
||||
stopTime: game.stopTime,
|
||||
exitCode: code
|
||||
})
|
||||
|
||||
// 如果启用了自动重启且不是正常退出
|
||||
if (game.config.autoRestart && code !== 0) {
|
||||
setTimeout(() => {
|
||||
this.startGame(socket, gameId)
|
||||
}, 5000) // 5秒后重启
|
||||
}
|
||||
})
|
||||
|
||||
// 处理进程错误
|
||||
gameProcess.on('error', (error) => {
|
||||
this.logger.error(`游戏进程错误 ${game.config.name}:`, error)
|
||||
|
||||
game.status = 'crashed'
|
||||
game.stopTime = new Date()
|
||||
game.process = undefined
|
||||
|
||||
this.addGameLog(game, 'error', `进程错误: ${error.message}`, 'system')
|
||||
|
||||
this.io.emit('game-status-changed', {
|
||||
gameId,
|
||||
status: game.status,
|
||||
error: error.message
|
||||
})
|
||||
})
|
||||
|
||||
// 等待一段时间确认启动成功
|
||||
setTimeout(() => {
|
||||
if (game.process && !game.process.killed) {
|
||||
game.status = 'running'
|
||||
this.io.emit('game-status-changed', {
|
||||
gameId,
|
||||
status: game.status
|
||||
})
|
||||
this.addGameLog(game, 'info', '游戏启动成功', 'system')
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('启动游戏失败:', error)
|
||||
socket.emit('game-error', {
|
||||
gameId,
|
||||
error: error instanceof Error ? error.message : '启动游戏失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止游戏
|
||||
*/
|
||||
public async stopGame(socket: Socket, gameId: string): Promise<void> {
|
||||
try {
|
||||
const game = this.games.get(gameId)
|
||||
if (!game) {
|
||||
socket.emit('game-error', { error: '游戏不存在' })
|
||||
return
|
||||
}
|
||||
|
||||
if (game.status !== 'running' && game.status !== 'starting') {
|
||||
socket.emit('game-error', { error: '游戏未在运行' })
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.info(`停止游戏: ${game.config.name} (${gameId})`)
|
||||
|
||||
game.status = 'stopping'
|
||||
|
||||
this.io.emit('game-status-changed', {
|
||||
gameId,
|
||||
status: game.status
|
||||
})
|
||||
|
||||
if (game.process && !game.process.killed) {
|
||||
// 尝试优雅关闭
|
||||
if (game.config.type === 'minecraft') {
|
||||
game.process.stdin?.write('stop\n')
|
||||
} else {
|
||||
game.process.kill('SIGTERM')
|
||||
}
|
||||
|
||||
// 如果10秒后还没有退出,强制杀死进程
|
||||
setTimeout(() => {
|
||||
if (game.process && !game.process.killed) {
|
||||
game.process.kill('SIGKILL')
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('停止游戏失败:', error)
|
||||
socket.emit('game-error', {
|
||||
gameId,
|
||||
error: error instanceof Error ? error.message : '停止游戏失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重启游戏
|
||||
*/
|
||||
public async restartGame(socket: Socket, gameId: string): Promise<void> {
|
||||
try {
|
||||
await this.stopGame(socket, gameId)
|
||||
|
||||
// 等待游戏完全停止后再启动
|
||||
const game = this.games.get(gameId)
|
||||
if (game) {
|
||||
const checkStopped = () => {
|
||||
if (game.status === 'stopped' || game.status === 'crashed') {
|
||||
this.startGame(socket, gameId)
|
||||
} else {
|
||||
setTimeout(checkStopped, 1000)
|
||||
}
|
||||
}
|
||||
setTimeout(checkStopped, 1000)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('重启游戏失败:', error)
|
||||
socket.emit('game-error', {
|
||||
gameId,
|
||||
error: error instanceof Error ? error.message : '重启游戏失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送命令到游戏
|
||||
*/
|
||||
public sendCommand(socket: Socket, gameId: string, command: string): void {
|
||||
try {
|
||||
const game = this.games.get(gameId)
|
||||
if (!game) {
|
||||
socket.emit('game-error', { error: '游戏不存在' })
|
||||
return
|
||||
}
|
||||
|
||||
if (game.status !== 'running' || !game.process) {
|
||||
socket.emit('game-error', { error: '游戏未在运行' })
|
||||
return
|
||||
}
|
||||
|
||||
this.addGameLog(game, 'info', `> ${command}`, 'system')
|
||||
|
||||
if (game.process.stdin && !game.process.stdin.destroyed) {
|
||||
game.process.stdin.write(command + '\n')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('发送游戏命令失败:', error)
|
||||
socket.emit('game-error', {
|
||||
gameId,
|
||||
error: error instanceof Error ? error.message : '发送命令失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除游戏
|
||||
*/
|
||||
public async deleteGame(socket: Socket, gameId: string): Promise<void> {
|
||||
try {
|
||||
const game = this.games.get(gameId)
|
||||
if (!game) {
|
||||
socket.emit('game-error', { error: '游戏不存在' })
|
||||
return
|
||||
}
|
||||
|
||||
// 如果游戏正在运行,先停止
|
||||
if (game.status === 'running' || game.status === 'starting') {
|
||||
await this.stopGame(socket, gameId)
|
||||
|
||||
// 等待游戏停止
|
||||
await new Promise(resolve => {
|
||||
const checkStopped = () => {
|
||||
if (game.status === 'stopped' || game.status === 'crashed') {
|
||||
resolve(void 0)
|
||||
} else {
|
||||
setTimeout(checkStopped, 1000)
|
||||
}
|
||||
}
|
||||
setTimeout(checkStopped, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
// 删除配置文件
|
||||
await this.deleteGameConfig(gameId)
|
||||
|
||||
// 从内存中移除
|
||||
this.games.delete(gameId)
|
||||
|
||||
// 通知客户端
|
||||
this.io.emit('game-deleted', { gameId })
|
||||
|
||||
this.logger.info(`游戏删除成功: ${game.config.name} (${gameId})`)
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('删除游戏失败:', error)
|
||||
socket.emit('game-error', {
|
||||
gameId,
|
||||
error: error instanceof Error ? error.message : '删除游戏失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取游戏列表
|
||||
*/
|
||||
public getGames(): any[] {
|
||||
return Array.from(this.games.values()).map(game => this.getGameInfo(game))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取游戏信息
|
||||
*/
|
||||
private getGameInfo(game: GameInstance): any {
|
||||
return {
|
||||
id: game.id,
|
||||
name: game.config.name,
|
||||
type: game.config.type,
|
||||
status: game.status,
|
||||
playerCount: game.players.length,
|
||||
maxPlayers: game.config.maxPlayers || 0,
|
||||
uptime: game.startTime ? Date.now() - game.startTime.getTime() : 0,
|
||||
stats: game.stats,
|
||||
port: game.config.port,
|
||||
autoStart: game.config.autoStart,
|
||||
autoRestart: game.config.autoRestart,
|
||||
description: game.config.description,
|
||||
icon: game.config.icon,
|
||||
createdAt: game.config.createdAt,
|
||||
updatedAt: game.config.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析游戏输出
|
||||
*/
|
||||
private parseGameOutput(game: GameInstance, output: string): void {
|
||||
// 根据游戏类型解析输出
|
||||
if (game.config.type === 'minecraft') {
|
||||
this.parseMinecraftOutput(game, output)
|
||||
}
|
||||
// 可以添加其他游戏类型的解析
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析Minecraft输出
|
||||
*/
|
||||
private parseMinecraftOutput(game: GameInstance, output: string): void {
|
||||
// 玩家加入
|
||||
const joinMatch = output.match(/\[.*\] \[.*\/INFO\]: (\w+) joined the game/)
|
||||
if (joinMatch) {
|
||||
const playerName = joinMatch[1]
|
||||
if (!game.players.find(p => p.name === playerName)) {
|
||||
game.players.push({
|
||||
name: playerName,
|
||||
joinTime: new Date()
|
||||
})
|
||||
this.io.emit('player-joined', {
|
||||
gameId: game.id,
|
||||
playerName,
|
||||
playerCount: game.players.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 玩家离开
|
||||
const leaveMatch = output.match(/\[.*\] \[.*\/INFO\]: (\w+) left the game/)
|
||||
if (leaveMatch) {
|
||||
const playerName = leaveMatch[1]
|
||||
game.players = game.players.filter(p => p.name !== playerName)
|
||||
this.io.emit('player-left', {
|
||||
gameId: game.id,
|
||||
playerName,
|
||||
playerCount: game.players.length
|
||||
})
|
||||
}
|
||||
|
||||
// 服务器启动完成
|
||||
if (output.includes('Done (') && output.includes('For help, type "help"')) {
|
||||
game.status = 'running'
|
||||
this.io.emit('game-status-changed', {
|
||||
gameId: game.id,
|
||||
status: game.status
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加游戏日志
|
||||
*/
|
||||
private addGameLog(game: GameInstance, level: GameLog['level'], message: string, source: GameLog['source']): void {
|
||||
const log: GameLog = {
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
message: message.trim(),
|
||||
source
|
||||
}
|
||||
|
||||
game.logs.push(log)
|
||||
|
||||
// 限制日志数量
|
||||
if (game.logs.length > 1000) {
|
||||
game.logs = game.logs.slice(-1000)
|
||||
}
|
||||
|
||||
// 发送日志到客户端
|
||||
this.io.emit('game-log', {
|
||||
gameId: game.id,
|
||||
log
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新游戏统计信息
|
||||
*/
|
||||
private updateGameStats(): void {
|
||||
for (const game of this.games.values()) {
|
||||
if (game.status === 'running' && game.process) {
|
||||
// 更新运行时间
|
||||
if (game.startTime) {
|
||||
game.stats.uptime = Date.now() - game.startTime.getTime()
|
||||
}
|
||||
|
||||
// 更新玩家数量
|
||||
game.stats.playerCount = game.players.length
|
||||
game.stats.maxPlayerCount = Math.max(game.stats.maxPlayerCount, game.players.length)
|
||||
|
||||
// 发送统计信息到客户端
|
||||
this.io.emit('game-stats-updated', {
|
||||
gameId: game.id,
|
||||
stats: game.stats
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存游戏配置
|
||||
*/
|
||||
private async saveGameConfig(config: GameConfig): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.configPath, { recursive: true })
|
||||
const configFile = path.join(this.configPath, `${config.id}.json`)
|
||||
await fs.writeFile(configFile, JSON.stringify(config, null, 2))
|
||||
} catch (error) {
|
||||
this.logger.error('保存游戏配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除游戏配置
|
||||
*/
|
||||
private async deleteGameConfig(gameId: string): Promise<void> {
|
||||
try {
|
||||
const configFile = path.join(this.configPath, `${gameId}.json`)
|
||||
await fs.unlink(configFile)
|
||||
} catch (error) {
|
||||
this.logger.error('删除游戏配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载游戏配置
|
||||
*/
|
||||
private async loadGameConfigs(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.configPath, { recursive: true })
|
||||
const files = await fs.readdir(this.configPath)
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
try {
|
||||
const configFile = path.join(this.configPath, file)
|
||||
const configData = await fs.readFile(configFile, 'utf-8')
|
||||
const config: GameConfig = JSON.parse(configData)
|
||||
|
||||
const gameInstance: GameInstance = {
|
||||
id: config.id,
|
||||
config,
|
||||
status: 'stopped',
|
||||
players: [],
|
||||
stats: {
|
||||
uptime: 0,
|
||||
playerCount: 0,
|
||||
maxPlayerCount: 0,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
networkIn: 0,
|
||||
networkOut: 0
|
||||
},
|
||||
logs: []
|
||||
}
|
||||
|
||||
this.games.set(config.id, gameInstance)
|
||||
|
||||
// 如果启用了自动启动
|
||||
if (config.autoStart) {
|
||||
setTimeout(() => {
|
||||
// 这里需要一个socket实例,暂时跳过自动启动
|
||||
// this.startGame(socket, config.id)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`加载游戏配置失败 ${file}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(`加载了 ${this.games.size} 个游戏配置`)
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('加载游戏配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有游戏
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.logger.info('开始清理所有游戏进程...')
|
||||
|
||||
for (const game of this.games.values()) {
|
||||
if (game.process && !game.process.killed) {
|
||||
try {
|
||||
game.process.kill('SIGTERM')
|
||||
} catch (error) {
|
||||
this.logger.error(`清理游戏进程失败 ${game.config.name}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info('所有游戏进程已清理完成')
|
||||
}
|
||||
}
|
||||
684
server/src/modules/system/SystemManager.ts
Normal file
684
server/src/modules/system/SystemManager.ts
Normal file
@@ -0,0 +1,684 @@
|
||||
import { Server as SocketIOServer, Socket } from 'socket.io'
|
||||
import winston from 'winston'
|
||||
import os from 'os'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
interface SystemInfo {
|
||||
platform: string
|
||||
arch: string
|
||||
hostname: string
|
||||
uptime: number
|
||||
totalMemory: number
|
||||
freeMemory: number
|
||||
cpuCount: number
|
||||
cpuModel: string
|
||||
nodeVersion: string
|
||||
serverVersion: string
|
||||
serverUptime: number
|
||||
}
|
||||
|
||||
interface SystemStats {
|
||||
timestamp: Date
|
||||
cpu: {
|
||||
usage: number
|
||||
cores: number
|
||||
model: string
|
||||
speed: number
|
||||
}
|
||||
memory: {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
usage: number
|
||||
}
|
||||
disk: {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
usage: number
|
||||
}
|
||||
network: {
|
||||
bytesIn: number
|
||||
bytesOut: number
|
||||
packetsIn: number
|
||||
packetsOut: number
|
||||
}
|
||||
processes: {
|
||||
total: number
|
||||
running: number
|
||||
sleeping: number
|
||||
}
|
||||
load: {
|
||||
avg1: number
|
||||
avg5: number
|
||||
avg15: number
|
||||
}
|
||||
}
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number
|
||||
name: string
|
||||
cpu: number
|
||||
memory: number
|
||||
status: string
|
||||
startTime: Date
|
||||
command: string
|
||||
}
|
||||
|
||||
interface DiskInfo {
|
||||
filesystem: string
|
||||
size: number
|
||||
used: number
|
||||
available: number
|
||||
usage: number
|
||||
mountpoint: string
|
||||
}
|
||||
|
||||
interface NetworkInterface {
|
||||
name: string
|
||||
address: string
|
||||
netmask: string
|
||||
family: string
|
||||
mac: string
|
||||
internal: boolean
|
||||
cidr: string
|
||||
}
|
||||
|
||||
interface SystemAlert {
|
||||
id: string
|
||||
type: 'cpu' | 'memory' | 'disk' | 'network' | 'process'
|
||||
level: 'info' | 'warning' | 'critical'
|
||||
message: string
|
||||
value: number
|
||||
threshold: number
|
||||
timestamp: Date
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
interface AlertThresholds {
|
||||
cpu: { warning: number; critical: number }
|
||||
memory: { warning: number; critical: number }
|
||||
disk: { warning: number; critical: number }
|
||||
network: { warning: number; critical: number }
|
||||
}
|
||||
|
||||
export class SystemManager extends EventEmitter {
|
||||
private io: SocketIOServer
|
||||
private logger: winston.Logger
|
||||
private serverStartTime: Date
|
||||
private statsHistory: SystemStats[] = []
|
||||
private alerts: Map<string, SystemAlert> = new Map()
|
||||
private alertThresholds: AlertThresholds
|
||||
private monitoringInterval?: NodeJS.Timeout
|
||||
private lastNetworkStats: any = null
|
||||
|
||||
constructor(io: SocketIOServer, logger: winston.Logger) {
|
||||
super()
|
||||
this.io = io
|
||||
this.logger = logger
|
||||
this.serverStartTime = new Date()
|
||||
|
||||
// 默认告警阈值
|
||||
this.alertThresholds = {
|
||||
cpu: { warning: 70, critical: 90 },
|
||||
memory: { warning: 80, critical: 95 },
|
||||
disk: { warning: 85, critical: 95 },
|
||||
network: { warning: 100 * 1024 * 1024, critical: 500 * 1024 * 1024 } // MB/s
|
||||
}
|
||||
|
||||
this.logger.info('系统监控管理器初始化完成')
|
||||
|
||||
// 开始监控
|
||||
this.startMonitoring()
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始系统监控
|
||||
*/
|
||||
private startMonitoring(): void {
|
||||
// 每5秒收集一次系统统计信息
|
||||
this.monitoringInterval = setInterval(async () => {
|
||||
try {
|
||||
const stats = await this.collectSystemStats()
|
||||
this.statsHistory.push(stats)
|
||||
|
||||
// 保持最近1小时的数据 (720个数据点)
|
||||
if (this.statsHistory.length > 720) {
|
||||
this.statsHistory = this.statsHistory.slice(-720)
|
||||
}
|
||||
|
||||
// 检查告警
|
||||
this.checkAlerts(stats)
|
||||
|
||||
// 发送统计信息到客户端
|
||||
this.io.to('system-stats').emit('system-stats', stats)
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('收集系统统计信息失败:', error)
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止系统监控
|
||||
*/
|
||||
private stopMonitoring(): void {
|
||||
if (this.monitoringInterval) {
|
||||
clearInterval(this.monitoringInterval)
|
||||
this.monitoringInterval = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统基本信息
|
||||
*/
|
||||
public async getSystemInfo(): Promise<SystemInfo> {
|
||||
const cpus = os.cpus()
|
||||
|
||||
return {
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
hostname: os.hostname(),
|
||||
uptime: os.uptime(),
|
||||
totalMemory: os.totalmem(),
|
||||
freeMemory: os.freemem(),
|
||||
cpuCount: cpus.length,
|
||||
cpuModel: cpus[0]?.model || 'Unknown',
|
||||
nodeVersion: process.version,
|
||||
serverVersion: process.env.npm_package_version || '1.0.0',
|
||||
serverUptime: Date.now() - this.serverStartTime.getTime()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集系统统计信息
|
||||
*/
|
||||
private async collectSystemStats(): Promise<SystemStats> {
|
||||
const cpuUsage = await this.getCpuUsage()
|
||||
const memoryInfo = this.getMemoryInfo()
|
||||
const diskInfo = await this.getDiskInfo()
|
||||
const networkInfo = await this.getNetworkInfo()
|
||||
const processInfo = await this.getProcessInfo()
|
||||
const loadInfo = this.getLoadInfo()
|
||||
|
||||
return {
|
||||
timestamp: new Date(),
|
||||
cpu: cpuUsage,
|
||||
memory: memoryInfo,
|
||||
disk: diskInfo,
|
||||
network: networkInfo,
|
||||
processes: processInfo,
|
||||
load: loadInfo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取CPU使用率
|
||||
*/
|
||||
private async getCpuUsage(): Promise<SystemStats['cpu']> {
|
||||
return new Promise((resolve) => {
|
||||
const cpus = os.cpus()
|
||||
const startMeasure = cpus.map(cpu => {
|
||||
const total = Object.values(cpu.times).reduce((acc, time) => acc + time, 0)
|
||||
const idle = cpu.times.idle
|
||||
return { total, idle }
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
const endMeasure = os.cpus().map(cpu => {
|
||||
const total = Object.values(cpu.times).reduce((acc, time) => acc + time, 0)
|
||||
const idle = cpu.times.idle
|
||||
return { total, idle }
|
||||
})
|
||||
|
||||
let totalUsage = 0
|
||||
for (let i = 0; i < startMeasure.length; i++) {
|
||||
const totalDiff = endMeasure[i].total - startMeasure[i].total
|
||||
const idleDiff = endMeasure[i].idle - startMeasure[i].idle
|
||||
const usage = 100 - (100 * idleDiff / totalDiff)
|
||||
totalUsage += usage
|
||||
}
|
||||
|
||||
const avgUsage = totalUsage / cpus.length
|
||||
|
||||
resolve({
|
||||
usage: Math.round(avgUsage * 100) / 100,
|
||||
cores: cpus.length,
|
||||
model: cpus[0]?.model || 'Unknown',
|
||||
speed: cpus[0]?.speed || 0
|
||||
})
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内存信息
|
||||
*/
|
||||
private getMemoryInfo(): SystemStats['memory'] {
|
||||
const total = os.totalmem()
|
||||
const free = os.freemem()
|
||||
const used = total - free
|
||||
const usage = (used / total) * 100
|
||||
|
||||
return {
|
||||
total,
|
||||
used,
|
||||
free,
|
||||
usage: Math.round(usage * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取磁盘信息
|
||||
*/
|
||||
private async getDiskInfo(): Promise<SystemStats['disk']> {
|
||||
try {
|
||||
let command: string
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
command = 'wmic logicaldisk get size,freespace,caption'
|
||||
} else {
|
||||
command = 'df -h /'
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(command)
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
// 解析Windows输出
|
||||
const lines = stdout.trim().split('\n').slice(1)
|
||||
let totalSize = 0
|
||||
let totalFree = 0
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/)
|
||||
if (parts.length >= 3) {
|
||||
const size = parseInt(parts[2]) || 0
|
||||
const free = parseInt(parts[1]) || 0
|
||||
totalSize += size
|
||||
totalFree += free
|
||||
}
|
||||
}
|
||||
|
||||
const used = totalSize - totalFree
|
||||
const usage = totalSize > 0 ? (used / totalSize) * 100 : 0
|
||||
|
||||
return {
|
||||
total: totalSize,
|
||||
used,
|
||||
free: totalFree,
|
||||
usage: Math.round(usage * 100) / 100
|
||||
}
|
||||
} else {
|
||||
// 解析Linux输出
|
||||
const lines = stdout.trim().split('\n')
|
||||
const dataLine = lines[1]
|
||||
const parts = dataLine.split(/\s+/)
|
||||
|
||||
const total = this.parseSize(parts[1])
|
||||
const used = this.parseSize(parts[2])
|
||||
const free = this.parseSize(parts[3])
|
||||
const usage = parseFloat(parts[4].replace('%', ''))
|
||||
|
||||
return {
|
||||
total,
|
||||
used,
|
||||
free,
|
||||
usage
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('获取磁盘信息失败:', error)
|
||||
return {
|
||||
total: 0,
|
||||
used: 0,
|
||||
free: 0,
|
||||
usage: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网络信息
|
||||
*/
|
||||
private async getNetworkInfo(): Promise<SystemStats['network']> {
|
||||
try {
|
||||
let command: string
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
command = 'typeperf "\\Network Interface(*)\\Bytes Total/sec" -sc 1'
|
||||
} else {
|
||||
command = 'cat /proc/net/dev'
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(command)
|
||||
|
||||
// 简化的网络统计,实际实现需要更复杂的解析
|
||||
const currentStats = {
|
||||
bytesIn: 0,
|
||||
bytesOut: 0,
|
||||
packetsIn: 0,
|
||||
packetsOut: 0
|
||||
}
|
||||
|
||||
if (this.lastNetworkStats) {
|
||||
return {
|
||||
bytesIn: Math.max(0, currentStats.bytesIn - this.lastNetworkStats.bytesIn),
|
||||
bytesOut: Math.max(0, currentStats.bytesOut - this.lastNetworkStats.bytesOut),
|
||||
packetsIn: Math.max(0, currentStats.packetsIn - this.lastNetworkStats.packetsIn),
|
||||
packetsOut: Math.max(0, currentStats.packetsOut - this.lastNetworkStats.packetsOut)
|
||||
}
|
||||
}
|
||||
|
||||
this.lastNetworkStats = currentStats
|
||||
return currentStats
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
bytesIn: 0,
|
||||
bytesOut: 0,
|
||||
packetsIn: 0,
|
||||
packetsOut: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进程信息
|
||||
*/
|
||||
private async getProcessInfo(): Promise<SystemStats['processes']> {
|
||||
try {
|
||||
let command: string
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
command = 'tasklist /fo csv | find /c /v ""'
|
||||
} else {
|
||||
command = 'ps aux | wc -l'
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(command)
|
||||
const total = parseInt(stdout.trim()) || 0
|
||||
|
||||
return {
|
||||
total,
|
||||
running: Math.floor(total * 0.1), // 估算
|
||||
sleeping: Math.floor(total * 0.9) // 估算
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
total: 0,
|
||||
running: 0,
|
||||
sleeping: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统负载信息
|
||||
*/
|
||||
private getLoadInfo(): SystemStats['load'] {
|
||||
try {
|
||||
const loadavg = os.loadavg()
|
||||
return {
|
||||
avg1: Math.round(loadavg[0] * 100) / 100,
|
||||
avg5: Math.round(loadavg[1] * 100) / 100,
|
||||
avg15: Math.round(loadavg[2] * 100) / 100
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
avg1: 0,
|
||||
avg5: 0,
|
||||
avg15: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网络接口信息
|
||||
*/
|
||||
public getNetworkInterfaces(): NetworkInterface[] {
|
||||
const interfaces = os.networkInterfaces()
|
||||
const result: NetworkInterface[] = []
|
||||
|
||||
for (const [name, addresses] of Object.entries(interfaces)) {
|
||||
if (addresses) {
|
||||
for (const addr of addresses) {
|
||||
result.push({
|
||||
name,
|
||||
address: addr.address,
|
||||
netmask: addr.netmask,
|
||||
family: addr.family,
|
||||
mac: addr.mac,
|
||||
internal: addr.internal,
|
||||
cidr: addr.cidr || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取磁盘信息列表
|
||||
*/
|
||||
public async getDiskList(): Promise<DiskInfo[]> {
|
||||
try {
|
||||
let command: string
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
command = 'wmic logicaldisk get size,freespace,caption'
|
||||
} else {
|
||||
command = 'df -h'
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(command)
|
||||
const result: DiskInfo[] = []
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
const lines = stdout.trim().split('\n').slice(1)
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/)
|
||||
if (parts.length >= 3) {
|
||||
const size = parseInt(parts[2]) || 0
|
||||
const free = parseInt(parts[1]) || 0
|
||||
const used = size - free
|
||||
const usage = size > 0 ? (used / size) * 100 : 0
|
||||
|
||||
result.push({
|
||||
filesystem: parts[0] || '',
|
||||
size,
|
||||
used,
|
||||
available: free,
|
||||
usage: Math.round(usage * 100) / 100,
|
||||
mountpoint: parts[0] || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const lines = stdout.trim().split('\n').slice(1)
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split(/\s+/)
|
||||
if (parts.length >= 6) {
|
||||
result.push({
|
||||
filesystem: parts[0],
|
||||
size: this.parseSize(parts[1]),
|
||||
used: this.parseSize(parts[2]),
|
||||
available: this.parseSize(parts[3]),
|
||||
usage: parseFloat(parts[4].replace('%', '')),
|
||||
mountpoint: parts[5]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
this.logger.error('获取磁盘列表失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进程列表
|
||||
*/
|
||||
public async getProcessList(): Promise<ProcessInfo[]> {
|
||||
try {
|
||||
let command: string
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
command = 'tasklist /fo csv'
|
||||
} else {
|
||||
command = 'ps aux'
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(command)
|
||||
const result: ProcessInfo[] = []
|
||||
|
||||
// 简化的进程列表解析
|
||||
const lines = stdout.trim().split('\n').slice(1)
|
||||
|
||||
for (let i = 0; i < Math.min(lines.length, 50); i++) { // 限制返回50个进程
|
||||
const line = lines[i]
|
||||
const parts = line.split(/\s+/)
|
||||
|
||||
if (parts.length >= 5) {
|
||||
result.push({
|
||||
pid: parseInt(parts[1]) || 0,
|
||||
name: parts[0] || '',
|
||||
cpu: parseFloat(parts[2]) || 0,
|
||||
memory: parseFloat(parts[3]) || 0,
|
||||
status: 'running',
|
||||
startTime: new Date(),
|
||||
command: parts.slice(4).join(' ')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
this.logger.error('获取进程列表失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查告警
|
||||
*/
|
||||
private checkAlerts(stats: SystemStats): void {
|
||||
// CPU告警
|
||||
this.checkAlert('cpu', stats.cpu.usage, this.alertThresholds.cpu, 'CPU使用率')
|
||||
|
||||
// 内存告警
|
||||
this.checkAlert('memory', stats.memory.usage, this.alertThresholds.memory, '内存使用率')
|
||||
|
||||
// 磁盘告警
|
||||
this.checkAlert('disk', stats.disk.usage, this.alertThresholds.disk, '磁盘使用率')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个指标告警
|
||||
*/
|
||||
private checkAlert(
|
||||
type: SystemAlert['type'],
|
||||
value: number,
|
||||
thresholds: { warning: number; critical: number },
|
||||
description: string
|
||||
): void {
|
||||
const alertId = `${type}-alert`
|
||||
const existingAlert = this.alerts.get(alertId)
|
||||
|
||||
let level: SystemAlert['level'] | null = null
|
||||
|
||||
if (value >= thresholds.critical) {
|
||||
level = 'critical'
|
||||
} else if (value >= thresholds.warning) {
|
||||
level = 'warning'
|
||||
}
|
||||
|
||||
if (level) {
|
||||
if (!existingAlert || existingAlert.level !== level) {
|
||||
const alert: SystemAlert = {
|
||||
id: alertId,
|
||||
type,
|
||||
level,
|
||||
message: `${description}达到${level === 'critical' ? '严重' : '警告'}阈值`,
|
||||
value,
|
||||
threshold: level === 'critical' ? thresholds.critical : thresholds.warning,
|
||||
timestamp: new Date(),
|
||||
resolved: false
|
||||
}
|
||||
|
||||
this.alerts.set(alertId, alert)
|
||||
this.io.emit('system-alert', alert)
|
||||
this.logger.warn(`系统告警: ${alert.message}, 当前值: ${value}%`)
|
||||
}
|
||||
} else if (existingAlert && !existingAlert.resolved) {
|
||||
// 告警解除
|
||||
existingAlert.resolved = true
|
||||
this.io.emit('system-alert-resolved', existingAlert)
|
||||
this.logger.info(`系统告警解除: ${existingAlert.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计历史
|
||||
*/
|
||||
public getStatsHistory(minutes: number = 60): SystemStats[] {
|
||||
const pointsNeeded = Math.floor(minutes * 60 / 5) // 每5秒一个数据点
|
||||
return this.statsHistory.slice(-pointsNeeded)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃告警
|
||||
*/
|
||||
public getActiveAlerts(): SystemAlert[] {
|
||||
return Array.from(this.alerts.values()).filter(alert => !alert.resolved)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置告警阈值
|
||||
*/
|
||||
public setAlertThresholds(thresholds: Partial<AlertThresholds>): void {
|
||||
this.alertThresholds = { ...this.alertThresholds, ...thresholds }
|
||||
this.logger.info('告警阈值已更新:', this.alertThresholds)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析大小字符串(如 "1.5G" -> 字节数)
|
||||
*/
|
||||
private parseSize(sizeStr: string): number {
|
||||
const units: { [key: string]: number } = {
|
||||
'K': 1024,
|
||||
'M': 1024 * 1024,
|
||||
'G': 1024 * 1024 * 1024,
|
||||
'T': 1024 * 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
const match = sizeStr.match(/^([0-9.]+)([KMGT]?)$/)
|
||||
if (!match) return 0
|
||||
|
||||
const value = parseFloat(match[1])
|
||||
const unit = match[2] || ''
|
||||
|
||||
return Math.floor(value * (units[unit] || 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.logger.info('开始清理系统监控资源...')
|
||||
this.stopMonitoring()
|
||||
this.alerts.clear()
|
||||
this.statsHistory = []
|
||||
this.logger.info('系统监控资源已清理完成')
|
||||
}
|
||||
}
|
||||
382
server/src/modules/terminal/TerminalManager.ts
Normal file
382
server/src/modules/terminal/TerminalManager.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { spawn, ChildProcess } from 'child_process'
|
||||
import { Server as SocketIOServer, Socket } from 'socket.io'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import winston from 'winston'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import os from 'os'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
interface PtySession {
|
||||
id: string
|
||||
process: ChildProcess
|
||||
socket: Socket
|
||||
workingDirectory: string
|
||||
createdAt: Date
|
||||
lastActivity: Date
|
||||
}
|
||||
|
||||
interface CreatePtyData {
|
||||
sessionId: string
|
||||
cols: number
|
||||
rows: number
|
||||
workingDirectory?: string
|
||||
}
|
||||
|
||||
interface TerminalInputData {
|
||||
sessionId: string
|
||||
data: string
|
||||
}
|
||||
|
||||
interface TerminalResizeData {
|
||||
sessionId: string
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
|
||||
export class TerminalManager {
|
||||
private sessions: Map<string, PtySession> = new Map()
|
||||
private io: SocketIOServer
|
||||
private logger: winston.Logger
|
||||
private ptyPath: string
|
||||
|
||||
constructor(io: SocketIOServer, logger: winston.Logger) {
|
||||
this.io = io
|
||||
this.logger = logger
|
||||
|
||||
// 根据操作系统选择PTY程序路径
|
||||
const platform = os.platform()
|
||||
if (platform === 'win32') {
|
||||
this.ptyPath = path.resolve(__dirname, '../../../PTY/pty_win32_x64.exe')
|
||||
} else {
|
||||
this.ptyPath = path.resolve(__dirname, '../../../PTY/pty_linux_x64')
|
||||
}
|
||||
|
||||
this.logger.info(`终端管理器初始化完成,PTY路径: ${this.ptyPath}`)
|
||||
|
||||
// 定期清理不活跃的会话
|
||||
setInterval(() => {
|
||||
this.cleanupInactiveSessions()
|
||||
}, 60000) // 每分钟检查一次
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的PTY会话
|
||||
*/
|
||||
public createPty(socket: Socket, data: CreatePtyData): void {
|
||||
try {
|
||||
const { sessionId, cols, rows, workingDirectory = process.cwd() } = data
|
||||
|
||||
this.logger.info(`创建PTY会话: ${sessionId}, 大小: ${cols}x${rows}`)
|
||||
|
||||
// 检查会话是否已存在
|
||||
if (this.sessions.has(sessionId)) {
|
||||
this.logger.warn(`会话 ${sessionId} 已存在,先关闭旧会话`)
|
||||
this.closePty(socket, { sessionId })
|
||||
}
|
||||
|
||||
// 构建PTY命令参数
|
||||
const args = [
|
||||
'-dir', workingDirectory,
|
||||
'-size', `${cols},${rows}`,
|
||||
'-coder', 'UTF-8'
|
||||
]
|
||||
|
||||
// 根据操作系统设置默认shell
|
||||
if (os.platform() === 'win32') {
|
||||
args.push('-cmd', JSON.stringify(['powershell.exe']))
|
||||
} else {
|
||||
args.push('-cmd', JSON.stringify(['/bin/bash']))
|
||||
}
|
||||
|
||||
this.logger.info(`启动PTY进程: ${this.ptyPath} ${args.join(' ')}`)
|
||||
|
||||
// 启动PTY进程
|
||||
const ptyProcess = spawn(this.ptyPath, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor'
|
||||
}
|
||||
})
|
||||
|
||||
this.logger.info(`PTY进程已启动,PID: ${ptyProcess.pid}`)
|
||||
|
||||
// 创建会话对象
|
||||
const session: PtySession = {
|
||||
id: sessionId,
|
||||
process: ptyProcess,
|
||||
socket,
|
||||
workingDirectory,
|
||||
createdAt: new Date(),
|
||||
lastActivity: new Date()
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, session)
|
||||
|
||||
// 处理PTY输出
|
||||
ptyProcess.stdout?.on('data', (data: Buffer) => {
|
||||
session.lastActivity = new Date()
|
||||
const output = data.toString()
|
||||
this.logger.debug(`PTY输出 ${sessionId}: ${JSON.stringify(output)}`)
|
||||
socket.emit('terminal-output', {
|
||||
sessionId,
|
||||
data: output
|
||||
})
|
||||
})
|
||||
|
||||
// 处理PTY错误输出
|
||||
ptyProcess.stderr?.on('data', (data: Buffer) => {
|
||||
session.lastActivity = new Date()
|
||||
const output = data.toString()
|
||||
this.logger.warn(`PTY错误输出 ${sessionId}: ${JSON.stringify(output)}`)
|
||||
socket.emit('terminal-output', {
|
||||
sessionId,
|
||||
data: output
|
||||
})
|
||||
})
|
||||
|
||||
// 处理进程退出
|
||||
ptyProcess.on('exit', (code, signal) => {
|
||||
this.logger.info(`PTY进程退出: ${sessionId}, 退出码: ${code}, 信号: ${signal}`)
|
||||
socket.emit('terminal-exit', {
|
||||
sessionId,
|
||||
code: code || 0,
|
||||
signal
|
||||
})
|
||||
this.sessions.delete(sessionId)
|
||||
})
|
||||
|
||||
// 处理进程错误
|
||||
ptyProcess.on('error', (error) => {
|
||||
this.logger.error(`PTY进程错误 ${sessionId}:`, error)
|
||||
socket.emit('terminal-error', {
|
||||
sessionId,
|
||||
error: error.message
|
||||
})
|
||||
this.sessions.delete(sessionId)
|
||||
})
|
||||
|
||||
// 发送创建成功事件
|
||||
socket.emit('pty-created', {
|
||||
sessionId,
|
||||
workingDirectory
|
||||
})
|
||||
|
||||
this.logger.info(`PTY会话创建成功: ${sessionId}`)
|
||||
|
||||
// 发送初始欢迎信息和提示符
|
||||
setTimeout(() => {
|
||||
if (ptyProcess.stdin && !ptyProcess.stdin.destroyed) {
|
||||
// 发送一个回车来触发初始提示符
|
||||
ptyProcess.stdin.write('\r')
|
||||
}
|
||||
}, 500) // 延迟500ms确保PTY完全初始化
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`创建PTY会话失败:`, error)
|
||||
socket.emit('terminal-error', {
|
||||
sessionId: data.sessionId,
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理终端输入
|
||||
*/
|
||||
public handleInput(socket: Socket, data: TerminalInputData): void {
|
||||
try {
|
||||
const { sessionId, data: inputData } = data
|
||||
const session = this.sessions.get(sessionId)
|
||||
|
||||
if (!session) {
|
||||
this.logger.warn(`会话不存在: ${sessionId}`)
|
||||
socket.emit('terminal-error', {
|
||||
sessionId,
|
||||
error: '会话不存在'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新最后活动时间
|
||||
session.lastActivity = new Date()
|
||||
|
||||
// 发送输入到PTY进程
|
||||
if (session.process.stdin && !session.process.stdin.destroyed) {
|
||||
session.process.stdin.write(inputData)
|
||||
} else {
|
||||
this.logger.warn(`PTY进程stdin不可用: ${sessionId}`)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`处理终端输入失败:`, error)
|
||||
socket.emit('terminal-error', {
|
||||
sessionId: data.sessionId,
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整终端大小
|
||||
*/
|
||||
public resizeTerminal(socket: Socket, data: TerminalResizeData): void {
|
||||
try {
|
||||
const { sessionId, cols, rows } = data
|
||||
const session = this.sessions.get(sessionId)
|
||||
|
||||
if (!session) {
|
||||
this.logger.warn(`会话不存在: ${sessionId}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.info(`调整终端大小: ${sessionId}, ${cols}x${rows}`)
|
||||
|
||||
// 更新最后活动时间
|
||||
session.lastActivity = new Date()
|
||||
|
||||
// 发送调整大小命令到PTY进程
|
||||
// 注意:这里需要根据PTY程序的具体实现来调整
|
||||
// 目前的PTY程序可能不支持动态调整大小,这里只是示例
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`调整终端大小失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭PTY会话
|
||||
*/
|
||||
public closePty(socket: Socket, data: { sessionId: string }): void {
|
||||
try {
|
||||
const { sessionId } = data
|
||||
const session = this.sessions.get(sessionId)
|
||||
|
||||
if (!session) {
|
||||
this.logger.warn(`尝试关闭不存在的会话: ${sessionId}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.info(`关闭PTY会话: ${sessionId}`)
|
||||
|
||||
// 终止PTY进程
|
||||
if (!session.process.killed) {
|
||||
session.process.kill('SIGTERM')
|
||||
|
||||
// 如果进程在3秒内没有退出,强制杀死
|
||||
setTimeout(() => {
|
||||
if (!session.process.killed) {
|
||||
session.process.kill('SIGKILL')
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 从会话列表中移除
|
||||
this.sessions.delete(sessionId)
|
||||
|
||||
// 通知客户端会话已关闭
|
||||
socket.emit('pty-closed', { sessionId })
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`关闭PTY会话失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端断开连接
|
||||
*/
|
||||
public handleDisconnect(socket: Socket): void {
|
||||
try {
|
||||
// 找到属于该socket的所有会话并关闭
|
||||
const sessionsToClose: string[] = []
|
||||
|
||||
for (const [sessionId, session] of this.sessions.entries()) {
|
||||
if (session.socket.id === socket.id) {
|
||||
sessionsToClose.push(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionId of sessionsToClose) {
|
||||
this.closePty(socket, { sessionId })
|
||||
}
|
||||
|
||||
if (sessionsToClose.length > 0) {
|
||||
this.logger.info(`客户端断开连接,关闭了 ${sessionsToClose.length} 个会话`)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`处理客户端断开连接失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理不活跃的会话
|
||||
*/
|
||||
private cleanupInactiveSessions(): void {
|
||||
try {
|
||||
const now = new Date()
|
||||
const inactiveThreshold = 30 * 60 * 1000 // 30分钟
|
||||
const sessionsToClose: string[] = []
|
||||
|
||||
for (const [sessionId, session] of this.sessions.entries()) {
|
||||
const inactiveTime = now.getTime() - session.lastActivity.getTime()
|
||||
|
||||
if (inactiveTime > inactiveThreshold) {
|
||||
sessionsToClose.push(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionId of sessionsToClose) {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
this.logger.info(`清理不活跃会话: ${sessionId}`)
|
||||
this.closePty(session.socket, { sessionId })
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`清理不活跃会话失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃会话统计
|
||||
*/
|
||||
public getSessionStats(): { total: number; sessions: Array<{ id: string; createdAt: Date; lastActivity: Date }> } {
|
||||
const sessions = Array.from(this.sessions.values()).map(session => ({
|
||||
id: session.id,
|
||||
createdAt: session.createdAt,
|
||||
lastActivity: session.lastActivity
|
||||
}))
|
||||
|
||||
return {
|
||||
total: sessions.length,
|
||||
sessions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有会话
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.logger.info('开始清理所有终端会话...')
|
||||
|
||||
for (const [sessionId, session] of this.sessions.entries()) {
|
||||
try {
|
||||
if (!session.process.killed) {
|
||||
session.process.kill('SIGTERM')
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`清理会话 ${sessionId} 失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
this.sessions.clear()
|
||||
this.logger.info('所有终端会话已清理完成')
|
||||
}
|
||||
}
|
||||
203
server/src/routes/auth.ts
Normal file
203
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { AuthManager } from '../modules/auth/AuthManager'
|
||||
import { authenticateToken, AuthenticatedRequest, requireAdmin } from '../middleware/auth'
|
||||
import logger from '../utils/logger'
|
||||
import Joi from 'joi'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 登录限流
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15分钟
|
||||
max: 5, // 最多5次尝试
|
||||
message: {
|
||||
error: '请求过于频繁',
|
||||
message: '登录尝试次数过多,请15分钟后再试'
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
// 验证schemas
|
||||
const loginSchema = Joi.object({
|
||||
username: Joi.string().alphanum().min(3).max(30).required().messages({
|
||||
'string.alphanum': '用户名只能包含字母和数字',
|
||||
'string.min': '用户名至少3个字符',
|
||||
'string.max': '用户名最多30个字符',
|
||||
'any.required': '用户名是必填项'
|
||||
}),
|
||||
password: Joi.string().min(6).required().messages({
|
||||
'string.min': '密码至少6个字符',
|
||||
'any.required': '密码是必填项'
|
||||
})
|
||||
})
|
||||
|
||||
const changePasswordSchema = Joi.object({
|
||||
oldPassword: Joi.string().required().messages({
|
||||
'any.required': '原密码是必填项'
|
||||
}),
|
||||
newPassword: Joi.string().min(6).required().messages({
|
||||
'string.min': '新密码至少6个字符',
|
||||
'any.required': '新密码是必填项'
|
||||
})
|
||||
})
|
||||
|
||||
// 设置认证路由的函数
|
||||
export function setupAuthRoutes(authManager: AuthManager): Router {
|
||||
// 登录接口
|
||||
router.post('/login', loginLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!authManager) {
|
||||
return res.status(500).json({ error: '认证管理器未初始化' })
|
||||
}
|
||||
|
||||
// 验证请求数据
|
||||
const { error, value } = loginSchema.validate(req.body)
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
error: '请求数据无效',
|
||||
message: error.details[0].message
|
||||
})
|
||||
}
|
||||
|
||||
const { username, password } = value
|
||||
const clientIP = req.ip || req.connection.remoteAddress || 'unknown'
|
||||
|
||||
const result = await authManager.login(username, password, clientIP)
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
token: result.token,
|
||||
user: result.user
|
||||
})
|
||||
} else {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: result.message
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('登录接口错误:', error)
|
||||
res.status(500).json({
|
||||
error: '服务器内部错误',
|
||||
message: '登录失败,请稍后重试'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 验证token接口
|
||||
router.get('/verify', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
||||
res.json({
|
||||
success: true,
|
||||
user: req.user,
|
||||
message: 'Token有效'
|
||||
})
|
||||
})
|
||||
|
||||
// 修改密码接口
|
||||
router.post('/change-password', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
if (!authManager) {
|
||||
return res.status(500).json({ error: '认证管理器未初始化' })
|
||||
}
|
||||
|
||||
// 验证请求数据
|
||||
const { error, value } = changePasswordSchema.validate(req.body)
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
error: '请求数据无效',
|
||||
message: error.details[0].message
|
||||
})
|
||||
}
|
||||
|
||||
const { oldPassword, newPassword } = value
|
||||
const username = req.user!.username
|
||||
|
||||
const result = await authManager.changePassword(username, oldPassword, newPassword)
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.message
|
||||
})
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.message
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('修改密码接口错误:', error)
|
||||
res.status(500).json({
|
||||
error: '服务器内部错误',
|
||||
message: '修改密码失败,请稍后重试'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取用户列表(仅管理员)
|
||||
router.get('/users', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
if (!authManager) {
|
||||
return res.status(500).json({ error: '认证管理器未初始化' })
|
||||
}
|
||||
|
||||
const users = authManager.getUsers()
|
||||
res.json({
|
||||
success: true,
|
||||
users
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取用户列表错误:', error)
|
||||
res.status(500).json({
|
||||
error: '服务器内部错误',
|
||||
message: '获取用户列表失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取登录尝试记录(仅管理员)
|
||||
router.get('/login-attempts', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
if (!authManager) {
|
||||
return res.status(500).json({ error: '认证管理器未初始化' })
|
||||
}
|
||||
|
||||
const limit = parseInt(req.query.limit as string) || 100
|
||||
const attempts = authManager.getLoginAttempts(limit)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
attempts
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取登录尝试记录错误:', error)
|
||||
res.status(500).json({
|
||||
error: '服务器内部错误',
|
||||
message: '获取登录记录失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 登出接口(客户端处理,服务端记录)
|
||||
router.post('/logout', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
logger.info(`用户 ${req.user!.username} 登出`)
|
||||
res.json({
|
||||
success: true,
|
||||
message: '登出成功'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('登出接口错误:', error)
|
||||
res.status(500).json({
|
||||
error: '服务器内部错误',
|
||||
message: '登出失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
606
server/src/routes/files.ts
Normal file
606
server/src/routes/files.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { createReadStream, createWriteStream } from 'fs'
|
||||
import archiver from 'archiver'
|
||||
import unzipper from 'unzipper'
|
||||
import multer from 'multer'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 配置文件上传
|
||||
const upload = multer({
|
||||
dest: 'uploads/',
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024 // 100MB
|
||||
}
|
||||
})
|
||||
|
||||
// 安全路径检查
|
||||
const isValidPath = (filePath: string): boolean => {
|
||||
const normalizedPath = path.normalize(filePath)
|
||||
return !normalizedPath.includes('..') && path.isAbsolute(normalizedPath)
|
||||
}
|
||||
|
||||
// 获取目录内容
|
||||
router.get('/list', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { path: dirPath = '/home' } = req.query
|
||||
|
||||
if (!isValidPath(dirPath as string)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的路径'
|
||||
})
|
||||
}
|
||||
|
||||
const stats = await fs.stat(dirPath as string)
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '指定路径不是目录'
|
||||
})
|
||||
}
|
||||
|
||||
const items = await fs.readdir(dirPath as string)
|
||||
const files = []
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dirPath as string, item)
|
||||
try {
|
||||
const itemStats = await fs.stat(itemPath)
|
||||
files.push({
|
||||
name: item,
|
||||
path: itemPath,
|
||||
type: itemStats.isDirectory() ? 'directory' : 'file',
|
||||
size: itemStats.size,
|
||||
modified: itemStats.mtime.toISOString()
|
||||
})
|
||||
} catch (error) {
|
||||
// 跳过无法访问的文件
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: files
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 读取文件内容
|
||||
router.get('/read', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { path: filePath, encoding = 'utf-8' } = req.query
|
||||
|
||||
if (!isValidPath(filePath as string)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的路径'
|
||||
})
|
||||
}
|
||||
|
||||
const stats = await fs.stat(filePath as string)
|
||||
if (!stats.isFile()) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '指定路径不是文件'
|
||||
})
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath as string, encoding as BufferEncoding)
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
content,
|
||||
encoding,
|
||||
size: stats.size,
|
||||
modified: stats.mtime.toISOString()
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 保存文件内容
|
||||
router.post('/save', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { path: filePath, content, encoding = 'utf-8' } = req.body
|
||||
|
||||
if (!isValidPath(filePath)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的路径'
|
||||
})
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, content, encoding)
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: '文件保存成功'
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 创建目录
|
||||
router.post('/mkdir', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { path: dirPath } = req.body
|
||||
|
||||
if (!isValidPath(dirPath)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的路径'
|
||||
})
|
||||
}
|
||||
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: '目录创建成功'
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除文件或目录
|
||||
router.delete('/delete', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { paths } = req.body
|
||||
|
||||
if (!Array.isArray(paths)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '路径必须是数组'
|
||||
})
|
||||
}
|
||||
|
||||
for (const filePath of paths) {
|
||||
if (!isValidPath(filePath)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: `无效的路径: ${filePath}`
|
||||
})
|
||||
}
|
||||
|
||||
const stats = await fs.stat(filePath)
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rmdir(filePath, { recursive: true })
|
||||
} else {
|
||||
await fs.unlink(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: '删除成功'
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 重命名文件或目录
|
||||
router.post('/rename', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { oldPath, newPath } = req.body
|
||||
|
||||
if (!isValidPath(oldPath) || !isValidPath(newPath)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的路径'
|
||||
})
|
||||
}
|
||||
|
||||
await fs.rename(oldPath, newPath)
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: '重命名成功'
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 复制文件或目录
|
||||
router.post('/copy', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sourcePath, targetPath } = req.body
|
||||
|
||||
if (!isValidPath(sourcePath) || !isValidPath(targetPath)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的路径'
|
||||
})
|
||||
}
|
||||
|
||||
const stats = await fs.stat(sourcePath)
|
||||
|
||||
if (stats.isFile()) {
|
||||
await fs.copyFile(sourcePath, targetPath)
|
||||
} else {
|
||||
// 递归复制目录
|
||||
await copyDirectory(sourcePath, targetPath)
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: '复制成功'
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 移动文件或目录
|
||||
router.post('/move', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sourcePath, targetPath } = req.body
|
||||
|
||||
if (!isValidPath(sourcePath) || !isValidPath(targetPath)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的路径'
|
||||
})
|
||||
}
|
||||
|
||||
await fs.rename(sourcePath, targetPath)
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: '移动成功'
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索文件
|
||||
router.get('/search', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
path: searchPath = '/home',
|
||||
query,
|
||||
type = 'all',
|
||||
case_sensitive = false,
|
||||
max_results = 100
|
||||
} = req.query
|
||||
|
||||
if (!query) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '搜索关键词不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
if (!isValidPath(searchPath as string)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的路径'
|
||||
})
|
||||
}
|
||||
|
||||
const results = await searchFiles(
|
||||
searchPath as string,
|
||||
query as string,
|
||||
type as string,
|
||||
case_sensitive === 'true',
|
||||
parseInt(max_results as string)
|
||||
)
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
results,
|
||||
total_found: results.length,
|
||||
truncated: results.length >= parseInt(max_results as string)
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 下载文件
|
||||
router.get('/download', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { path: filePath } = req.query
|
||||
|
||||
if (!isValidPath(filePath as string)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的路径'
|
||||
})
|
||||
}
|
||||
|
||||
const stats = await fs.stat(filePath as string)
|
||||
if (!stats.isFile()) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '指定路径不是文件'
|
||||
})
|
||||
}
|
||||
|
||||
const fileName = path.basename(filePath as string)
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`)
|
||||
res.setHeader('Content-Type', 'application/octet-stream')
|
||||
|
||||
const fileStream = createReadStream(filePath as string)
|
||||
fileStream.pipe(res)
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 上传文件
|
||||
router.post('/upload', upload.array('files'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { targetPath } = req.body
|
||||
const files = req.files as Express.Multer.File[]
|
||||
|
||||
if (!isValidPath(targetPath)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的目标路径'
|
||||
})
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '没有上传文件'
|
||||
})
|
||||
}
|
||||
|
||||
const uploadedFiles = []
|
||||
|
||||
for (const file of files) {
|
||||
const targetFilePath = path.join(targetPath, file.originalname)
|
||||
await fs.rename(file.path, targetFilePath)
|
||||
uploadedFiles.push({
|
||||
name: file.originalname,
|
||||
path: targetFilePath,
|
||||
size: file.size
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: '文件上传成功',
|
||||
files: uploadedFiles
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 压缩文件夹
|
||||
router.post('/compress', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
sourcePaths,
|
||||
targetPath,
|
||||
archiveName,
|
||||
format = 'zip',
|
||||
compressionLevel = 6
|
||||
} = req.body
|
||||
|
||||
if (!Array.isArray(sourcePaths) || sourcePaths.length === 0) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '源路径不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
for (const sourcePath of sourcePaths) {
|
||||
if (!isValidPath(sourcePath)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: `无效的源路径: ${sourcePath}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValidPath(targetPath)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的目标路径'
|
||||
})
|
||||
}
|
||||
|
||||
const archivePath = path.join(targetPath, archiveName)
|
||||
|
||||
await compressFiles(sourcePaths, archivePath, format, compressionLevel)
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: '压缩完成',
|
||||
archivePath
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 解压文件
|
||||
router.post('/extract', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { archivePath, targetPath } = req.body
|
||||
|
||||
if (!isValidPath(archivePath) || !isValidPath(targetPath)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: '无效的路径'
|
||||
})
|
||||
}
|
||||
|
||||
await extractArchive(archivePath, targetPath)
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: '解压完成'
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 辅助函数
|
||||
async function copyDirectory(src: string, dest: string) {
|
||||
await fs.mkdir(dest, { recursive: true })
|
||||
const items = await fs.readdir(src)
|
||||
|
||||
for (const item of items) {
|
||||
const srcPath = path.join(src, item)
|
||||
const destPath = path.join(dest, item)
|
||||
const stats = await fs.stat(srcPath)
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await copyDirectory(srcPath, destPath)
|
||||
} else {
|
||||
await fs.copyFile(srcPath, destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function searchFiles(
|
||||
searchPath: string,
|
||||
query: string,
|
||||
type: string,
|
||||
caseSensitive: boolean,
|
||||
maxResults: number
|
||||
): Promise<any[]> {
|
||||
const results: any[] = []
|
||||
const searchQuery = caseSensitive ? query : query.toLowerCase()
|
||||
|
||||
async function searchRecursive(currentPath: string) {
|
||||
if (results.length >= maxResults) return
|
||||
|
||||
try {
|
||||
const items = await fs.readdir(currentPath)
|
||||
|
||||
for (const item of items) {
|
||||
if (results.length >= maxResults) break
|
||||
|
||||
const itemPath = path.join(currentPath, item)
|
||||
const stats = await fs.stat(itemPath)
|
||||
const itemName = caseSensitive ? item : item.toLowerCase()
|
||||
|
||||
if (itemName.includes(searchQuery)) {
|
||||
if (type === 'all' ||
|
||||
(type === 'file' && stats.isFile()) ||
|
||||
(type === 'directory' && stats.isDirectory())) {
|
||||
results.push({
|
||||
name: item,
|
||||
path: itemPath,
|
||||
type: stats.isDirectory() ? 'directory' : 'file',
|
||||
size: stats.size,
|
||||
modified: stats.mtime.toISOString(),
|
||||
parent_dir: currentPath
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await searchRecursive(itemPath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 跳过无法访问的目录
|
||||
}
|
||||
}
|
||||
|
||||
await searchRecursive(searchPath)
|
||||
return results
|
||||
}
|
||||
|
||||
async function compressFiles(
|
||||
sourcePaths: string[],
|
||||
archivePath: string,
|
||||
format: string,
|
||||
compressionLevel: number
|
||||
) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const output = createWriteStream(archivePath)
|
||||
const archive = archiver(format as any, {
|
||||
zlib: { level: compressionLevel }
|
||||
})
|
||||
|
||||
output.on('close', () => resolve())
|
||||
archive.on('error', (err) => reject(err))
|
||||
|
||||
archive.pipe(output)
|
||||
|
||||
for (const sourcePath of sourcePaths) {
|
||||
const stats = require('fs').statSync(sourcePath)
|
||||
const name = path.basename(sourcePath)
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
archive.directory(sourcePath, name)
|
||||
} else {
|
||||
archive.file(sourcePath, { name })
|
||||
}
|
||||
}
|
||||
|
||||
archive.finalize()
|
||||
})
|
||||
}
|
||||
|
||||
async function extractArchive(archivePath: string, targetPath: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
createReadStream(archivePath)
|
||||
.pipe(unzipper.Extract({ path: targetPath }))
|
||||
.on('close', () => resolve())
|
||||
.on('error', (err) => reject(err))
|
||||
})
|
||||
}
|
||||
|
||||
export default router
|
||||
472
server/src/routes/games.ts
Normal file
472
server/src/routes/games.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { GameManager } from '../modules/game/GameManager'
|
||||
import logger from '../utils/logger'
|
||||
import Joi from 'joi'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 注意:这里需要在实际使用时注入GameManager实例
|
||||
let gameManager: GameManager
|
||||
|
||||
// 设置GameManager实例的函数
|
||||
export function setGameManager(manager: GameManager) {
|
||||
gameManager = manager
|
||||
}
|
||||
|
||||
// 游戏配置验证模式
|
||||
const gameConfigSchema = Joi.object({
|
||||
name: Joi.string().required().min(1).max(100),
|
||||
type: Joi.string().valid('minecraft', 'terraria', 'custom').required(),
|
||||
executable: Joi.string().required(),
|
||||
args: Joi.array().items(Joi.string()),
|
||||
workingDirectory: Joi.string().required(),
|
||||
autoStart: Joi.boolean().default(false),
|
||||
autoRestart: Joi.boolean().default(false),
|
||||
maxMemory: Joi.string().optional(),
|
||||
minMemory: Joi.string().optional(),
|
||||
javaPath: Joi.string().optional(),
|
||||
port: Joi.number().integer().min(1).max(65535).optional(),
|
||||
maxPlayers: Joi.number().integer().min(1).optional(),
|
||||
description: Joi.string().max(500).optional(),
|
||||
icon: Joi.string().optional()
|
||||
})
|
||||
|
||||
// 获取游戏模板列表
|
||||
router.get('/templates', (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!gameManager) {
|
||||
return res.status(500).json({ error: '游戏管理器未初始化' })
|
||||
}
|
||||
|
||||
const templates = gameManager.getTemplates()
|
||||
res.json({
|
||||
success: true,
|
||||
data: templates
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取游戏模板失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取模板失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取游戏列表
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!gameManager) {
|
||||
return res.status(500).json({ error: '游戏管理器未初始化' })
|
||||
}
|
||||
|
||||
const games = gameManager.getGames()
|
||||
res.json({
|
||||
success: true,
|
||||
data: games
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取游戏列表失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取游戏列表失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 创建新游戏
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!gameManager) {
|
||||
return res.status(500).json({ error: '游戏管理器未初始化' })
|
||||
}
|
||||
|
||||
// 验证请求数据
|
||||
const { error, value } = gameConfigSchema.validate(req.body)
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '配置验证失败',
|
||||
details: error.details.map(d => d.message)
|
||||
})
|
||||
}
|
||||
|
||||
// 创建游戏(这里需要模拟socket)
|
||||
const mockSocket = {
|
||||
emit: (event: string, data: any) => {
|
||||
logger.info(`Socket事件: ${event}`, data)
|
||||
}
|
||||
} as any
|
||||
|
||||
await gameManager.createGame(mockSocket, value)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '游戏创建成功'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('创建游戏失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '创建游戏失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取单个游戏信息
|
||||
router.get('/:gameId', (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!gameManager) {
|
||||
return res.status(500).json({ error: '游戏管理器未初始化' })
|
||||
}
|
||||
|
||||
const { gameId } = req.params
|
||||
const games = gameManager.getGames()
|
||||
const game = games.find(g => g.id === gameId)
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '游戏不存在'
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: game
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取游戏信息失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取游戏信息失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 启动游戏
|
||||
router.post('/:gameId/start', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!gameManager) {
|
||||
return res.status(500).json({ error: '游戏管理器未初始化' })
|
||||
}
|
||||
|
||||
const { gameId } = req.params
|
||||
|
||||
const mockSocket = {
|
||||
emit: (event: string, data: any) => {
|
||||
logger.info(`Socket事件: ${event}`, data)
|
||||
}
|
||||
} as any
|
||||
|
||||
await gameManager.startGame(mockSocket, gameId)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '游戏启动命令已发送'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('启动游戏失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '启动游戏失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 停止游戏
|
||||
router.post('/:gameId/stop', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!gameManager) {
|
||||
return res.status(500).json({ error: '游戏管理器未初始化' })
|
||||
}
|
||||
|
||||
const { gameId } = req.params
|
||||
|
||||
const mockSocket = {
|
||||
emit: (event: string, data: any) => {
|
||||
logger.info(`Socket事件: ${event}`, data)
|
||||
}
|
||||
} as any
|
||||
|
||||
await gameManager.stopGame(mockSocket, gameId)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '游戏停止命令已发送'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('停止游戏失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '停止游戏失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 重启游戏
|
||||
router.post('/:gameId/restart', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!gameManager) {
|
||||
return res.status(500).json({ error: '游戏管理器未初始化' })
|
||||
}
|
||||
|
||||
const { gameId } = req.params
|
||||
|
||||
const mockSocket = {
|
||||
emit: (event: string, data: any) => {
|
||||
logger.info(`Socket事件: ${event}`, data)
|
||||
}
|
||||
} as any
|
||||
|
||||
await gameManager.restartGame(mockSocket, gameId)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '游戏重启命令已发送'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('重启游戏失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '重启游戏失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 发送游戏命令
|
||||
router.post('/:gameId/command', (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!gameManager) {
|
||||
return res.status(500).json({ error: '游戏管理器未初始化' })
|
||||
}
|
||||
|
||||
const { gameId } = req.params
|
||||
const { command } = req.body
|
||||
|
||||
if (!command || typeof command !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '命令不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
const mockSocket = {
|
||||
emit: (event: string, data: any) => {
|
||||
logger.info(`Socket事件: ${event}`, data)
|
||||
}
|
||||
} as any
|
||||
|
||||
gameManager.sendCommand(mockSocket, gameId, command)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '命令已发送'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('发送游戏命令失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '发送命令失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除游戏
|
||||
router.delete('/:gameId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!gameManager) {
|
||||
return res.status(500).json({ error: '游戏管理器未初始化' })
|
||||
}
|
||||
|
||||
const { gameId } = req.params
|
||||
|
||||
const mockSocket = {
|
||||
emit: (event: string, data: any) => {
|
||||
logger.info(`Socket事件: ${event}`, data)
|
||||
}
|
||||
} as any
|
||||
|
||||
await gameManager.deleteGame(mockSocket, gameId)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '游戏删除成功'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('删除游戏失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '删除游戏失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 验证游戏配置
|
||||
router.post('/validate', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { error, value } = gameConfigSchema.validate(req.body)
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '配置验证失败',
|
||||
details: error.details.map(d => ({
|
||||
field: d.path.join('.'),
|
||||
message: d.message
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '配置验证通过',
|
||||
data: value
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('验证游戏配置失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '配置验证失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取游戏类型的默认配置
|
||||
router.get('/types/:type/defaults', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type } = req.params
|
||||
|
||||
const defaults: { [key: string]: any } = {
|
||||
minecraft: {
|
||||
executable: 'java',
|
||||
args: ['-Xmx2G', '-Xms1G', '-jar', 'server.jar', 'nogui'],
|
||||
port: 25565,
|
||||
maxPlayers: 20,
|
||||
maxMemory: '2G',
|
||||
minMemory: '1G'
|
||||
},
|
||||
terraria: {
|
||||
executable: 'TerrariaServer.exe',
|
||||
args: ['-server', '-world', 'world.wld'],
|
||||
port: 7777,
|
||||
maxPlayers: 8
|
||||
},
|
||||
custom: {
|
||||
executable: '',
|
||||
args: [],
|
||||
port: 25565,
|
||||
maxPlayers: 10
|
||||
}
|
||||
}
|
||||
|
||||
const defaultConfig = defaults[type]
|
||||
if (!defaultConfig) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '不支持的游戏类型'
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: defaultConfig
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取默认配置失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取默认配置失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取支持的游戏类型
|
||||
router.get('/types', (req: Request, res: Response) => {
|
||||
try {
|
||||
const types = [
|
||||
{
|
||||
id: 'minecraft',
|
||||
name: 'Minecraft',
|
||||
description: 'Minecraft 服务器',
|
||||
icon: '🎮',
|
||||
requiresJava: true,
|
||||
defaultPort: 25565
|
||||
},
|
||||
{
|
||||
id: 'terraria',
|
||||
name: 'Terraria',
|
||||
description: 'Terraria 专用服务器',
|
||||
icon: '🌍',
|
||||
requiresJava: false,
|
||||
defaultPort: 7777
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: '自定义',
|
||||
description: '自定义游戏服务器',
|
||||
icon: '🔧',
|
||||
requiresJava: false,
|
||||
defaultPort: 25565
|
||||
}
|
||||
]
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: types
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取游戏类型失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取游戏类型失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 检查Java环境
|
||||
router.get('/java/check', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { exec } = require('child_process')
|
||||
const { promisify } = require('util')
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync('java -version')
|
||||
const versionMatch = stdout.match(/version "([^"]+)"/)
|
||||
const version = versionMatch ? versionMatch[1] : 'Unknown'
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
installed: true,
|
||||
version,
|
||||
path: process.env.JAVA_HOME || 'java'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
error: 'Java未安装或不在PATH中'
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('检查Java环境失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '检查Java环境失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 设置路由的函数
|
||||
export function setupGameRoutes(manager: GameManager) {
|
||||
setGameManager(manager)
|
||||
return router
|
||||
}
|
||||
|
||||
export default router
|
||||
35
server/src/routes/index.ts
Normal file
35
server/src/routes/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Router } from 'express'
|
||||
import terminalRoutes from './terminal'
|
||||
import gameRoutes from './games'
|
||||
import systemRoutes from './system'
|
||||
import fileRoutes from './files'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 健康检查
|
||||
router.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
version: process.env.npm_package_version || '1.0.0'
|
||||
})
|
||||
})
|
||||
|
||||
// API版本信息
|
||||
router.get('/version', (req, res) => {
|
||||
res.json({
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch
|
||||
})
|
||||
})
|
||||
|
||||
// 注册子路由
|
||||
router.use('/terminal', terminalRoutes)
|
||||
router.use('/games', gameRoutes)
|
||||
router.use('/system', systemRoutes)
|
||||
router.use('/files', fileRoutes)
|
||||
|
||||
export default router
|
||||
515
server/src/routes/system.ts
Normal file
515
server/src/routes/system.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { SystemManager } from '../modules/system/SystemManager'
|
||||
import logger from '../utils/logger'
|
||||
import os from 'os'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 注意:这里需要在实际使用时注入SystemManager实例
|
||||
let systemManager: SystemManager
|
||||
|
||||
// 设置SystemManager实例的函数
|
||||
export function setSystemManager(manager: SystemManager) {
|
||||
systemManager = manager
|
||||
}
|
||||
|
||||
// 获取系统基本信息
|
||||
router.get('/info', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!systemManager) {
|
||||
return res.status(500).json({ error: '系统管理器未初始化' })
|
||||
}
|
||||
|
||||
const systemInfo = await systemManager.getSystemInfo()
|
||||
res.json({
|
||||
success: true,
|
||||
data: systemInfo
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取系统信息失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取系统信息失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取系统统计历史
|
||||
router.get('/stats', (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!systemManager) {
|
||||
return res.status(500).json({ error: '系统管理器未初始化' })
|
||||
}
|
||||
|
||||
const minutes = parseInt(req.query.minutes as string) || 60
|
||||
const stats = systemManager.getStatsHistory(minutes)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
stats,
|
||||
period: minutes,
|
||||
count: stats.length
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取系统统计失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取系统统计失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取活跃告警
|
||||
router.get('/alerts', (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!systemManager) {
|
||||
return res.status(500).json({ error: '系统管理器未初始化' })
|
||||
}
|
||||
|
||||
const alerts = systemManager.getActiveAlerts()
|
||||
res.json({
|
||||
success: true,
|
||||
data: alerts
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取系统告警失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取告警失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 设置告警阈值
|
||||
router.post('/alerts/thresholds', (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!systemManager) {
|
||||
return res.status(500).json({ error: '系统管理器未初始化' })
|
||||
}
|
||||
|
||||
const thresholds = req.body
|
||||
|
||||
// 验证阈值格式
|
||||
const validKeys = ['cpu', 'memory', 'disk', 'network']
|
||||
for (const key of Object.keys(thresholds)) {
|
||||
if (!validKeys.includes(key)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `无效的阈值类型: ${key}`
|
||||
})
|
||||
}
|
||||
|
||||
const threshold = thresholds[key]
|
||||
if (!threshold.warning || !threshold.critical ||
|
||||
threshold.warning >= threshold.critical ||
|
||||
threshold.warning < 0 || threshold.critical > 100) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `无效的阈值配置: ${key}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
systemManager.setAlertThresholds(thresholds)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '告警阈值设置成功'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('设置告警阈值失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '设置阈值失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取网络接口信息
|
||||
router.get('/network', (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!systemManager) {
|
||||
return res.status(500).json({ error: '系统管理器未初始化' })
|
||||
}
|
||||
|
||||
const interfaces = systemManager.getNetworkInterfaces()
|
||||
res.json({
|
||||
success: true,
|
||||
data: interfaces
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取网络接口失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取网络接口失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取磁盘信息
|
||||
router.get('/disks', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!systemManager) {
|
||||
return res.status(500).json({ error: '系统管理器未初始化' })
|
||||
}
|
||||
|
||||
const disks = await systemManager.getDiskList()
|
||||
res.json({
|
||||
success: true,
|
||||
data: disks
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取磁盘信息失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取磁盘信息失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取进程列表
|
||||
router.get('/processes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!systemManager) {
|
||||
return res.status(500).json({ error: '系统管理器未初始化' })
|
||||
}
|
||||
|
||||
const processes = await systemManager.getProcessList()
|
||||
res.json({
|
||||
success: true,
|
||||
data: processes
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取进程列表失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取进程列表失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取CPU信息
|
||||
router.get('/cpu', (req: Request, res: Response) => {
|
||||
try {
|
||||
const cpus = os.cpus()
|
||||
const cpuInfo = {
|
||||
model: cpus[0]?.model || 'Unknown',
|
||||
speed: cpus[0]?.speed || 0,
|
||||
cores: cpus.length,
|
||||
architecture: os.arch(),
|
||||
details: cpus.map((cpu, index) => ({
|
||||
core: index,
|
||||
model: cpu.model,
|
||||
speed: cpu.speed,
|
||||
times: cpu.times
|
||||
}))
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: cpuInfo
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取CPU信息失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取CPU信息失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取内存信息
|
||||
router.get('/memory', (req: Request, res: Response) => {
|
||||
try {
|
||||
const total = os.totalmem()
|
||||
const free = os.freemem()
|
||||
const used = total - free
|
||||
|
||||
const memoryInfo = {
|
||||
total,
|
||||
free,
|
||||
used,
|
||||
usage: (used / total) * 100,
|
||||
available: free,
|
||||
formatted: {
|
||||
total: formatBytes(total),
|
||||
free: formatBytes(free),
|
||||
used: formatBytes(used)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: memoryInfo
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取内存信息失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取内存信息失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取系统负载
|
||||
router.get('/load', (req: Request, res: Response) => {
|
||||
try {
|
||||
const loadavg = os.loadavg()
|
||||
const uptime = os.uptime()
|
||||
|
||||
const loadInfo = {
|
||||
avg1: loadavg[0],
|
||||
avg5: loadavg[1],
|
||||
avg15: loadavg[2],
|
||||
uptime,
|
||||
uptimeFormatted: formatUptime(uptime)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: loadInfo
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取系统负载失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取系统负载失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取环境变量
|
||||
router.get('/env', (req: Request, res: Response) => {
|
||||
try {
|
||||
// 只返回安全的环境变量
|
||||
const safeEnvVars = {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NODE_VERSION: process.version,
|
||||
PLATFORM: process.platform,
|
||||
ARCH: process.arch,
|
||||
HOME: process.env.HOME || process.env.USERPROFILE,
|
||||
USER: process.env.USER || process.env.USERNAME,
|
||||
SHELL: process.env.SHELL,
|
||||
PATH: process.env.PATH,
|
||||
JAVA_HOME: process.env.JAVA_HOME,
|
||||
PORT: process.env.PORT
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: safeEnvVars
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取环境变量失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取环境变量失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取日志文件列表
|
||||
router.get('/logs', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const logDir = path.resolve(process.cwd(), 'logs')
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(logDir)
|
||||
const logFiles = []
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.log')) {
|
||||
const filePath = path.join(logDir, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
|
||||
logFiles.push({
|
||||
name: file,
|
||||
size: stats.size,
|
||||
sizeFormatted: formatBytes(stats.size),
|
||||
modified: stats.mtime,
|
||||
created: stats.birthtime
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: logFiles
|
||||
})
|
||||
} catch (error) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
message: '日志目录不存在或为空'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('获取日志文件列表失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取日志列表失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取日志文件内容
|
||||
router.get('/logs/:filename', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filename } = req.params
|
||||
const lines = parseInt(req.query.lines as string) || 100
|
||||
|
||||
// 安全检查:只允许读取.log文件
|
||||
if (!filename.endsWith('.log') || filename.includes('..')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '无效的文件名'
|
||||
})
|
||||
}
|
||||
|
||||
const logDir = path.resolve(process.cwd(), 'logs')
|
||||
const filePath = path.join(logDir, filename)
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const logLines = content.split('\n').filter(line => line.trim())
|
||||
const recentLines = logLines.slice(-lines)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filename,
|
||||
lines: recentLines,
|
||||
totalLines: logLines.length,
|
||||
requestedLines: lines
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: '日志文件不存在'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('读取日志文件失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '读取日志失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 清理日志文件
|
||||
router.delete('/logs/:filename', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filename } = req.params
|
||||
|
||||
// 安全检查
|
||||
if (!filename.endsWith('.log') || filename.includes('..')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '无效的文件名'
|
||||
})
|
||||
}
|
||||
|
||||
const logDir = path.resolve(process.cwd(), 'logs')
|
||||
const filePath = path.join(logDir, filename)
|
||||
|
||||
await fs.unlink(filePath)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '日志文件删除成功'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('删除日志文件失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '删除日志失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 系统重启(仅重启应用)
|
||||
router.post('/restart', (req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
success: true,
|
||||
message: '应用重启命令已发送'
|
||||
})
|
||||
|
||||
// 延迟重启以确保响应发送
|
||||
setTimeout(() => {
|
||||
logger.info('应用重启中...')
|
||||
process.exit(0)
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
logger.error('重启应用失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '重启失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取系统时间
|
||||
router.get('/time', (req: Request, res: Response) => {
|
||||
try {
|
||||
const now = new Date()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
timestamp: now.getTime(),
|
||||
iso: now.toISOString(),
|
||||
local: now.toLocaleString(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
offset: now.getTimezoneOffset()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取系统时间失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取时间失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 工具函数:格式化字节数
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 工具函数:格式化运行时间
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
const parts = []
|
||||
if (days > 0) parts.push(`${days}天`)
|
||||
if (hours > 0) parts.push(`${hours}小时`)
|
||||
if (minutes > 0) parts.push(`${minutes}分钟`)
|
||||
if (secs > 0 || parts.length === 0) parts.push(`${secs}秒`)
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
// 设置路由的函数
|
||||
export function setupSystemRoutes(manager: SystemManager) {
|
||||
setSystemManager(manager)
|
||||
return router
|
||||
}
|
||||
|
||||
export default router
|
||||
307
server/src/routes/terminal.ts
Normal file
307
server/src/routes/terminal.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { TerminalManager } from '../modules/terminal/TerminalManager'
|
||||
import logger from '../utils/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 注意:这里需要在实际使用时注入TerminalManager实例
|
||||
let terminalManager: TerminalManager
|
||||
|
||||
// 设置TerminalManager实例的函数
|
||||
export function setTerminalManager(manager: TerminalManager) {
|
||||
terminalManager = manager
|
||||
}
|
||||
|
||||
// 获取终端会话统计
|
||||
router.get('/stats', (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!terminalManager) {
|
||||
return res.status(500).json({ error: '终端管理器未初始化' })
|
||||
}
|
||||
|
||||
const stats = terminalManager.getSessionStats()
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取终端统计失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取统计失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取终端会话列表
|
||||
router.get('/sessions', (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!terminalManager) {
|
||||
return res.status(500).json({ error: '终端管理器未初始化' })
|
||||
}
|
||||
|
||||
const stats = terminalManager.getSessionStats()
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessions: stats.sessions,
|
||||
total: stats.total
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取终端会话列表失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取会话列表失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 验证终端配置
|
||||
router.post('/validate-config', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { workingDirectory, shell } = req.body
|
||||
|
||||
// 基本验证
|
||||
const errors: string[] = []
|
||||
|
||||
if (!workingDirectory) {
|
||||
errors.push('工作目录不能为空')
|
||||
}
|
||||
|
||||
if (shell && typeof shell !== 'string') {
|
||||
errors.push('Shell配置格式错误')
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '配置验证通过'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('验证终端配置失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '配置验证失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取系统默认Shell
|
||||
router.get('/default-shell', (req: Request, res: Response) => {
|
||||
try {
|
||||
const platform = process.platform
|
||||
let defaultShell: string
|
||||
let availableShells: string[]
|
||||
|
||||
if (platform === 'win32') {
|
||||
defaultShell = 'powershell.exe'
|
||||
availableShells = [
|
||||
'powershell.exe',
|
||||
'cmd.exe',
|
||||
'pwsh.exe' // PowerShell Core
|
||||
]
|
||||
} else {
|
||||
defaultShell = process.env.SHELL || '/bin/bash'
|
||||
availableShells = [
|
||||
'/bin/bash',
|
||||
'/bin/sh',
|
||||
'/bin/zsh',
|
||||
'/bin/fish'
|
||||
]
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
platform,
|
||||
defaultShell,
|
||||
availableShells,
|
||||
currentShell: process.env.SHELL || defaultShell
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取默认Shell失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取默认Shell失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取终端主题配置
|
||||
router.get('/themes', (req: Request, res: Response) => {
|
||||
try {
|
||||
const themes = [
|
||||
{
|
||||
name: 'default',
|
||||
displayName: '默认',
|
||||
colors: {
|
||||
background: '#000000',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#ffffff',
|
||||
selection: '#ffffff40'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'dark',
|
||||
displayName: '深色',
|
||||
colors: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
selection: '#264f78'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'light',
|
||||
displayName: '浅色',
|
||||
colors: {
|
||||
background: '#ffffff',
|
||||
foreground: '#000000',
|
||||
cursor: '#000000',
|
||||
selection: '#0078d4'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'monokai',
|
||||
displayName: 'Monokai',
|
||||
colors: {
|
||||
background: '#272822',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f0',
|
||||
selection: '#49483e'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'solarized-dark',
|
||||
displayName: 'Solarized Dark',
|
||||
colors: {
|
||||
background: '#002b36',
|
||||
foreground: '#839496',
|
||||
cursor: '#93a1a1',
|
||||
selection: '#073642'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: themes
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取终端主题失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取主题失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取终端字体配置
|
||||
router.get('/fonts', (req: Request, res: Response) => {
|
||||
try {
|
||||
const fonts = [
|
||||
{
|
||||
family: 'Consolas',
|
||||
displayName: 'Consolas',
|
||||
monospace: true
|
||||
},
|
||||
{
|
||||
family: 'Monaco',
|
||||
displayName: 'Monaco',
|
||||
monospace: true
|
||||
},
|
||||
{
|
||||
family: 'Menlo',
|
||||
displayName: 'Menlo',
|
||||
monospace: true
|
||||
},
|
||||
{
|
||||
family: 'Courier New',
|
||||
displayName: 'Courier New',
|
||||
monospace: true
|
||||
},
|
||||
{
|
||||
family: 'monospace',
|
||||
displayName: '系统等宽字体',
|
||||
monospace: true
|
||||
},
|
||||
{
|
||||
family: 'Fira Code',
|
||||
displayName: 'Fira Code',
|
||||
monospace: true,
|
||||
ligatures: true
|
||||
},
|
||||
{
|
||||
family: 'Source Code Pro',
|
||||
displayName: 'Source Code Pro',
|
||||
monospace: true
|
||||
},
|
||||
{
|
||||
family: 'JetBrains Mono',
|
||||
displayName: 'JetBrains Mono',
|
||||
monospace: true,
|
||||
ligatures: true
|
||||
}
|
||||
]
|
||||
|
||||
const sizes = [8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 22, 24, 26, 28, 30]
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fonts,
|
||||
sizes,
|
||||
defaultFont: 'Consolas',
|
||||
defaultSize: 14
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取终端字体失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取字体失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 测试终端连接
|
||||
router.post('/test-connection', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { workingDirectory } = req.body
|
||||
|
||||
// 简单的连接测试
|
||||
// 在实际实现中,可以尝试创建一个临时的PTY会话来测试
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '终端连接测试成功',
|
||||
data: {
|
||||
workingDirectory: workingDirectory || process.cwd(),
|
||||
platform: process.platform,
|
||||
shell: process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/bash')
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('测试终端连接失败:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '连接测试失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 设置路由的函数
|
||||
export function setupTerminalRoutes(manager: TerminalManager) {
|
||||
setTerminalManager(manager)
|
||||
return router
|
||||
}
|
||||
|
||||
export default router
|
||||
177
server/src/utils/logger.ts
Normal file
177
server/src/utils/logger.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import winston from 'winston'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
// 确保日志目录存在
|
||||
const logDir = path.resolve(process.cwd(), 'logs')
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 自定义日志格式
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.printf(({ timestamp, level, message, stack }) => {
|
||||
return `${timestamp} [${level.toUpperCase()}]: ${stack || message}`
|
||||
})
|
||||
)
|
||||
|
||||
// 创建日志器
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
transports: [
|
||||
// 控制台输出
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
logFormat
|
||||
)
|
||||
}),
|
||||
|
||||
// 所有日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'app.log'),
|
||||
maxsize: 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: 5,
|
||||
tailable: true
|
||||
}),
|
||||
|
||||
// 错误日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: 5,
|
||||
tailable: true
|
||||
}),
|
||||
|
||||
// 终端日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'terminal.log'),
|
||||
level: 'info',
|
||||
maxsize: 50 * 1024 * 1024, // 50MB
|
||||
maxFiles: 3,
|
||||
tailable: true,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.printf(({ timestamp, level, message }) => {
|
||||
return `${timestamp} [TERMINAL]: ${message}`
|
||||
})
|
||||
)
|
||||
}),
|
||||
|
||||
// 游戏日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'games.log'),
|
||||
level: 'info',
|
||||
maxsize: 50 * 1024 * 1024, // 50MB
|
||||
maxFiles: 3,
|
||||
tailable: true,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.printf(({ timestamp, level, message }) => {
|
||||
return `${timestamp} [GAMES]: ${message}`
|
||||
})
|
||||
)
|
||||
}),
|
||||
|
||||
// 系统监控日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'system.log'),
|
||||
level: 'info',
|
||||
maxsize: 20 * 1024 * 1024, // 20MB
|
||||
maxFiles: 3,
|
||||
tailable: true,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.printf(({ timestamp, level, message }) => {
|
||||
return `${timestamp} [SYSTEM]: ${message}`
|
||||
})
|
||||
)
|
||||
})
|
||||
],
|
||||
|
||||
// 处理未捕获的异常
|
||||
exceptionHandlers: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'exceptions.log'),
|
||||
maxsize: 10 * 1024 * 1024,
|
||||
maxFiles: 3
|
||||
})
|
||||
],
|
||||
|
||||
// 处理未处理的Promise拒绝
|
||||
rejectionHandlers: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'rejections.log'),
|
||||
maxsize: 10 * 1024 * 1024,
|
||||
maxFiles: 3
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
// 创建专用日志器
|
||||
export const terminalLogger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.printf(({ timestamp, message }) => {
|
||||
return `${timestamp} ${message}`
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'terminal.log'),
|
||||
maxsize: 50 * 1024 * 1024,
|
||||
maxFiles: 3,
|
||||
tailable: true
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
export const gameLogger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.printf(({ timestamp, message }) => {
|
||||
return `${timestamp} ${message}`
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'games.log'),
|
||||
maxsize: 50 * 1024 * 1024,
|
||||
maxFiles: 3,
|
||||
tailable: true
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
export const systemLogger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.printf(({ timestamp, message }) => {
|
||||
return `${timestamp} ${message}`
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'system.log'),
|
||||
maxsize: 20 * 1024 * 1024,
|
||||
maxFiles: 3,
|
||||
tailable: true
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
// 在生产环境中不输出到控制台
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
logger.remove(logger.transports[0]) // 移除控制台传输
|
||||
}
|
||||
|
||||
export default logger
|
||||
34
server/tsconfig.json
Normal file
34
server/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"removeComments": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
24
报错
Normal file
24
报错
Normal file
@@ -0,0 +1,24 @@
|
||||
react-dom.development.js:18704
|
||||
The above error occurred in the <App> component:
|
||||
|
||||
at App (http://localhost:3000/src/App.tsx:30:38)
|
||||
at ErrorBoundary (http://localhost:3000/src/main.tsx:10:5)
|
||||
|
||||
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
|
||||
main.tsx:21
|
||||
应用程序错误: TypeError: initializeTheme is not a function
|
||||
at App.tsx:18:5
|
||||
|
||||
{componentStack: '\n at App (http://localhost:3000/src/App.tsx:30:…oundary (http://localhost:3000/src/main.tsx:10:5)'}
|
||||
react-dom.development.js:18704
|
||||
The above error occurred in the <App> component:
|
||||
|
||||
at App (http://localhost:3000/src/App.tsx:30:38)
|
||||
at ErrorBoundary (http://localhost:3000/src/main.tsx:10:5)
|
||||
|
||||
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
|
||||
main.tsx:21
|
||||
应用程序错误: TypeError: initializeTheme is not a function
|
||||
at App.tsx:18:5
|
||||
|
||||
{componentStack: '\n at App (http://localhost:3000/src/App.tsx:30:…oundary (http://localhost:3000/src/main.tsx:10:5)'}
|
||||
Reference in New Issue
Block a user