From e1e1a93745f86c871093fe8d094ed541ab3db927 Mon Sep 17 00:00:00 2001 From: xiwangly2 Date: Wed, 29 Jul 2026 11:57:06 +0800 Subject: [PATCH 01/10] feat: add Terraria game support with installation and configuration documentation --- ...00\346\261\202\350\257\264\346\230\216.md" | 107 ++++++ ...0\346\213\211\347\221\236\344\272\232.yml" | 346 ++++++++++++++++++ server/data/games/installgame.json | 22 +- 3 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 "docs/\346\263\260\346\213\211\347\221\236\344\272\232\344\270\216\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" create mode 100644 "server/data/gameconfig/\346\263\260\346\213\211\347\221\236\344\272\232.yml" diff --git "a/docs/\346\263\260\346\213\211\347\221\236\344\272\232\344\270\216\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" "b/docs/\346\263\260\346\213\211\347\221\236\344\272\232\344\270\216\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" new file mode 100644 index 00000000..e99f05c0 --- /dev/null +++ "b/docs/\346\263\260\346\213\211\347\221\236\344\272\232\344\270\216\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" @@ -0,0 +1,107 @@ +# 泰拉瑞亚与内网穿透插件需求说明 + +## 背景 + +这次需求来自两个使用场景: + +- Steam 游戏部署列表中缺少原版泰拉瑞亚,用户需要回到文档手动下载、上传、解压和交互式配置。 +- 家用主机、NAS、宿舍网络、运营商 CGNAT 等没有公网入口的环境,游戏服务端即使启动成功,也不方便让外部玩家连接。 + +## 已落地的最小改进 + +### 泰拉瑞亚 Steam 部署入口 + +在 `server/data/games/installgame.json` 中新增 `Terraria`: + +- 使用 Terraria Dedicated Server Tool AppID:`105610`。 +- 默认端口:`7777/TCP`。 +- 关联现有泰拉瑞亚开服文档。 +- 在提示中建议用 `serverconfig.txt` 启动,避免首次启动进入交互式建世界流程。 + +推荐实例启动命令: + +```bash +./TerrariaServer.bin.x86_64 -config serverconfig.txt +``` + +Windows 可改为: + +```powershell +.\TerrariaServer.exe -config serverconfig.txt +``` + +### 泰拉瑞亚可视化配置模板 + +在 `server/data/gameconfig/泰拉瑞亚.yml` 中新增 `serverconfig.txt` 模板: + +- 世界路径、自动建图、种子、世界名、难度。 +- 最大玩家数、端口、密码、MOTD。 +- 世界目录、封禁列表、作弊防护、语言、UPnP、NPC 同步、优先级。 +- 旅途模式权限项。 + +模板使用现有 `properties` 解析器。Terraria 的 `0/1` 开关用 `select` 表达,避免保存成 `true/false` 后与服务端配置格式不一致。 + +## 内网穿透插件候选需求 + +### 目标 + +提供一个可选工具,让没有公网 IP 的设备也能把游戏服务端端口暴露给外部玩家。插件应当只负责网络入口,不改变游戏实例本身的部署和启动逻辑。 + +### 支持对象 + +第一阶段建议支持: + +- frp 客户端模式:适合已有 frps 服务器的用户。 +- EasyTier 组网模式:适合希望多节点虚拟局域网互通的用户。 + +### MVP 能力 + +- 从实例列表选择一个游戏实例,读取实例名称、工作目录和端口信息。 +- 允许手动填写本地地址、本地端口、远端端口、远端服务器、认证 token 或组网密钥。 +- 生成 frpc 或 EasyTier 配置文件,默认保存到 `server/data/tunnels/` 下。 +- 通过实例管理创建一个独立的“穿透实例”,让用户用现有实例启停能力管理隧道进程。 +- 显示连接说明,例如 `远端地址:远端端口` 或 EasyTier 虚拟 IP。 + +### 非目标 + +- 不把 frp/EasyTier 变成游戏部署前置依赖。 +- 不替用户申请公网服务器或托管中继。 +- 不绕过游戏本身的账号、白名单、RCON、反作弊或服务端安全配置。 +- 不默认启用 UPnP 或自动开放所有端口。 + +## 推荐实现方式 + +### 插件优先 + +先做成 `server/data/plugins/tunnel-helper` 这类可选插件: + +- `plugin.json` 默认 `enabled: false`,避免默认安装后自动出现网络暴露入口。 +- `index.html` 只做配置生成、实例创建和状态查看。 +- 复用 `gsm3-api.js` 的实例 API、文件 API 和终端/进程列表 API。 + +这种方式与当前插件体系匹配,风险较低,也不会把第三方网络工具耦合进核心部署页。 + +### 核心 API 补强 + +如果后续要做成完整的一键体验,核心需要补一个受限的后台进程/工具管理能力: + +- 管理受信任工具定义:工具名称、版本、下载地址、校验和、支持平台。 +- 统一保存配置:配置文件、敏感字段、脱敏展示、备份恢复。 +- 统一管理进程:启动、停止、重启、日志、健康状态、退出码。 +- 限制命令注入:不要让插件拼接任意 shell 字符串后直接执行。 +- 与实例端口关联:从游戏配置或实例元数据中读取端口,减少重复输入。 + +## 安全与维护约束 + +- 所有后端接口必须加认证中间件。 +- 持久化数据放在 `server/data` 下,兼容开发和打包路径。 +- token、密钥、服务端地址等敏感字段不要出现在日志和普通列表展示中。 +- 不建议在仓库内直接提交第三方二进制;如需要下载,必须有来源、版本、哈希校验和失败回滚。 +- Windows、Linux、Docker 三种运行方式的路径、权限和停止信号要分别验证。 + +## PR 拆分建议 + +1. 泰拉瑞亚 Steam 安装清单与 `serverconfig.txt` 可视化模板。 +2. 内网穿透插件需求说明和界面草图。 +3. `tunnel-helper` 插件 MVP:生成配置并创建受管实例。 +4. 核心进程/工具 API 补强,完善二进制下载、校验、日志和状态检测。 diff --git "a/server/data/gameconfig/\346\263\260\346\213\211\347\221\236\344\272\232.yml" "b/server/data/gameconfig/\346\263\260\346\213\211\347\221\236\344\272\232.yml" new file mode 100644 index 00000000..8ca9ac80 --- /dev/null +++ "b/server/data/gameconfig/\346\263\260\346\213\211\347\221\236\344\272\232.yml" @@ -0,0 +1,346 @@ +meta: + game_name: "泰拉瑞亚" + config_file: "serverconfig.txt" + parser: "properties" + +sections: + - key: "serverconfig.txt" + fields: + - name: "world" + display: "世界文件路径" + default: "Worlds/world1.wld" + type: "string" + description: "要加载的世界文件。使用相对路径时会相对于服务端工作目录解析,建议与服务端文件放在同一持久化目录中" + - name: "autocreate" + display: "自动创建世界大小" + default: "1" + type: "select" + description: "当 world 指定的世界不存在时自动创建世界" + options: + - value: "0" + label: "不自动创建" + - value: "1" + label: "小世界" + - value: "2" + label: "中世界" + - value: "3" + label: "大世界" + - name: "seed" + display: "世界种子" + default: "" + type: "string" + description: "自动创建世界时使用的种子,留空则随机生成" + - name: "worldname" + display: "世界名称" + default: "Terraria" + type: "string" + description: "自动创建世界时使用的世界名称" + - name: "difficulty" + display: "世界难度" + default: "0" + type: "select" + description: "自动创建世界时使用的难度" + options: + - value: "0" + label: "经典" + - value: "1" + label: "专家" + - value: "2" + label: "大师" + - value: "3" + label: "旅途" + - name: "maxplayers" + display: "最大玩家数" + default: 8 + type: "number" + description: "允许加入服务器的最大玩家数量,官方配置示例范围为 1 到 255" + - name: "port" + display: "服务器端口" + default: 7777 + type: "number" + description: "Terraria 默认使用 7777 TCP" + - name: "password" + display: "服务器密码" + default: "" + type: "string" + description: "加入服务器需要输入的密码,留空表示无密码" + - name: "motd" + display: "每日消息" + default: "" + type: "string" + description: "玩家进入服务器时显示的消息" + - name: "worldpath" + display: "世界目录" + default: "Worlds" + type: "string" + description: "世界文件保存目录。Terraria 配置文件中不支持 $HOME 或 ~ 展开,建议使用相对路径或绝对路径" + - name: "worldrollbackstokeep" + display: "世界回滚备份数量" + default: 2 + type: "number" + description: "保留的滚动世界备份数量" + - name: "banlist" + display: "封禁列表路径" + default: "banlist.txt" + type: "string" + description: "封禁列表文件位置,默认在服务端工作目录下" + - name: "secure" + display: "作弊防护" + default: "1" + type: "select" + description: "启用 Terraria 服务端的额外作弊防护" + options: + - value: "1" + label: "启用" + - value: "0" + label: "关闭" + - name: "language" + display: "服务器语言" + default: "zh-Hans" + type: "select" + description: "服务端输出语言" + options: + - value: "zh-Hans" + label: "简体中文" + - value: "en-US" + label: "English" + - value: "de-DE" + label: "Deutsch" + - value: "it-IT" + label: "Italiano" + - value: "fr-FR" + label: "Francais" + - value: "es-ES" + label: "Espanol" + - value: "ru-RU" + label: "Русский" + - value: "pt-BR" + label: "Portugues" + - value: "pl-PL" + label: "Polski" + - name: "upnp" + display: "UPnP 自动端口转发" + default: "0" + type: "select" + description: "是否尝试通过 UPnP 自动转发端口。没有公网 IP 或处于 CGNAT 时仍需使用内网穿透等方案" + options: + - value: "0" + label: "关闭" + - value: "1" + label: "启用" + - name: "npcstream" + display: "NPC 同步频率" + default: 60 + type: "number" + description: "降低敌怪跳跃感但会增加带宽占用,0 表示关闭" + - name: "priority" + display: "进程优先级" + default: "1" + type: "select" + description: "服务端进程优先级" + options: + - value: "0" + label: "实时" + - value: "1" + label: "高" + - value: "2" + label: "高于正常" + - value: "3" + label: "正常" + - value: "4" + label: "低于正常" + - value: "5" + label: "空闲" + - name: "slowliquids" + display: "降低液体更新量" + default: "0" + type: "select" + description: "可降低同一时间移动的液体上限,可能减少卡顿但液体结算更慢" + options: + - value: "0" + label: "关闭" + - value: "1" + label: "启用" + + - key: "journey" + fields: + - name: "journeypermission_time_setfrozen" + display: "旅途权限:冻结时间" + default: "2" + type: "select" + description: "0 为所有人锁定,1 为仅主机可改,2 为所有人可改" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_time_setdawn" + display: "旅途权限:设置黎明" + default: "2" + type: "select" + description: "控制旅途模式中设置黎明的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_time_setnoon" + display: "旅途权限:设置中午" + default: "2" + type: "select" + description: "控制旅途模式中设置中午的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_time_setdusk" + display: "旅途权限:设置黄昏" + default: "2" + type: "select" + description: "控制旅途模式中设置黄昏的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_time_setmidnight" + display: "旅途权限:设置午夜" + default: "2" + type: "select" + description: "控制旅途模式中设置午夜的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_godmode" + display: "旅途权限:上帝模式" + default: "2" + type: "select" + description: "控制旅途模式中上帝模式的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_wind_setstrength" + display: "旅途权限:风力强度" + default: "2" + type: "select" + description: "控制旅途模式中设置风力强度的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_rain_setstrength" + display: "旅途权限:雨量强度" + default: "2" + type: "select" + description: "控制旅途模式中设置雨量强度的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_time_setspeed" + display: "旅途权限:时间速度" + default: "2" + type: "select" + description: "控制旅途模式中设置时间速度的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_rain_setfrozen" + display: "旅途权限:冻结雨天" + default: "2" + type: "select" + description: "控制旅途模式中冻结雨天的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_wind_setfrozen" + display: "旅途权限:冻结风" + default: "2" + type: "select" + description: "控制旅途模式中冻结风的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_increaseplacementrange" + display: "旅途权限:增加放置范围" + default: "2" + type: "select" + description: "控制旅途模式中增加放置范围的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_setdifficulty" + display: "旅途权限:调整难度" + default: "2" + type: "select" + description: "控制旅途模式中调整难度的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_biomespread_setfrozen" + display: "旅途权限:冻结生物群系蔓延" + default: "2" + type: "select" + description: "控制旅途模式中冻结腐化、猩红等蔓延的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" + - name: "journeypermission_setspawnrate" + display: "旅途权限:调整生成率" + default: "2" + type: "select" + description: "控制旅途模式中调整敌怪生成率的权限" + options: + - value: "0" + label: "锁定" + - value: "1" + label: "仅主机" + - value: "2" + label: "所有人" diff --git a/server/data/games/installgame.json b/server/data/games/installgame.json index 17d0e0d4..da166aed 100644 --- a/server/data/games/installgame.json +++ b/server/data/games/installgame.json @@ -166,6 +166,26 @@ "url": "https://store.steampowered.com/app/322330/_/", "docs": "https://docs.gsm.xiaozhuhouses.asia/%E6%B8%B8%E6%88%8F%E7%99%BE%E7%A7%91/Steam/%E9%A5%A5%E8%8D%92%E9%A3%9F%E7%94%A8%E8%AF%B4%E6%98%8E.html" }, + "Terraria": { + "game_nameCN": "泰拉瑞亚", + "appid": "105610", + "tip": "默认端口:7777 TCP。建议创建实例时使用 serverconfig.txt 启动,例如 Linux 使用 ./TerrariaServer.bin.x86_64 -config serverconfig.txt,Windows 使用 .\\TerrariaServer.exe -config serverconfig.txt,可避免首次启动进入交互式配置流程。", + "image": "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/105600/header.jpg", + "url": "https://store.steampowered.com/app/105600/Terraria/", + "docs": "https://docs.gsm.xiaozhuhouses.asia/%E6%B8%B8%E6%88%8F%E7%99%BE%E7%A7%91/Steam/%E6%B3%B0%E6%8B%89%E7%91%9E%E4%BA%9A%E9%A3%9F%E7%94%A8%E8%AF%B4%E6%98%8E.html", + "system": [ + "Windows", + "Linux", + "MacOS" + ], + "ports": [ + { + "port": 7777, + "protocol": "tcp" + } + ], + "memory": 1 + }, "Project_Zomboid": { "game_nameCN": "僵尸毁灭工程", "appid": "380870", @@ -836,4 +856,4 @@ "Windows" ] } -} \ No newline at end of file +} From 49d9430d19b90dc0e9c12faede7d760638c09681 Mon Sep 17 00:00:00 2001 From: xiwangly2 Date: Wed, 29 Jul 2026 12:16:48 +0800 Subject: [PATCH 02/10] feat: enhance Terraria support with installation paths, start commands, and documentation updates --- client/src/pages/GameDeploymentPage.tsx | 15 ++- ...00\346\261\202\350\257\264\346\230\216.md" | 4 +- server/data/games/installgame.json | 12 +- server/src/routes/gameDeployment.ts | 110 ++++++++++++++++-- 4 files changed, 123 insertions(+), 18 deletions(-) diff --git a/client/src/pages/GameDeploymentPage.tsx b/client/src/pages/GameDeploymentPage.tsx index a27a383e..4e7b7a0e 100644 --- a/client/src/pages/GameDeploymentPage.tsx +++ b/client/src/pages/GameDeploymentPage.tsx @@ -41,6 +41,7 @@ interface GameInfo { url: string docs?: string system?: string[] + login_anonymous?: boolean supportedOnCurrentPlatform?: boolean currentPlatform?: string panelCompatibleOnCurrentPlatform?: boolean @@ -2553,7 +2554,7 @@ const GameDeploymentPage: React.FC = () => { : `app_update ${selectedGame.info.appid}` // force_install_dir 必须在 login 之前,否则 SteamCMD 会报错 - const fullCommand = `steamcmd +${forceInstallDir} +${loginCommand} +${appUpdateCommand} +quit` + const fullCommand = `+${forceInstallDir} +${loginCommand} +${appUpdateCommand} +quit` setSteamcmdCommand(fullCommand) } }, [showInstallModal, selectedGame, useAnonymous, steamUsername, steamPassword, validateGameIntegrity, installPath]) @@ -2628,6 +2629,12 @@ const GameDeploymentPage: React.FC = () => { // 打开安装对话框的通用函数 const openInstallModal = async (gameKey: string, gameInfo: GameInfo) => { const defaultInstanceName = gameInfo.game_nameCN + const shouldUseAnonymous = gameInfo.login_anonymous !== false + setUseAnonymous(shouldUseAnonymous) + if (shouldUseAnonymous) { + setSteamUsername('') + setSteamPassword('') + } // 检查是否存在同名实例 try { @@ -5844,18 +5851,18 @@ const GameDeploymentPage: React.FC = () => {
+ 每行一个 peer。未填写时只启动本地节点,通常无法自动发现其它节点。 + +
+ +
+ + +
+

生成结果

+
+
配置目录-
+
启动命令-
+
连接地址-
+
+

+      
+ + + + + + diff --git a/server/data/plugins/tunnel-helper/plugin.json b/server/data/plugins/tunnel-helper/plugin.json new file mode 100644 index 00000000..66c849bc --- /dev/null +++ b/server/data/plugins/tunnel-helper/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "tunnel-helper", + "displayName": "内网穿透助手", + "description": "为任意游戏实例生成 frp 或 EasyTier 穿透配置,并创建独立的穿透实例。", + "version": "0.1.0", + "author": "GSM3 Community", + "enabled": false, + "hasWebInterface": true, + "entryPoint": "index.html", + "icon": "network", + "category": "工具", + "keywords": ["内网穿透", "frp", "EasyTier", "游戏联机"] +} diff --git a/server/src/modules/plugin/PluginManager.ts b/server/src/modules/plugin/PluginManager.ts index c84fc2d5..9a1a228f 100644 --- a/server/src/modules/plugin/PluginManager.ts +++ b/server/src/modules/plugin/PluginManager.ts @@ -1,4 +1,5 @@ import { promises as fs } from 'fs' +import fsSync from 'fs' import path from 'path' import { fileURLToPath } from 'url' import winston from 'winston' @@ -39,8 +40,22 @@ export class PluginManager { constructor(logger: winston.Logger) { this.logger = logger - // this.pluginsDir = path.join(__dirname, '../../../data/plugins') - this.pluginsDir = path.join(__dirname, '../../data/plugins') + const baseDir = process.cwd() + const possiblePaths = [ + path.join(baseDir, 'data', 'plugins'), // 打包后的路径 + path.join(baseDir, 'server', 'data', 'plugins'), // 开发环境路径 + path.join(__dirname, '../../data/plugins'), // dist/modules/plugin -> data/plugins + path.join(__dirname, '../../../data/plugins') // src/modules/plugin -> server/data/plugins + ] + + this.pluginsDir = possiblePaths[0] + for (const possiblePath of possiblePaths) { + if (fsSync.existsSync(possiblePath)) { + this.pluginsDir = possiblePath + break + } + } + this.initializePluginsDirectory() } @@ -317,4 +332,4 @@ export class PluginManager { this.plugins.clear() this.logger.info('PluginManager 已清理') } -} \ No newline at end of file +} From 78b1c25e893f7652cc2f618901a92abcbc20ee5d Mon Sep 17 00:00:00 2001 From: xiwangly2 Date: Wed, 29 Jul 2026 13:30:11 +0800 Subject: [PATCH 06/10] feat: add support for tunnel instance creation and enhance plugin management UI --- .gitignore | 4 +- client/src/components/Layout.tsx | 5 +- client/src/pages/PluginsPage.tsx | 39 ++++++++------ ...00\346\261\202\350\257\264\346\230\216.md" | 15 ++++++ server/data/plugins/tunnel-helper/index.html | 52 ++++++++++++++++--- server/data/plugins/tunnel-helper/plugin.json | 7 ++- 6 files changed, 92 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index e75da8b9..37f90c32 100644 --- a/.gitignore +++ b/.gitignore @@ -29,8 +29,10 @@ favorites.json server/data/environment server/data/wallpapers/ server/data/lib/ +server/data/tunnels/ data/wallpapers/ data/lib/ +data/tunnels/ dist/ @@ -144,4 +146,4 @@ src/public/1.png .turbo 参考文件/ -.报错.txt \ No newline at end of file +.报错.txt diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index f7b99ea5..6b3e89f8 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -31,6 +31,7 @@ import { HelpCircle, CheckCircle, Coffee, + Puzzle, ChevronLeft, ChevronRight } from 'lucide-react' @@ -67,7 +68,7 @@ const Layout: React.FC = ({ children }) => { { name: '定时任务', href: '/scheduled-tasks', icon: Clock }, { name: '文件管理', href: '/files', icon: FolderOpen }, { name: '环境管理', href: '/environment', icon: Coffee }, - // { name: '插件', href: '/plugins', icon: Puzzle }, + { name: '插件', href: '/plugins', icon: Puzzle }, { name: '设置', href: '/settings', icon: Settings }, { name: '关于项目', href: '/about', icon: Info }, ] @@ -869,4 +870,4 @@ const Layout: React.FC = ({ children }) => { ) } -export default Layout \ No newline at end of file +export default Layout diff --git a/client/src/pages/PluginsPage.tsx b/client/src/pages/PluginsPage.tsx index bf36f22a..98ac72a0 100644 --- a/client/src/pages/PluginsPage.tsx +++ b/client/src/pages/PluginsPage.tsx @@ -502,13 +502,13 @@ const PluginsPage: React.FC = () => { -
- {plugin.enabled ? ( -
- ) : ( -
- )} -
+ + {plugin.enabled ? '已启用' : '已禁用'} +
{/* 插件信息 */} @@ -534,10 +534,10 @@ const PluginsPage: React.FC = () => {
handleTogglePlugin(plugin)} - className={`p-2 rounded-lg transition-colors ${ + className={`inline-flex items-center space-x-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ plugin.enabled ? 'bg-green-500/20 text-green-600 hover:bg-green-500/30' : 'bg-gray-500/20 text-gray-600 hover:bg-gray-500/30' @@ -545,16 +545,23 @@ const PluginsPage: React.FC = () => { title={plugin.enabled ? '禁用插件' : '启用插件'} > {plugin.enabled ? : } + {plugin.enabled ? '禁用' : '启用'} - {plugin.hasWebInterface && plugin.enabled && ( + {plugin.hasWebInterface && ( handleOpenPlugin(plugin)} - className="p-2 bg-blue-500/20 text-blue-600 rounded-lg hover:bg-blue-500/30 transition-colors" - title="打开插件" + disabled={!plugin.enabled} + className={`inline-flex items-center space-x-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ + plugin.enabled + ? 'bg-blue-500/20 text-blue-600 hover:bg-blue-500/30' + : 'bg-gray-500/10 text-gray-400 cursor-not-allowed' + }`} + title={plugin.enabled ? '打开插件' : '启用后可打开插件'} > + 打开 )}
@@ -846,4 +853,4 @@ const PluginsPage: React.FC = () => { ) } -export default PluginsPage \ No newline at end of file +export default PluginsPage diff --git "a/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" "b/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" index 4e8c6732..e424f1d2 100644 --- "a/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" +++ "b/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" @@ -39,6 +39,21 @@ - EasyTier 模式生成 `tunnels//easytier-command.txt`,并创建使用命令行参数加入虚拟网络的独立实例。 - 同步生成 `tunnels//manifest.json`,记录关联游戏实例、端口、协议、配置路径和连接地址。 - 右侧预览会脱敏展示 `auth.token` 和 `--network-secret`。 +- 创建成功后会在插件内显示明确结果,并通过主面板通知提示用户到“实例管理”启动穿透实例。 + +### 启用和禁用 + +插件入口已加入侧边栏: + +1. 打开“插件”页面。 +2. 找到“内网穿透助手”。 +3. 点击卡片上的“启用”或“禁用”按钮。 +4. 启用后点击“打开”进入插件界面。 + +### 已知排查点 + +- 如果配置文件已经生成但界面没有明显变化,先到“实例管理”检查是否已经创建了 `穿透-<名称>` 实例。 +- Windows 下启动命令应当带 PowerShell 调用运算符,例如 `& 'frpc' -c 'frpc.toml'`。旧版本插件生成的 `'frpc' -c 'frpc.toml'` 不会执行,需要删除后重新创建或手动修正实例启动命令。 ## 非目标 diff --git a/server/data/plugins/tunnel-helper/index.html b/server/data/plugins/tunnel-helper/index.html index 217f46dc..eff3d592 100644 --- a/server/data/plugins/tunnel-helper/index.html +++ b/server/data/plugins/tunnel-helper/index.html @@ -437,10 +437,22 @@

生成结果

] const read = (id) => $(id).value.trim() + const selectedInstanceLooksWindows = () => { + const instance = getSelectedInstance() + const workingDirectory = String(instance && instance.workingDirectory || '') + const startCommand = String(instance && instance.startCommand || '') + return /^[a-z]:[\\/]/i.test(workingDirectory) || + workingDirectory.includes('\\') || + startCommand.includes('.\\') || + /\.exe\b/i.test(startCommand) + } + const isWindows = () => { const raw = state.systemInfo && state.systemInfo.rawPlatform const platform = state.systemInfo && state.systemInfo.platform - return raw === 'win32' || String(platform || '').toLowerCase().includes('windows') + return raw === 'win32' || + String(platform || '').toLowerCase().includes('windows') || + selectedInstanceLooksWindows() } function showMessage(type, text) { @@ -463,6 +475,12 @@

生成结果

return [] } + function notify(type, message) { + if (window.gsm3 && typeof window.gsm3.showNotification === 'function') { + window.gsm3.showNotification(type, message) + } + } + function sanitizeSlug(value) { return value .toLowerCase() @@ -546,8 +564,11 @@

生成结果

} } - async function loadInstances() { - clearMessage() + async function loadInstances(options = {}) { + const { showLoadedMessage = true, selectedInstanceId = read('instanceSelect') } = options + if (showLoadedMessage) { + clearMessage() + } $('instanceSelect').innerHTML = '' try { const result = await window.gsm3.getInstances() @@ -568,11 +589,14 @@

生成结果

const option = document.createElement('option') option.value = instance.id option.textContent = `${instance.name} (${instance.status || 'unknown'})` + option.selected = instance.id === selectedInstanceId $('instanceSelect').appendChild(option) } updateInstanceMeta() - showMessage('success', `已加载 ${gameInstances.length} 个游戏实例`) + if (showLoadedMessage) { + showMessage('success', `已加载 ${gameInstances.length} 个游戏实例`) + } } catch (error) { $('instanceSelect').innerHTML = '' showMessage('error', `获取实例列表失败:${error.message}`) @@ -768,13 +792,23 @@

生成结果

const plan = buildPlan(true) if (!plan) return + const tunnelName = `穿透-${plan.profile}` + const existingTunnel = state.instances.find(instance => instance.name === tunnelName) + if (existingTunnel) { + showMessage('warn', `已存在同名穿透实例:${tunnelName},可到实例管理中启动或删除后重新创建`) + notify('warning', `已存在同名穿透实例:${tunnelName}`) + return + } + $('createButton').disabled = true + const originalButtonText = $('createButton').textContent + $('createButton').textContent = '创建中...' try { await window.gsm3.writeFile(plan.configPath, plan.configContent, 'utf-8') await window.gsm3.writeFile(`${plan.dataDir}/manifest.json`, JSON.stringify(buildManifest(plan), null, 2), 'utf-8') const result = await window.gsm3.createInstance({ - name: `穿透-${plan.profile}`, + name: tunnelName, description: plan.instanceDescription, workingDirectory: plan.workingDirectory, startCommand: plan.startCommand, @@ -783,12 +817,16 @@

生成结果

}) const created = result.data || result - showMessage('success', `穿透实例已创建:${created.name || `穿透-${plan.profile}`}`) - await loadInstances() + await loadInstances({ showLoadedMessage: false, selectedInstanceId: plan.instance.id }) + const createdName = created.name || tunnelName + showMessage('success', `穿透实例已创建:${createdName}。请到实例管理中启动该实例`) + notify('success', `穿透实例已创建:${createdName}`) } catch (error) { showMessage('error', `创建穿透实例失败:${error.message}`) + notify('error', `创建穿透实例失败:${error.message}`) } finally { $('createButton').disabled = false + $('createButton').textContent = originalButtonText } } diff --git a/server/data/plugins/tunnel-helper/plugin.json b/server/data/plugins/tunnel-helper/plugin.json index 66c849bc..8be95dab 100644 --- a/server/data/plugins/tunnel-helper/plugin.json +++ b/server/data/plugins/tunnel-helper/plugin.json @@ -4,10 +4,9 @@ "description": "为任意游戏实例生成 frp 或 EasyTier 穿透配置,并创建独立的穿透实例。", "version": "0.1.0", "author": "GSM3 Community", - "enabled": false, + "enabled": true, "hasWebInterface": true, "entryPoint": "index.html", "icon": "network", - "category": "工具", - "keywords": ["内网穿透", "frp", "EasyTier", "游戏联机"] -} + "category": "工具" +} \ No newline at end of file From a34ec878ad3f8a9bd6f0faf27d21994b148d8657 Mon Sep 17 00:00:00 2001 From: xiwangly2 Date: Wed, 29 Jul 2026 13:45:25 +0800 Subject: [PATCH 07/10] feat: enhance configuration management with theme support and save options --- ...00\346\261\202\350\257\264\346\230\216.md" | 11 +- server/data/plugins/tunnel-helper/index.html | 465 +++++++++++++++++- 2 files changed, 466 insertions(+), 10 deletions(-) diff --git "a/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" "b/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" index e424f1d2..991ea713 100644 --- "a/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" +++ "b/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" @@ -40,6 +40,9 @@ - 同步生成 `tunnels//manifest.json`,记录关联游戏实例、端口、协议、配置路径和连接地址。 - 右侧预览会脱敏展示 `auth.token` 和 `--network-secret`。 - 创建成功后会在插件内显示明确结果,并通过主面板通知提示用户到“实例管理”启动穿透实例。 +- 支持“仅保存配置”和“保存并创建实例”两个动作,便于先保存或更新配置,再按需创建受管实例。 +- 打开插件后会读取 `server/data/tunnels/` 下已有配置,支持预览、载入表单和删除配置目录。 +- 插件页面会跟随主面板明暗主题,避免主 UI 为深色时插件 iframe 仍显示浅色页面。 ### 启用和禁用 @@ -53,6 +56,8 @@ ### 已知排查点 - 如果配置文件已经生成但界面没有明显变化,先到“实例管理”检查是否已经创建了 `穿透-<名称>` 实例。 +- 如果只想更新已有配置,使用“仅保存配置”;“保存并创建实例”会在存在同名 `穿透-<名称>` 实例时阻止重复创建。 +- “删除配置”只删除 `server/data/tunnels/<名称>` 配置目录,不删除已经创建的穿透实例;实例仍需到“实例管理”中删除。 - Windows 下启动命令应当带 PowerShell 调用运算符,例如 `& 'frpc' -c 'frpc.toml'`。旧版本插件生成的 `'frpc' -c 'frpc.toml'` 不会执行,需要删除后重新创建或手动修正实例启动命令。 ## 非目标 @@ -108,10 +113,8 @@ ## 后续实现计划 1. 让插件从游戏部署清单或实例元数据中自动带出常见端口,减少手动输入。 -2. 增加“只生成配置”和“保存并创建实例”两个明确动作,便于只想拿配置文件的用户使用。 -3. 增加配置列表和更新能力,避免重复创建同名穿透实例。 -4. 如需服务端日志脱敏,补核心 API 层的敏感字段处理。 -5. 再评估一键下载:确认工具版本、平台包、哈希校验和更新策略后,再考虑下载器和工具管理 API。 +2. 如需服务端日志脱敏,补核心 API 层的敏感字段处理。 +3. 再评估一键下载:确认工具版本、平台包、哈希校验和更新策略后,再考虑下载器和工具管理 API。 ## PR 拆分建议 diff --git a/server/data/plugins/tunnel-helper/index.html b/server/data/plugins/tunnel-helper/index.html index eff3d592..76391622 100644 --- a/server/data/plugins/tunnel-helper/index.html +++ b/server/data/plugins/tunnel-helper/index.html @@ -22,6 +22,22 @@ --code: #0f172a; } + :root[data-theme="dark"] { + color-scheme: dark; + --bg: #0f172a; + --panel: #111827; + --panel-soft: #1f2937; + --border: #334155; + --text: #e5e7eb; + --muted: #94a3b8; + --primary: #3b82f6; + --primary-strong: #60a5fa; + --success: #22c55e; + --danger: #f87171; + --warning: #facc15; + --code: #020617; + } + @media (prefers-color-scheme: dark) { :root { --bg: #0f172a; @@ -39,6 +55,22 @@ } } + :root[data-theme="light"] { + color-scheme: light; + --bg: #f6f7f9; + --panel: #ffffff; + --panel-soft: #f1f5f9; + --border: #d8dee8; + --text: #162033; + --muted: #64748b; + --primary: #2563eb; + --primary-strong: #1d4ed8; + --success: #15803d; + --danger: #b91c1c; + --warning: #a16207; + --code: #0f172a; + } + * { box-sizing: border-box; } @@ -186,6 +218,11 @@ border: 1px solid var(--border); } + button.danger { + background: var(--danger); + color: #fff; + } + button:disabled { cursor: not-allowed; opacity: .55; @@ -199,6 +236,10 @@ background: var(--border); } + button.danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger), #000 14%); + } + .message { display: none; border-radius: 6px; @@ -261,6 +302,55 @@ font-weight: 600; } + .config-list { + display: grid; + gap: 10px; + } + + .config-card { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + } + + .config-card.active { + border-color: var(--primary); + box-shadow: 0 0 0 2px rgb(37 99 235 / 14%); + } + + .config-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .config-title { + min-width: 0; + font-weight: 700; + overflow-wrap: anywhere; + } + + .config-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .config-actions button { + padding: 6px 9px; + font-size: 12px; + } + + .config-confirm { + color: var(--danger); + font-size: 12px; + font-weight: 600; + } + .hidden { display: none !important; } @@ -291,11 +381,12 @@

内网穿透助手

-

为现有游戏实例生成 frp 或 EasyTier 配置,并创建独立穿透实例。

+

为现有游戏实例生成和管理 frp / EasyTier 配置,可创建独立穿透实例。

+
@@ -409,6 +500,23 @@

EasyTier 参数

+
+

已有配置

+ +
+
+

正在加载配置...

+
+ +

配置预览

+
+
配置名称-
+
工具-
+
关联实例-
+
连接地址-
+
+

+
         

生成结果

配置目录-
@@ -425,9 +533,15 @@

生成结果

mode: 'frp', instances: [], systemInfo: null, - lastPlan: null + lastPlan: null, + savedConfigs: [], + selectedConfigPath: '', + pendingDeletePath: '' } + let themeSyncTimer = null + let themeObserver = null + const $ = (id) => document.getElementById(id) const fields = [ @@ -481,6 +595,77 @@

生成结果

} } + function applyPanelTheme() { + let theme = '' + try { + const storedTheme = window.parent && window.parent.localStorage + ? window.parent.localStorage.getItem('gsm3-theme') + : '' + if (storedTheme) { + const parsedTheme = JSON.parse(storedTheme) + theme = parsedTheme && parsedTheme.state && parsedTheme.state.theme + } + + const parentRoot = window.parent && window.parent.document && window.parent.document.documentElement + if (!theme && parentRoot && parentRoot.classList.contains('dark')) { + theme = 'dark' + } + } catch { + // 无法访问父页面时回退到系统主题 + } + + if (!theme && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + theme = 'dark' + } + + document.documentElement.dataset.theme = theme === 'dark' ? 'dark' : 'light' + } + + function startThemeSync() { + applyPanelTheme() + + try { + const parentRoot = window.parent && window.parent.document && window.parent.document.documentElement + if (parentRoot && !themeObserver) { + themeObserver = new MutationObserver(applyPanelTheme) + themeObserver.observe(parentRoot, { attributes: true, attributeFilter: ['class'] }) + } + } catch { + // 父页面不可访问时由定时同步兜底 + } + + if (!themeSyncTimer) { + themeSyncTimer = window.setInterval(applyPanelTheme, 1000) + } + } + + function dataExists(result) { + if (!result || result.success === false) return false + if (typeof result.exists === 'boolean') return result.exists + if (typeof result.data === 'boolean') return result.data + if (result.data && typeof result.data.exists === 'boolean') return result.data.exists + return true + } + + function delay(ms) { + return new Promise(resolve => window.setTimeout(resolve, ms)) + } + + async function waitForPluginToken() { + if (!window.gsm3) return false + + for (let i = 0; i < 30; i++) { + if (window.gsm3.token) return true + if (window.gsm3Token) { + window.gsm3.token = window.gsm3Token + return true + } + await delay(100) + } + + return Boolean(window.gsm3.token) + } + function sanitizeSlug(value) { return value .toLowerCase() @@ -772,7 +957,9 @@

生成结果

$('preview').textContent = redactSensitiveText(plan.configContent) } - function buildManifest(plan) { + function buildManifest(plan, existing = null) { + const now = new Date().toISOString() + return { name: plan.profile, tool: plan.tool, @@ -784,7 +971,267 @@

生成结果

publicAddress: plan.publicAddress, workingDirectory: plan.workingDirectory, configPath: plan.configPath, - createdAt: new Date().toISOString() + createdAt: existing && existing.createdAt ? existing.createdAt : now, + updatedAt: now + } + } + + async function writeTunnelFiles(plan) { + let existingManifest = null + try { + existingManifest = await readJsonFile(`${plan.dataDir}/manifest.json`) + } catch { + existingManifest = null + } + + await window.gsm3.writeFile(plan.configPath, plan.configContent, 'utf-8') + await window.gsm3.writeFile(`${plan.dataDir}/manifest.json`, JSON.stringify(buildManifest(plan, existingManifest), null, 2), 'utf-8') + } + + async function readJsonFile(filePath) { + const result = await window.gsm3.readFile(filePath, 'utf-8') + const content = result && result.data ? result.data.content : '' + return JSON.parse(content) + } + + async function readTextFile(filePath) { + const result = await window.gsm3.readFile(filePath, 'utf-8') + return result && result.data ? result.data.content : '' + } + + function setSavedPreview(config, content) { + $('savedName').textContent = config ? config.name || config.dirName || '-' : '-' + $('savedTool').textContent = config ? config.tool || '-' : '-' + $('savedInstance').textContent = config ? config.linkedInstanceName || '-' : '-' + $('savedAddress').textContent = config ? config.publicAddress || '-' : '-' + $('savedPreview').textContent = config + ? redactSensitiveText(content || '') + : '' + } + + function textNode(tagName, className, text) { + const node = document.createElement(tagName) + if (className) node.className = className + node.textContent = text + return node + } + + async function loadSavedConfigs() { + const list = $('configList') + list.innerHTML = '

正在加载配置...

' + + try { + const exists = await window.gsm3.exists('tunnels') + if (!dataExists(exists)) { + state.savedConfigs = [] + list.innerHTML = '

暂无已保存配置

' + setSavedPreview(null, '') + return + } + + const result = await window.gsm3.listDirectory('tunnels') + const dirs = normalizeListResponse(result).filter(item => item.type === 'directory') + const configs = [] + + for (const dir of dirs) { + const manifestPath = `${dir.path}/manifest.json` + try { + const manifest = await readJsonFile(manifestPath) + configs.push({ + ...manifest, + dirName: dir.name, + dirPath: dir.path, + manifestPath, + modified: dir.modified + }) + } catch { + configs.push({ + name: dir.name, + tool: 'unknown', + dirName: dir.name, + dirPath: dir.path, + manifestPath, + modified: dir.modified + }) + } + } + + state.savedConfigs = configs + renderSavedConfigs() + } catch (error) { + state.savedConfigs = [] + list.innerHTML = '' + list.appendChild(textNode('p', '', `读取配置失败:${error.message}`)) + setSavedPreview(null, '') + } + } + + function renderSavedConfigs() { + const list = $('configList') + list.innerHTML = '' + + if (state.savedConfigs.length === 0) { + list.innerHTML = '

暂无已保存配置

' + setSavedPreview(null, '') + return + } + + for (const config of state.savedConfigs) { + const isPendingDelete = config.dirPath === state.pendingDeletePath + const card = document.createElement('div') + card.className = `config-card ${config.dirPath === state.selectedConfigPath ? 'active' : ''}` + + const title = document.createElement('div') + title.className = 'config-head' + title.append( + textNode('div', 'config-title', config.name || config.dirName || '未命名配置'), + textNode('span', 'hint', config.tool || 'unknown') + ) + + const meta = document.createElement('div') + meta.className = 'hint' + meta.textContent = `${config.linkedInstanceName || '未关联实例'} · ${config.publicAddress || '未记录连接地址'}` + + const actions = document.createElement('div') + actions.className = 'config-actions' + + const previewButton = document.createElement('button') + previewButton.className = 'secondary' + previewButton.type = 'button' + previewButton.textContent = '预览' + previewButton.addEventListener('click', () => previewSavedConfig(config)) + + const loadButton = document.createElement('button') + loadButton.className = 'secondary' + loadButton.type = 'button' + loadButton.textContent = '载入表单' + loadButton.addEventListener('click', () => loadConfigIntoForm(config)) + + if (isPendingDelete) { + const confirmButton = document.createElement('button') + confirmButton.className = 'danger' + confirmButton.type = 'button' + confirmButton.textContent = '确认删除' + confirmButton.addEventListener('click', () => deleteSavedConfig(config)) + + const cancelButton = document.createElement('button') + cancelButton.className = 'secondary' + cancelButton.type = 'button' + cancelButton.textContent = '取消' + cancelButton.addEventListener('click', cancelDeleteConfig) + + const confirmText = textNode('div', 'config-confirm', '只删除配置目录,不删除已创建的穿透实例。') + actions.append(confirmButton, cancelButton) + card.append(title, meta, confirmText, actions) + list.appendChild(card) + continue + } + + const deleteButton = document.createElement('button') + deleteButton.className = 'secondary' + deleteButton.type = 'button' + deleteButton.textContent = '删除配置' + deleteButton.addEventListener('click', () => requestDeleteConfig(config)) + + actions.append(previewButton, loadButton, deleteButton) + card.append(title, meta, actions) + list.appendChild(card) + } + } + + async function previewSavedConfig(config) { + try { + state.selectedConfigPath = config.dirPath + renderSavedConfigs() + + const configPath = config.configPath || `${config.dirPath}/frpc.toml` + let content = '' + try { + content = await readTextFile(configPath) + } catch { + content = JSON.stringify(config, null, 2) + } + + setSavedPreview(config, content) + } catch (error) { + showMessage('error', `预览配置失败:${error.message}`) + } + } + + function loadConfigIntoForm(config) { + state.pendingDeletePath = '' + $('profileName').value = config.name || config.dirName || '' + $('localAddress').value = config.localAddress || '127.0.0.1' + $('localPort').value = config.localPort || '' + $('protocol').value = config.protocol || 'tcp' + + if (String(config.tool || '').toLowerCase() === 'easytier') { + setMode('easytier') + } else { + setMode('frp') + } + + buildPlan(false) + showMessage('success', `已载入配置:${config.name || config.dirName}`) + } + + function requestDeleteConfig(config) { + state.pendingDeletePath = config.dirPath + renderSavedConfigs() + showMessage('warn', `确认删除配置:${config.name || config.dirName}`) + } + + function cancelDeleteConfig() { + state.pendingDeletePath = '' + renderSavedConfigs() + clearMessage() + } + + async function deleteSavedConfig(config) { + try { + await window.gsm3.deleteDirectory(config.dirPath, true) + if (state.selectedConfigPath === config.dirPath) { + state.selectedConfigPath = '' + setSavedPreview(null, '') + } + state.pendingDeletePath = '' + await loadSavedConfigs() + showMessage('success', `配置已删除:${config.name || config.dirName}`) + notify('success', `配置已删除:${config.name || config.dirName}`) + } catch (error) { + showMessage('error', `删除配置失败:${error.message}`) + notify('error', `删除配置失败:${error.message}`) + } + } + + async function saveTunnelConfig() { + const plan = buildPlan(true) + if (!plan) return + + $('saveConfigButton').disabled = true + const originalButtonText = $('saveConfigButton').textContent + $('saveConfigButton').textContent = '保存中...' + + try { + state.pendingDeletePath = '' + await writeTunnelFiles(plan) + await loadSavedConfigs() + + const saved = state.savedConfigs.find(config => config.dirPath === plan.dataDir) + if (saved) { + await previewSavedConfig(saved) + } else { + setSavedPreview(buildManifest(plan), plan.configContent) + } + + showMessage('success', `配置已保存:${plan.profile}`) + notify('success', `配置已保存:${plan.profile}`) + } catch (error) { + showMessage('error', `保存配置失败:${error.message}`) + notify('error', `保存配置失败:${error.message}`) + } finally { + $('saveConfigButton').disabled = false + $('saveConfigButton').textContent = originalButtonText } } @@ -804,8 +1251,7 @@

生成结果

const originalButtonText = $('createButton').textContent $('createButton').textContent = '创建中...' try { - await window.gsm3.writeFile(plan.configPath, plan.configContent, 'utf-8') - await window.gsm3.writeFile(`${plan.dataDir}/manifest.json`, JSON.stringify(buildManifest(plan), null, 2), 'utf-8') + await writeTunnelFiles(plan) const result = await window.gsm3.createInstance({ name: tunnelName, @@ -821,6 +1267,7 @@

生成结果

const createdName = created.name || tunnelName showMessage('success', `穿透实例已创建:${createdName}。请到实例管理中启动该实例`) notify('success', `穿透实例已创建:${createdName}`) + await loadSavedConfigs() } catch (error) { showMessage('error', `创建穿透实例失败:${error.message}`) notify('error', `创建穿透实例失败:${error.message}`) @@ -832,7 +1279,9 @@

生成结果

function attachEvents() { $('refreshInstancesButton').addEventListener('click', loadInstances) + $('refreshConfigsButton').addEventListener('click', loadSavedConfigs) $('previewButton').addEventListener('click', () => buildPlan(true)) + $('saveConfigButton').addEventListener('click', saveTunnelConfig) $('createButton').addEventListener('click', createTunnelInstance) $('modeFrp').addEventListener('click', () => setMode('frp')) $('modeEasyTier').addEventListener('click', () => setMode('easytier')) @@ -850,14 +1299,18 @@

生成结果

} async function boot() { + startThemeSync() + if (!window.gsm3) { showMessage('error', '插件 API 未加载') return } attachEvents() + await waitForPluginToken() await loadSystemInfo() await loadInstances() + await loadSavedConfigs() buildPlan(false) } From 6aa6d29e22cfb6857d92bff76ea2a2a2f712961a Mon Sep 17 00:00:00 2001 From: xiwangly2 Date: Wed, 29 Jul 2026 14:00:10 +0800 Subject: [PATCH 08/10] fix: add file path resolution and executable detection for improved instance management --- client/src/pages/InstanceManagerPage.tsx | 29 +++- client/src/utils/api.ts | 10 ++ ...00\346\261\202\350\257\264\346\230\216.md" | 6 +- server/data/plugins/tunnel-helper/gsm3-api.js | 14 +- server/data/plugins/tunnel-helper/index.html | 156 +++++++++++++++++- server/src/routes/files.ts | 59 +++++++ server/src/routes/pluginApi.ts | 125 +++++++++++++- 7 files changed, 384 insertions(+), 15 deletions(-) diff --git a/client/src/pages/InstanceManagerPage.tsx b/client/src/pages/InstanceManagerPage.tsx index b155a009..2f92197f 100644 --- a/client/src/pages/InstanceManagerPage.tsx +++ b/client/src/pages/InstanceManagerPage.tsx @@ -1031,13 +1031,26 @@ const InstanceManagerPage: React.FC = () => { } // 打开文件目录 - const handleOpenDirectory = (instance: Instance) => { - navigate(`/files?path=${encodeURIComponent(instance.workingDirectory)}`) - addNotification({ - type: 'success', - title: '跳转成功', - message: `已打开 "${instance.name}" 的工作目录` - }) + const handleOpenDirectory = async (instance: Instance) => { + try { + const response = await apiClient.resolveFilePath(instance.workingDirectory) + const targetPath = response.data?.resolvedPath || instance.workingDirectory + navigate(`/files?path=${encodeURIComponent(targetPath)}`) + addNotification({ + type: response.data?.exists === false ? 'warning' : 'success', + title: response.data?.exists === false ? '路径不存在' : '跳转成功', + message: response.data?.exists === false + ? `已跳转到解析后的路径,但目录不存在:${targetPath}` + : `已打开 "${instance.name}" 的工作目录` + }) + } catch (error: any) { + navigate(`/files?path=${encodeURIComponent(instance.workingDirectory)}`) + addNotification({ + type: 'warning', + title: '路径解析失败', + message: error.message || '已使用原始工作目录跳转' + }) + } } // 重置表单 @@ -2513,4 +2526,4 @@ const InstanceManagerPage: React.FC = () => { ) } -export default InstanceManagerPage \ No newline at end of file +export default InstanceManagerPage diff --git a/client/src/utils/api.ts b/client/src/utils/api.ts index 10b00427..6436bbf0 100644 --- a/client/src/utils/api.ts +++ b/client/src/utils/api.ts @@ -421,6 +421,16 @@ class ApiClient { return this.get('/files', { params: { path } }) } + async resolveFilePath(path: string) { + return this.get<{ + originalPath: string + resolvedPath: string + isAbsolute: boolean + exists: boolean + type?: 'file' | 'directory' + }>('/files/resolve-path', { params: { path } }) + } + async uploadFile(file: File, path: string) { const formData = new FormData() formData.append('file', file) diff --git "a/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" "b/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" index 991ea713..b18afe9e 100644 --- "a/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" +++ "b/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" @@ -35,7 +35,7 @@ - `plugin.json` 默认 `enabled: false`,作为可选工具提供。 - 复用示例插件的 `gsm3-api.js`。 - `index.html` 提供 frp / EasyTier 两种模式。 -- frp 模式生成 `tunnels//frpc.toml`,并创建使用 `frpc -c frpc.toml` 的独立实例。 +- frp 模式生成 `tunnels//frpc.toml`,并创建带可执行文件预检的 `frpc -c frpc.toml` 独立实例。 - EasyTier 模式生成 `tunnels//easytier-command.txt`,并创建使用命令行参数加入虚拟网络的独立实例。 - 同步生成 `tunnels//manifest.json`,记录关联游戏实例、端口、协议、配置路径和连接地址。 - 右侧预览会脱敏展示 `auth.token` 和 `--network-secret`。 @@ -43,6 +43,9 @@ - 支持“仅保存配置”和“保存并创建实例”两个动作,便于先保存或更新配置,再按需创建受管实例。 - 打开插件后会读取 `server/data/tunnels/` 下已有配置,支持预览、载入表单和删除配置目录。 - 插件页面会跟随主面板明暗主题,避免主 UI 为深色时插件 iframe 仍显示浅色页面。 +- 插件不内置 frpc 或 EasyTier 二进制文件;可检测当前填写的可执行文件是否在本机可用。 +- 生成的穿透实例启动命令会先检查可执行文件,缺失时在终端输出明确提示后退出。 +- 实例管理的“文件”按钮会把相对工作目录解析为服务端绝对路径,避免 `data/tunnels/...` 被文件管理器当成系统根路径。 ### 启用和禁用 @@ -58,6 +61,7 @@ - 如果配置文件已经生成但界面没有明显变化,先到“实例管理”检查是否已经创建了 `穿透-<名称>` 实例。 - 如果只想更新已有配置,使用“仅保存配置”;“保存并创建实例”会在存在同名 `穿透-<名称>` 实例时阻止重复创建。 - “删除配置”只删除 `server/data/tunnels/<名称>` 配置目录,不删除已经创建的穿透实例;实例仍需到“实例管理”中删除。 +- 启动穿透实例前需要先安装对应客户端,或在插件里填写 `frpc.exe` / `easytier-core` 的绝对路径并点击“检测”确认。 - Windows 下启动命令应当带 PowerShell 调用运算符,例如 `& 'frpc' -c 'frpc.toml'`。旧版本插件生成的 `'frpc' -c 'frpc.toml'` 不会执行,需要删除后重新创建或手动修正实例启动命令。 ## 非目标 diff --git a/server/data/plugins/tunnel-helper/gsm3-api.js b/server/data/plugins/tunnel-helper/gsm3-api.js index b1b88b88..9ea45613 100644 --- a/server/data/plugins/tunnel-helper/gsm3-api.js +++ b/server/data/plugins/tunnel-helper/gsm3-api.js @@ -458,6 +458,18 @@ class GSM3API { }) } + /** + * 检测可执行文件是否存在 + * @param {string} executable 可执行文件名或路径 + * @param {string} workingDirectory 相对路径解析用工作目录,可选 + */ + async resolveExecutable(executable, workingDirectory = '') { + return await this.request('/tools/resolve-executable', { + method: 'POST', + body: { executable, workingDirectory } + }) + } + // ==================== 通用API ==================== /** @@ -604,4 +616,4 @@ document.addEventListener('DOMContentLoaded', () => { }, '*') } }) -}) \ No newline at end of file +}) diff --git a/server/data/plugins/tunnel-helper/index.html b/server/data/plugins/tunnel-helper/index.html index 76391622..073409d1 100644 --- a/server/data/plugins/tunnel-helper/index.html +++ b/server/data/plugins/tunnel-helper/index.html @@ -181,6 +181,34 @@ font-weight: 400; } + .input-action { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + } + + .input-action button { + min-width: 68px; + } + + .help-link { + color: var(--primary); + text-decoration: none; + font-weight: 600; + } + + .tool-status.ok { + color: var(--success); + } + + .tool-status.warn { + color: var(--warning); + } + + .tool-status.error { + color: var(--danger); + } + .segmented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -443,7 +471,14 @@

穿透方式

frp 参数