This commit is contained in:
小朱
2025-07-08 11:51:05 +08:00
commit 208aaba672
50 changed files with 20588 additions and 0 deletions

125
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,12 @@
使用React框架写一个游戏面板后端为node.js 目前只需要三个页面即可 首页 终端(需要实现功能) 设置 要同时兼容Windows和Linux
目前后端已经存在了,你需要写一个美观漂亮并且符合游戏风格的面板
要支持全局深色和浅色模式更改
# 配置文件
统一保存在 server/data 目录下
# 终端
需要做成拓展性很强,因为在后续功能需要调用此终端 需要做到灵活调用并且终端需要在刷新网页时仍然为刷新网页前的状态和所有命令记录由于后端pty已经是一个编译好的模块可以直接通过进程获取具体信息 具体你可以查看后端代码
要支持终端的所有交互
# 登录
需要实现用户登录jwt密钥不要采用硬编 而是通过每次启动后随机生成到配置文件

102
client/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

43
client/package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

103
client/src/App.tsx Normal file
View 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

View 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

View 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

View 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
View 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
View 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>,
)

View 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

View 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

View 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

View 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

View 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,
}),
}
)
)

View 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: [] })
},
}))

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

Binary file not shown.

67
server/PTY/介绍.md Normal file
View 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

File diff suppressed because it is too large Load Diff

42
server/package.json Normal file
View 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
View 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 }

View 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)
}

View 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)
}
}

View 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
}
}

View 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('所有游戏进程已清理完成')
}
}

View 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('系统监控资源已清理完成')
}
}

View 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
View 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
View 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
View 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

View 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
View 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

View 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
View 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
View 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
View 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)'}