Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c98716dd7b |
@@ -0,0 +1,491 @@
|
||||
---
|
||||
AIGC:
|
||||
ContentProducer: '001191110102MAD55U9H0F10002'
|
||||
ContentPropagator: '001191110102MAD55U9H0F10002'
|
||||
Label: '1'
|
||||
ProduceID: '1148f2f2-715c-4f80-80d7-010cab5af4b0'
|
||||
PropagateID: '1148f2f2-715c-4f80-80d7-010cab5af4b0'
|
||||
ReservedCode1: '57f1878c-c8f1-41ec-9020-31dae82dcead'
|
||||
ReservedCode2: '57f1878c-c8f1-41ec-9020-31dae82dcead'
|
||||
---
|
||||
|
||||
# WFJ Panel 项目说明文档
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
WFJ Panel 是一款基于 Electron + React + ssh2 构建的**远程服务器管理面板**桌面应用,通过 SSH 协议远程连接 Linux 服务器,提供服务器概览监控、文件管理、交互式终端、Docker 容器管理、应用商店一键部署、防火墙管理等功能。
|
||||
|
||||
- **产品名称**:WFJ Panel
|
||||
- **版本**:v0.0.1
|
||||
- **许可**:MIT
|
||||
- **构建产物**:NSIS 安装程序(Windows x64,约 107MB)
|
||||
|
||||
---
|
||||
|
||||
## 二、技术栈
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 桌面框架 | Electron 44 + electron-vite 5 | 跨平台桌面应用,Vite 驱动构建 |
|
||||
| 前端框架 | React 19 | 函数组件 + Hooks |
|
||||
| 终端模拟 | @xterm/xterm 5 + @xterm/addon-fit | 交互式终端渲染 |
|
||||
| SSH 通信 | ssh2 1.17 | 纯 JS 实现的 SSH2 客户端 |
|
||||
| 打包工具 | electron-builder 26 | NSIS 安装包 |
|
||||
| 后端服务 | Flask 3 + mysql-connector-python | 应用商店 API 服务 |
|
||||
| 数据库 | MySQL 8 | 应用商店模板与版本数据 |
|
||||
|
||||
---
|
||||
|
||||
## 三、项目结构
|
||||
|
||||
```
|
||||
ssh_panel/
|
||||
├── package.json # 项目元数据、依赖、构建配置
|
||||
├── electron.vite.config.mjs # electron-vite 配置(main/preload/renderer 三入口)
|
||||
├── .npmrc # Electron 国内镜像配置
|
||||
├── .gitignore
|
||||
├── build/
|
||||
│ └── icon.png # 应用图标 (256x256)
|
||||
├── src/
|
||||
│ ├── main/
|
||||
│ │ └── index.js # ★ 主进程(1377行)—— SSH 连接、IPC、Docker、部署、SFTP、终端
|
||||
│ ├── preload/
|
||||
│ │ └── index.js # ★ Preload 桥接层 —— 暴露 window.panel API(90行)
|
||||
│ └── renderer/
|
||||
│ ├── index.html # HTML 入口
|
||||
│ └── src/
|
||||
│ ├── main.jsx # React 挂载入口
|
||||
│ ├── App.jsx # ★ 根组件 —— 登录/面板切换、连接状态管理
|
||||
│ ├── theme.css # 设计令牌(色彩/间距/阴影/按钮体系)
|
||||
│ ├── main.css # 全局样式
|
||||
│ └── pages/
|
||||
│ ├── Login.jsx # SSH 登录页(凭据保存/自动填充)
|
||||
│ ├── Panel.jsx # ★ 主面板(导航栏 + 功能模块切换 + 版本更新)
|
||||
│ ├── overview/ # 服务器概览监控
|
||||
│ │ ├── Overview.jsx
|
||||
│ │ ├── useMetric.js # 通用指标采集 Hook
|
||||
│ │ └── cards/ # CPU/内存/硬盘/网络/进程 五张卡片
|
||||
│ ├── files/ # SFTP 远程文件管理
|
||||
│ │ └── FileManager.jsx
|
||||
│ ├── terminal/ # 交互式终端
|
||||
│ │ └── TerminalPanel.jsx
|
||||
│ ├── container/ # Docker 容器管理
|
||||
│ │ ├── ContainerPanel.jsx # 容器面板入口(状态检测+子菜单)
|
||||
│ │ ├── ContainerList.jsx # 容器列表(启动/停止/删除/日志)
|
||||
│ │ ├── ImageList.jsx # 镜像列表
|
||||
│ │ ├── AppStore.jsx # ★ 应用商店(在线模板+一键部署)
|
||||
│ │ ├── TaskCenter.jsx # 后台部署任务中心
|
||||
│ │ ├── DockerConfig.jsx # Docker 配置文件查看/编辑
|
||||
│ │ ├── NetworkPanel.jsx # Docker 网络管理
|
||||
│ │ ├── VolumePanel.jsx # Docker 卷管理
|
||||
│ │ ├── ContainerCreate.jsx # 手动创建容器
|
||||
│ │ ├── DockerLog.jsx # 容器日志查看
|
||||
│ │ ├── cmdParser.js # docker run 命令解析
|
||||
│ │ └── appTemplates.js # 内置应用模板(离线备用)
|
||||
│ └── firewall/ # 系统防火墙管理
|
||||
│ └── Firewall.jsx
|
||||
├── appstore-server/ # 应用商店后端服务
|
||||
│ ├── app.py # Flask API(/api/apps、/api/version、/api/health)
|
||||
│ ├── init.sql # 数据库建表 + 初始数据(Redis/MySQL/Halo 模板)
|
||||
│ ├── requirements.txt # Python 依赖
|
||||
│ ├── Dockerfile # API 服务容器化
|
||||
│ └── deploy.sh # 一键部署脚本
|
||||
└── release/ # 打包产物输出目录(.gitignore)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、架构设计
|
||||
|
||||
### 4.1 Electron 三层架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 渲染进程 (Renderer) │
|
||||
│ React 19 + xterm.js + CSS │
|
||||
│ ┌──────────┬──────────┬──────────┬────────┬──────────┐ │
|
||||
│ │ 概览监控 │ 文件管理 │ 交互终端 │ Docker │ 防火墙 │ │
|
||||
│ └─────┬────┴─────┬────┴─────┬────┴───┬────┴─────┬────┘ │
|
||||
│ │ │ │ │ │ │
|
||||
│ └──────────┴──────────┴────────┴──────────┘ │
|
||||
│ window.panel.* │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Preload (桥接层) │
|
||||
│ contextBridge.exposeInMainWorld │
|
||||
│ ipcRenderer.invoke / on │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 主进程 (Main) │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ SSH 连接管理 (ssh2 Client) │ │
|
||||
│ │ ┌──────────┬──────────┬───────────┐ │ │
|
||||
│ │ │ 主连接 │ Docker │ 部署连接 │ │ │
|
||||
│ │ │ (exec + │ 专用连接 │ 专用连接 │ │ │
|
||||
│ │ │ sftp + │ (独立队列)│ (后台任务) │ │ │
|
||||
│ │ │ shell) │ │ │ │ │
|
||||
│ │ └──────────┴──────────┴───────────┘ │ │
|
||||
│ │ 串行队列 (execQueue) │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ IPC Handlers │
|
||||
│ ssh / cred / sftp / shell / │
|
||||
│ docker / appstore / app │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 应用商店后端 (appstore-server) │
|
||||
│ Flask API ←→ MySQL 8 │
|
||||
│ /api/apps · /api/version · /api/health │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 三条 SSH 连接设计
|
||||
|
||||
这是本项目的核心架构决策,通过连接隔离避免不同模块互相阻塞:
|
||||
|
||||
| 连接 | 变量名 | 用途 | 特点 |
|
||||
|------|--------|------|------|
|
||||
| **主连接** | `activeConnection` | 概览监控、SFTP 文件管理、交互式终端 | 登录时建立;exec + sftp 共用串行队列 |
|
||||
| **Docker 连接** | `dockerConnection` | Docker 容器/镜像/网络/卷管理 | 登录时自动建立;独立串行队列 |
|
||||
| **部署连接** | `deployConnection` | 应用商店后台部署任务 | 懒创建(首次部署时建立);直接并发 exec |
|
||||
|
||||
**串行队列机制**:
|
||||
- 主连接和 Docker 连接各有独立的串行队列(`execQueue` / `dockerQueue`)
|
||||
- 同一连接同一时刻只执行一个 channel,避免并发打开过多 channel 导致 `Channel open failure: open failed` 错误
|
||||
- 命令轻量(如 `free -m`),串行执行几乎无感知延迟
|
||||
- 部署连接不串行(部署任务内部已 `await` 顺序执行,任务间可并行)
|
||||
|
||||
### 4.3 安全设计
|
||||
|
||||
- **contextIsolation: true**:渲染进程与 Node.js 隔离,无法直接 `require`
|
||||
- **nodeIntegration: false**:渲染进程无 Node.js 权限
|
||||
- **sandbox: false**:preload 可用 Node.js(暴露受控 API)
|
||||
- **密码加密存储**:使用 Electron `safeStorage` API(Windows 上为 DPAPI 加密),不可用时降级 base64
|
||||
- **API 鉴权**:应用商店 API 通过 `Authorization: Bearer <token>` 校验
|
||||
|
||||
---
|
||||
|
||||
## 五、功能模块详解
|
||||
|
||||
### 5.1 登录与凭据管理
|
||||
|
||||
- SSH 连接表单(host / port / username / password)
|
||||
- 「记住密码」:连接成功后加密保存到本机 `userData/credentials.json`
|
||||
- 已保存服务器列表:点击快速填充、删除
|
||||
- 打开时自动预填最近一次保存的凭据
|
||||
|
||||
### 5.2 服务器概览监控
|
||||
|
||||
五张实时卡片,每张卡片独立轮询:
|
||||
|
||||
| 卡片 | 数据源命令 | 刷新间隔 |
|
||||
|------|-----------|----------|
|
||||
| CPU | `top -bn1` / `mpstat` / `vmstat` | 2s |
|
||||
| 内存 | `free -m` | 3s |
|
||||
| 硬盘 | `df -h` | 15s |
|
||||
| 网络 | `/proc/net/dev` 两次采样计算速率 | 2s |
|
||||
| 进程 | `ps aux` + `ss -tlnp`/`netstat -tlnp`(监听端口) | 5s |
|
||||
|
||||
- **依赖检测**:`useMetric` Hook 检测命令是否存在(如 `sysstat` 未安装时卡片置灰,提供一键安装按钮)
|
||||
- **CPU% / 内存% 点击排序**(降序,表头高亮箭头)
|
||||
- 进程卡片显示监听端口(蓝色徽章)
|
||||
|
||||
### 5.3 SFTP 文件管理
|
||||
|
||||
- 远程目录浏览(目录优先排序)
|
||||
- 文件上传(支持多选,实时进度推送 `sftp:progress`)
|
||||
- 文件下载(单文件保存对话框,批量下载到指定目录)
|
||||
- 新建远程目录
|
||||
- 删除(统一用 `rm -rf`,路径用单引号包裹防止注入)
|
||||
- SFTP 会话缓存复用(同一个 channel)
|
||||
|
||||
### 5.4 交互式终端
|
||||
|
||||
- 基于 xterm.js + ssh2 PTY 实现
|
||||
- 深色主题(`#1a1d29` 背景,靛蓝光标)
|
||||
- 支持窗口大小自适应(FitAddon)
|
||||
- 切换导航/断开连接/切换服务器/关窗时自动清理 Shell
|
||||
- React StrictMode 双挂载防护(`setTimeout(0)` + `disposed` 标记)
|
||||
|
||||
### 5.5 Docker 容器管理
|
||||
|
||||
子菜单包含 8 个功能页:
|
||||
|
||||
| 子菜单 | 功能 |
|
||||
|--------|------|
|
||||
| 概览 | 容器数/镜像数/CPU/内存/缓存/存储路径/配置文件 |
|
||||
| 容器列表 | 启动/停止/删除/查看日志/进入终端 |
|
||||
| 镜像 | 镜像列表/删除/清理 |
|
||||
| 应用商店 | ★ 在线模板同步 + 一键部署 |
|
||||
| 任务中心 | 后台部署任务进度(步骤+日志) |
|
||||
| 配置 | Docker `daemon.json` 查看/编辑 |
|
||||
| 网络 | Docker 网络列表/创建/删除 |
|
||||
| 卷 | Docker 数据卷列表/删除 |
|
||||
|
||||
**Docker 状态管理**:
|
||||
- 自动检测:`command -v docker` → `docker info`
|
||||
- 状态机:checking → running / stopped / notInstalled / error
|
||||
- 启动/停止按钮(停止时先杀容器再 kill dockerd,防止卡死)
|
||||
|
||||
### 5.6 应用商店一键部署
|
||||
|
||||
这是项目的核心特色功能:
|
||||
|
||||
**数据流**:
|
||||
```
|
||||
MySQL (app_templates + 子表) → Flask API → Electron 主进程 (HTTP GET)
|
||||
→ 本地缓存 (appstore-cache.json) → 前端渲染应用卡片
|
||||
→ 用户填写参数 → 提交部署 → 后台任务执行 → 实时进度推送
|
||||
```
|
||||
|
||||
**部署步骤**(4 步):
|
||||
1. **拉取镜像** `docker pull <image>`(超时 5 分钟)
|
||||
2. **创建目录** `mkdir -p` 宿主机挂载目录
|
||||
3. **写入配置** `cat > file << 'EOF'` heredoc 方式写入配置文件
|
||||
4. **创建容器** 动态构建 `docker run` 命令并执行
|
||||
|
||||
**模板参数系统**:
|
||||
- `{{}}` 占位符替换(如 `{{REDIS_PASSWORD}}`、`{{PORT}}`)
|
||||
- 特殊占位符 `{{REDIS_PASSWORD_LINE}}`:根据密码是否存在动态生成配置行
|
||||
- 条件渲染 `show_when`:如 Halo 选择 MySQL 数据库时才显示 MySQL 配置字段
|
||||
- 下拉选项 `options`(JSON 数组):如 Halo 数据库类型选择 H2/MySQL
|
||||
- 环境变量值含空格时自动加单引号包裹(修复 `-Xmx256m -Xms256m` 的问题)
|
||||
- `extra_args` 额外参数在镜像名前插入(如 Halo 数据库连接参数)
|
||||
|
||||
**内置模板**:
|
||||
| 应用 | 镜像 | 说明 |
|
||||
|------|------|------|
|
||||
| Redis | `redis:8.10.0` | 密码可选,自定义配置文件 |
|
||||
| MySQL | `mysql:8.4.11` | root 密码必填,自定义 my.cnf |
|
||||
| Halo | `registry.fit2cloud.com/halo/halo:2.26` | 支持 H2/MySQL 双模式,JVM 参数可调 |
|
||||
|
||||
### 5.7 防火墙管理
|
||||
|
||||
- 自动检测防火墙类型:`firewalld` / `ufw` / `iptables`
|
||||
- firewalld:状态开关、服务/端口管理(添加端口弹窗)
|
||||
- ufw / iptables:仅展示规则列表
|
||||
- 全程复用 `window.panel.exec`,零新增 IPC
|
||||
|
||||
### 5.8 客户端版本管理
|
||||
|
||||
- 主进程内置 `CLIENT_VERSION` 常量
|
||||
- 「更新」按钮触发 `/api/version` 接口检查
|
||||
- 弹窗显示三种状态:有新版本(显示更新说明+前往下载)、已是最新、检查失败
|
||||
- 下载链接通过 `shell.openExternal` 在系统浏览器打开
|
||||
|
||||
---
|
||||
|
||||
## 六、IPC API 参考
|
||||
|
||||
### 6.1 SSH 连接
|
||||
|
||||
| IPC 通道 | Preload API | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `ssh:connect` | `panel.connect(config)` | 建立 SSH 连接,自动建立 Docker/部署连接 |
|
||||
| `ssh:disconnect` | `panel.disconnect()` | 断开所有连接 |
|
||||
| `ssh:session` | `panel.getSession()` | 获取当前会话信息 |
|
||||
| `ssh:exec` | `panel.exec(cmd, timeout)` | 在主连接上执行命令(串行队列) |
|
||||
| `ssh:disconnected` | `panel.onDisconnected(cb)` | 监听连接断开事件 |
|
||||
|
||||
### 6.2 凭据管理
|
||||
|
||||
| IPC 通道 | Preload API | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `cred:save` | `panel.saveCredential(cred)` | 保存凭据(密码加密) |
|
||||
| `cred:list` | `panel.listCredentials()` | 列出已保存凭据 |
|
||||
| `cred:delete` | `panel.deleteCredential(key)` | 删除凭据 |
|
||||
|
||||
### 6.3 SFTP 文件管理
|
||||
|
||||
| IPC 通道 | Preload API | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `sftp:list` | `panel.sftpList(path)` | 列目录 |
|
||||
| `sftp:upload` | `panel.sftpUpload(local, remote)` | 上传文件 |
|
||||
| `sftp:download` | `panel.sftpDownload(remote, local)` | 下载文件 |
|
||||
| `sftp:mkdir` | `panel.sftpMkdir(path)` | 创建目录 |
|
||||
| `sftp:delete` | `panel.sftpDelete(path, isDir, recursive)` | 删除文件/目录 |
|
||||
| `sftp:selectLocalFile` | `panel.sftpSelectLocalFile()` | 选择本地文件(多选) |
|
||||
| `sftp:selectSavePath` | `panel.sftpSelectSavePath(name)` | 选择保存路径 |
|
||||
| `sftp:selectDownloadDir` | `panel.sftpSelectDownloadDir()` | 选择下载目录 |
|
||||
| `sftp:progress` | `panel.onSftpProgress(cb)` | 上传/下载进度事件 |
|
||||
|
||||
### 6.4 交互式终端
|
||||
|
||||
| IPC 通道 | Preload API | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `shell:start` | `panel.shellStart(cols, rows)` | 启动 PTY Shell |
|
||||
| `shell:write` | `panel.shellWrite(data)` | 向 Shell 写入数据 |
|
||||
| `shell:resize` | `panel.shellResize(cols, rows)` | 调整终端窗口大小 |
|
||||
| `shell:close` | `panel.shellClose()` | 关闭 Shell |
|
||||
| `shell:data` | `panel.onShellData(cb)` | 监听 Shell 输出 |
|
||||
| `shell:close` | `panel.onShellClose(cb)` | 监听 Shell 关闭 |
|
||||
|
||||
### 6.5 Docker 管理
|
||||
|
||||
| IPC 通道 | Preload API | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `docker:exec` | `panel.dockerExec(cmd, timeout)` | 在 Docker 连接上执行命令 |
|
||||
| `docker:disconnect` | `panel.dockerDisconnect()` | 断开 Docker 连接 |
|
||||
| `docker:deployApp` | `panel.dockerDeployApp(deploy, params)` | 提交部署任务 |
|
||||
| `docker:checkConnections` | `panel.dockerCheckConnections()` | 检查 SSH 连接数 |
|
||||
| `docker:listTasks` | `panel.dockerListTasks()` | 列出所有部署任务 |
|
||||
| `deploy:taskUpdate` | `panel.onDeployTaskUpdate(cb)` | 监听部署进度更新 |
|
||||
| `docker:ready` | `panel.onDockerReady(cb)` | Docker 连接就绪事件 |
|
||||
| `docker:disconnected` | `panel.onDockerDisconnected(cb)` | Docker 连接断开事件 |
|
||||
|
||||
### 6.6 应用商店与版本管理
|
||||
|
||||
| IPC 通道 | Preload API | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `appstore:sync` | `panel.appstoreSync()` | 从远程 API 同步模板到本地 |
|
||||
| `appstore:getLocal` | `panel.appstoreGetLocal()` | 获取本地缓存模板 |
|
||||
| `appstore:getApiUrl` | `panel.appstoreGetApiUrl()` | 获取 API 地址 |
|
||||
| `appstore:setApiUrl` | `panel.appstoreSetApiUrl(url)` | 设置 API 地址 |
|
||||
| `appstore:checkInstalled` | `panel.appstoreCheckInstalled()` | 检查已安装应用 |
|
||||
| `app:checkUpdate` | `panel.appCheckUpdate()` | 检查客户端版本更新 |
|
||||
| `app:getVersion` | `panel.appGetVersion()` | 获取当前版本号 |
|
||||
| `app:openUrl` | `panel.appOpenUrl(url)` | 用系统浏览器打开 URL |
|
||||
|
||||
---
|
||||
|
||||
## 七、应用商店后端服务
|
||||
|
||||
### 7.1 API 端点
|
||||
|
||||
| 路由 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/apps` | GET | 获取所有应用模板(含完整详情和子表) |
|
||||
| `/api/apps/<app_key>` | GET | 获取单个应用模板详情 |
|
||||
| `/api/version` | GET | 获取客户端最新版本信息 |
|
||||
| `/api/health` | GET | 健康检查 |
|
||||
|
||||
### 7.2 数据库表结构
|
||||
|
||||
```
|
||||
app_templates (主表)
|
||||
├── app_ports (端口映射子表)
|
||||
├── app_volumes (挂载卷子表)
|
||||
├── app_envs (环境变量子表)
|
||||
├── app_mkdirs (自动创建目录子表)
|
||||
├── app_write_files (配置文件子表)
|
||||
└── app_params (用户参数子表,含下拉选项和条件显示)
|
||||
|
||||
app_version (客户端版本管理表)
|
||||
```
|
||||
|
||||
### 7.3 部署方式
|
||||
|
||||
```bash
|
||||
# 在服务器上执行
|
||||
cd appstore-server
|
||||
bash deploy.sh
|
||||
# 流程:建表 → 构建镜像 → 启动容器(3001 端口)
|
||||
```
|
||||
|
||||
环境变量配置:
|
||||
- `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME`:数据库连接
|
||||
- `API_PORT`:API 监听端口(默认 3001)
|
||||
- `API_TOKEN`:鉴权 token(可选)
|
||||
|
||||
---
|
||||
|
||||
## 八、开发与构建
|
||||
|
||||
### 8.1 环境要求
|
||||
|
||||
- Node.js 18+
|
||||
- npm(国内网络建议配置 `.npmrc` 中的 Electron 镜像)
|
||||
- Python 3.12+(应用商店后端)
|
||||
- MySQL 8(应用商店数据库)
|
||||
|
||||
### 8.2 开发
|
||||
|
||||
```bash
|
||||
npm install # 安装依赖(Electron 自动走国内镜像)
|
||||
npm run dev # 启动开发服务器(Vite HMR 热更新)
|
||||
```
|
||||
|
||||
开发模式下按 `F12` 打开开发者工具。
|
||||
|
||||
### 8.3 构建
|
||||
|
||||
```bash
|
||||
npm run build # 构建产物(输出到 out/)
|
||||
npm run build:win # 构建 + 打包 Windows 安装程序(输出到 release/)
|
||||
```
|
||||
|
||||
### 8.4 关键配置说明
|
||||
|
||||
**`.npmrc`(Electron 国内镜像)**:
|
||||
```
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
```
|
||||
|
||||
**`electron-builder` 配置要点**:
|
||||
- `npmRebuild: false`:跳过 native 模块编译(ssh2 纯 JS 无需编译)
|
||||
- `target: nsis`:Windows NSIS 安装程序
|
||||
- `icon: build/icon.png`:256x256 应用图标
|
||||
- 国内打包需配置 `ELECTRON_BUILDER_BINARIES_MIRROR` 镜像
|
||||
|
||||
---
|
||||
|
||||
## 九、关键设计决策与踩坑记录
|
||||
|
||||
### 9.1 SSH Channel 并发问题
|
||||
|
||||
**问题**:概览页 5 张卡片各自独立轮询,同时打开的 channel 数超出服务器上限(通常 10 个),报错 `Channel open failure: open failed`。
|
||||
|
||||
**方案**:在主进程 `execSsh` 函数增加串行队列(`execQueue`),所有请求逐个执行,同一时刻只占一个 channel。命令轻量,串行执行几乎无感知延迟。
|
||||
|
||||
### 9.2 React StrictMode 双挂载
|
||||
|
||||
**问题**:开发模式下 `React.StrictMode` 会执行两次 `useEffect`(mount→unmount→remount),导致终端首次打开输出重复。
|
||||
|
||||
**方案**:`shellStart` 放入 `setTimeout(0)` 延迟执行并配合 `disposed` 标记,第一次同步 mount→unmount 时被 cleanup 拦截,仅真正 mount 时连接一次。
|
||||
|
||||
### 9.3 Electron 二进制下载失败
|
||||
|
||||
**问题**:`npm install` 时 Electron postinstall 从 GitHub 下载二进制,国内网络经常失败。
|
||||
|
||||
**方案**:项目根目录创建 `.npmrc` 写入 `electron_mirror=https://npmmirror.com/mirrors/electron/`,安装时自动走国内镜像。
|
||||
|
||||
### 9.4 Docker 环境变量含空格
|
||||
|
||||
**问题**:Halo 部署时 `JVM_OPTS=-Xmx256m -Xms256m` 中空格导致 `docker run` 拆分参数报 `unknown shorthand flag`。
|
||||
|
||||
**方案**:环境变量值含空格时自动加单引号包裹(`parts.push('-e', "'${pair}'")`)。
|
||||
|
||||
### 9.5 部署连接隔离
|
||||
|
||||
**问题**:部署任务耗时较长(拉取镜像可能数分钟),如果复用主连接的串行队列会阻塞概览监控等常规操作。
|
||||
|
||||
**方案**:使用独立的第三条 SSH 连接(`deployConnection`),懒创建,部署任务直接 `exec` 不走串行队列,任务内部 `await` 顺序执行,任务间可并行。
|
||||
|
||||
---
|
||||
|
||||
## 十、扩展指南
|
||||
|
||||
### 10.1 新增应用模板
|
||||
|
||||
在 `appstore-server/init.sql` 中或通过 API 添加新行到 `app_templates` 主表及对应子表即可,前端无需改代码。`mapDbTemplateToFront` 函数自动将数据库格式转换为前端 deploy 格式。
|
||||
|
||||
### 10.2 新增功能页面
|
||||
|
||||
1. 在 `src/renderer/src/pages/` 下新建组件目录
|
||||
2. 在 `Panel.jsx` 的 `NAV_ITEMS` 数组添加导航项
|
||||
3. 在 `Panel.jsx` 的 `<main>` 区域添加条件渲染
|
||||
4. 如需主进程支持,在 `src/main/index.js` 添加 IPC handler,在 `src/preload/index.js` 暴露 API
|
||||
|
||||
### 10.3 新增 IPC 通道
|
||||
|
||||
```js
|
||||
// 主进程 (src/main/index.js)
|
||||
ipcMain.handle('my:action', async (event, { param }) => {
|
||||
// 业务逻辑
|
||||
return { ok: true, data: result }
|
||||
})
|
||||
|
||||
// Preload (src/preload/index.js)
|
||||
myAction: (param) => ipcRenderer.invoke('my:action', { param })
|
||||
|
||||
// 渲染进程
|
||||
const result = await window.panel.myAction(param)
|
||||
```
|
||||
|
||||
> AI生成
|
||||
@@ -287,4 +287,25 @@ VALUES ('v0.0.1', 'https://114.66.55.63:3001/download/wfj-panel-v0.0.1.exe',
|
||||
- 交互式终端(xterm.js)
|
||||
- Docker 容器管理(概览/容器列表/镜像/应用商店/任务中心/配置/网络/卷)
|
||||
- 应用商店一键部署(Redis/MySQL/Halo)
|
||||
- 系统防火墙管理(firewalld/ufw/iptables)', 1, NOW());
|
||||
- 系统防火墙管理(firewalld/ufw/iptables)', 0, NOW());
|
||||
|
||||
-- v0.0.2
|
||||
INSERT INTO app_version (version, download_url, release_notes, is_latest, created_at)
|
||||
VALUES ('v0.0.2', 'https://114.66.55.63:3001/download/wfj-panel-v0.0.2.exe',
|
||||
'WFJ Panel v0.0.2 更新
|
||||
|
||||
新增功能:
|
||||
- 应用商店新增 Java 应用模板(支持选择 Java 版本、自定义启动命令、端口映射、路径可视化选择)
|
||||
- 运行时类容器支持重复部署(自动生成不重复容器名)
|
||||
- 应用商店参数新增 path 类型(远程目录浏览器选择宿主机路径)
|
||||
|
||||
问题修复:
|
||||
- 修复任务中心显示占位符 {{CONTAINER_NAME}} 未替换的问题
|
||||
- 修复端口映射容器端口占位符未替换导致部署失败的问题
|
||||
- 修复 execDeploy 超时后 SSH stream 未销毁导致 channel 泄漏
|
||||
- 修复 select 类型参数无默认值时部署按钮误判为必填项未填而置灰
|
||||
- docker pull 超时从 5 分钟提升到 10 分钟
|
||||
|
||||
其他改进:
|
||||
- 窗口标题由 SSH Panel 改为 WFJ Panel
|
||||
- 登录页 LOGO 改为 WFJ Panel', 1, NOW());
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- ============================================================
|
||||
-- Java 应用模板 —— 应用商店热更新(支持选择 Java 版本)
|
||||
-- 执行方式:在 appstore 数据库中运行此 SQL
|
||||
-- 执行后在 WFJ Panel 应用商店点击「同步」即可看到新模板
|
||||
-- ============================================================
|
||||
|
||||
-- ─── 扩大端口字段长度,以容纳占位符(如 {{CONTAINER_PORT}} 为 18 字符,原 VARCHAR(16) 不够)───
|
||||
ALTER TABLE app_ports MODIFY COLUMN host_port VARCHAR(32) NOT NULL COMMENT '宿主机端口';
|
||||
ALTER TABLE app_ports MODIFY COLUMN container_port VARCHAR(32) NOT NULL COMMENT '容器端口';
|
||||
|
||||
-- ─── 主表 ───
|
||||
-- image 使用 {{JAVA_IMAGE}} 占位符,部署时由用户选择的 Java 版本替换
|
||||
INSERT INTO app_templates (app_key, name, tagline, description, icon, category, image, container_name, network, restart, hostname, workdir, command, sort_order, is_active)
|
||||
VALUES (
|
||||
'java',
|
||||
'Java 应用',
|
||||
'运行 Java jar 或 war 文件',
|
||||
'Java 镜像默认工作目录为 /app。将主机中包含 jar/war 文件的目录挂载到 /app,通过自定义启动命令运行 Java 应用。支持 JVM 参数、环境变量、Profile 等灵活配置。适合部署 Spring Boot、Quarkus 等打包为 jar 的 Java 服务。',
|
||||
'java',
|
||||
'运行时',
|
||||
'{{JAVA_IMAGE}}',
|
||||
'{{CONTAINER_NAME}}',
|
||||
'',
|
||||
'always',
|
||||
'',
|
||||
'/app',
|
||||
'{{START_COMMAND}}',
|
||||
4,
|
||||
1
|
||||
);
|
||||
|
||||
SET @java_id = LAST_INSERT_ID();
|
||||
|
||||
-- ─── 端口映射:宿主机端口 → 容器端口(Java 应用必须暴露端口才能访问)───
|
||||
INSERT INTO app_ports (template_id, host_port, container_port, protocol, sort_order) VALUES
|
||||
(@java_id, '{{HOST_PORT}}', '{{CONTAINER_PORT}}', 'tcp', 0);
|
||||
|
||||
-- ─── 挂载卷:宿主机应用目录 → /app ───
|
||||
INSERT INTO app_volumes (template_id, host_path, container_path, mode, sort_order) VALUES
|
||||
(@java_id, '{{APP_DIR}}', '/app', 'rw', 0);
|
||||
|
||||
-- ─── 用户参数 ───
|
||||
INSERT INTO app_params (template_id, param_key, label, type, placeholder, default_value, required, options, show_when, sort_order) VALUES
|
||||
-- Java 版本下拉选择,value 即完整镜像地址
|
||||
(@java_id, 'JAVA_IMAGE', 'Java 版本', 'select', '', '', 1,
|
||||
'[{"value":"bitnami/java:latest","label":"Java Latest (最新版)"},{"value":"bitnami/java:21","label":"Java 21 LTS"},{"value":"bitnami/java:17","label":"Java 17 LTS"},{"value":"bitnami/java:11","label":"Java 11 LTS"},{"value":"bitnami/java:8","label":"Java 8 LTS"}]',
|
||||
NULL, 0),
|
||||
(@java_id, 'CONTAINER_NAME', '容器名称', 'text', 'java-app', 'java-app', 1, NULL, NULL, 1),
|
||||
-- 启动命令:完全自定义,支持 JVM 参数、Spring Profile 等
|
||||
-- 常见示例:
|
||||
-- java -jar app.jar
|
||||
-- java -jar app.jar --spring.profiles.active=prod
|
||||
-- java -Xmx512m -Xms256m -jar app.jar
|
||||
-- java -Xmx512m -Dserver.port=8080 -jar app.jar --spring.profiles.active=prod
|
||||
(@java_id, 'START_COMMAND', '启动命令', 'text', 'java -jar app.jar', 'java -jar app.jar', 1, NULL, NULL, 2),
|
||||
(@java_id, 'APP_DIR', '宿主机应用目录', 'path', '/path/to/app', '', 1, NULL, NULL, 3),
|
||||
-- 宿主机端口:外部访问端口,默认 8080
|
||||
(@java_id, 'HOST_PORT', '宿主机端口', 'number', '8080', '8080', 1, NULL, NULL, 4),
|
||||
-- 容器端口:Java 应用监听端口,需与启动命令中 server.port 一致,默认 8080
|
||||
(@java_id, 'CONTAINER_PORT', '容器端口', 'number', '8080', '8080', 1, NULL, NULL, 5);
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ssh-panel",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.2",
|
||||
"description": "基于 SSH 的服务器管理面板",
|
||||
"main": "out/main/index.js",
|
||||
"scripts": {
|
||||
|
||||
+27
-5
@@ -260,9 +260,14 @@ function execDeploy(cmd, timeoutMs = 30000) {
|
||||
let settled = false
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let streamRef = null
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
// 销毁 stream,防止 channel 泄漏和远程进程继续运行
|
||||
if (streamRef) {
|
||||
try { streamRef.destroy() } catch (_) {}
|
||||
}
|
||||
resolve({ ok: false, message: '命令执行超时' })
|
||||
}
|
||||
}, timeoutMs)
|
||||
@@ -276,6 +281,12 @@ function execDeploy(cmd, timeoutMs = 30000) {
|
||||
}
|
||||
return
|
||||
}
|
||||
streamRef = stream
|
||||
// 如果已超时,立即销毁新拿到的 stream
|
||||
if (settled) {
|
||||
try { stream.destroy() } catch (_) {}
|
||||
return
|
||||
}
|
||||
stream.on('data', (chunk) => (stdout += chunk.toString()))
|
||||
stream.stderr.on('data', (chunk) => (stderr += chunk.toString()))
|
||||
stream.on('close', (code) => {
|
||||
@@ -895,11 +906,21 @@ ipcMain.handle('docker:exec', (event, { cmd, timeout }) => execDocker(cmd, timeo
|
||||
// 部署步骤:1.拉取镜像 2.创建目录 3.写入配置 4.创建容器
|
||||
|
||||
ipcMain.handle('docker:deployApp', async (event, { deploy, paramValues }) => {
|
||||
// 参数占位符替换函数(提前定义,用于 appName 等字段)
|
||||
function tplEarly(str) {
|
||||
if (!str || typeof str !== 'string') return str
|
||||
let result = str
|
||||
for (const [key, val] of Object.entries(paramValues || {})) {
|
||||
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), val)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 创建任务对象
|
||||
const taskId = ++taskCounter
|
||||
const task = {
|
||||
id: taskId,
|
||||
appName: deploy.containerName || 'unknown',
|
||||
appName: tplEarly(deploy.containerName) || deploy.appName || 'unknown',
|
||||
status: 'pending',
|
||||
steps: [],
|
||||
logs: [],
|
||||
@@ -964,8 +985,8 @@ async function runDeployTask(task, deploy, paramValues) {
|
||||
const imageName = tpl(deploy.image)
|
||||
setTaskStep(task, 'pull', `正在拉取镜像 ${imageName}...`)
|
||||
|
||||
// 拉取镜像可能耗时较长,超时设为 5 分钟
|
||||
const pullResult = await execDeploy(`docker pull ${imageName}`, 300000)
|
||||
// 拉取镜像可能耗时较长(国内镜像加速也常超 5 分钟),超时设为 10 分钟
|
||||
const pullResult = await execDeploy(`docker pull ${imageName}`, 600000)
|
||||
if (!pullResult.ok || (pullResult.code !== 0 && !pullResult.stdout.includes('Status: Image is up to date') && !pullResult.stdout.includes('Already exists'))) {
|
||||
const errMsg = pullResult.stderr || pullResult.message || '未知错误'
|
||||
setTaskStep(task, 'pull-error', `镜像拉取失败: ${errMsg}`)
|
||||
@@ -1027,9 +1048,10 @@ async function runDeployTask(task, deploy, paramValues) {
|
||||
// 端口映射
|
||||
for (const port of (deploy.ports || [])) {
|
||||
const hostPort = tpl(port.host)
|
||||
if (!port.container) continue
|
||||
const containerPort = tpl(port.container)
|
||||
if (!containerPort) continue
|
||||
let p = hostPort ? `${hostPort}:` : ''
|
||||
p += port.container
|
||||
p += containerPort
|
||||
if (port.protocol && port.protocol !== 'tcp') p += '/' + port.protocol
|
||||
parts.push('-p', p)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>SSH Panel · 服务器管理面板</title>
|
||||
<title>WFJ Panel · 服务器管理面板</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:"
|
||||
|
||||
@@ -80,6 +80,11 @@
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.app-icon-java {
|
||||
background: rgba(237, 117, 23, 0.08);
|
||||
color: #ed7517;
|
||||
}
|
||||
|
||||
/* ---- 应用卡片信息 ---- */
|
||||
.app-card-info {
|
||||
flex: 1;
|
||||
@@ -853,3 +858,160 @@
|
||||
color: var(--status-critical);
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
远程目录选择器
|
||||
============================================================ */
|
||||
.path-input-wrapper {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.path-input-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.path-browse-btn {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.path-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1400;
|
||||
}
|
||||
|
||||
.path-picker-dialog {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.path-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.path-picker-header h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.path-picker-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 22px;
|
||||
color: var(--ink-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.path-picker-close:hover {
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.path-picker-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.path-picker-cwd {
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink-secondary);
|
||||
background: var(--surface-dim);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-picker-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
min-height: 200px;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.path-picker-loading,
|
||||
.path-picker-empty,
|
||||
.path-picker-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.path-picker-error {
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
.path-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--ink-strong);
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.path-picker-item:hover {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.path-picker-item svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.path-picker-item:hover svg {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.path-picker-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -32,10 +32,148 @@ function AppIcon({ type }) {
|
||||
<path d="M3 9h18M9 21V9" />
|
||||
</svg>
|
||||
),
|
||||
java: (
|
||||
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M8 20c2-1 3-3 3-5-1 1-2 3-2 5" />
|
||||
<path d="M11 20c2-1 3-3 3-5-1 1-2 3-2 5" />
|
||||
<path d="M14 20c2-1 3-3 3-5-1 1-2 3-2 5" />
|
||||
<path d="M6 16h12M6 16v-4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v4" />
|
||||
<path d="M9 11V7a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v4" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
return icons[type] || icons.default
|
||||
}
|
||||
|
||||
// 远程目录选择器
|
||||
function PathPicker({ value, onChange, placeholder }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [currentPath, setCurrentPath] = useState('/')
|
||||
const [entries, setEntries] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loadDir = useCallback(async (dir) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const r = await window.panel.sftpList(dir)
|
||||
if (r.ok) {
|
||||
setEntries(r.list.filter((e) => e.isDirectory))
|
||||
setCurrentPath(dir)
|
||||
} else {
|
||||
setError(r.message || '读取目录失败')
|
||||
setEntries([])
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || '连接异常')
|
||||
setEntries([])
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
const openPicker = () => {
|
||||
// 如果已有值,打开其父目录;否则从根目录开始
|
||||
if (value) {
|
||||
const parts = value.split('/').filter(Boolean)
|
||||
parts.pop()
|
||||
const parent = '/' + parts.join('/')
|
||||
setCurrentPath(parent || '/')
|
||||
} else {
|
||||
setCurrentPath('/')
|
||||
}
|
||||
setOpen(true)
|
||||
loadDir(value ? value.replace(/\/[^/]*$/, '') || '/' : '/')
|
||||
}
|
||||
|
||||
const goToDir = (sub) => {
|
||||
const next = currentPath === '/' ? '/' + sub : currentPath + '/' + sub
|
||||
loadDir(next)
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
if (currentPath === '/') return
|
||||
const parts = currentPath.split('/').filter(Boolean)
|
||||
parts.pop()
|
||||
loadDir('/' + parts.join('/'))
|
||||
}
|
||||
|
||||
const selectCurrent = () => {
|
||||
onChange(currentPath)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
if (open) {
|
||||
return (
|
||||
<div className="path-picker-overlay" onClick={() => setOpen(false)}>
|
||||
<div className="path-picker-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="path-picker-header">
|
||||
<h3>选择目录</h3>
|
||||
<button className="path-picker-close" onClick={() => setOpen(false)}>×</button>
|
||||
</div>
|
||||
<div className="path-picker-bar">
|
||||
<button className="btn btn-ghost btn-sm" onClick={goUp} disabled={currentPath === '/'}>
|
||||
↑ 上级
|
||||
</button>
|
||||
<code className="path-picker-cwd">{currentPath}</code>
|
||||
</div>
|
||||
<div className="path-picker-body">
|
||||
{loading ? (
|
||||
<div className="path-picker-loading">加载中...</div>
|
||||
) : error ? (
|
||||
<div className="path-picker-error">{error}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="path-picker-empty">没有子目录</div>
|
||||
) : (
|
||||
entries.map((e) => (
|
||||
<div
|
||||
key={e.filename}
|
||||
className="path-picker-item"
|
||||
onClick={() => goToDir(e.filename)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 7a1 1 0 0 1 1-1h4l2 2h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7z" />
|
||||
</svg>
|
||||
<span>{e.filename}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="path-picker-footer">
|
||||
<button className="btn btn-ghost" onClick={() => setOpen(false)}>取消</button>
|
||||
<button className="btn btn-primary" onClick={selectCurrent}>
|
||||
选择此目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="path-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="param-input path-input-field"
|
||||
placeholder={placeholder}
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost path-browse-btn"
|
||||
onClick={openPicker}
|
||||
title="浏览远程目录"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 7a1 1 0 0 1 1-1h4l2 2h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7z" />
|
||||
</svg>
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// SSH 连接数警告弹窗
|
||||
function ConnectionWarning({ count, onConfirm, onCancel }) {
|
||||
return (
|
||||
@@ -185,11 +323,32 @@ export default function AppStore({ onDeployed, onViewTasks }) {
|
||||
|
||||
// 判断某个模板是否已安装
|
||||
const isInstalled = useCallback((template) => {
|
||||
// 运行时类模板允许重复部署,不显示"已安装"状态
|
||||
if (template.category === '运行时') return false
|
||||
const cn = template.deploy?.containerName
|
||||
if (!cn) return false
|
||||
// containerName 含 {{}} 占位符的也允许重复部署
|
||||
if (cn.includes('{{')) return false
|
||||
return installedApps.includes(cn)
|
||||
}, [installedApps])
|
||||
|
||||
// 为运行时类模板自动生成不重复的容器名
|
||||
const autoContainerName = useCallback((template) => {
|
||||
const base = template.deploy?.containerName || ''
|
||||
// 如果 containerName 不含占位符,直接返回原值
|
||||
if (!base.includes('{{')) return base
|
||||
// 提取占位符外的静态前缀(如 {{CONTAINER_NAME}} → 无前缀)
|
||||
// 用模板 app_key 作为基础名
|
||||
const prefix = template.id || template.deploy?.containerName.replace(/{{.*?}}/g, '') || 'app'
|
||||
// 找一个不冲突的名称:prefix, prefix-2, prefix-3 ...
|
||||
let name = prefix
|
||||
let suffix = 2
|
||||
while (installedApps.includes(name)) {
|
||||
name = `${prefix}-${suffix++}`
|
||||
}
|
||||
return name
|
||||
}, [installedApps])
|
||||
|
||||
// 同步在线商店
|
||||
const handleSync = useCallback(async () => {
|
||||
setSyncing(true)
|
||||
@@ -221,7 +380,12 @@ export default function AppStore({ onDeployed, onViewTasks }) {
|
||||
if (!selectedApp) return
|
||||
const defaults = {}
|
||||
for (const param of selectedApp.deploy.params || []) {
|
||||
defaults[param.key] = param.default || ''
|
||||
if (param.type === 'select' && !param.default && param.options && param.options.length > 0) {
|
||||
// select 无默认值时自动选中第一个选项,避免状态为空导致按钮置灰
|
||||
defaults[param.key] = param.options[0].value
|
||||
} else {
|
||||
defaults[param.key] = param.default || ''
|
||||
}
|
||||
}
|
||||
// Halo + MySQL 联动:如果 MySQL 容器已安装,自动填充默认值
|
||||
if (selectedApp.id === 'halo' && installedApps.includes('mysql')) {
|
||||
@@ -231,8 +395,17 @@ export default function AppStore({ onDeployed, onViewTasks }) {
|
||||
defaults['DB_NAME'] = 'halodb'
|
||||
defaults['DB_USER'] = 'root'
|
||||
}
|
||||
// 运行时类模板:自动填充不重复的容器名
|
||||
if (selectedApp.category === '运行时') {
|
||||
const autoName = autoContainerName(selectedApp)
|
||||
// 找到 CONTAINER_NAME 参数并覆盖默认值
|
||||
const hasContainerNameParam = (selectedApp.deploy.params || []).some((p) => p.key === 'CONTAINER_NAME')
|
||||
if (hasContainerNameParam) {
|
||||
defaults['CONTAINER_NAME'] = autoName
|
||||
}
|
||||
}
|
||||
setParamValues(defaults)
|
||||
}, [selectedApp, installedApps])
|
||||
}, [selectedApp, installedApps, autoContainerName])
|
||||
|
||||
// 执行部署(先检查连接数)
|
||||
const handleDeploy = useCallback(async () => {
|
||||
@@ -441,7 +614,7 @@ export default function AppStore({ onDeployed, onViewTasks }) {
|
||||
</div>
|
||||
|
||||
{/* 参数输入表单 */}
|
||||
{installed ? (
|
||||
{(installed && app.category !== '运行时') ? (
|
||||
<div className="app-installed-notice">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
@@ -474,6 +647,12 @@ export default function AppStore({ onDeployed, onViewTasks }) {
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : param.type === 'path' ? (
|
||||
<PathPicker
|
||||
value={paramValues[param.key] || ''}
|
||||
onChange={(val) => setParamValues((prev) => ({ ...prev, [param.key]: val }))}
|
||||
placeholder={param.placeholder}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={param.type === 'password' ? 'password' : 'text'}
|
||||
@@ -507,13 +686,13 @@ export default function AppStore({ onDeployed, onViewTasks }) {
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleDeploy}
|
||||
disabled={installed || deploying || (d.params || []).some((p) => {
|
||||
disabled={(installed && app.category !== '运行时') || deploying || (d.params || []).some((p) => {
|
||||
if (!p.required) return false
|
||||
if (p.showWhen && paramValues[p.showWhen.param] !== p.showWhen.equals) return false
|
||||
return !paramValues[p.key]
|
||||
})}
|
||||
>
|
||||
{installed ? '已安装' : deploying ? '提交中...' : '一键部署'}
|
||||
{(installed && app.category !== '运行时') ? '已安装' : deploying ? '提交中...' : '一键部署'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user