新增在线部署

This commit is contained in:
小朱
2025-07-15 14:26:33 +08:00
parent 88b6bd50aa
commit e6dfa80938
9 changed files with 1139 additions and 5 deletions

View File

@@ -0,0 +1,32 @@
在“游戏部署”-“更多游戏部署”的右边加一个新的标签页“在线部署”并按照下面要求实现功能
进入这个页面首先先校验赞助者密钥是否存在以及在有效期内
然后需要使用后端代理请求向第二后端发送请求
# 获取在线部署游戏
请求地址 POST http://langlangy.server.xiaozhuhouses.asia:10002/api/online-games
请求体
```json
{
"system": "string",
"key": "string"
}
```
system 提交操作系统类型 Windows提交为Windows Linux平台提交Linux
key 提交赞助者密钥
返回
```json
{
"status": "success",
"message": "获取在线部署游戏列表成功",
"system": "Windows",
"data": {
"我的世界基岩版": {
"txt": "我的世界是一款游戏",
"image": "http://images.server.xiaozhuhouses.asia:40061/i/2025/06/12/t0i9wh.jpg",
"download": "http://langlangy.server.xiaozhuhouses.asia:8082/disk1/MC%e5%86%85%e5%ae%b9/%e6%95%b4%e5%90%88%e5%8c%85/%e5%8e%9f%e7%89%88/BE/bedrock-server.zip"
}
}
}
```
前端需要根据返回data中的信息按照steam游戏部署风格进行展示
当用户点击安装需要输入安装路径然后根据download链接后端下载压缩包解压到用户安装的路径。要求下载过程需要做成实时进度并且支持取消功能

View File

@@ -115,10 +115,26 @@ const GameDeploymentPage: React.FC = () => {
const [mrpackInstanceDescription, setMrpackInstanceDescription] = useState('')
const [creatingMrpackInstance, setCreatingMrpackInstance] = useState(false)
// 在线部署相关状态
const [onlineGames, setOnlineGames] = useState<any[]>([])
const [onlineGamesLoading, setOnlineGamesLoading] = useState(false)
const [sponsorKeyValid, setSponsorKeyValid] = useState<boolean | null>(null)
const [sponsorKeyChecking, setSponsorKeyChecking] = useState(false)
const [selectedOnlineGame, setSelectedOnlineGame] = useState<any>(null)
const [onlineGameInstallPath, setOnlineGameInstallPath] = useState('')
const [onlineGameDeploying, setOnlineGameDeploying] = useState(false)
const [onlineGameDeployProgress, setOnlineGameDeployProgress] = useState<any>(null)
const [onlineGameDeployLogs, setOnlineGameDeployLogs] = useState<string[]>([])
const [onlineGameDeployComplete, setOnlineGameDeployComplete] = useState(false)
const [onlineGameDeployResult, setOnlineGameDeployResult] = useState<any>(null)
const [showOnlineGameInstallModal, setShowOnlineGameInstallModal] = useState(false)
const [onlineGameInstallModalAnimating, setOnlineGameInstallModalAnimating] = useState(false)
const socketRef = useRef<Socket | null>(null)
const currentDownloadId = useRef<string | null>(null)
const currentMoreGameDeploymentId = useRef<string | null>(null)
const currentMrpackDeploymentId = useRef<string | null>(null)
const currentOnlineGameDeploymentId = useRef<string | null>(null)
// 获取游戏列表
const fetchGames = async () => {
@@ -166,6 +182,34 @@ const GameDeploymentPage: React.FC = () => {
}
}
// 检查赞助者密钥
const checkSponsorKey = async () => {
try {
setSponsorKeyChecking(true)
const response = await apiClient.getSponsorKeyInfo()
if (response.success && response.data) {
setSponsorKeyValid(response.data.isValid)
if (!response.data.isValid) {
addNotification({
type: 'warning',
title: '密钥已过期',
message: '您的赞助者密钥已过期,请前往设置页面更新密钥'
})
}
} else {
setSponsorKeyValid(false)
}
} catch (error: any) {
console.error('检查赞助者密钥失败:', error)
setSponsorKeyValid(false)
} finally {
setSponsorKeyChecking(false)
}
}
// 部署更多游戏
const deployMoreGame = async () => {
if (!selectedMoreGame || !moreGameInstallPath) {
@@ -622,6 +666,36 @@ const GameDeploymentPage: React.FC = () => {
})
}
})
// 监听在线游戏部署日志
socketRef.current.on('online-deploy-log', (data) => {
if (data.deploymentId === currentOnlineGameDeploymentId.current) {
const message = typeof data.message === 'string' ? data.message : JSON.stringify(data.message)
setOnlineGameDeployLogs(prev => [...prev, message])
}
})
// 监听在线游戏部署进度
socketRef.current.on('online-deploy-progress', (data) => {
console.log('收到在线部署进度:', data)
if (data.deploymentId === currentOnlineGameDeploymentId.current) {
setOnlineGameDeployProgress(data)
}
})
// 监听在线游戏部署完成
socketRef.current.on('online-deploy-complete', (data) => {
console.log('收到在线部署完成事件:', data)
if (data.deploymentId === currentOnlineGameDeploymentId.current) {
setOnlineGameDeploying(false)
setOnlineGameDeployComplete(true)
setOnlineGameDeployResult(data.result)
currentOnlineGameDeploymentId.current = null
// 不显示通知,让用户在模态框中看到结果
// 成功或失败的状态会在UI中显示
}
})
}
// 下载Minecraft服务端
@@ -926,7 +1000,13 @@ const GameDeploymentPage: React.FC = () => {
if (activeTab === 'more-games') {
fetchMoreGames()
}
}, [activeTab])
if (activeTab === 'online-deploy') {
checkSponsorKey()
if (sponsorKeyValid) {
fetchOnlineGames()
}
}
}, [activeTab, sponsorKeyValid])
// 清理WebSocket连接和定时器
useEffect(() => {
@@ -1122,6 +1202,172 @@ const GameDeploymentPage: React.FC = () => {
}
}
// 获取在线游戏列表
const fetchOnlineGames = async () => {
try {
setOnlineGamesLoading(true)
const response = await apiClient.getOnlineGames()
if (response.success) {
setOnlineGames(response.data || [])
} else {
throw new Error(response.message || '获取在线游戏列表失败')
}
} catch (error: any) {
console.error('获取在线游戏列表失败:', error)
addNotification({
type: 'error',
title: '获取失败',
message: error.message || '无法获取在线游戏列表'
})
} finally {
setOnlineGamesLoading(false)
}
}
// 打开在线游戏安装对话框
const handleOpenOnlineGameInstallModal = (game: any) => {
setSelectedOnlineGame(game)
setOnlineGameInstallPath('')
setShowOnlineGameInstallModal(true)
setTimeout(() => setOnlineGameInstallModalAnimating(true), 10)
}
// 关闭在线游戏安装对话框
const handleCloseOnlineGameInstallModal = () => {
setOnlineGameInstallModalAnimating(false)
setTimeout(() => {
setShowOnlineGameInstallModal(false)
setSelectedOnlineGame(null)
setOnlineGameInstallPath('')
// 重置部署相关状态
setOnlineGameDeploying(false)
setOnlineGameDeployProgress(null)
setOnlineGameDeployLogs([])
setOnlineGameDeployComplete(false)
setOnlineGameDeployResult(null)
currentOnlineGameDeploymentId.current = null
}, 300)
}
// 开始在线游戏部署
const startOnlineGameDeployment = async () => {
if (!selectedOnlineGame || !onlineGameInstallPath.trim()) {
addNotification({
type: 'error',
title: '参数错误',
message: '请选择游戏并填写安装路径'
})
return
}
try {
// 重置状态
setOnlineGameDeploying(true)
setOnlineGameDeployProgress(null)
setOnlineGameDeployLogs([])
setOnlineGameDeployComplete(false)
setOnlineGameDeployResult(null)
// 初始化WebSocket连接
initializeSocket()
// 等待WebSocket连接建立
const waitForConnection = () => {
return new Promise<string>((resolve, reject) => {
if (socketRef.current?.connected && socketRef.current?.id) {
resolve(socketRef.current.id)
return
}
const timeout = setTimeout(() => {
reject(new Error('WebSocket连接超时'))
}, 10000)
const checkConnection = () => {
if (socketRef.current?.connected && socketRef.current?.id) {
clearTimeout(timeout)
resolve(socketRef.current.id)
} else {
setTimeout(checkConnection, 100)
}
}
checkConnection()
})
}
const socketId = await waitForConnection()
// 调用部署API
const response = await apiClient.deployOnlineGame({
gameId: selectedOnlineGame.id,
installPath: onlineGameInstallPath.trim(),
socketId
})
if (response.success && response.data?.deploymentId) {
currentOnlineGameDeploymentId.current = response.data.deploymentId
addNotification({
type: 'success',
title: '部署已启动',
message: `${selectedOnlineGame.name} 部署已开始`
})
// 不关闭模态框,保持打开状态以显示部署进度
} else {
throw new Error(response.message || '启动部署失败')
}
} catch (error: any) {
console.error('启动在线游戏部署失败:', error)
setOnlineGameDeploying(false)
addNotification({
type: 'error',
title: '部署失败',
message: error.message || '无法启动在线游戏部署'
})
}
}
// 取消在线游戏部署
const cancelOnlineGameDeployment = async () => {
if (!currentOnlineGameDeploymentId.current) {
addNotification({
type: 'warning',
title: '无法取消',
message: '没有正在进行的在线游戏部署'
})
return
}
try {
const response = await apiClient.cancelOnlineGameDeployment(currentOnlineGameDeploymentId.current)
if (response.success) {
setOnlineGameDeploying(false)
setOnlineGameDeployProgress(null)
currentOnlineGameDeploymentId.current = null
addNotification({
type: 'info',
title: '部署已取消',
message: '在线游戏部署已取消'
})
} else {
throw new Error(response.message || '取消部署失败')
}
} catch (error: any) {
console.error('取消在线游戏部署失败:', error)
addNotification({
type: 'error',
title: '取消失败',
message: error.message || '无法取消在线游戏部署'
})
}
}
// 筛选游戏
const filteredGames = Object.entries(games).filter(([gameKey, gameInfo]) => {
// 搜索筛选
@@ -1148,7 +1394,8 @@ const GameDeploymentPage: React.FC = () => {
{ id: 'steamcmd', name: 'SteamCMD', icon: Download },
{ id: 'minecraft', name: 'Minecraft部署', icon: Pickaxe },
{ id: 'mrpack', name: 'Minecraft整合包部署', icon: Package },
{ id: 'more-games', name: '更多游戏部署', icon: Server }
{ id: 'more-games', name: '更多游戏部署', icon: Server },
{ id: 'online-deploy', name: '在线部署', icon: ExternalLink }
]
if (loading) {
@@ -1361,6 +1608,124 @@ const GameDeploymentPage: React.FC = () => {
</div>
)}
{/* 在线部署标签页内容 */}
{activeTab === 'online-deploy' && (
<div className="space-y-6">
{/* 赞助者密钥状态 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4">
<div className="flex items-center space-x-3">
{sponsorKeyChecking ? (
<Loader className="w-5 h-5 animate-spin text-blue-500" />
) : sponsorKeyValid ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<AlertCircle className="w-5 h-5 text-red-500" />
)}
<div>
<h3 className="font-medium text-gray-900 dark:text-white">
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{sponsorKeyChecking
? '检查中...'
: sponsorKeyValid
? '密钥有效,可以使用在线部署功能'
: '密钥无效或未设置,请前往设置页面配置赞助者密钥'}
</p>
</div>
<button
onClick={checkSponsorKey}
className="ml-auto px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
>
</button>
</div>
</div>
{/* 在线游戏列表 */}
{sponsorKeyValid ? (
onlineGamesLoading ? (
<div className="flex items-center justify-center h-64">
<Loader className="w-8 h-8 animate-spin text-blue-500" />
<span className="ml-2 text-gray-600 dark:text-gray-400">线...</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{onlineGames.length === 0 ? (
<div className="col-span-full text-center py-12">
<div className="text-gray-500 dark:text-gray-400">
<Server className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">线</p>
<p className="text-sm"></p>
</div>
</div>
) : (
onlineGames.map((game) => (
<div
key={game.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
>
{/* 游戏图片 */}
<div className="aspect-video bg-gray-200 dark:bg-gray-700 relative">
<img
src={game.image || 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZGRkIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPuaXoOazleWKoOi9veWbvueJhzwvdGV4dD48L3N2Zz4='}
alt={game.name}
className="w-full h-full object-cover"
/>
<div className="absolute top-2 right-2">
<div className="bg-black/50 text-white px-2 py-1 rounded text-xs">
线
</div>
</div>
</div>
{/* 游戏信息 */}
<div className="p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2 text-center">
{game.name}
</h3>
{/* 游戏描述 */}
{game.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 text-center line-clamp-2">
{game.description}
</p>
)}
{/* 操作按钮 */}
<button
onClick={() => handleOpenOnlineGameInstallModal(game)}
className="w-full bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded-lg transition-colors flex items-center justify-center space-x-2"
>
<Download className="w-4 h-4" />
<span></span>
</button>
</div>
</div>
))
)}
</div>
)
) : (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-yellow-600 dark:text-yellow-400" />
<h3 className="text-lg font-medium text-yellow-800 dark:text-yellow-200 mb-2">
</h3>
<p className="text-yellow-700 dark:text-yellow-300 mb-4">
线使
</p>
<button
onClick={() => navigate('/settings')}
className="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-lg transition-colors"
>
</button>
</div>
)}
</div>
)}
{/* Minecraft 标签页内容 */}
{activeTab === 'minecraft' && (
<div className="space-y-6">
@@ -2571,6 +2936,179 @@ const GameDeploymentPage: React.FC = () => {
</div>
)}
{/* 在线游戏安装对话框 */}
{showOnlineGameInstallModal && selectedOnlineGame && (
<div className={`fixed inset-0 bg-black/50 flex items-center justify-center z-50 transition-opacity duration-300 ${
onlineGameInstallModalAnimating ? 'opacity-100' : 'opacity-0'
}`}>
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 transform transition-all duration-300 ${
onlineGameInstallModalAnimating ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
}`}>
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{selectedOnlineGame.name}
</h3>
<button
onClick={handleCloseOnlineGameInstallModal}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
{/* 游戏信息 */}
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>:</strong> {selectedOnlineGame.name}
</p>
{selectedOnlineGame.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
<strong>:</strong> {selectedOnlineGame.description}
</p>
)}
</div>
{/* 安装路径 */}
{!onlineGameDeploying && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="text"
value={onlineGameInstallPath}
onChange={(e) => setOnlineGameInstallPath(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="输入游戏安装路径"
/>
</div>
)}
{/* 部署进度 */}
{onlineGameDeploying && onlineGameDeployProgress && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{onlineGameDeployProgress.percentage}%
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${onlineGameDeployProgress.percentage}%` }}
></div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
{onlineGameDeployProgress.currentStep}
</p>
</div>
)}
{/* 部署日志 */}
{onlineGameDeploying && onlineGameDeployLogs.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</h4>
<div className="bg-gray-900 text-green-400 p-3 rounded-lg text-xs font-mono max-h-32 overflow-y-auto">
{onlineGameDeployLogs.map((log, index) => (
<div key={index} className="mb-1">
{log}
</div>
))}
</div>
</div>
)}
{/* 部署完成结果 */}
{onlineGameDeployComplete && (
<div className={`rounded-lg p-3 ${
onlineGameDeployResult?.success !== false
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}>
<div className="flex items-center space-x-2">
{onlineGameDeployResult?.success !== false ? (
<CheckCircle className="w-5 h-5 text-green-600" />
) : (
<AlertCircle className="w-5 h-5 text-red-600" />
)}
<h4 className={`text-sm font-medium ${
onlineGameDeployResult?.success !== false
? 'text-green-800 dark:text-green-400'
: 'text-red-800 dark:text-red-400'
}`}>
{onlineGameDeployResult?.success !== false ? '部署完成' : '部署失败'}
</h4>
</div>
<p className={`text-sm mt-1 ${
onlineGameDeployResult?.success !== false
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}>
{onlineGameDeployResult?.message || (onlineGameDeployResult?.success !== false ? '在线游戏部署完成!' : '部署过程中发生错误')}
</p>
{onlineGameDeployResult?.installPath && (
<p className={`text-xs mt-1 ${
onlineGameDeployResult?.success !== false
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}>
: {onlineGameDeployResult.installPath}
</p>
)}
</div>
)}
</div>
<div className="flex space-x-3 p-6 border-t border-gray-200 dark:border-gray-700">
{onlineGameDeployComplete ? (
<button
onClick={handleCloseOnlineGameInstallModal}
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors flex items-center justify-center space-x-2"
>
<CheckCircle className="w-4 h-4" />
<span></span>
</button>
) : (
<>
<button
onClick={onlineGameDeploying ? cancelOnlineGameDeployment : handleCloseOnlineGameInstallModal}
className="flex-1 px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-600 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-500 transition-colors"
>
{onlineGameDeploying ? '取消部署' : '取消'}
</button>
<button
onClick={startOnlineGameDeployment}
disabled={!onlineGameInstallPath.trim() || onlineGameDeploying}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors flex items-center justify-center space-x-2"
>
{onlineGameDeploying ? (
<>
<Loader className="w-4 h-4 animate-spin" />
<span>...</span>
</>
) : (
<>
<Download className="w-4 h-4" />
<span></span>
</>
)}
</button>
</>
)}
</div>
</div>
</div>
)}
{/* 创建整合包实例对话框 */}
{showCreateMrpackInstanceModal && mrpackDeployResult && (
<div className={`fixed inset-0 bg-black/50 flex items-center justify-center z-50 transition-opacity duration-300 ${

View File

@@ -591,6 +591,23 @@ class ApiClient {
async checkPythonEnvironment() {
return this.get('/instances/python/check')
}
// 在线部署API
async getOnlineGames() {
return this.get('/online-deploy/games')
}
async deployOnlineGame(data: {
gameId: string
installPath: string
socketId?: string
}) {
return this.post('/online-deploy/deploy', data)
}
async cancelOnlineGameDeployment(deploymentId: string) {
return this.post('/online-deploy/cancel', { deploymentId })
}
}
// 创建单例实例

View File

@@ -8,6 +8,7 @@
"name": "gsm3-server",
"version": "1.0.0",
"dependencies": {
"adm-zip": "^0.5.10",
"archiver": "^6.0.1",
"axios": "^1.6.2",
"bcryptjs": "^2.4.3",
@@ -31,6 +32,7 @@
"yauzl": "^2.10.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.5",
"@types/archiver": "^6.0.2",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
@@ -1482,6 +1484,16 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@types/adm-zip": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz",
"integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/archiver": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz",
@@ -1852,6 +1864,15 @@
"node": ">= 0.6"
}
},
"node_modules/adm-zip": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",

View File

@@ -30,7 +30,8 @@
"axios": "^1.6.2",
"fs-extra": "^11.2.0",
"yauzl": "^2.10.0",
"mime-types": "^2.1.35"
"mime-types": "^2.1.35",
"adm-zip": "^0.5.10"
},
"devDependencies": {
"@types/cors": "^2.8.17",
@@ -47,6 +48,7 @@
"@types/fs-extra": "^11.0.4",
"@types/yauzl": "^2.10.3",
"@types/mime-types": "^2.1.4",
"@types/adm-zip": "^0.5.5",
"jest": "^29.7.0",
"tsx": "^4.6.2",
"typescript": "^5.3.3"

View File

@@ -35,6 +35,7 @@ import weatherRouter from './routes/weather.js'
import pluginsRouter, { setPluginManager } from './routes/plugins.js'
import pluginApiRouter, { setPluginApiDependencies } from './routes/pluginApi.js'
import sponsorRouter, { setSponsorDependencies } from './routes/sponsor.js'
import onlineDeployRouter from './routes/onlineDeploy.js'
// 获取当前文件目录
const __filename = fileURLToPath(import.meta.url)
@@ -511,6 +512,11 @@ async function startServer() {
// 设置赞助者路由
setSponsorDependencies(configManager)
app.use('/api/sponsor', sponsorRouter)
// 设置在线部署路由
const { setOnlineDeployDependencies } = await import('./routes/onlineDeploy.js')
setOnlineDeployDependencies(io, configManager)
app.use('/api/online-deploy', onlineDeployRouter)
// 前端路由处理SPA支持
app.get('*', (req, res) => {

View File

@@ -80,8 +80,8 @@ export function setGameDeploymentManagers(
// 获取可安装的游戏列表
router.get('/games', authenticateToken, async (req: Request, res: Response) => {
try {
// const gamesFilePath = path.join(__dirname, '../../data/games/installgame.json')
const gamesFilePath = path.join(__dirname, '../data/games/installgame.json')
const gamesFilePath = path.join(__dirname, '../../data/games/installgame.json')
// const gamesFilePath = path.join(__dirname, '../data/games/installgame.json')
const gamesData = await fs.readFile(gamesFilePath, 'utf-8')
const allGames: { [key: string]: SteamGameInfo } = JSON.parse(gamesData)

View File

@@ -5,6 +5,7 @@ import systemRoutes from './system.js'
import fileRoutes from './files.js'
import instanceRoutes from './instances.js'
import { minecraftRouter } from './minecraft.js'
import onlineDeployRoutes from './onlineDeploy.js'
const router = Router()
@@ -35,5 +36,6 @@ router.use('/system', systemRoutes)
router.use('/files', fileRoutes)
router.use('/instances', instanceRoutes)
router.use('/minecraft', minecraftRouter)
router.use('/online-deploy', onlineDeployRoutes)
export default router

View File

@@ -0,0 +1,516 @@
import { Router, Request, Response } from 'express'
import { authenticateToken } from '../middleware/auth.js'
import { ConfigManager } from '../modules/config/ConfigManager.js'
import logger from '../utils/logger.js'
import { Server as SocketIOServer } from 'socket.io'
import { v4 as uuidv4 } from 'uuid'
import axios from 'axios'
import fs from 'fs/promises'
import path from 'path'
import { createWriteStream, createReadStream } from 'fs'
import { pipeline } from 'stream/promises'
import AdmZip from 'adm-zip'
const router = Router()
let io: SocketIOServer
let configManager: ConfigManager
// 设置依赖
export function setOnlineDeployDependencies(socketIO: SocketIOServer, config: ConfigManager) {
io = socketIO
configManager = config
}
// 平台类型枚举
export enum Platform {
WINDOWS = 'windows',
LINUX = 'linux',
MACOS = 'macos'
}
// 在线游戏信息接口
export interface OnlineGameInfo {
id: string
name: string
description?: string
image?: string
downloadUrl?: string
category?: string
supportedPlatforms: Platform[]
deploymentScript?: string
version?: string
}
// 部署选项接口
export interface OnlineDeploymentOptions {
gameId: string
installPath: string
options?: any
}
// 部署结果接口
export interface OnlineDeploymentResult {
success: boolean
message: string
data?: any
}
// 活动部署映射
const activeDeployments = new Map<string, any>()
// 获取当前平台
function getCurrentPlatform(): Platform {
const platform = process.platform
switch (platform) {
case 'win32':
return Platform.WINDOWS
case 'linux':
return Platform.LINUX
case 'darwin':
return Platform.MACOS
default:
return Platform.LINUX
}
}
// 检查游戏是否支持当前平台
function isGameSupportedOnCurrentPlatform(game: OnlineGameInfo): boolean {
const currentPlatform = getCurrentPlatform()
return game.supportedPlatforms.includes(currentPlatform)
}
// 验证赞助者密钥
async function validateSponsorKey(): Promise<boolean> {
try {
const sponsorConfig = await configManager.getSponsorConfig()
if (!sponsorConfig || !sponsorConfig.key || !sponsorConfig.isValid) {
return false
}
// 检查密钥是否过期
if (sponsorConfig.expiryTime && new Date() > new Date(sponsorConfig.expiryTime)) {
return false
}
return true
} catch (error) {
logger.error('验证赞助者密钥失败:', error)
return false
}
}
// 获取在线游戏列表
router.get('/games', authenticateToken, async (req: Request, res: Response) => {
try {
// 验证赞助者密钥
const isValidSponsor = await validateSponsorKey()
if (!isValidSponsor) {
return res.status(403).json({
success: false,
message: '需要有效的赞助者密钥才能访问在线部署功能'
})
}
const currentPlatform = getCurrentPlatform()
// 获取赞助者密钥
const sponsorConfig = await configManager.getSponsorConfig()
if (!sponsorConfig || !sponsorConfig.key) {
return res.status(403).json({
success: false,
message: '未找到赞助者密钥'
})
}
// 映射平台名称
const systemName = currentPlatform === Platform.WINDOWS ? 'Windows' :
currentPlatform === Platform.LINUX ? 'Linux' : 'Linux'
try {
// 向第三方API请求在线游戏列表
const response = await axios.post('http://langlangy.server.xiaozhuhouses.asia:10002/api/online-games', {
system: systemName,
key: sponsorConfig.key
}, {
timeout: 30000, // 30秒超时
headers: {
'Content-Type': 'application/json'
}
})
if (response.data.status !== 'success') {
throw new Error(response.data.message || '获取在线游戏列表失败')
}
// 转换API返回的数据格式
const gameData = response.data.data || {}
const supportedGames = Object.entries(gameData).map(([gameName, gameInfo]: [string, any]) => ({
id: gameName.toLowerCase().replace(/\s+/g, '-'),
name: gameName,
description: gameInfo.txt || '',
image: gameInfo.image || '',
downloadUrl: gameInfo.download || '',
supportedPlatforms: [currentPlatform], // 基于请求的系统类型
supported: true,
currentPlatform
}))
logger.info(`成功获取到 ${supportedGames.length} 个在线游戏`)
res.json({
success: true,
data: supportedGames
})
} catch (apiError: any) {
logger.error('请求第三方API失败:', apiError.message)
// 如果API请求失败返回空列表而不是错误
res.json({
success: true,
data: [],
message: '暂时无法获取在线游戏列表,请稍后重试'
})
}
} catch (error) {
logger.error('获取在线游戏列表失败:', error)
res.status(500).json({
success: false,
message: '获取在线游戏列表失败'
})
}
})
// 部署在线游戏
router.post('/deploy', authenticateToken, async (req: Request, res: Response) => {
try {
// 验证赞助者密钥
const isValidSponsor = await validateSponsorKey()
if (!isValidSponsor) {
return res.status(403).json({
success: false,
message: '需要有效的赞助者密钥才能使用在线部署功能'
})
}
const { gameId, installPath, socketId } = req.body
if (!gameId || !installPath) {
return res.status(400).json({
success: false,
message: '缺少必要参数'
})
}
const deploymentId = uuidv4()
// 模拟部署过程
const deploymentProcess = {
id: deploymentId,
gameId,
installPath,
status: 'running',
startTime: new Date()
}
activeDeployments.set(deploymentId, deploymentProcess)
// 异步执行部署
setImmediate(async () => {
try {
// 检查部署是否被取消
if (deploymentProcess.status === 'cancelled') {
return
}
// 发送开始部署消息
if (io && socketId) {
io.to(socketId).emit('online-deploy-log', {
deploymentId,
message: `开始部署 ${gameId}...`,
type: 'info',
timestamp: new Date().toISOString()
})
}
// 步骤1: 验证安装路径
if (io && socketId) {
io.to(socketId).emit('online-deploy-log', {
deploymentId,
message: '正在验证安装路径...',
type: 'info',
timestamp: new Date().toISOString()
})
io.to(socketId).emit('online-deploy-progress', {
deploymentId,
percentage: 10,
currentStep: '验证安装路径'
})
}
// 确保安装目录存在
await fs.mkdir(installPath, { recursive: true })
// 步骤2: 获取游戏信息和下载链接
if (deploymentProcess.status === 'cancelled') return
if (io && socketId) {
io.to(socketId).emit('online-deploy-log', {
deploymentId,
message: '正在获取游戏下载信息...',
type: 'info',
timestamp: new Date().toISOString()
})
io.to(socketId).emit('online-deploy-progress', {
deploymentId,
percentage: 20,
currentStep: '获取下载信息'
})
}
// 重新获取游戏列表以获取下载链接
const sponsorConfig = await configManager.getSponsorConfig()
const currentPlatform = getCurrentPlatform()
const systemName = currentPlatform === Platform.WINDOWS ? 'Windows' : 'Linux'
const gameListResponse = await axios.post('http://langlangy.server.xiaozhuhouses.asia:10002/api/online-games', {
system: systemName,
key: sponsorConfig.key
})
if (gameListResponse.data.status !== 'success') {
throw new Error('无法获取游戏下载信息')
}
// 查找对应的游戏
const gameData = gameListResponse.data.data || {}
const gameEntry = Object.entries(gameData).find(([name]) =>
name.toLowerCase().replace(/\s+/g, '-') === gameId
) as [string, { download?: string; [key: string]: any }] | undefined
if (!gameEntry || !gameEntry[1].download) {
throw new Error('未找到游戏下载链接')
}
const downloadUrl = gameEntry[1].download
const gameName = gameEntry[0]
// 步骤3: 下载游戏文件
if (deploymentProcess.status === 'cancelled') return
if (io && socketId) {
io.to(socketId).emit('online-deploy-log', {
deploymentId,
message: `正在下载 ${gameName}...`,
type: 'info',
timestamp: new Date().toISOString()
})
}
const fileName = path.basename(downloadUrl) || `${gameId}.zip`
const downloadPath = path.join(installPath, fileName)
// 下载文件
const response = await axios({
method: 'GET',
url: downloadUrl,
responseType: 'stream',
timeout: 300000 // 5分钟超时
})
const totalSize = parseInt(response.headers['content-length'] || '0')
let downloadedSize = 0
const writer = createWriteStream(downloadPath)
response.data.on('data', (chunk: Buffer) => {
if (deploymentProcess.status === 'cancelled') {
response.data.destroy()
writer.destroy()
return
}
downloadedSize += chunk.length
const percentage = totalSize > 0 ? Math.round((downloadedSize / totalSize) * 50) + 20 : 30
if (io && socketId) {
io.to(socketId).emit('online-deploy-progress', {
deploymentId,
percentage,
currentStep: `下载中... ${Math.round(downloadedSize / 1024 / 1024)}MB${totalSize > 0 ? `/${Math.round(totalSize / 1024 / 1024)}MB` : ''}`
})
}
})
await pipeline(response.data, writer)
if (deploymentProcess.status === 'cancelled') {
// 清理下载的文件
try {
await fs.unlink(downloadPath)
} catch {}
return
}
// 步骤4: 解压文件
if (io && socketId) {
io.to(socketId).emit('online-deploy-log', {
deploymentId,
message: '正在解压游戏文件...',
type: 'info',
timestamp: new Date().toISOString()
})
io.to(socketId).emit('online-deploy-progress', {
deploymentId,
percentage: 80,
currentStep: '解压文件'
})
}
// 解压ZIP文件
const zip = new AdmZip(downloadPath)
const extractPath = path.join(installPath, 'game')
await fs.mkdir(extractPath, { recursive: true })
zip.extractAllTo(extractPath, true)
// 删除下载的ZIP文件
await fs.unlink(downloadPath)
if (deploymentProcess.status === 'cancelled') return
// 步骤5: 完成部署
if (io && socketId) {
io.to(socketId).emit('online-deploy-log', {
deploymentId,
message: '部署完成!',
type: 'success',
timestamp: new Date().toISOString()
})
io.to(socketId).emit('online-deploy-progress', {
deploymentId,
percentage: 100,
currentStep: '部署完成'
})
}
// 部署完成
deploymentProcess.status = 'completed'
if (io && socketId) {
io.to(socketId).emit('online-deploy-complete', {
deploymentId,
success: true,
result: {
installPath: extractPath,
gameId,
gameName,
message: `${gameName} 部署成功!`
}
})
}
} catch (error) {
logger.error('在线游戏部署失败:', error)
deploymentProcess.status = 'failed'
if (io && socketId) {
io.to(socketId).emit('online-deploy-complete', {
deploymentId,
success: false,
error: error instanceof Error ? error.message : '部署失败'
})
}
} finally {
// 清理部署记录
setTimeout(() => {
activeDeployments.delete(deploymentId)
}, 300000) // 5分钟后清理
}
})
res.json({
success: true,
data: {
deploymentId
},
message: '部署已开始'
})
} catch (error) {
logger.error('启动在线游戏部署失败:', error)
res.status(500).json({
success: false,
message: '启动部署失败'
})
}
})
// 取消部署
router.post('/cancel/:deploymentId', authenticateToken, async (req: Request, res: Response) => {
try {
const { deploymentId } = req.params
const deployment = activeDeployments.get(deploymentId)
if (!deployment) {
return res.status(404).json({
success: false,
message: '部署任务不存在'
})
}
// 标记为已取消
deployment.status = 'cancelled'
// 发送取消通知
if (io) {
io.emit('online-deploy-log', {
deploymentId,
message: '部署已被用户取消',
type: 'warning',
timestamp: new Date().toISOString()
})
io.emit('online-deploy-complete', {
deploymentId,
success: false,
error: '部署已取消'
})
}
// 延迟删除,给清理操作一些时间
setTimeout(() => {
activeDeployments.delete(deploymentId)
}, 5000)
res.json({
success: true,
message: '部署已取消'
})
} catch (error) {
logger.error('取消在线游戏部署失败:', error)
res.status(500).json({
success: false,
message: '取消部署失败'
})
}
})
// 获取活动部署列表
router.get('/deployments', authenticateToken, async (req: Request, res: Response) => {
try {
const deployments = Array.from(activeDeployments.values())
res.json({
success: true,
data: deployments
})
} catch (error) {
logger.error('获取活动部署列表失败:', error)
res.status(500).json({
success: false,
message: '获取部署列表失败'
})
}
})
export default router