19 Commits

34 changed files with 3636 additions and 641 deletions
+25 -1
View File
@@ -4,10 +4,18 @@
WOC_USER=admin
WOC_PASSWORD=wechat
# 镜像版本:默认 latest;上线后建议钉到具体版本(如 v1.0.0)以便可控升级。
# 镜像版本:默认 latest;上线后建议钉到具体版本(如 1.0.0,注意不带 v)以便可控升级。
# 同时作用于面板镜像和新建微信实例所用镜像。
WOC_VERSION=latest
# 镜像源前缀(registry + 命名空间)。默认 GHCR 官方。
# 中国大陆网络拉 ghcr.io 常 TLS 超时,可改用 GHCR 反代镜像(前提:GHCR 包已设为 Public):
# WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 南京大学镜像站反代 ghcr.io(较稳定)
# 反代会把 ghcr.io/gloridust/xxx 映射为 <反代>/gloridust/xxx。
# 若用自己的国内仓库(阿里云 ACR / 腾讯 TCR / 华为 SWR),填该仓库的完整前缀即可,例如:
# WOC_IMAGE_PREFIX=registry.cn-hangzhou.aliyuncs.com/你的命名空间
WOC_IMAGE_PREFIX=ghcr.io/gloridust
# 宿主用户 uid/gid(飞牛上用 `id` 命令查看;单用户 NAS 一般是 1000)。
# 透传给每个微信实例容器,决定面板数据与微信数据卷的属主。
WOC_PUID=1000
@@ -19,3 +27,19 @@ WOC_TZ=Asia/Shanghai
# 面板对外端口(宿主侧,默认用冷门端口避免冲突;容器内固定 8080)。
# 面板是唯一对外入口;微信实例不直接对宿主暴露,由面板反向代理。
WOC_HTTP_PORT=36080
# ── 音频 / 麦克风 / 摄像头 ───────────────────────────────────
# 音频(听):开箱即用,进入桌面后点 KasmVNC 左侧工具条的扬声器开启。
# 麦克风(说) / 摄像头(视频):浏览器要求"安全上下文",即必须通过 HTTPS 访问面板
# (或 localhost)。生产环境务必给面板套 HTTPS(反代/证书),否则浏览器会禁用麦克风与摄像头。
#
# 摄像头还需要宿主提供一个虚拟视频设备(v4l2loopback):
# 1) 宿主安装并加载内核模块:
# Ubuntu/Debian: sudo apt install v4l2loopback-dkms && sudo modprobe v4l2loopback
# (飞牛/其它发行版若自带 v4l2loopback 同理 modprobe;没有则摄像头不可用,其余功能正常)
# 2) 确认出现了 /dev/videoN 设备。
# 3) 二选一让实例容器拿到该设备:
# a) 自动:保留 compose 里的 `/dev:/host-dev:ro` 挂载,面板会自动探测并映射;
# b) 显式:在下面列出设备(逗号分隔),并可删掉那条 /host-dev 挂载。
# 留空 = 不映射摄像头(音频/麦克风不受影响)。
WOC_VIDEO_DEVICES=
+1
View File
@@ -140,3 +140,4 @@ dist
# WechatOnCloud: 面板账号数据(含密码哈希,勿提交)
/data-panel/
/.claude
.DS_Store
+103 -195
View File
@@ -1,116 +1,71 @@
# WechatOnCloud
<div align="center">
在飞牛 NASx86_64 / arm64)上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。
<img src="doc/img/icon-192.png" width="88" height="88" alt="云微 logo" />
> 设计与选型详见 [技术方案.md](技术方案.md)。
> 部署形态:拉取 GHCR 预构建多架构镜像(或本地自构建),面板按需动态创建微信实例容器。不熟悉 Docker?直接看 [Docker 运行模式详解](#docker-运行模式详解新手向)。
<h1>云微 · WechatOnCloud</h1>
<p><b>在自己的 NAS / 服务器上运行「服务端微信」,多端浏览器共享同一个微信会话</b></p>
<p>
<a href="https://github.com/Gloridust/WechatOnCloud/stargazers"><img src="https://img.shields.io/github/stars/Gloridust/WechatOnCloud?style=flat-square&logo=github" alt="stars" /></a>
<a href="https://github.com/Gloridust/WechatOnCloud/releases"><img src="https://img.shields.io/github/v/release/Gloridust/WechatOnCloud?style=flat-square" alt="release" /></a>
<a href="https://github.com/Gloridust/WechatOnCloud/issues"><img src="https://img.shields.io/github/issues/Gloridust/WechatOnCloud?style=flat-square" alt="issues" /></a>
<img src="https://img.shields.io/badge/arch-amd64%20%7C%20arm64-2496ED?style=flat-square&logo=docker&logoColor=white" alt="arch" />
<img src="https://img.shields.io/badge/PWA-ready-5A0FC8?style=flat-square" alt="pwa" />
</p>
<p>
<a href="#快速开始">快速开始</a> ·
<a href="#核心特性">核心特性</a> ·
<a href="doc/运行原理.md">运行原理</a> ·
<a href="#安全须知必读">安全须知</a> ·
<a href="doc/技术方案.md">技术方案</a>
</p>
<table>
<tr>
<td width="50%"><img src="doc/img/Screenshot-1.png" alt="云微 · 面板主界面" /></td>
<td width="50%"><img src="doc/img/Screenshot-2.png" alt="云微 · 实例桌面" /></td>
</tr>
</table>
</div>
在飞牛 NASx86_64 / arm64)或任意 Docker 主机上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。**不修改微信客户端。**
**一句话原理**:每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器;同一实例被多个浏览器连 = 共享同一个微信会话。前面一层自研**面板**是唯一对外入口,经 docker.sock 按需创建/销毁实例并反向代理。
---
## 工作原理(一句话)
## 核心特性
每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器。同一实例被多个浏览器连 = 共享同一个微信会话。**不修改微信客户端**
前面一层自研 **面板(panel** 是唯一对外入口:负责账号登录、子账号与**实例权限**管理,经 docker 引擎**按需创建/销毁**微信实例容器,并反向代理到对应实例——浏览器只和面板打交道,KasmVNC 的凭据由面板在服务端注入,不下发前端
```
浏览器 ──▶ panel(:36080) ──┬─ / 面板 SPA(登录 / 实例网格 / 子账号 / 进入桌面)
cookie 鉴权 ├─ /api/* 账号、实例、权限接口
└─ /desktop/:id/* 反代 → 对应实例 KasmVNC(注入 Basic 鉴权)
panel ──(docker.sock)──▶ docker 引擎 ──▶ 按需创建/销毁微信实例容器 woc-wx-<id>
每个实例 = 独立容器 + 独立数据卷 + 独立微信会话
实例只在 docker 网络内暴露,不直连宿主
```
- 🗂️ **多实例** — 一个面板管理多个独立微信会话,每个实例独立容器 + 独立数据卷,互不干扰
- 👥 **多端共享 + 权限** — 多浏览器 / 设备共享同一会话;子账号体系,按账号分配可访问的实例(RBAC)。
- 🖥️ **微信 PC 式界面** — 左侧实例栏 + 右侧内嵌桌面,侧栏可折叠,移动端自动转抽屉
- 📦 **微信本体运行时下载** — 镜像不打包微信,面板一键「下载安装 / 更新」带进度条;按 CPU 架构自动取包。
- 🔁 **实例生命周期** — 启动 / 停止 / 重启 / 升级(拉新镜像重建、保留聊天记录),均在面板内一键完成。
- 📎 **文件传输 + 文本剪贴板** — 拖拽上传 + 下载 + 删除,直达微信桌面 `~/Desktop`;文本可经剪贴板中转送进微信(局域网 http 下也可用)。
- 🧩 **多端协作软锁** — 同一实例多人操作时自动只读 + 申请接管,避免键鼠打架。
- 🔒 **安全优先** — 面板为唯一入口,KasmVNC 凭据服务端注入、永不下发前端;docker.sock 仅管理员可触达。
- 📱 **PWA** — iOS「添加到主屏幕」、桌面 Chrome「安装」当原生 App。
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(GHCR + GitHub Actions 自动发布)。
---
## Docker 运行模式详解(新手向)
## 文档
如果你对 Docker 不熟,这一节把本项目「怎么跑起来的」讲透。读完你就能看懂上面的图。
### 0. 先认识 5 个 Docker 概念
| 概念 | 一句话理解 | 在本项目里是什么 |
|------|-----------|------------------|
| **镜像 Image** | 只读的「软件安装包」,里面装好了程序和依赖 | `woc-panel`(面板)、`wechat-on-cloud`(微信实例) |
| **容器 Container** | 镜像「运行起来」的实例,相当于「正在跑的程序」。一个镜像能跑出多个容器 | `woc-panel` 容器、多个 `woc-wx-<id>` 容器 |
| **卷 Volume** | 容器之外的持久磁盘。容器删了,卷里的数据还在 | 每个微信实例一个卷 `woc-data-<id>`,存登录态和消息 |
| **网络 Network** | 容器之间互通的「虚拟局域网」,容器之间可用**容器名**当域名互访 | 面板和所有实例在同一网络里,面板用 `http://woc-wx-<id>:3000` 找实例 |
| **docker.sock** | Docker 引擎的「遥控器」(宿主上的一个特殊文件 `/var/run/docker.sock`)。谁拿到它,谁就能指挥 Docker 创建/删除容器 | 挂进面板容器,面板才能「动态造微信实例」 |
> **Compose** 则是「用一个 `docker-compose.yml` 文件描述要跑哪些容器,`docker compose up -d` 一条命令拉起」。本项目的 compose 里**只有面板一个服务**。
### 1. 本项目有两类容器(运行角色不同)
这是本项目最容易迷惑的地方:**不是所有容器都写在 `docker-compose.yml` 里。**
| | ① 面板容器 | ② 微信实例容器 |
|---|-----------|---------------|
| 容器名 | `woc-panel`(固定一个) | `woc-wx-<随机id>`(可有多个) |
| 用哪个镜像 | `woc-panel` | `wechat-on-cloud` |
| 谁来启动 | **你** 执行 `docker compose up -d` | **面板**:你在网页点「新建微信实例」时,面板通过 docker.sock 自动 `docker run` |
| 写在 compose 里吗 | 是 | **否**(运行期动态创建,compose 里看不到) |
| 对外暴露端口 | 是,宿主 `36080` → 容器 `8080` | 否,只在 docker 网络内,由面板反代 |
| 数据存哪 | 宿主目录 `./data-panel` | 各自的命名卷 `woc-data-<id>` |
| 生命周期 | 常驻 | 你在面板「删除实例」时销毁(默认保留卷) |
一句话:**你只手动管面板这一个容器;微信实例是面板帮你按需开关的。** 这就是为什么面板要挂 `docker.sock`——它需要「遥控」Docker 去开关微信实例容器。
### 2. 镜像从哪来:两种「构建/获取」模式
容器要跑,先得有镜像。本项目的两个镜像有两条获取途径,**任选其一**([快速开始](#快速开始)对应方式 A / B):
| | 方式 A · 本地自构建 | 方式 B · 拉取官方镜像 |
|---|--------------------|----------------------|
| 怎么做 | `./scripts/build-local.sh`(用本仓库 Dockerfile 在你机器上造镜像) | `docker compose up -d`(自动从 GHCR 下载现成镜像) |
| 适合谁 | 官方还没发布镜像时 / 想自己改代码 / 内网无法访问 GHCR | 普通用户,开箱即用 |
| 前提 | 本机能拉到基础镜像(node、KasmVNC base | GHCR 上已发布且包为公开(见[发布到 GHCR](#发布到-ghcr) |
| 产物 | 本地镜像,标签和 compose 里写的一模一样 | 同名镜像,来自云端 |
> compose 的拉取策略是默认值(`missing`):**本地已有同名镜像就直接用,没有才去 GHCR 拉**。所以方式 A 构建完,`docker compose up -d` 会直接用你的本地镜像,不会再联网。想升级到 GHCR 最新版:`docker compose pull && docker compose up -d`。
> 第三个「镜像」其实是**微信本体**:它**不打进任何镜像**,而是你在面板点「下载并安装」时,由实例容器实时从腾讯官方 CDN 下到自己的卷里(见[数据持久化](#数据持久化))。
### 3. 从零到能用,整体发生了什么
```
你: docker compose up -d
└─▶ Docker 读取 docker-compose.yml
└─▶ 拉起【面板容器 woc-panel】,挂上 ./data-panel 和 docker.sock,暴露 36080 端口
你: 浏览器开 http://NAS:36080 → 登录 → 点「新建微信实例」
└─▶ 面板通过 docker.sock 指挥 Docker:
├─ docker run 一个【微信实例容器 woc-wx-xxx】
├─ 给它挂一个新卷 woc-data-xxx(存登录态/消息)
└─ 接到同一个 docker 网络(面板才能反代到它)
你: 进入该实例 → 点「下载并安装」
└─▶ 面板 docker exec 进实例容器,触发脚本从腾讯 CDN 下载微信、解压到卷
你: 点「进入电脑版微信」→ 手机扫码
└─▶ 浏览器 ⇄ 面板(反代+注入鉴权) ⇄ 实例容器的 KasmVNC ⇄ 微信窗口
```
### 4. 常用命令速查
```bash
docker compose up -d # 启动面板(首次会拉/用镜像)
docker compose down # 停止并删除面板容器(不动数据卷和微信实例)
docker compose pull # 把面板/微信镜像更新到 GHCR 最新
docker ps # 看正在运行的容器(能看到 woc-panel 和各 woc-wx-*
docker logs -f woc-panel # 看面板日志
docker logs -f woc-wx-<id> # 看某个微信实例日志
docker volume ls | grep woc # 看所有微信实例的数据卷
```
> ⚠️ 微信实例容器请**始终在面板网页里增删**,不要手动 `docker rm` 它们——否则面板的实例登记和真实容器会对不上。
| 文档 | 内容 |
|------|------|
| [运行原理与 Docker 指南](doc/运行原理.md) | 工作原理 + 架构图;面向 Docker 新手的逐步拆解、常用命令、架构自动适配 |
| [部署与运维](doc/部署与运维.md) | 数据持久化、常见问题排查、忘记超管密码的离线找回、目录结构 |
| [发布到 GHCR](doc/发布到GHCR.md) | 用 GitHub Actions 或本机 buildx 把镜像发布到 GHCR |
| [技术方案](doc/技术方案.md) | 完整设计文档与选型权衡 |
---
## 快速开始
> 需已安装 Docker(含 Compose 插件)。x86_64 / arm64 均可。
> 需已安装 Docker(含 Compose 插件)。x86_64 / arm64 均可。不熟悉 Docker?先读 [运行原理与 Docker 指南](doc/运行原理.md)。
`docker-compose.yml` 引用的是 GHCR 上的镜像 `ghcr.io/gloridust/{woc-panel,wechat-on-cloud}`
**这两个镜像需先存在**——要么官方已发布(你能直接拉取),要么你在本地自行构建。二选一:
@@ -118,7 +73,7 @@ docker volume ls | grep woc # 看所有微信实例的数据卷
**方式 A · 本地自构建(官方尚未发布镜像时用这个)**
```bash
git clone <this-repo> WechatOnCloud
git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
cd WechatOnCloud
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
./scripts/build-local.sh # 构建面板 + 微信实例镜像,打成 compose 用的同名标签
@@ -128,13 +83,13 @@ docker compose up -d # compose 默认优先用本地镜像,不会
**方式 B · 拉取官方镜像(已发布到 GHCR 后)**
```bash
git clone <this-repo> WechatOnCloud
git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
cd WechatOnCloud
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
docker compose up -d # 直接从 GHCR 拉取
```
> 报错 `error from registry: denied`?说明 GHCR 上还没有该镜像(或包是私有的)。用方式 A 本地构建,或见下方[「发布到 GHCR](#发布到-ghcr)。
> 报错 `error from registry: denied`?说明 GHCR 上还没有该镜像(或包是私有的)。用方式 A 本地构建,或见 [发布到 GHCR](doc/发布到GHCR.md)。
无论哪种方式,都会拉起面板容器 `woc-panel`(唯一对外服务)。浏览器访问 `http://<NAS_IP>:36080`
@@ -145,7 +100,7 @@ docker compose up -d # 直接从 GHCR 拉取
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**微信会话。
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本见 `.env`。
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本/账号见 `.env`(可配置项见 [.env.example](.env.example))。镜像会按 CPU 架构自动适配([详见文档](doc/运行原理.md#架构自动适配))
### 面板能做什么
@@ -155,128 +110,56 @@ docker compose up -d # 直接从 GHCR 拉取
| 实例权限分配 | 管理员 | 在实例上改「可访问账户」,或在账户上改「可访问实例」,双向管理 |
| 下载并安装 / 更新微信 | 管理员 | 对某实例一键下载官方微信 Linux 版到其数据卷、解压安装;带进度条;后续可一键「更新到最新版」 |
| 进入电脑版微信 | 被授权用户 | 在浏览器里操作对应实例的微信,扫码登录、收发消息 |
| 文件 / 文本传输 | 被授权用户 | 拖拽上传 / 下载文件;文本经剪贴板中转送进微信 |
| 修改密码 | 所有人 | 改自己的登录密码 |
| 子账号管理 | 管理员 | 创建 / 禁用 / 重置 / 删除子账号,并分配实例访问权限 |
| 安装为 App | 所有人 | iOS Safari「添加到主屏幕」、桌面 Chrome「安装」当原生 AppPWA |
> 子账号是**访问这套面板的身份**,不是另开一个微信。管理员隐式拥有全部实例访问权;子账号只能看到被授权的实例。
> 微信本体**不打进镜像**,而是新建实例后在面板点「下载并安装」时下载到该实例的数据卷,所以镜像很小、构建快、不依赖腾讯 CDN。
### 架构自动适配
镜像本身多架构(amd64/arm64);下载微信时容器内**运行时再自动检测 CPU 架构**(`dpkg --print-architecture`)取对应官方包:
| 运行机器 | 架构 | 自动下载 |
|----------|------|----------|
| Intel/AMD NAS、x86 服务器 | amd64 | `WeChatLinux_x86_64.deb` |
| ARM NAS、Apple Silicon Mac | arm64 | `WeChatLinux_arm64.deb` |
到飞牛上(无论 x64 还是 arm`docker compose up -d` 同一条命令,无需改任何架构相关配置。
### 自定义配置(可选)
复制 `.env.example``.env` 后按需修改,可配置项见 [.env.example](.env.example):管理员账号密码、镜像版本(`WOC_VERSION`,建议上线后钉到具体版本)、PUID/PGID、时区、端口。
> 微信本体**不打进镜像**,而是新建实例后在面板点「下载并安装」时下载到该实例的数据卷,所以镜像很小、构建快。
---
## 发布到 GHCR
## 资源占用
仓库自带 GitHub Actions[.github/workflows/release.yml](.github/workflows/release.yml)),在你**推送 `vX.Y.Z` 标签或发布 Release** 时,自动构建多架构(amd64+arm64)镜像并推到 GHCR
实测(8 核 / 8 GiB 宿主,实例均已登录微信、含 Chromium 内核的 WeChatAppEx
```bash
git tag v1.0.0
git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1.0.0 等标签
```
| 状态 | CPU | 内存(RSS |
|------|-----|------------|
| 单实例 · 空闲(已登录、无人观看) | ~0.10.2 核 | ~0.6 GiB |
| 单实例 · 活跃(有人在浏览器操作 / 刷消息) | ~0.5–1 核(可突发) | ~11.5 GiB |
| 面板本身 | 可忽略 | ~0.12 GiB |
首次发布后还需把 GHCR 包设为公开,否则别人 `docker compose up -d` 会报 `denied`
- 容器**不设硬性 CPU/内存上限**:空闲时省,活跃时按宿主余量突发;每实例另预留 **1 GiB `/dev/shm`**(微信 Chromium 内核所需,tmpfs,按需占用)。
- 估算:**面板 ≈ 0.15 GiB 常驻;每个微信实例按 1 vCPU + 1.5 GiB 内存预留**较从容(轻度使用可更低)。
- 参考容量:**2 核 / 2 GiB** 跑 1 个实例(轻度);**4 核 / 8 GiB** 跑 3–4 个实例;视频通话等重负载需再加预留。
1. 打开 GitHub → 你的头像 → **Packages** → 分别进入 `woc-panel``wechat-on-cloud`
2. **Package settings → Change visibility → Public**
> 若想保持私有,则使用者需先 `docker login ghcr.io`(用具备 `read:packages` 的 PAT)才能拉取。
> 在镜像发布之前,本地用 `./scripts/build-local.sh` 自构建即可,无需等待发布。
> 内存是主要瓶颈,CPU 多为短时突发。实例越多越吃内存,按上表线性叠加即可估算。
---
## 数据持久化
## 安全须知(必读)
- **面板数据**(用户、实例元信息、密码哈希):容器内 `/data`,映射到宿主 `./data-panel`
- **每个微信实例**:独立的 docker 命名卷 `woc-data-<id>`,挂到该实例容器的 `/config`(微信本体在 `/config/wechat`,登录态与消息缓存在 `/config` 其余位置)。
> ⚠️ **这套系统暴露的是已登录的微信,请务必认真阅读本节。**
要点
- 删除实例**默认保留**其数据卷,下次同名重建可复用;只有显式勾选「彻底清除」才会删卷。
- 备份某实例 = 备份对应的 `woc-data-<id>` 卷(`docker volume` 系列命令)。
- 卷需支持执行权限(微信本体直接从卷里运行);放在 `noexec` 卷上微信将无法启动。
- 备份面板 = 备份 `./data-panel`
> **从旧版(单微信容器 + `./data` 绑定挂载)迁移**:旧形态把微信数据放在宿主 `./data`。新版用 docker 命名卷,结构不同,无自动迁移。如需保留旧会话,最简单是新建一个实例、重新扫码登录;或手动把旧 `./data` 内容拷进新实例的 `woc-data-<id>` 卷。
---
## ⚠️ 安全须知(必读)
这套系统暴露的是**已登录的微信**——能登录面板的人就能看聊天记录、以你身份发消息。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
能登录面板的人就能看聊天记录、以你身份发消息。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此
- **绝不要把面板裸暴露公网**:只在内网访问,或经飞牛远程访问 / VPN / 内网穿透;
- 务必改掉默认密码(默认 admin / wechat):`cp .env.example .env` 后改 `WOC_PASSWORD`,或登录后在「修改密码」里改;
- 实例的增删、微信安装/更新等触碰 docker 引擎的操作**仅限管理员**;docker API 绝不暴露给前端;
- KasmVNC 凭据由面板服务端注入,**浏览器永远拿不到**;实例容器名由内部随机 ID 派生,避免注入;
- 面板与外网之间再套一层 HTTPS 反代(飞牛自带反代 / Caddy / Nginx)获得正经 TLS
- 进一步加固(陌生设备验证码、审计日志、并发控制)见 [技术方案.md](技术方案.md) 第 5 节。
- 进一步加固(陌生设备验证码、审计日志、并发控制)见 [技术方案.md](doc/技术方案.md) 第 5 节。
---
## 中文输入
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像默认开启 KasmVNC 的
「IME Input Mode」:拼音联想在你本机完成,只把成品汉字发进容器。直接在微信输入框打字即可。
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像默认开启 KasmVNC 的「IME Input Mode」,并对 noVNC 的 IME 合成逻辑做了修复——**只在输入法「上屏」那一刻把成品汉字整串发进容器**,规避了原生实现逐字符差分带来的丢字 / 卡顿。直接在微信输入框打字即可。
- 默认值只对**未存过该设置的浏览器**生效。之前手动开/关过的,浏览器 localStorage 值优先;想验证默认效果用无痕窗口。
- 已知小毛病:超长拼音串未全部转成汉字就回车,偶尔丢字([issue #97](https://github.com/linuxserver/docker-baseimage-kasmvnc/issues/97)),长句分段输入即可
- 兜底:Chrome/Edge 下本地 `⌘C` → 远端 `Ctrl+V` 无缝粘贴;Firefox 用控制面板的 Clipboard 文本框中转
## 常见问题
| 现象 | 排查 |
|------|------|
| 新建实例失败 | 多为面板拉不到微信镜像或连不上 docker.sock。确认 `docker.sock` 已挂载、宿主能访问 GHCR;看面板日志 `docker logs woc-panel` |
| 界面/消息显示成方块 | 中文字体没装好,确认实例镜像含 `fonts-noto-cjk` |
| 微信起不来 / 黑屏 | 看实例日志 `docker logs woc-wx-<id>`;确认 `seccomp=unconfined``shm_size` 生效。微信 deb 漏声明的运行时依赖已在 Dockerfile 内置 |
| 排查缺哪个库 | `docker exec woc-wx-<id> ldd /config/wechat/opt/wechat/wechat`,看 `not found` 项补进 Dockerfile 依赖层 |
| 多人同时操作很乱 | 单会话多端共享、键鼠会打架。未做并发控制,建议同一时刻一人操作(见技术方案 6.1) |
| 过段时间掉登录 | 微信桌面会话会定期失效,需手机重新扫码(见技术方案 6.2) |
| 下载 / 更新微信失败 | 腾讯 CDN 偶发波动,重新点「下载并安装 / 更新」即可;脚本已内置主/备 CDN 自动回退 |
| 架构不支持报错 | 微信仅提供 x86_64 / arm64;其他架构下载时会在面板状态里报错 |
查看面板日志:`docker logs -f woc-panel`;查看某实例日志:`docker logs -f woc-wx-<id>`(实例 ID 可在面板看到,或 `docker ps | grep woc-wx`)。
---
## 目录结构
```
WechatOnCloud/
├── .github/workflows/
│ └── release.yml # 打 tag / 发 Release 时构建多架构镜像并推送 GHCR
├── docker/ # 微信实例镜像(ghcr.io/<owner>/wechat-on-cloud
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + 默认开 IME(不打包微信本体)
│ ├── wechat-ctl.sh # 运行时下载/解压/更新微信(面板经 docker exec 触发,状态写 /config/.woc-state
│ └── autostart # openbox 会话启动:等待微信就绪 + 常驻拉起(含崩溃自重启)
├── panel/ # 自研面板(ghcr.io/<owner>/woc-panel,唯一对外入口)
│ ├── Dockerfile # 前端 Vite 打包 + 后端 Fastify 网关(多架构)
│ ├── server/ # Fastifycookie 鉴权 + 账号/实例/权限 API + dockerode 管理实例 + 反代
│ └── web/ # React + TS + PWA(牛奶布艺 + 微信绿主题)
├── fnos/ # 飞牛 fnOS 应用打包(.fpk 工程 + 构建说明)
├── scripts/
│ └── build-local.sh # 本地构建面板+微信镜像(发布前自测 / 自托管自构建)
├── docker-compose.yml # 单服务:panel(挂 docker.sock,按需创建实例)
├── .env.example # 可选配置(账号密码、镜像版本、PUID/PGID、端口、时区)
├── 技术方案.md # 完整设计文档
└── README.md
```
数据:面板账号(含密码哈希)在 `./data-panel`,各微信实例在 docker 命名卷 `woc-data-<id>``./data-panel` 已在 `.gitignore` 中。
- **跨设备文本**:实例工具栏的「剪贴板」可把文本送入容器剪贴板,再在微信里 `Ctrl+V` 粘贴——不依赖浏览器异步剪贴板 API,**局域网 http 访问下也可用**
- **文件**:用工具栏「文件」拖拽上传,微信收到的文件另存到桌面即可在此下载
---
@@ -287,7 +170,32 @@ WechatOnCloud/
- [x] 微信本体运行时下载到数据卷:面板一键「下载并安装 / 更新」,带进度条
- [x] 多实例管理 + 按账号的实例访问权限(RBAC)
- [x] 预构建多架构镜像发布到 GHCR + GitHub Actions 自动化
- [x] 中文输入修复 + 文本剪贴板中转 + 实例日志导出
- [ ] 面板外层 TLS / 陌生设备验证码 / 审计日志
- [ ] 多端并发控制(控制权令牌
- [x] 多端并发控制(操作控制权心跳软锁 + 只读遮罩 + 申请接管
- [ ] 掉登录时 web 端二维码重扫入口
- [~] 打包成飞牛原生 fpk 分发(工程已就绪见 [fnos/](fnos/),待真实设备验证 docker.sock 权限)
## 致谢
创意启发自懒猫微服(原 Deepin 团队做的硬件产品),推荐有经济实力、追求稳定运营的朋友尝试!
也感谢每一位 Star / Issue / PR 的朋友——**两天突破 500 ⭐**,是继续打磨的最大动力 🙌
## Star History
<a href="https://www.star-history.com/?repos=Gloridust%2FWechatOnCloud&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&legend=top-left" />
</picture>
</a>
---
<div align="center">
<sub>如果这个项目帮到了你,欢迎点个 ⭐ Star 支持一下 ·
<a href="https://github.com/Gloridust/WechatOnCloud/issues">反馈问题</a> ·
<a href="https://github.com/Gloridust/WechatOnCloud/pulls">参与贡献</a></sub>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+58
View File
@@ -0,0 +1,58 @@
# 发布到 GHCR
> 返回 [← README](../README.md)
把两个镜像(`woc-panel``wechat-on-cloud`)发布到 GitHub Container Registry,供他人 `docker compose up -d` 直接拉取。两种方式任选其一。
---
## 方式 A · GitHub Actions(推荐)
仓库自带 GitHub Actions[.github/workflows/release.yml](../.github/workflows/release.yml)),在你**推送 `vX.Y.Z` 标签或发布 Release** 时,自动构建多架构(amd64+arm64)镜像并推到 GHCR
```bash
git tag v1.0.0
git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1.0.0 等标签
# 或在 GitHub 上 Publish 一个 Release(会额外打 latest):
gh release create v1.0.0 --title v1.0.0 --notes "..."
```
> 注意:单纯 push tag 只产出 `X.Y.Z / X.Y / X`**不会更新 `latest`**;要更新 `latest` 请改用 **发布 Release** 或在 Actions 里手动 `workflow_dispatch`。
---
## 方式 B · 本机 buildx 手动构建并推送(不走 Actions)
适合想立刻出包、或不依赖 CI 的场景。需要 Docker BuildxDocker Desktop 自带;纯 Linux 跨架构需先装 QEMU:`docker run --privileged --rm tonistiigi/binfmt --install all`)。
```bash
# 1) 登录 GHCRPAT 需 write:packages 权限)
echo <YOUR_GITHUB_PAT> | docker login ghcr.io -u <github 用户名> --password-stdin
# 2) 首次创建并启用多架构构建器(已建过改用 docker buildx use woc
docker buildx create --name woc --use
# 3) 构建并推送两个镜像(amd64 + arm64)。VER 与 git tag 保持一致(不带 v)
VER=1.0.1
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/woc-panel:$VER -t ghcr.io/gloridust/woc-panel:latest \
--push ./panel
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/wechat-on-cloud:$VER -t ghcr.io/gloridust/wechat-on-cloud:latest \
--push ./docker
```
> 把 `gloridust` 换成你的 GHCR 命名空间(与 `docker-compose.yml` / `WOC_IMAGE_PREFIX` 一致)。
> 只想本机自用、不推 GHCR,用 [`./scripts/build-local.sh`](../scripts/build-local.sh) 构建本机架构单架构镜像即可。
---
## 发布后:把包设为公开
首次发布后还需把 GHCR 包设为公开,否则别人 `docker compose up -d` 会报 `denied`
1. 打开 GitHub → 你的头像 → **Packages** → 分别进入 `woc-panel``wechat-on-cloud`
2. **Package settings → Change visibility → Public**
> 若想保持私有,则使用者需先 `docker login ghcr.io`(用具备 `read:packages` 的 PAT)才能拉取。
> 在镜像发布之前,本地用 [`./scripts/build-local.sh`](../scripts/build-local.sh) 自构建即可,无需等待发布。
+2 -5
View File
@@ -1,7 +1,6 @@
# WechatOnCloud 技术方案
> 目标:在飞牛 NASx86_64)上运行一个服务端微信,多个 web 用户通过浏览器访问同一个微信会话,实现跨设备消息同步、多端共享,解决原生微信"一台电脑一个登录"的痛点。
> 参考形态:懒猫微服「云微信」。
> 目标:在飞牛 NASx86_64或任何服务器上运行服务端微信,多个 web 用户通过浏览器访问同一个微信会话,实现跨设备消息同步、多端共享,解决原生微信"一台电脑一个登录"的痛点。
---
@@ -147,7 +146,6 @@
```
WechatOnCloud/
├── 技术方案.md # 本文档
├── docker/
│ ├── Dockerfile # Debian + Xvfb + 微信deb + KasmVNC
│ ├── entrypoint.sh # 启动 Xvfb → WM → 微信 → KasmVNC
@@ -183,8 +181,7 @@ WechatOnCloud/
- [微信 Linux 官网](https://linux.weixin.qq.com/en)
- [腾讯上线 Linux 微信官网(x86/Arm/LoongArch](https://finance.sina.com.cn/tech/roll/2024-11-06/doc-incvceuc0172323.shtml)
- [懒猫微服 云微信](https://lazycat.cloud/lcmd/)
- [开源云微信部署方案(低配版懒猫远程微信)](https://linux.do/t/topic/1306818)
- [懒猫微服 云微信](https://lazycat.cloud/)
- [Xpra 官方文档](https://xpra.org/manual)
- [Xpra HTML5 客户端](https://github.com/Xpra-org/xpra-html5)
- [飞牛 fpk 框架文档](https://developer.fnnas.com/docs/core-concepts/framework)
+119
View File
@@ -0,0 +1,119 @@
# 运行原理与 Docker 指南
> 返回 [← README](../README.md) · 深入设计见 [技术方案.md](技术方案.md)
本篇把「云微怎么跑起来」讲透:先一句话原理 + 架构图,再面向 Docker 新手的逐步拆解。
---
## 工作原理(一句话)
每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器。同一实例被多个浏览器连 = 共享同一个微信会话。**不修改微信客户端**。
前面一层自研 **面板(panel** 是唯一对外入口:负责账号登录、子账号与**实例权限**管理,经 docker 引擎**按需创建/销毁**微信实例容器,并反向代理到对应实例——浏览器只和面板打交道,KasmVNC 的凭据由面板在服务端注入,不下发前端。
```
浏览器 ──▶ panel(:36080) ──┬─ / 面板 SPA(登录 / 实例网格 / 子账号 / 进入桌面)
cookie 鉴权 ├─ /api/* 账号、实例、权限接口
└─ /desktop/:id/* 反代 → 对应实例 KasmVNC(注入 Basic 鉴权)
panel ──(docker.sock)──▶ docker 引擎 ──▶ 按需创建/销毁微信实例容器 woc-wx-<id>
每个实例 = 独立容器 + 独立数据卷 + 独立微信会话
实例只在 docker 网络内暴露,不直连宿主
```
---
## Docker 运行模式详解(新手向)
如果你对 Docker 不熟,这一节把本项目「怎么跑起来的」讲透。读完你就能看懂上面的图。
### 0. 先认识 5 个 Docker 概念
| 概念 | 一句话理解 | 在本项目里是什么 |
|------|-----------|------------------|
| **镜像 Image** | 只读的「软件安装包」,里面装好了程序和依赖 | `woc-panel`(面板)、`wechat-on-cloud`(微信实例) |
| **容器 Container** | 镜像「运行起来」的实例,相当于「正在跑的程序」。一个镜像能跑出多个容器 | `woc-panel` 容器、多个 `woc-wx-<id>` 容器 |
| **卷 Volume** | 容器之外的持久磁盘。容器删了,卷里的数据还在 | 每个微信实例一个卷 `woc-data-<id>`,存登录态和消息 |
| **网络 Network** | 容器之间互通的「虚拟局域网」,容器之间可用**容器名**当域名互访 | 面板和所有实例在同一网络里,面板用 `http://woc-wx-<id>:3000` 找实例 |
| **docker.sock** | Docker 引擎的「遥控器」(宿主上的一个特殊文件 `/var/run/docker.sock`)。谁拿到它,谁就能指挥 Docker 创建/删除容器 | 挂进面板容器,面板才能「动态造微信实例」 |
> **Compose** 则是「用一个 `docker-compose.yml` 文件描述要跑哪些容器,`docker compose up -d` 一条命令拉起」。本项目的 compose 里**只有面板一个服务**。
### 1. 本项目有两类容器(运行角色不同)
这是本项目最容易迷惑的地方:**不是所有容器都写在 `docker-compose.yml` 里。**
| | ① 面板容器 | ② 微信实例容器 |
|---|-----------|---------------|
| 容器名 | `woc-panel`(固定一个) | `woc-wx-<随机id>`(可有多个) |
| 用哪个镜像 | `woc-panel` | `wechat-on-cloud` |
| 谁来启动 | **你** 执行 `docker compose up -d` | **面板**:你在网页点「新建微信实例」时,面板通过 docker.sock 自动 `docker run` |
| 写在 compose 里吗 | 是 | **否**(运行期动态创建,compose 里看不到) |
| 对外暴露端口 | 是,宿主 `36080` → 容器 `8080` | 否,只在 docker 网络内,由面板反代 |
| 数据存哪 | 宿主目录 `./data-panel` | 各自的命名卷 `woc-data-<id>` |
| 生命周期 | 常驻 | 你在面板「删除实例」时销毁(默认保留卷) |
一句话:**你只手动管面板这一个容器;微信实例是面板帮你按需开关的。** 这就是为什么面板要挂 `docker.sock`——它需要「遥控」Docker 去开关微信实例容器。
### 2. 镜像从哪来:两种「构建/获取」模式
容器要跑,先得有镜像。本项目的两个镜像有两条获取途径,**任选其一**([README 快速开始](../README.md#快速开始)对应方式 A / B):
| | 方式 A · 本地自构建 | 方式 B · 拉取官方镜像 |
|---|--------------------|----------------------|
| 怎么做 | `./scripts/build-local.sh`(用本仓库 Dockerfile 在你机器上造镜像) | `docker compose up -d`(自动从 GHCR 下载现成镜像) |
| 适合谁 | 官方还没发布镜像时 / 想自己改代码 / 内网无法访问 GHCR | 普通用户,开箱即用 |
| 前提 | 本机能拉到基础镜像(node、KasmVNC base | GHCR 上已发布且包为公开(见[发布到 GHCR](发布到GHCR.md) |
| 产物 | 本地镜像,标签和 compose 里写的一模一样 | 同名镜像,来自云端 |
> compose 的拉取策略是默认值(`missing`):**本地已有同名镜像就直接用,没有才去 GHCR 拉**。所以方式 A 构建完,`docker compose up -d` 会直接用你的本地镜像,不会再联网。想升级到 GHCR 最新版:`docker compose pull && docker compose up -d`。
> 第三个「镜像」其实是**微信本体**:它**不打进任何镜像**,而是你在面板点「下载并安装」时,由实例容器实时从腾讯官方 CDN 下到自己的卷里(见[部署与运维 · 数据持久化](部署与运维.md#数据持久化))。
### 3. 从零到能用,整体发生了什么
```
你: docker compose up -d
└─▶ Docker 读取 docker-compose.yml
└─▶ 拉起【面板容器 woc-panel】,挂上 ./data-panel 和 docker.sock,暴露 36080 端口
你: 浏览器开 http://NAS:36080 → 登录 → 点「新建微信实例」
└─▶ 面板通过 docker.sock 指挥 Docker:
├─ docker run 一个【微信实例容器 woc-wx-xxx】
├─ 给它挂一个新卷 woc-data-xxx(存登录态/消息)
└─ 接到同一个 docker 网络(面板才能反代到它)
你: 进入该实例 → 点「下载并安装」
└─▶ 面板 docker exec 进实例容器,触发脚本从腾讯 CDN 下载微信、解压到卷
你: 点「进入电脑版微信」→ 手机扫码
└─▶ 浏览器 ⇄ 面板(反代+注入鉴权) ⇄ 实例容器的 KasmVNC ⇄ 微信窗口
```
### 4. 常用命令速查
```bash
docker compose up -d # 启动面板(首次会拉/用镜像)
docker compose down # 停止并删除面板容器(不动数据卷和微信实例)
docker compose pull # 把面板/微信镜像更新到 GHCR 最新
docker ps # 看正在运行的容器(能看到 woc-panel 和各 woc-wx-*
docker logs -f woc-panel # 看面板日志
docker logs -f woc-wx-<id> # 看某个微信实例日志
docker volume ls | grep woc # 看所有微信实例的数据卷
```
> ⚠️ 微信实例容器请**始终在面板网页里增删**,不要手动 `docker rm` 它们——否则面板的实例登记和真实容器会对不上。
---
## 架构自动适配
镜像本身多架构(amd64/arm64);下载微信时容器内**运行时再自动检测 CPU 架构**(`dpkg --print-architecture`)取对应官方包:
| 运行机器 | 架构 | 自动下载 |
|----------|------|----------|
| Intel/AMD NAS、x86 服务器 | amd64 | `WeChatLinux_x86_64.deb` |
| ARM NAS、Apple Silicon Mac | arm64 | `WeChatLinux_arm64.deb` |
到飞牛上(无论 x64 还是 arm`docker compose up -d` 同一条命令,无需改任何架构相关配置。
+101
View File
@@ -0,0 +1,101 @@
# 部署与运维
> 返回 [← README](../README.md)
数据持久化、常见问题排查、忘记超管密码的离线找回,以及仓库目录结构。
---
## 数据持久化
- **面板数据**(用户、实例元信息、密码哈希):容器内 `/data`,映射到宿主 `./data-panel`
- **每个微信实例**:独立的 docker 命名卷 `woc-data-<id>`,挂到该实例容器的 `/config`(微信本体在 `/config/wechat`,登录态与消息缓存在 `/config` 其余位置)。
要点:
- 删除实例**默认保留**其数据卷,下次同名重建可复用;只有显式勾选「彻底清除」才会删卷。
- 备份某实例 = 备份对应的 `woc-data-<id>` 卷(`docker volume` 系列命令)。
- 卷需支持执行权限(微信本体直接从卷里运行);放在 `noexec` 卷上微信将无法启动。
- 备份面板 = 备份 `./data-panel`
> **从旧版(单微信容器 + `./data` 绑定挂载)迁移**:旧形态把微信数据放在宿主 `./data`。新版用 docker 命名卷,结构不同,无自动迁移。如需保留旧会话,最简单是新建一个实例、重新扫码登录;或手动把旧 `./data` 内容拷进新实例的 `woc-data-<id>` 卷。
---
## 常见问题
| 现象 | 排查 |
|------|------|
| 新建实例失败 | 多为面板拉不到微信镜像或连不上 docker.sock。确认 `docker.sock` 已挂载、宿主能访问 GHCR;看面板日志 `docker logs woc-panel` |
| 界面/消息显示成方块 | 中文字体没装好,确认实例镜像含 `fonts-noto-cjk` |
| 微信起不来 / 黑屏 | 看实例日志 `docker logs woc-wx-<id>`;确认 `seccomp=unconfined``shm_size` 生效。微信 deb 漏声明的运行时依赖已在 Dockerfile 内置 |
| 排查缺哪个库 | `docker exec woc-wx-<id> ldd /config/wechat/opt/wechat/wechat`,看 `not found` 项补进 Dockerfile 依赖层 |
| 多人同时操作很乱 | 已内置「操作控制权」软锁:当前操作者每数秒心跳续约,其余端自动转为**只读遮罩**(仍可看画面),空闲超时(约 10s)自动释放,他人可点「申请控制」接管。仍建议同一时刻一人操作 |
| 过段时间掉登录 | 微信桌面会话会定期失效,需手机重新扫码(见技术方案 6.2) |
| 下载 / 更新微信失败 | 腾讯 CDN 偶发波动,重新点「下载并安装 / 更新」即可;脚本已内置主/备 CDN 自动回退 |
| 架构不支持报错 | 微信仅提供 x86_64 / arm64;其他架构下载时会在面板状态里报错 |
| 忘记超管密码 | 见下方「重置超管密码」离线找回 |
查看面板日志:`docker logs -f woc-panel`;查看某实例日志:`docker logs -f woc-wx-<id>`(实例 ID 可在面板看到,或 `docker ps | grep woc-wx`)。也可在面板的「管理」页或实例页点「查看日志」直接导出实例容器日志。
---
## 重置超管密码(离线找回)
管理员密码无法被他人重置,忘记时按以下步骤离线找回:
```bash
docker compose stop panel # 1) 先停面板,避免覆盖你的手动修改
```
2) 编辑 `./data-panel/accounts.json`,给对应用户对象加一行 `"resetPassword": true`
```json
{
"id": "...", "username": "admin", "role": "admin",
"passwordHash": "...", "disabled": false,
"resetPassword": true
}
```
```bash
docker compose up -d # 3) 启动,面板初始化时会重置该账号
```
> ⚠️ 重置逻辑只在面板**进程启动**时执行。若你没先 `stop` 而面板仍在运行,直接 `docker compose up -d` 会因「容器无变化」而空操作(输出 `Running` 而非 `Started`),重置不会发生。此时执行 **`docker compose restart panel`**(或 `docker restart woc-panel`)强制重启即可生效。
重启后该账号密码被重置为 `PANEL_ADMIN_PASSWORD`(即 `.env``WOC_PASSWORD`,默认 `wechat`),并自动**解禁**、清除该标记;用此密码登录后请立即在「修改密码」改掉。日志会打印 `[store] 已重置用户 '<用户名>' 的密码`
---
## 目录结构
```
WechatOnCloud/
├── .github/workflows/
│ └── release.yml # 打 tag / 发 Release 时构建多架构镜像并推送 GHCR
├── docker/ # 微信实例镜像(ghcr.io/<owner>/wechat-on-cloud
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + xdotool + IME 补丁(不打包微信本体)
│ ├── woc-www-patch.sh # 构建期补丁:开启 KasmVNC IME 模式 + 修复 noVNC 中文输入(配合 woc-ime.pl
│ ├── woc-ime.pl # noVNC 键盘 IME 逻辑的 perl 补丁(合成提交时整串发送,规避丢字)
│ ├── wechat-ctl.sh # 运行时下载/解压/更新微信(面板经 docker exec 触发,状态写 /config/.woc-state
│ ├── autostart # openbox 会话启动:常驻拉起微信(崩溃自重启)+ 最小化窗口自动复原看守
│ └── woc-update-autostart # 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本
├── panel/ # 自研面板(ghcr.io/<owner>/woc-panel,唯一对外入口)
│ ├── Dockerfile # 前端 Vite 打包 + 后端 Fastify 网关(多架构)
│ ├── server/ # Fastifycookie 鉴权 + 账号/实例/权限/生命周期 API + dockerode + 反代
│ └── web/ # React + TS + PWA(微信 PC 式布局,牛奶布艺 + 微信绿主题)
├── fnos/ # 飞牛 fnOS 应用打包(.fpk 工程 + 构建说明)
├── scripts/
│ └── build-local.sh # 本地构建面板+微信镜像(发布前自测 / 自托管自构建)
├── doc/ # 文档与素材
│ ├── 技术方案.md # 完整设计文档
│ ├── 运行原理.md # 工作原理 + Docker 运行模式详解(新手向)
│ ├── 发布到GHCR.md # 镜像发布到 GHCR 的两种方式
│ ├── 部署与运维.md # 本文档:数据持久化 / 常见问题 / 密码找回 / 目录结构
│ └── img/ # logo 与界面截图
├── docker-compose.yml # 单服务:panel(挂 docker.sock,按需创建实例)
├── .env.example # 可选配置(账号密码、镜像版本、PUID/PGID、端口、时区)
└── README.md
```
数据:面板账号(含密码哈希)在 `./data-panel`,各微信实例在 docker 命名卷 `woc-data-<id>``./data-panel` 已在 `.gitignore` 中。
+10 -3
View File
@@ -3,7 +3,8 @@
# 镜像全部从 GHCR 拉取,无需本地构建。要改配置:复制 .env.example 为 .env 后修改。
services:
panel:
image: ghcr.io/gloridust/woc-panel:${WOC_VERSION:-latest}
# 镜像源前缀默认 GHCR;大陆网络拉不动时改 .env 的 WOC_IMAGE_PREFIX 切到国内反代(见 .env.example)。
image: ${WOC_IMAGE_PREFIX:-ghcr.io/gloridust}/woc-panel:${WOC_VERSION:-latest}
container_name: woc-panel
# pull_policy 用默认(missing):本地已有同名镜像就直接用,没有才去 GHCR 拉。
# 这样「发布前本地自构建」与「线上拉取」都能用同一份 compose。
@@ -11,12 +12,15 @@ services:
environment:
- PORT=8080
# 新建微信实例时使用的镜像(多架构,amd64/arm64 自动匹配)
- WOC_WECHAT_IMAGE=ghcr.io/gloridust/wechat-on-cloud:${WOC_VERSION:-latest}
# 新建微信实例时使用的镜像(多架构,amd64/arm64 自动匹配);前缀同样跟随 WOC_IMAGE_PREFIX。
- WOC_WECHAT_IMAGE=${WOC_IMAGE_PREFIX:-ghcr.io/gloridust}/wechat-on-cloud:${WOC_VERSION:-latest}
# 透传给每个微信实例容器(KasmVNC 基础镜像用它们降权运行)
- PUID=${WOC_PUID:-1000}
- PGID=${WOC_PGID:-1000}
- TZ=${WOC_TZ:-Asia/Shanghai}
# 摄像头直通:逗号分隔的宿主视频设备(如 /dev/video0)。留空则自动探测(见下方 /host-dev 挂载)或禁用摄像头。
# 启用前需在宿主加载 v4l2loopback 内核模块。详见 .env.example。
- WOC_VIDEO_DEVICES=${WOC_VIDEO_DEVICES:-}
# 面板首个管理员账号(仅首次启动、无账号文件时写入;务必改掉默认密码)
- PANEL_ADMIN_USER=${WOC_USER:-admin}
- PANEL_ADMIN_PASSWORD=${WOC_PASSWORD:-wechat}
@@ -28,6 +32,9 @@ services:
# 面板经 docker 引擎创建/启动/删除微信实例容器、exec 触发下载、读取进度。
# 注意:docker.sock 等同宿主 root 权限,故实例增删仅限管理员,docker API 绝不暴露给前端。
- /var/run/docker.sock:/var/run/docker.sock
# 摄像头自动探测(可选):把宿主 /dev 只读挂进来,面板据此发现 /dev/videoN 并映射给实例。
# 不想自动探测就删掉此行,改用上面的 WOC_VIDEO_DEVICES 显式指定。
- /dev:/host-dev:ro
ports:
- "${WOC_HTTP_PORT:-36080}:8080" # 面板 = 唯一对外入口
+15 -15
View File
@@ -14,7 +14,8 @@ RUN set -eux; \
apt-get install -y --no-install-recommends \
curl ca-certificates locales dpkg \
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
libnss3 libgbm1 libasound2 libxss1; \
libnss3 libgbm1 libasound2 libxss1 \
xdotool xclip; \
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
locale-gen; \
apt-get clean; \
@@ -53,28 +54,27 @@ ENV LANG=zh_CN.UTF-8 \
LC_ALL=zh_CN.UTF-8 \
LIBGL_ALWAYS_SOFTWARE=1
# KasmVNC web 客户端默认开启 IME 输入模式
# 用户用本地(客户端)输入法打中文,拼音联想在本地完成、只把成品汉字发进容器,无需容器内装 IME。
# 默认值仅在浏览器未存过该设置时生效,不覆盖用户手动改过的偏好。
# KasmVNC web 客户端的 webpack 产物 dist/*.bundle.js
# (1) 默认开启 IME 输入模式:本地(客户端)输入法打中文,拼音联想在本地完成、只把成品汉字
# 发进容器,无需容器内装 IME。默认值仅在浏览器未存过该设置时生效,不覆盖用户手动改过的偏好。
# (2) 修复 noVNC 中文 IME 输入:原实现靠隐藏 textarea 差分逐字符重发 keysym,会泄漏中间拼音、
# 累积不 reset、退格风暴,导致大量丢字 / ~21 字卡住 / 跨浏览器不稳。改为只在 compositionend
# 用 e.data 直发成品字符串(详见 woc-www-patch.sh / woc-ime.pl)。
# 注意:实际加载的是 webpack 产物 dist/main.bundle.jsapp/ui.js 是未打包源码、运行时不加载),故必须改 bundle。
# 末尾的 grep 作为断言:若 base 镜像换了打包结构、改不到任何文件,则构建直接失败而非静默放过。
RUN set -eux; \
patched=0; \
for f in /usr/share/kasmvnc/www/dist/*.bundle.js /usr/local/share/kasmvnc/www/dist/*.bundle.js; do \
if [ -f "$f" ] && grep -q "initSetting('enable_ime', false)" "$f"; then \
sed -i "s/initSetting('enable_ime', false)/initSetting('enable_ime', true)/g" "$f"; \
patched=1; \
fi; \
done; \
[ "$patched" = "1" ]
COPY woc-www-patch.sh woc-ime.pl /woc/
RUN chmod +x /woc/woc-www-patch.sh && /woc/woc-www-patch.sh
# 微信下载/解压控制脚本(运行时由面板经 docker exec 触发,状态写入数据卷 /config/.woc-state
COPY wechat-ctl.sh /woc/wechat-ctl.sh
RUN chmod +x /woc/wechat-ctl.sh
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守
COPY autostart /defaults/autostart
RUN chmod +x /defaults/autostart
# 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑)
COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart
RUN chmod +x /custom-cont-init.d/01-woc-autostart
# 3000 = HTTP web 客户端, 3001 = HTTPS
EXPOSE 3000 3001
+13
View File
@@ -10,6 +10,19 @@ WECHAT_BIN=/config/wechat/opt/wechat/wechat
# 容器内无 GPU,强制软件渲染
export LIBGL_ALWAYS_SOFTWARE=1
# 防“最小化后丢失”:本桌面(openbox)无任务栏,微信被最小化就无处恢复 → 黑屏。
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。
(
export DISPLAY="${DISPLAY:-:1}"
while sleep 2; do
all=$(xdotool search --name '.+' 2>/dev/null) || continue
vis=$(xdotool search --onlyvisible --name '.+' 2>/dev/null)
for w in ${all}; do
printf '%s\n' "${vis}" | grep -qx "${w}" || xdotool windowactivate "${w}" 2>/dev/null || true
done
done
) &
# 1) 等待微信安装就绪(首次需在面板点「下载并安装」)
notified=0
while [ ! -x "${WECHAT_BIN}" ]; do
+41
View File
@@ -0,0 +1,41 @@
# perl -0777 -pe 补丁脚本(被 woc-www-patch.sh 引用)。
# 对 dist/*.bundle.js 里 noVNC 键盘 IME 逻辑做两处替换,全程字面匹配(\Q..\E)。
#
# 背景:noVNC 原实现靠"隐藏 textarea 差分→逐字符重发 keysym"还原 IME 输入,会在合成过程中
# 把中间拼音也发给远端、且永不 reset 导致累积+退格风暴 → 大量丢字 / 卡住 / 跨浏览器不稳。
#
# 改法:彻底不靠 textarea 差分或 VNC keysym 还原中文。
# - 合成进行中(input 事件):只同步 _lastKeyboardInput、不发送(避免中间拼音泄漏 / 丢字)。
# - 提交时(compositionend):只同步 _lastKeyboardInput 并返回,不再逐字发 keysym。
# 成品文本由面板前端捕获后通过 xclip/xdotool 粘贴进远端窗口,绕开 KasmVNC XKB
# keysym 容量限制,也避免和粘贴路径重复上屏。
# - 若个别浏览器在 compositionend 后还补发一次"提交 input":此时 isComposing/_imeHold 均为假,
# 落到非 IME 差分分支,但 newValue 与刚同步的 _lastKeyboardInput 相等 → 差分为空 → 不重复发送。
# (A) _handleCompositionEnd:提交时只同步 _lastKeyboardInput,文本由面板粘贴路径负责。
s~\Q if (this._enableIME) {
this._imeInProgress = false;
}
if (isChromiumBased()) {
this._imeHold = false;
}\E~ if (this._enableIME) { // WOC-IME
this._imeInProgress = false;
this._imeHold = false;
this._lastKeyboardInput = e.target.value;
return;
}
if (isChromiumBased()) {
this._imeHold = false;
}~;
# (B) _handleInput 顶部守卫:合成进行中只同步值、不发送;提交已在 compositionend 完成。
s~\Q if (this._enableIME && this._imeHold) {
Debug("IME input change, sending differential");\E~ if (this._enableIME && (e.isComposing || this._imeHold || this._imeInProgress)) { // WOC-IME
this._lastKeyboardInput = e.target.value;
return;
}
if (this._enableIME && this._imeHold) {
Debug("IME input change, sending differential");~;
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/with-contenv bash
# linuxserver 启动钩子(/custom-cont-init.droot 身份,每次启动执行)。
# 作用:始终用镜像内最新的 /defaults/autostart 覆盖数据卷里的副本。
# 原因:baseimage 的 init-kasmvnc-config 只在 /config/.config/openbox/autostart "缺失时"才拷贝,
# 导致旧实例(卷里已有旧 autostart)升级镜像后仍跑旧逻辑(如缺少"最小化自动复原"看守)。
mkdir -p /config/.config/openbox
cp /defaults/autostart /config/.config/openbox/autostart
chmod +x /config/.config/openbox/autostart
chown "${PUID:-1000}:${PGID:-1000}" /config/.config/openbox/autostart 2>/dev/null || true
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# 构建期补丁:改 KasmVNC web 客户端的 webpack 产物 dist/*.bundle.js
# (1) 默认开启 IME 输入模式(本地输入法打中文,成品汉字发进容器,容器内不装 IME)
# (2) 修复 noVNC 的中文 IME 输入:原实现靠"隐藏 textarea 差分→逐字符重发 keysym"
# 会在合成过程中把中间拼音也发给远端、且永不 reset 导致累积+退格风暴;
# 改为合成期间和提交时都只同步内部 textarea 状态,不再发送中文 keysym。
# 最终成品文本由面板前端捕获后通过 xclip/xdotool 粘贴,绕过 KasmVNC XKB keysym 限制。
# 末尾断言:若 base 镜像换了打包结构、一个文件都没改到,则构建失败而非静默放过。
set -euo pipefail
PATCH_PL="$(dirname "$0")/woc-ime.pl"
patched=0
for f in /usr/share/kasmvnc/www/dist/*.bundle.js /usr/local/share/kasmvnc/www/dist/*.bundle.js; do
[ -f "$f" ] || continue
changed=0
# (1) enable_ime 默认开启
if grep -q "initSetting('enable_ime', false)" "$f"; then
sed -i "s/initSetting('enable_ime', false)/initSetting('enable_ime', true)/g" "$f"
changed=1
fi
# (2) IME 差分逻辑修复(仅含 noVNC 键盘逻辑的 bundle
# 幂等:/usr/share/kasmvnc 是 /usr/local/share/kasmvnc 的软链,两个 glob 会命中同一 inode,
# 故已含 _imeJustCommitted 的文件直接跳过,避免重复注入守卫块。
if grep -q "IME input change, sending differential" "$f" && ! grep -q "WOC-IME" "$f"; then
perl -0777 -i -pe "$(cat "$PATCH_PL")" "$f"
after="$(grep -c "WOC-IME" "$f" || true)"
# 断言两处替换都命中(compositionend 标记 1 + _handleInput 守卫标记 1 = 2 行)
if [ "$after" -ne 2 ]; then
echo "FATAL: IME patch mismatch on $f (markers=$after, expect 2)" >&2
exit 1
fi
changed=1
fi
[ "$changed" = "1" ] && { echo "woc-www-patch: patched $f"; patched=1; }
done
[ "$patched" = "1" ] || { echo "FATAL: no bundle patched" >&2; exit 1; }
echo "woc-www-patch: done"
+187 -9
View File
@@ -1,4 +1,5 @@
import { hostname } from 'node:os';
import { existsSync, readdirSync } from 'node:fs';
import Docker from 'dockerode';
import type { Instance } from './store.js';
@@ -30,6 +31,32 @@ export async function ensureNetwork(): Promise<string | null> {
return networkName;
}
// 摄像头直通:把宿主的 v4l2 视频设备映射进实例容器
// (浏览器摄像头 → KasmVNC → 容器内 /dev/videoN(v4l2loopback) → 微信)。
// 来源优先级:
// 1) WOC_VIDEO_DEVICES 显式指定(逗号分隔,如 /dev/video0,/dev/video1)——Ubuntu/无法自动探测时用;
// 2) 自动探测:把宿主 /dev 以只读挂到面板的 /host-dev(compose 可选),扫描其中的 videoN。
// 一个都找不到则返回空:音频/麦克风不受影响,仅摄像头不可用(优雅降级)。
function videoDevices(): string[] {
const explicit = (process.env.WOC_VIDEO_DEVICES || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (explicit.length) return explicit;
for (const dir of ['/host-dev', '/dev']) {
try {
if (!existsSync(dir)) continue;
const vids = readdirSync(dir)
.filter((n) => /^video\d+$/.test(n))
.map((n) => `/dev/${n}`); // 宿主侧设备路径
if (vids.length) return vids;
} catch {
/* 无权限/不可读,忽略 */
}
}
return [];
}
function envList(inst: Instance): string[] {
return [
`PUID=${PUID}`,
@@ -62,19 +89,27 @@ export async function runInstance(inst: Instance): Promise<void> {
} catch {
/* 不存在,正常 */
}
// 摄像头设备(探测不到则为空数组 → 仅摄像头不可用,音频/麦克风照常)
const vids = videoDevices();
const hostConfig: Docker.HostConfig = {
Binds: [`${inst.volumeName}:/config`],
NetworkMode: net || undefined,
SecurityOpt: ['seccomp=unconfined'],
ShmSize: SHM_SIZE,
RestartPolicy: { Name: 'unless-stopped' },
};
if (vids.length) {
hostConfig.Devices = vids.map((d) => ({ PathOnHost: d, PathInContainer: d, CgroupPermissions: 'rwm' }));
hostConfig.GroupAdd = ['video']; // 让容器内 abc 用户能访问 /dev/videoN
console.log(`[docker] 实例 ${inst.id} 挂载摄像头设备: ${vids.join(', ')}`);
}
const container = await docker.createContainer({
name: inst.containerName,
Image: WECHAT_IMAGE,
Hostname: inst.containerName,
Env: envList(inst),
ExposedPorts: { '3000/tcp': {} },
HostConfig: {
Binds: [`${inst.volumeName}:/config`],
NetworkMode: net || undefined,
SecurityOpt: ['seccomp=unconfined'],
ShmSize: SHM_SIZE,
RestartPolicy: { Name: 'unless-stopped' },
},
HostConfig: hostConfig,
});
await container.start();
}
@@ -90,6 +125,26 @@ export async function ensureRunning(inst: Instance): Promise<void> {
}
}
// 升级实例:拉取最新微信镜像后重建容器(保留数据卷 → 登录态不丢)。
// 拉取失败(本地自构建 / 离线 / 仓库不可达)则用本地现有镜像重建,不阻断。
export async function upgradeInstance(inst: Instance): Promise<void> {
try {
await pullImage();
} catch (e: any) {
console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e);
}
await runInstance(inst);
}
// 停止实例容器(保留容器与数据卷,可再启动)。
export async function stopInstance(inst: Instance): Promise<void> {
try {
await docker.getContainer(inst.containerName).stop({ t: 5 } as any);
} catch {
/* 已停止或不存在 */
}
}
export async function removeInstance(inst: Instance, purgeVolume: boolean): Promise<void> {
try {
const c = docker.getContainer(inst.containerName);
@@ -115,7 +170,7 @@ export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
}
}
// 在实例容器内执行命令,返回 stdoutdemux 后只取标准输出)
// 在实例容器内执行命令,返回 stdout;若命令失败,把 stderr 透出给调用方
async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
const c = docker.getContainer(inst.containerName);
const exec = await c.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false, User: 'abc' });
@@ -126,7 +181,18 @@ async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
const stdout = { write: (b: Buffer) => { out += b.toString('utf8'); } } as any;
const stderr = { write: (b: Buffer) => { err += b.toString('utf8'); } } as any;
docker.modem.demuxStream(stream, stdout, stderr);
stream.on('end', () => resolve(out || err));
stream.on('end', async () => {
try {
const info = await exec.inspect();
if (info.ExitCode && info.ExitCode !== 0) {
reject(new Error((err || out || `命令执行失败,退出码 ${info.ExitCode}`).trim()));
return;
}
resolve(out || err);
} catch (e) {
reject(e);
}
});
stream.on('error', reject);
});
}
@@ -178,6 +244,118 @@ export async function pullImage(onProgress?: (line: any) => void): Promise<void>
});
}
// ---------- 文件中转(上传/下载) ----------
// 中转目录 = abc 家目录下的 Desktop/config 持久卷)。上传落这里,微信文件选择器可直接选到;
// 反向:把微信收到的文件另存到桌面,即可在面板里下载。
const TRANSFER_DIR = '/config/Desktop';
// 极简单文件 tar 编码(putArchive 需要 tar;避免引入第三方依赖)。
function tarSingleFile(name: string, content: Buffer): Buffer {
const h = Buffer.alloc(512, 0);
h.write(name.slice(0, 100), 0, 'utf8'); // name
h.write('0000644\0', 100); // mode
h.write('0001750\0', 108); // uid 1000(octal 1750)
h.write('0001750\0', 116); // gid 1000
h.write(content.length.toString(8).padStart(11, '0') + '\0', 124); // size
h.write('00000000000\0', 136); // mtime
h.write(' ', 148); // checksum 占位(8 空格)
h.write('0', 156); // typeflag 普通文件
h.write('ustar\0', 257);
h.write('00', 263);
let sum = 0;
for (let i = 0; i < 512; i++) sum += h[i];
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148); // 真实校验和
const pad = (512 - (content.length % 512)) % 512;
return Buffer.concat([h, content, Buffer.alloc(pad, 0), Buffer.alloc(1024, 0)]);
}
// 校验文件名为安全 basename(防路径穿越)。
function safeName(name: string): boolean {
return !!name && name.length <= 200 && !name.includes('/') && !name.includes('\0') && name !== '.' && name !== '..';
}
export async function uploadToInstance(inst: Instance, name: string, content: Buffer): Promise<void> {
if (!safeName(name)) throw new Error('文件名不合法');
await execCapture(inst, ['sh', '-c', `mkdir -p ${TRANSFER_DIR}`]); // abc 家目录可写
const c = docker.getContainer(inst.containerName);
await c.putArchive(tarSingleFile(name, content), { path: TRANSFER_DIR });
}
export interface TransferFile {
name: string;
size: number;
}
export async function listInstanceFiles(inst: Instance): Promise<TransferFile[]> {
const out = await execCapture(inst, [
'sh',
'-c',
`find ${TRANSFER_DIR} -maxdepth 1 -type f -printf '%f\\t%s\\n' 2>/dev/null`,
]);
return out
.split('\n')
.filter(Boolean)
.map((line) => {
const [name, size] = line.split('\t');
return { name, size: Number(size) || 0 };
});
}
export async function deleteInstanceFile(inst: Instance, name: string): Promise<void> {
if (!safeName(name)) throw new Error('文件名不合法');
// argv 数组直传,不经 shellsafeName 已排除路径穿越
await execCapture(inst, ['rm', '-f', `${TRANSFER_DIR}/${name}`]);
}
export async function downloadFromInstance(inst: Instance, name: string): Promise<Buffer> {
if (!safeName(name)) throw new Error('文件名不合法');
const c = docker.getContainer(inst.containerName);
const stream = (await c.getArchive({ path: `${TRANSFER_DIR}/${name}` })) as NodeJS.ReadableStream;
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
stream.on('data', (d: Buffer) => chunks.push(d));
stream.on('end', () => resolve());
stream.on('error', reject);
});
const tar = Buffer.concat(chunks);
if (tar.length < 512) return Buffer.alloc(0);
const sizeStr = tar.toString('ascii', 124, 135).replace(/\0/g, '').trim();
const size = parseInt(sizeStr, 8) || 0;
return tar.subarray(512, 512 + size);
}
// 拉取实例容器日志(末尾 N 行),供前端"查看/导出日志"排错。
export async function instanceLogs(inst: Instance, tail = 600): Promise<string> {
const c = docker.getContainer(inst.containerName);
const buf = (await c.logs({ stdout: true, stderr: true, tail, timestamps: true })) as unknown as Buffer;
// docker 非 TTY 日志为多路复用流:每帧 8 字节头([stream,0,0,0,size BE]+ 负载;解出纯文本。
let out = '';
let i = 0;
while (i + 8 <= buf.length) {
const size = buf.readUInt32BE(i + 4);
if (size < 0 || i + 8 + size > buf.length) break;
out += buf.subarray(i + 8, i + 8 + size).toString('utf8');
i += 8 + size;
}
return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用
}
// 通过 xdotool 在实例容器内输入文字(绕过 VNC keysym 限制,解决中文 IME 吞字问题)。
// 用 base64 传递文本避免 shell 转义问题,xclip 写入剪贴板后 xdotool 模拟 Ctrl+V 粘贴。
export async function typeInInstance(inst: Instance, text: string): Promise<void> {
const b64 = Buffer.from(text, 'utf8').toString('base64');
const cmd = [
'set -e',
'display="${DISPLAY:-}"',
'if [ -z "$display" ]; then for x in /tmp/.X11-unix/X*; do [ -e "$x" ] || continue; display=":${x##*X}"; break; done; fi',
'export DISPLAY="${display:-:1}"',
'command -v xclip >/dev/null 2>&1 || { echo "xclip not installed in instance image" >&2; exit 127; }',
'command -v xdotool >/dev/null 2>&1 || { echo "xdotool not installed in instance image" >&2; exit 127; }',
`echo '${b64}' | base64 -d | xclip -selection clipboard -i`,
'xdotool key --clearmodifiers ctrl+v',
].join('; ');
await execCapture(inst, ['bash', '-c', cmd]);
}
// 实例容器名(供反代构造 target)。
export function instanceTarget(inst: Instance): string {
return `http://${inst.containerName}:3000`;
+212
View File
@@ -24,6 +24,7 @@ import {
userCanAccess,
createInstance,
removeInstance as removeInstanceRecord,
renameInstance,
setInstanceUsers,
publicInstance,
type User,
@@ -33,11 +34,19 @@ import {
ensureNetwork,
ensureRunning,
runInstance,
stopInstance,
upgradeInstance,
removeInstance as removeInstanceContainer,
instanceRuntime,
triggerWechat,
wechatStatus,
instanceTarget,
uploadToInstance,
listInstanceFiles,
downloadFromInstance,
deleteInstanceFile,
instanceLogs,
typeInInstance,
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
@@ -56,6 +65,8 @@ initStore();
const app = Fastify({ logger: true, trustProxy: true });
await app.register(cookie);
// 文件上传走原始二进制(前端以 application/octet-stream 直传 File
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
// ---------- 鉴权辅助 ----------
function currentUser(req: FastifyRequest): User | null {
@@ -242,9 +253,74 @@ app.delete('/api/admin/instances/:id', async (req, reply) => {
if (!inst) return reply.code(404).send({ error: '实例不存在' });
await removeInstanceContainer(inst, purge);
removeInstanceRecord(id);
controlHolders.delete(id);
return { ok: true };
});
// 重命名实例(仅管理员):只改显示名,不动容器/卷。
app.post('/api/admin/instances/:id/rename', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const { name } = (req.body as any) ?? {};
try {
return { instance: renameInstance((req.params as any).id, String(name ?? '')) };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
// 启动实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。
app.post('/api/admin/instances/:id/start', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await ensureRunning(inst);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: '启动失败:' + (e?.message || e) });
}
});
// 停止实例容器(仅管理员):保留容器与数据卷。
app.post('/api/admin/instances/:id/stop', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await stopInstance(inst);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: '停止失败:' + (e?.message || e) });
}
});
// 重启实例容器(仅管理员):按当前本地镜像重建(保留数据卷 → 登录态不丢;快速,不联网拉取)。
app.post('/api/admin/instances/:id/restart', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await runInstance(inst);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: '重启失败:' + (e?.message || e) });
}
});
// 升级实例(仅管理员):拉取最新微信镜像后重建(保留数据卷)。用于把旧实例更新到新版镜像
// (如修复"最小化丢失"等),类似「更新微信」但更新的是实例容器镜像本身。
app.post('/api/admin/instances/:id/upgrade', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await upgradeInstance(inst);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: '升级失败:' + (e?.message || e) });
}
});
// 实例侧:设置该实例可被哪些账户访问
app.post('/api/admin/instances/:id/users', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
@@ -258,6 +334,142 @@ app.post('/api/admin/instances/:id/users', async (req, reply) => {
}
});
// ---------- 文件中转(有访问权限即可用;走面板鉴权,不额外暴露) ----------
// 上传:原始二进制直传,落到实例 ~/Desktop,微信文件选择器可直接选到。
app.post('/api/instances/:id/upload', { bodyLimit: 512 * 1024 * 1024 }, async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
const name = String((req.query as any)?.name || '').trim();
const body = req.body as Buffer;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
try {
await uploadToInstance(findInstance(id)!, name, body);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '上传失败' });
}
});
// 列出可下载的中转文件
app.get('/api/instances/:id/files', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
try {
return { files: await listInstanceFiles(findInstance(id)!) };
} catch {
return { files: [] };
}
});
// 删除某个中转文件(有访问权限即可)
app.delete('/api/instances/:id/files', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
const name = String((req.query as any)?.name || '').trim();
try {
await deleteInstanceFile(findInstance(id)!, name);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '删除失败' });
}
});
// 下载某个中转文件
app.get('/api/instances/:id/download', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
const name = String((req.query as any)?.name || '').trim();
try {
const buf = await downloadFromInstance(findInstance(id)!, name);
reply.header('content-type', 'application/octet-stream');
reply.header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`);
return reply.send(buf);
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '下载失败' });
}
});
// ---------- 多端协作:操作控制权(心跳软锁,避免多人同时操作打架) ----------
// 同一实例被多个浏览器连的是同一会话,键鼠会互相打架。这里用"心跳持锁":
// 当前操作者每隔几秒 beat 续约;TTL 内他人只读(前端盖只读遮罩)。空闲超 TTL 自动释放。
const CONTROL_TTL = 10_000; // ms:超过则视为已空闲,可被接管
const controlHolders = new Map<string, { userId: string; username: string; at: number }>();
// 续约/认领:无人持有、已超时、或本来就是我 → 我成为操作者;否则返回当前操作者。
app.post('/api/instances/:id/control/beat', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
const now = Date.now();
const h = controlHolders.get(id);
if (!h || now - h.at > CONTROL_TTL || h.userId === u.id) {
controlHolders.set(id, { userId: u.id, username: u.username, at: now });
return { mine: true, holder: u.username };
}
return { mine: false, holder: h.username };
});
// 只读查询当前操作者(前端轮询;不认领)。超 TTL 视为空闲。
app.get('/api/instances/:id/control', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
const h = controlHolders.get(id);
if (!h || Date.now() - h.at > CONTROL_TTL) return { free: true, mine: false, holder: null };
return { free: false, mine: h.userId === u.id, holder: h.username };
});
// 主动接管("申请控制"):强制把操作权抢过来。
app.post('/api/instances/:id/control/take', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
controlHolders.set(id, { userId: u.id, username: u.username, at: Date.now() });
return { mine: true, holder: u.username };
});
// 通过 xdotool 在实例容器内输入文字(绕过 VNC XKB keysym 容量限制,修复中文 IME 吞字)
app.post('/api/instances/:id/type', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
const { text } = (req.body as any) ?? {};
if (!text || typeof text !== 'string' || text.length > 500) return reply.code(400).send({ error: '文字为空或过长' });
try {
await typeInInstance(findInstance(id)!, text);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '输入失败' });
}
});
// 查看实例容器日志(仅管理员):排查"无法进入/未安装/卡死"等。inline 文本,浏览器可直接看/另存。
app.get('/api/admin/instances/:id/logs', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
const text = await instanceLogs(inst);
reply.header('content-type', 'text/plain; charset=utf-8');
return reply.send(text || '(暂无日志)');
} catch (e: any) {
reply.header('content-type', 'text/plain; charset=utf-8');
return reply.send('获取日志失败:' + (e?.message || e));
}
});
// 该实例的微信安装状态(有访问权限即可看)
app.get('/api/instances/:id/wechat/status', async (req, reply) => {
const u = requireAuth(req, reply);
+46 -2
View File
@@ -14,8 +14,17 @@ export interface User {
createdAt: string;
// 该账户可访问的微信实例 id 列表。admin 隐式全部,忽略此字段。
allowedInstances: string[];
// 仍在使用初始默认密码时为 true,前端据此提示尽快改密;任意一次改密/重置后清除。
mustChangePassword?: boolean;
// 离线密码找回:在 accounts.json 手动把某用户置为 true,重启面板即重置其密码并清除此标记。
// 兼容下划线写法 reset_password。
resetPassword?: boolean;
reset_password?: boolean;
}
// 初始默认管理员密码;管理员仍在用它时强烈提示改密。
const DEFAULT_ADMIN_PASSWORD = 'wechat';
export interface Instance {
id: string; // 短 id,用于容器/卷命名
name: string; // 显示名
@@ -68,9 +77,32 @@ export function initStore() {
}
if (!data.users.some((u) => u.role === 'admin')) {
const username = process.env.PANEL_ADMIN_USER || 'admin';
const password = process.env.PANEL_ADMIN_PASSWORD || 'wechat';
data.users.push(makeUser(username, password, 'admin'));
const password = process.env.PANEL_ADMIN_PASSWORD || DEFAULT_ADMIN_PASSWORD;
const admin = makeUser(username, password, 'admin');
// 用默认密码初始化时标记,提醒尽快改密
if (password === DEFAULT_ADMIN_PASSWORD) admin.mustChangePassword = true;
data.users.push(admin);
console.log(`[store] 已初始化管理员账号 '${username}'`);
} else {
// 兼容旧账号文件:管理员若仍能用默认密码登录,补打"需改密"标记
for (const u of data.users) {
if (u.role === 'admin' && u.mustChangePassword === undefined) {
u.mustChangePassword = bcrypt.compareSync(DEFAULT_ADMIN_PASSWORD, u.passwordHash);
}
}
}
// 离线密码找回:忘记超管密码时,停掉面板 → 在 accounts.json 给该用户加 "resetPassword": true
// → 重启面板。这里把其密码重置为 PANEL_ADMIN_PASSWORD(默认 wechat)、解禁,并清除标记。
for (const u of data.users) {
if ((u as any).resetPassword === true || (u as any).reset_password === true) {
const pw = process.env.PANEL_ADMIN_PASSWORD || DEFAULT_ADMIN_PASSWORD;
u.passwordHash = bcrypt.hashSync(pw, 10);
u.mustChangePassword = pw === DEFAULT_ADMIN_PASSWORD; // 重置成默认密码则提示尽快改密
u.disabled = false;
delete (u as any).resetPassword;
delete (u as any).reset_password;
console.log(`[store] 已重置用户 '${u.username}' 的密码(resetPassword 标记,密码=PANEL_ADMIN_PASSWORD 或默认 wechat`);
}
}
persist();
}
@@ -84,6 +116,7 @@ export function publicUser(u: User) {
disabled: u.disabled,
createdAt: u.createdAt,
allowedInstances: u.role === 'admin' ? [] : u.allowedInstances,
mustChangePassword: !!u.mustChangePassword,
};
}
@@ -128,6 +161,7 @@ export function resetPassword(id: string, password: string) {
const u = findById(id);
if (!u) throw new Error('用户不存在');
u.passwordHash = bcrypt.hashSync(password, 10);
u.mustChangePassword = false; // 改过密就不再提示
persist();
return publicUser(u);
}
@@ -205,6 +239,16 @@ export function createInstance(name: string, createdBy: string, allowedUserIds:
return inst;
}
export function renameInstance(id: string, name: string) {
const inst = findInstance(id);
if (!inst) throw new Error('实例不存在');
const n = (name || '').trim();
if (!n || n.length > 30) throw new Error('实例名称为 1-30 个字符');
inst.name = n;
persist();
return publicInstance(inst);
}
export function removeInstance(id: string) {
const inst = findInstance(id);
if (!inst) throw new Error('实例不存在');
+2 -3
View File
@@ -7,9 +7,8 @@
</defs>
<rect width="100" height="100" rx="23" fill="url(#bg)"/>
<rect x="3.5" y="3.5" width="93" height="93" rx="20" fill="none" stroke="#ffffff" stroke-opacity="0.18" stroke-width="2"/>
<circle cx="22" cy="36" r="3.4" fill="#ffffff" fill-opacity="0.92"/>
<g fill="none" stroke="#ffffff" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M34 30 L48 44 L34 58"/>
<path d="M56 60 L72 60"/>
<path d="M28 24 L42 38 L28 52"/>
<path d="M50 54 L66 54"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 715 B

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

+61 -24
View File
@@ -1,11 +1,14 @@
// 生成 PWA / Apple 图标:微信绿圆角方块(纯前端依赖,无需外部工具)。
// 想换成更精致的图标,直接用设计稿替换 public/icon-*.png 即可
// 生成 PWA / Apple 图标:绿底 macOS 终端风格(白色 `>_` 提示符 + 左上小圆点)。
// 纯 Node 实现,无需外部工具(rsvg/imagemagick),保证本地 / Docker / CI 构建都产出一致图标
// 与 public/favicon.svg 同一视觉;改图标时两边一起改(坐标基于 100×100 视图)。
import { deflateSync } from 'node:zlib';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const COLOR = [7, 193, 96]; // #07C160
const TOP = [0x13, 0xd8, 0x73]; // 顶部亮绿 #13D873
const BOT = [0x05, 0xa8, 0x52]; // 底部深绿 #05A852
const WHITE = [255, 255, 255];
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
const CRC_TABLE = (() => {
@@ -33,34 +36,68 @@ function chunk(type, data) {
return Buffer.concat([len, typeBuf, data, crc]);
}
// 点到线段距离(用于画带圆角端点的笔画)
function distToSeg(px, py, ax, ay, bx, by) {
const dx = bx - ax;
const dy = by - ay;
const len2 = dx * dx + dy * dy || 1;
let t = ((px - ax) * dx + (py - ay) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const cx = ax + t * dx;
const cy = ay + t * dy;
return Math.hypot(px - cx, py - cy);
}
const clamp01 = (v) => Math.max(0, Math.min(1, v));
function makePng(size) {
const radius = Math.round(size * 0.22);
const s = size / 100; // 视图 100 → 像素
const radius = 23 * s; // 圆角,与 favicon 一致
const stroke = (7.5 / 2) * s; // 笔画半宽
// 提示符坐标(基于 100 视图)
const chevron = [
[28, 24],
[42, 38],
[28, 52],
].map(([x, y]) => [x * s, y * s]);
const underline = [
[50, 54],
[66, 54],
].map(([x, y]) => [x * s, y * s]);
const inRounded = (x, y) => {
const r = radius;
const cx = x < r ? r : x > size - r ? size - r : x;
const cy = y < r ? r : y > size - r ? size - r : y;
if (cx === x && cy === y) return 1;
const d = Math.hypot(x - cx, y - cy);
return clamp01(r - d + 0.5); // 边缘 1px 抗锯齿
};
const rowLen = size * 4 + 1;
const raw = Buffer.alloc(rowLen * size);
const inRounded = (x, y) => {
// 圆角:四角之外的像素透明
const corners = [
[radius, radius],
[size - radius, radius],
[radius, size - radius],
[size - radius, size - radius],
];
if ((x < radius || x >= size - radius) && (y < radius || y >= size - radius)) {
const cx = x < radius ? corners[0][0] : corners[1][0];
const cy = y < radius ? corners[0][1] : corners[2][1];
return (x - cx) ** 2 + (y - cy) ** 2 <= radius ** 2;
}
return true;
};
for (let y = 0; y < size; y++) {
raw[y * rowLen] = 0; // filter type 0
const gy = y + 0.5;
const mix = gy / size; // 竖直渐变系数
const bg = [
Math.round(TOP[0] + (BOT[0] - TOP[0]) * mix),
Math.round(TOP[1] + (BOT[1] - TOP[1]) * mix),
Math.round(TOP[2] + (BOT[2] - TOP[2]) * mix),
];
for (let x = 0; x < size; x++) {
const gx = x + 0.5;
// 白色提示符覆盖度(取各形状最大值)
let cov = 0;
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, chevron[0][0], chevron[0][1], chevron[1][0], chevron[1][1]) + 0.5));
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, chevron[1][0], chevron[1][1], chevron[2][0], chevron[2][1]) + 0.5));
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, underline[0][0], underline[0][1], underline[1][0], underline[1][1]) + 0.5));
const o = y * rowLen + 1 + x * 4;
const on = inRounded(x, y);
raw[o] = COLOR[0];
raw[o + 1] = COLOR[1];
raw[o + 2] = COLOR[2];
raw[o + 3] = on ? 255 : 0;
raw[o] = Math.round(bg[0] + (WHITE[0] - bg[0]) * cov);
raw[o + 1] = Math.round(bg[1] + (WHITE[1] - bg[1]) * cov);
raw[o + 2] = Math.round(bg[2] + (WHITE[2] - bg[2]) * cov);
raw[o + 3] = Math.round(255 * inRounded(gx, gy));
}
}
const ihdr = Buffer.alloc(13);
+10 -27
View File
@@ -1,9 +1,8 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { AuthProvider, useAuth } from './auth';
import { UIProvider } from './ui';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Admin from './pages/Admin';
import Desktop from './pages/Desktop';
import AppShell from './AppShell';
import type { ReactNode } from 'react';
function Splash() {
@@ -14,11 +13,10 @@ function Splash() {
);
}
function RequireAuth({ children, admin }: { children: ReactNode; admin?: boolean }) {
function RequireAuth({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
if (loading) return <Splash />;
if (!user) return <Navigate to="/login" replace />;
if (admin && user.role !== 'admin') return <Navigate to="/" replace />;
return <>{children}</>;
}
@@ -27,38 +25,23 @@ function Shell() {
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
path="/*"
element={
<RequireAuth>
<Dashboard />
<AppShell />
</RequireAuth>
}
/>
<Route
path="/admin"
element={
<RequireAuth admin>
<Admin />
</RequireAuth>
}
/>
<Route
path="/desktop/:id"
element={
<RequireAuth>
<Desktop />
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default function App() {
return (
<AuthProvider>
<Shell />
</AuthProvider>
<UIProvider>
<AuthProvider>
<Shell />
</AuthProvider>
</UIProvider>
);
}
+343
View File
@@ -0,0 +1,343 @@
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './auth';
import { useUI, PasswordInput } from './ui';
import { api, type InstanceWithStatus } from './api';
import InstanceView from './pages/Desktop';
import Admin from './pages/Admin';
const BUSY = ['downloading', 'extracting', 'installing'];
// ---- 实例数据:侧栏 / 主页 / 实例视图共享,安装中轮询 ----
interface InstancesState {
instances: InstanceWithStatus[];
loaded: boolean;
reload: () => Promise<void>;
}
const InstancesCtx = createContext<InstancesState>({ instances: [], loaded: false, reload: async () => {} });
export const useInstances = () => useContext(InstancesCtx);
function useInstancesLoader(): InstancesState {
const [instances, setInstances] = useState<InstanceWithStatus[]>([]);
const [loaded, setLoaded] = useState(false);
const timer = useRef<number | undefined>(undefined);
const reload = async () => {
try {
const { instances } = await api.listInstances();
setInstances(instances);
} catch {
/* 401 会被 api 层重定向到登录 */
} finally {
setLoaded(true);
}
};
useEffect(() => {
reload();
return () => window.clearTimeout(timer.current);
}, []);
useEffect(() => {
window.clearTimeout(timer.current);
if (instances.some((i) => BUSY.includes(i.wechat.phase))) timer.current = window.setTimeout(reload, 1500);
return () => window.clearTimeout(timer.current);
}, [instances]);
return { instances, loaded, reload };
}
// 实例状态点(颜色 + 文案)
export function statusOf(inst: InstanceWithStatus): { cls: string; text: string } {
const offline = inst.runtime !== 'running';
if (offline) return { cls: 'st-off', text: inst.runtime === 'missing' ? '未创建' : '已停止' };
if (BUSY.includes(inst.wechat.phase)) return { cls: 'st-busy', text: '处理中' };
if (inst.wechat.installed) return { cls: 'st-on', text: '在线' };
return { cls: 'st-warn', text: '待安装' };
}
// ---- 图标 ----
const Icon = {
home: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 10.5 12 3l9 7.5" /><path d="M5 9.5V20h14V9.5" /><path d="M9.5 20v-6h5v6" />
</svg>
),
gear: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3.2" /><path d="M19.4 13a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-2.7-1.1l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0-1.1-2.7H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.1-2.7l-.1-.1A2 2 0 1 1 6.9 4.5l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z" />
</svg>
),
logout: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="M16 17l5-5-5-5" /><path d="M21 12H9" />
</svg>
),
collapse: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="16" rx="2.5" /><path d="M9 4v16" />
</svg>
),
menu: (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
),
};
export default function AppShell() {
const state = useInstancesLoader();
const { refresh } = useAuth();
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('woc_sb_collapsed') === '1');
const [drawer, setDrawer] = useState(false);
const [showPw, setShowPw] = useState(false);
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 768px)').matches);
const loc = useLocation();
useEffect(() => {
const m = window.matchMedia('(min-width: 768px)');
const h = () => setIsDesktop(m.matches);
m.addEventListener('change', h);
return () => m.removeEventListener('change', h);
}, []);
useEffect(() => setDrawer(false), [loc.pathname]); // 路由变化关抽屉
// 路由切换时刷新共享实例列表:管理页用的是独立列表,新建/安装实例后不会动到这个共享 context,
// 否则进入实例页 / 回主页都读到陈旧列表(实例缺失),需手动刷新整页才出现。导航即拉一次最新即可。
// 不清空旧数据,拉取期间沿用旧列表,无闪烁。
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => void state.reload(), [loc.pathname]);
// 移动端不收成窄栏(改用抽屉);折叠仅桌面生效
const railed = collapsed && isDesktop;
const toggleCollapsed = () =>
setCollapsed((c) => {
localStorage.setItem('woc_sb_collapsed', c ? '0' : '1');
return !c;
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') {
e.preventDefault();
toggleCollapsed();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const openMenu = () => setDrawer(true);
const openChangePassword = () => setShowPw(true);
return (
<InstancesCtx.Provider value={state}>
<div className={'shell' + (railed ? ' collapsed' : '') + (drawer ? ' drawer-open' : '')}>
<Sidebar collapsed={railed} onToggleCollapsed={toggleCollapsed} />
<div className="shell-backdrop" onClick={() => setDrawer(false)} />
<main className="workspace">
<Routes>
<Route path="/" element={<HomeView onOpenMenu={openMenu} onChangePassword={openChangePassword} />} />
<Route path="/admin" element={<Admin onOpenMenu={openMenu} onChangePassword={openChangePassword} />} />
<Route path="/i/:id" element={<InstanceView onOpenMenu={openMenu} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
{showPw && <ChangePassword onClose={() => setShowPw(false)} onSaved={() => refresh()} />}
</InstancesCtx.Provider>
);
}
function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggleCollapsed: () => void }) {
const { user, logout } = useAuth();
const { confirm } = useUI();
const { instances } = useInstances();
const nav = useNavigate();
const loc = useLocation();
const isAdmin = user?.role === 'admin';
const go = (p: string) => nav(p);
return (
<aside className="sidebar">
<div className="sb-top">
<div className="sb-brand">
<img src="/favicon.svg" className="sb-logo" alt="" />
{!collapsed && <span className="sb-name"></span>}
</div>
<button className="sb-collapse" title="折叠侧栏 (⌘B)" onClick={onToggleCollapsed}>
{Icon.collapse}
</button>
</div>
<nav className="sb-nav">
<button className={'sb-item' + (loc.pathname === '/' ? ' on' : '')} onClick={() => go('/')} title="主页">
<span className="sb-ic">{Icon.home}</span>
{!collapsed && <span className="sb-label"></span>}
</button>
</nav>
{!collapsed && <div className="sb-section"></div>}
<div className="sb-list">
{instances.length === 0 && !collapsed && <div className="sb-empty"></div>}
{instances.map((inst) => {
const on = loc.pathname === `/i/${inst.id}`;
const st = statusOf(inst);
return (
<button key={inst.id} className={'sb-item sb-inst' + (on ? ' on' : '')} onClick={() => go(`/i/${inst.id}`)} title={inst.name}>
<span className="sb-avatar">
{inst.name.slice(0, 1)}
<span className={'sb-dot ' + st.cls} />
</span>
{!collapsed && <span className="sb-label">{inst.name}</span>}
{!collapsed && <span className="sb-stxt">{st.text}</span>}
</button>
);
})}
</div>
<div className="sb-footer">
<button className={'sb-item' + (loc.pathname === '/admin' ? ' on' : '')} onClick={() => go('/admin')} title={isAdmin ? '管理' : '设置'}>
<span className="sb-ic">{Icon.gear}</span>
{!collapsed && <span className="sb-label">{isAdmin ? '管理' : '设置'}</span>}
</button>
<button
className="sb-item"
title="退出"
onClick={async () => {
if (await confirm({ title: '退出登录?', confirmText: '退出' })) logout();
}}
>
<span className="sb-ic">{Icon.logout}</span>
{!collapsed && <span className="sb-label">退</span>}
</button>
{!collapsed && (
<div className="sb-user">
{user?.username}
{isAdmin && ' · 管理员'}
</div>
)}
</div>
</aside>
);
}
function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
const { user } = useAuth();
const { instances, loaded } = useInstances();
const nav = useNavigate();
const isAdmin = user?.role === 'admin';
return (
<div className="ws-page">
<header className="ws-head">
<button className="ws-menu" onClick={onOpenMenu} aria-label="菜单">
{Icon.menu}
</button>
<span className="ws-title"></span>
</header>
<div className="content">
<div className="hello">
<b>{user?.username}</b>
{isAdmin && <span className="tag"></span>}
</div>
{user?.mustChangePassword && (
<button className="warn-banner" onClick={onChangePassword}>
<span className="warn-icon">!</span>
<span className="warn-text">
<b>使</b>
<span> </span>
</span>
</button>
)}
<div className="section-row">
<span className="section-title"></span>
{isAdmin && (
<button className="btn-text" onClick={() => nav('/admin')}>
</button>
)}
</div>
{loaded && instances.length === 0 ? (
<div className="empty-state">
<div className="empty-blob">
<img src="/favicon.svg" alt="" />
</div>
<div className="empty-title"></div>
<div className="empty-sub">{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}</div>
</div>
) : (
<div className="inst-grid">
{instances.map((inst) => {
const st = statusOf(inst);
return (
<button key={inst.id} className="home-card" onClick={() => nav(`/i/${inst.id}`)}>
<span className="home-card-av">{inst.name.slice(0, 1)}</span>
<span className="home-card-main">
<span className="home-card-name">{inst.name}</span>
<span className={'home-card-st ' + st.cls}> {st.text}</span>
</span>
<span className="enter-arrow"></span>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
export function ChangePassword({ onClose, onSaved }: { onClose: () => void; onSaved?: () => void }) {
const [oldPassword, setOld] = useState('');
const [newPassword, setNew] = useState('');
const [confirm, setConfirm] = useState('');
const [msg, setMsg] = useState('');
const [busy, setBusy] = useState(false);
const mismatch = confirm.length > 0 && newPassword !== confirm;
const canSubmit = !busy && !!oldPassword && newPassword.length >= 6 && newPassword === confirm;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setMsg('');
if (newPassword !== confirm) {
setMsg('两次输入的新密码不一致');
return;
}
setBusy(true);
try {
await api.changePassword(oldPassword, newPassword);
setMsg('修改成功');
onSaved?.();
setTimeout(onClose, 800);
} catch (e: any) {
setMsg(e.message || '修改失败');
} finally {
setBusy(false);
}
};
return (
<div className="modal-mask" onClick={onClose}>
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2></h2>
<PasswordInput placeholder="原密码" autoComplete="current-password" value={oldPassword} onChange={setOld} />
<PasswordInput placeholder="新密码(至少 6 位)" autoComplete="new-password" value={newPassword} onChange={setNew} />
<PasswordInput placeholder="再次输入新密码" autoComplete="new-password" value={confirm} onChange={setConfirm} />
{mismatch && <div className="error"></div>}
{msg && <div className={msg === '修改成功' ? 'ok' : 'error'}>{msg}</div>}
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
</button>
<button className="btn btn-primary" disabled={!canSubmit}>
</button>
</div>
</form>
</div>
);
}
+37 -1
View File
@@ -5,6 +5,7 @@ export interface PanelUser {
disabled: boolean;
createdAt: string;
allowedInstances: string[]; // admin 为空数组(隐式全部)
mustChangePassword?: boolean; // 仍在用默认密码时为 true
}
export type WechatPhase = 'idle' | 'downloading' | 'extracting' | 'installing' | 'done' | 'error';
@@ -38,7 +39,14 @@ async function req<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
headers,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as any).error || `请求失败 (${res.status})`);
if (!res.ok) {
// 会话过期:除登录/探测接口外,任意接口收到 401 都说明 cookie 失效,直接回登录页(避免页面卡在错误态)
const isAuthProbe = path.includes('/api/auth/login') || path.includes('/api/auth/me');
if (res.status === 401 && !isAuthProbe && location.pathname !== '/login') {
location.assign('/login');
}
throw new Error((data as any).error || `请求失败 (${res.status})`);
}
return data as T;
}
@@ -72,6 +80,8 @@ export const api = {
method: 'POST',
body: JSON.stringify({ name, allowedUserIds }),
}),
renameInstance: (id: string, name: string) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }),
deleteInstance: (id: string, purge = false) =>
req(`/api/admin/instances/${id}${purge ? '?purge=1' : ''}`, { method: 'DELETE' }),
setInstanceUsers: (id: string, userIds: string[]) =>
@@ -79,4 +89,30 @@ export const api = {
instanceWechatStatus: (id: string) => req<{ status: WechatStatus }>(`/api/instances/${id}/wechat/status`),
instanceWechatInstall: (id: string) => req(`/api/admin/instances/${id}/wechat/install`, { method: 'POST' }),
instanceWechatUpdate: (id: string) => req(`/api/admin/instances/${id}/wechat/update`, { method: 'POST' }),
instanceStart: (id: string) => req(`/api/admin/instances/${id}/start`, { method: 'POST' }),
instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }),
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`,
// 文件中转
listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`),
uploadFile: async (id: string, file: File) => {
const res = await fetch(`/api/instances/${id}/upload?name=${encodeURIComponent(file.name)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' },
body: file,
});
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as any).error || '上传失败');
return res.json();
},
downloadFileUrl: (id: string, name: string) => `/api/instances/${id}/download?name=${encodeURIComponent(name)}`,
deleteFile: (id: string, name: string) => req(`/api/instances/${id}/files?name=${encodeURIComponent(name)}`, { method: 'DELETE' }),
// 多端协作:操作控制权
controlStatus: (id: string) => req<{ free: boolean; mine: boolean; holder: string | null }>(`/api/instances/${id}/control`),
controlBeat: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/beat`, { method: 'POST' }),
controlTake: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/take`, { method: 'POST' }),
typeInInstance: (id: string, text: string) => req(`/api/instances/${id}/type`, { method: 'POST', body: JSON.stringify({ text }) }),
};
+470 -98
View File
@@ -1,9 +1,22 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, type PanelUser, type InstanceWithStatus } from '../api';
import { useUI, PasswordInput } from '../ui';
import { useAuth } from '../auth';
export default function Admin() {
const BUSY_PHASES = ['downloading', 'extracting', 'installing'];
const MenuIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
const nav = useNavigate();
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const { toast, confirm } = useUI();
const [users, setUsers] = useState<PanelUser[]>([]);
const [instances, setInstances] = useState<InstanceWithStatus[]>([]);
const [err, setErr] = useState('');
@@ -11,10 +24,23 @@ export default function Admin() {
const [creatingInst, setCreatingInst] = useState(false);
const [assignInst, setAssignInst] = useState<InstanceWithStatus | null>(null); // 给实例选账户
const [assignUser, setAssignUser] = useState<PanelUser | null>(null); // 给账户选实例
const [resetTarget, setResetTarget] = useState<PanelUser | null>(null); // 重置密码弹窗
const [deleteInst, setDeleteInst] = useState<InstanceWithStatus | null>(null); // 删除实例弹窗
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
const setAct = (id: string, label: string | null) =>
setActing((a) => {
const n = { ...a };
if (label) n[id] = label;
else delete n[id];
return n;
});
const subs = users.filter((u) => u.role !== 'admin');
const timer = useRef<number | undefined>(undefined);
const load = async () => {
if (!isAdmin) return; // 子账号无管理数据权限,管理页只给改密
try {
const [{ users }, { instances }] = await Promise.all([api.listUsers(), api.listInstances()]);
setUsers(users);
@@ -26,126 +52,196 @@ export default function Admin() {
useEffect(() => {
load();
return () => window.clearTimeout(timer.current);
}, []);
// 安装/更新进行中时轮询进度
useEffect(() => {
window.clearTimeout(timer.current);
if (instances.some((i) => BUSY_PHASES.includes(i.wechat.phase))) timer.current = window.setTimeout(load, 1500);
return () => window.clearTimeout(timer.current);
}, [instances]);
const trigger = async (inst: InstanceWithStatus, kind: 'install' | 'update') => {
try {
await (kind === 'install' ? api.instanceWechatInstall(inst.id) : api.instanceWechatUpdate(inst.id));
setInstances((list) =>
list.map((i) =>
i.id === inst.id ? { ...i, wechat: { ...i.wechat, phase: 'downloading', percent: -1, message: '正在准备…' } } : i,
),
);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(load, 1000);
toast(kind === 'install' ? '已开始下载微信' : '已开始更新', 'ok');
} catch (e: any) {
toast(e.message || '操作失败', 'error');
}
};
const start = async (inst: InstanceWithStatus) => {
setAct(inst.id, '启动中…');
try {
await api.instanceStart(inst.id);
toast('实例已启动', 'ok');
await load();
} catch (e: any) {
toast(e.message || '启动失败', 'error');
} finally {
setAct(inst.id, null);
}
};
const lifecycle = async (inst: InstanceWithStatus, kind: 'stop' | 'restart' | 'upgrade') => {
const label = kind === 'stop' ? '停止中…' : kind === 'upgrade' ? '升级中…' : '重启中…';
setAct(inst.id, label);
if (kind === 'upgrade') toast('正在升级实例:拉取最新镜像并重建,可能需要几分钟,请勿离开…', 'info');
try {
await (kind === 'stop' ? api.instanceStop(inst.id) : kind === 'upgrade' ? api.instanceUpgrade(inst.id) : api.instanceRestart(inst.id));
toast(kind === 'stop' ? '已停止' : kind === 'upgrade' ? '已升级到最新镜像并重启' : '已重启', 'ok');
await load();
} catch (e: any) {
toast(e.message || '操作失败', 'error');
} finally {
setAct(inst.id, null);
}
};
const instName = (id: string) => instances.find((i) => i.id === id)?.name || id;
const usersForInstance = (id: string) => subs.filter((u) => u.allowedInstances.includes(id));
const toggle = async (u: PanelUser) => {
await api.setDisabled(u.id, !u.disabled).catch((e) => alert(e.message));
load();
};
const reset = async (u: PanelUser) => {
const pw = prompt(`${u.username} 设置新密码(至少 6 位)`);
if (!pw) return;
try {
await api.resetUser(u.id, pw);
alert('已重置');
await api.setDisabled(u.id, !u.disabled);
toast(u.disabled ? '已启用' : '已禁用', 'ok');
} catch (e: any) {
alert(e.message);
toast(e.message, 'error');
}
load();
};
const removeUser = async (u: PanelUser) => {
if (!confirm(`确定删除子账号 ${u.username}`)) return;
await api.deleteUser(u.id).catch((e) => alert(e.message));
load();
};
const removeInst = async (inst: InstanceWithStatus) => {
if (!confirm(`删除实例「${inst.name}」?容器会被移除,但聊天记录(数据卷)会保留。`)) return;
let purge = false;
if (confirm('是否同时永久删除该实例的聊天记录(数据卷)?此操作不可恢复。\n\n确定=连数据一起删,取消=仅删容器保留数据')) {
purge = true;
const ok = await confirm({ title: `删除子账号${u.username}`, body: '该账户将无法再登录。', danger: true, confirmText: '删除' });
if (!ok) return;
try {
await api.deleteUser(u.id);
toast('已删除', 'ok');
} catch (e: any) {
toast(e.message, 'error');
}
await api.deleteInstance(inst.id, purge).catch((e) => alert(e.message));
load();
};
return (
<div className="page">
<header className="topbar">
<button className="btn-text" onClick={() => nav('/')}>
<div className="ws-page">
<header className="ws-head">
<button className="ws-menu" onClick={onOpenMenu} aria-label="菜单">
{MenuIcon}
</button>
<span className="topbar-title"></span>
<span style={{ width: 48 }} />
<span className="ws-title">{isAdmin ? '管理' : '设置'}</span>
</header>
<main className="content">
{err && <div className="error">{err}</div>}
<div className="section-row">
<span className="section-title"></span>
<button className="btn-text" onClick={() => setCreatingInst(true)}>
+
</button>
</div>
<div className="list">
{instances.length === 0 && <div className="muted small" style={{ padding: '14px 16px' }}></div>}
{instances.map((inst) => (
<div key={inst.id} className="user-row">
<div className="user-main">
<span className="user-name">{inst.name}</span>
<span className="muted small">访 {usersForInstance(inst.id).length} </span>
</div>
<div className="user-actions">
<button className="btn-text" onClick={() => setAssignInst(inst)}>
</button>
<button className="btn-text danger" onClick={() => removeInst(inst)}>
</button>
</div>
{isAdmin && (
<>
<div className="section-row">
<span className="section-title"></span>
<button className="btn-text" onClick={() => setCreatingInst(true)}>
+
</button>
</div>
))}
</div>
{instances.length === 0 ? (
<div className="list">
<div className="muted small" style={{ padding: '14px 16px' }}></div>
</div>
) : (
<div className="inst-grid">
{instances.map((inst) => (
<InstanceAdminCard
key={inst.id}
inst={inst}
userCount={usersForInstance(inst.id).length}
acting={acting[inst.id]}
onEnter={() => nav(`/i/${inst.id}`)}
onTrigger={trigger}
onStart={() => start(inst)}
onStop={() => lifecycle(inst, 'stop')}
onRestart={() => lifecycle(inst, 'restart')}
onUpgrade={() => lifecycle(inst, 'upgrade')}
onRename={() => setRenameInst(inst)}
onAssign={() => setAssignInst(inst)}
onDelete={() => setDeleteInst(inst)}
/>
))}
</div>
)}
<div className="section-row" style={{ marginTop: 22 }}>
<span className="section-title"></span>
<button className="btn-text" onClick={() => setCreatingUser(true)}>
+
</button>
</div>
<div className="list">
{users.map((u) => (
<div key={u.id} className="user-row">
<div className="user-main">
<span className="user-name">
{u.username}
{u.role === 'admin' && <span className="tag"></span>}
{u.disabled && <span className="tag tag-off"></span>}
</span>
{u.role === 'admin' ? (
<span className="muted small">访</span>
) : u.allowedInstances.length > 0 ? (
<span className="chip-row">
{u.allowedInstances.map((id) => (
<span key={id} className="chip chip-static">
{instName(id)}
</span>
))}
</span>
) : (
<span className="muted small"></span>
)}
</div>
{u.role !== 'admin' && (
<div className="user-actions">
<button className="btn-text" onClick={() => setAssignUser(u)}>
访
</button>
<button className="btn-text" onClick={() => toggle(u)}>
{u.disabled ? '启用' : '禁用'}
</button>
<button className="btn-text" onClick={() => reset(u)}>
</button>
<button className="btn-text danger" onClick={() => removeUser(u)}>
</button>
</div>
)}
<div className="section-row" style={{ marginTop: 22 }}>
<span className="section-title"></span>
<button className="btn-text" onClick={() => setCreatingUser(true)}>
+
</button>
</div>
))}
{subs.length === 0 ? (
<div className="list">
<div className="muted small" style={{ padding: '14px 16px' }}></div>
</div>
) : (
<div className="inst-grid">
{subs.map((u) => (
<div key={u.id} className="inst-card">
<div className="inst-head">
<span className="inst-name">{u.username}</span>
{u.disabled ? <span className="tag tag-off"></span> : <span className="tag tag-on"></span>}
</div>
<div className="inst-sub">{u.allowedInstances.length > 0 ? `可访问 ${u.allowedInstances.length} 个实例` : '未分配实例'}</div>
{u.allowedInstances.length > 0 && (
<div className="chip-row" style={{ marginTop: 8 }}>
{u.allowedInstances.map((id) => (
<span key={id} className="chip chip-static">
{instName(id)}
</span>
))}
</div>
)}
<div className="inst-admin-links">
<button className="btn-text" onClick={() => setAssignUser(u)}>
访
</button>
<button className="btn-text" onClick={() => toggle(u)}>
{u.disabled ? '启用' : '禁用'}
</button>
<button className="btn-text" onClick={() => setResetTarget(u)}>
</button>
<button className="btn-text danger" onClick={() => removeUser(u)}>
</button>
</div>
</div>
))}
</div>
)}
</>
)}
{/* 账号:所有人(含子账号)都能在此改密 */}
<div className="section-row" style={{ marginTop: isAdmin ? 22 : 0 }}>
<span className="section-title"></span>
</div>
<div className="inst-grid">
<div className="inst-card">
<div className="inst-head">
<span className="inst-name">{user?.username}</span>
{isAdmin ? <span className="tag"></span> : <span className="tag tag-on"></span>}
</div>
<div className="inst-sub">{isAdmin ? '可访问全部实例' : `可访问 ${user?.allowedInstances.length ?? 0} 个实例`}</div>
<div className="inst-actions">
<button className="btn btn-primary inst-act-wide" onClick={onChangePassword}>
</button>
</div>
</div>
</div>
</main>
@@ -191,6 +287,282 @@ export default function Admin() {
}}
/>
)}
{resetTarget && (
<ResetPassword
user={resetTarget}
onClose={() => setResetTarget(null)}
onDone={() => {
setResetTarget(null);
toast('密码已重置', 'ok');
}}
/>
)}
{deleteInst && (
<DeleteInstance
inst={deleteInst}
onClose={() => setDeleteInst(null)}
onDone={() => {
setDeleteInst(null);
toast('实例已删除', 'ok');
load();
}}
/>
)}
{renameInst && (
<RenameInstance
inst={renameInst}
onClose={() => setRenameInst(null)}
onDone={() => {
setRenameInst(null);
toast('已重命名', 'ok');
load();
}}
/>
)}
</div>
);
}
function RenameInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
const [name, setName] = useState(inst.name);
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
setBusy(true);
try {
await api.renameInstance(inst.id, name.trim());
onDone();
} catch (e: any) {
setErr(e.message || '重命名失败');
} finally {
setBusy(false);
}
};
return (
<div className="modal-mask" onClick={onClose}>
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2></h2>
<input className="input" placeholder="实例名称" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
{err && <div className="error">{err}</div>}
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
</button>
<button className="btn btn-primary" disabled={busy || !name.trim() || name.trim() === inst.name}>
</button>
</div>
</form>
</div>
);
}
function ResetPassword({ user, onClose, onDone }: { user: PanelUser; onClose: () => void; onDone: () => void }) {
const [pw, setPw] = useState('');
const [confirm, setConfirm] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const mismatch = confirm.length > 0 && pw !== confirm;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
if (pw !== confirm) {
setErr('两次输入的新密码不一致');
return;
}
setBusy(true);
try {
await api.resetUser(user.id, pw);
onDone();
} catch (e: any) {
setErr(e.message || '重置失败');
} finally {
setBusy(false);
}
};
return (
<div className="modal-mask" onClick={onClose}>
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2>{user.username}</h2>
<PasswordInput placeholder="新密码(至少 6 位)" autoComplete="new-password" value={pw} onChange={setPw} />
<PasswordInput placeholder="再次输入新密码" autoComplete="new-password" value={confirm} onChange={setConfirm} />
{(mismatch || err) && <div className="error">{mismatch ? '两次输入的新密码不一致' : err}</div>}
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
</button>
<button className="btn btn-primary" disabled={busy || pw.length < 6 || pw !== confirm}>
</button>
</div>
</form>
</div>
);
}
function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
const [purge, setPurge] = useState(false);
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const submit = async () => {
setErr('');
setBusy(true);
try {
await api.deleteInstance(inst.id, purge);
onDone();
} catch (e: any) {
setErr(e.message || '删除失败');
setBusy(false);
}
};
return (
<div className="modal-mask" onClick={onClose}>
<div className="card modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 360 }}>
<h2>{inst.name}</h2>
<div className="muted" style={{ fontSize: 14, lineHeight: 1.5 }}>
</div>
<label className={'purge-opt' + (purge ? ' on' : '')} onClick={() => setPurge((v) => !v)}>
<span className="purge-check">{purge ? '✓' : ''}</span>
<span>
<span className="muted small" style={{ display: 'block' }}></span>
</span>
</label>
{err && <div className="error">{err}</div>}
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
</button>
<button type="button" className="btn btn-danger" disabled={busy} onClick={submit}>
{purge ? '连数据一起删除' : '删除实例'}
</button>
</div>
</div>
</div>
);
}
// 管理页的实例卡片:含微信版本管理(下载/更新)+ 重命名/分配/删除
function InstanceAdminCard({
inst,
userCount,
acting,
onEnter,
onTrigger,
onStart,
onStop,
onRestart,
onUpgrade,
onRename,
onAssign,
onDelete,
}: {
inst: InstanceWithStatus;
userCount: number;
acting?: string;
onEnter: () => void;
onTrigger: (inst: InstanceWithStatus, kind: 'install' | 'update') => void;
onStart: () => void;
onStop: () => void;
onRestart: () => void;
onUpgrade: () => void;
onRename: () => void;
onAssign: () => void;
onDelete: () => void;
}) {
const wx = inst.wechat;
const busy = BUSY_PHASES.includes(wx.phase);
const installed = wx.installed && wx.phase !== 'downloading';
const offline = inst.runtime !== 'running';
const working = !!acting || busy; // 生命周期操作中 或 微信下载/更新中 → 锁住卡片
let badge: { text: string; cls: string };
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
else if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
else if (busy) badge = { text: '处理中', cls: 'tag-busy' };
else if (installed) badge = { text: '在线', cls: 'tag-on' };
else badge = { text: '待安装', cls: 'tag-warn' };
let sub: string;
if (acting) sub = acting;
else if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试';
else if (offline) sub = inst.runtime === 'missing' ? '容器尚未创建' : '容器已停止';
else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装';
else sub = '微信尚未安装';
return (
<div className="inst-card">
<div className="inst-head">
<span className="inst-name">{inst.name}</span>
<span className={'tag ' + badge.cls}>{badge.text}</span>
</div>
<div className="inst-sub">
{sub}
{!acting && ` · 可访问 ${userCount}`}
</div>
{working && (
<div className="wx-progress">
<div
className={'wx-progress-bar' + (acting || wx.percent < 0 ? ' indeterminate' : '')}
style={!acting && wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
/>
</div>
)}
{/* 进行中(升级/重启/停止/下载)时隐藏所有操作,避免重复点击 */}
{!working && (
<>
<div className="inst-actions">
{offline ? (
<button className="btn btn-primary inst-act-wide" onClick={onStart}>
{inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
</button>
) : (
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
</button>
)}
</div>
<div className="inst-admin-links">
{!offline && (
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
{installed ? '更新微信' : '下载安装'}
</button>
)}
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录),把实例更新到新版">
</button>
{!offline && (
<button className="btn-text" onClick={onRestart}>
</button>
)}
{!offline && (
<button className="btn-text" onClick={onStop}>
</button>
)}
<button className="btn-text" onClick={onRename}>
</button>
<button className="btn-text" onClick={onAssign}>
</button>
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志(排错)">
</button>
<button className="btn-text danger" onClick={onDelete}>
</button>
</div>
</>
)}
</div>
);
}
@@ -257,7 +629,7 @@ function CreateUser({ instances, onClose, onDone }: { instances: InstanceWithSta
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input className="input" type="password" placeholder="初始密码(至少 6 位)" value={password} onChange={(e) => setPassword(e.target.value)} />
<PasswordInput placeholder="初始密码(至少 6 位)" autoComplete="new-password" value={password} onChange={setPassword} />
<div className="field-label">访</div>
<ChipMultiSelect
options={instances.map((i) => ({ id: i.id, label: i.name }))}
+2 -221
View File
@@ -1,221 +1,2 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth';
import { api, type InstanceWithStatus } from '../api';
const BUSY_PHASES = ['downloading', 'extracting', 'installing'];
export default function Dashboard() {
const { user, logout } = useAuth();
const nav = useNavigate();
const [showPw, setShowPw] = useState(false);
const [instances, setInstances] = useState<InstanceWithStatus[] | null>(null);
const [err, setErr] = useState('');
const timer = useRef<number | undefined>(undefined);
const isAdmin = user?.role === 'admin';
const load = async () => {
try {
const { instances } = await api.listInstances();
setInstances(instances);
} catch (e: any) {
setErr(e.message || '加载失败');
}
};
useEffect(() => {
load();
return () => window.clearTimeout(timer.current);
}, []);
// 任一实例安装/更新进行中时轮询
useEffect(() => {
window.clearTimeout(timer.current);
const busy = instances?.some((i) => BUSY_PHASES.includes(i.wechat.phase));
if (busy) timer.current = window.setTimeout(load, 1500);
return () => window.clearTimeout(timer.current);
}, [instances]);
const trigger = async (inst: InstanceWithStatus, kind: 'install' | 'update') => {
setErr('');
try {
await (kind === 'install' ? api.instanceWechatInstall(inst.id) : api.instanceWechatUpdate(inst.id));
setInstances(
(list) =>
list?.map((i) =>
i.id === inst.id ? { ...i, wechat: { ...i.wechat, phase: 'downloading', percent: -1, message: '正在准备…' } } : i,
) ?? list,
);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(load, 1000);
} catch (e: any) {
setErr(e.message || '操作失败');
}
};
return (
<div className="page">
<header className="topbar">
<span className="topbar-title"></span>
<button className="btn-text" onClick={() => logout()}>
退
</button>
</header>
<main className="content">
<div className="hello">
<b>{user?.username}</b>
{isAdmin && <span className="tag"></span>}
</div>
{err && <div className="error">{err}</div>}
<div className="section-row">
<span className="section-title"></span>
{isAdmin && (
<button className="btn-text" onClick={() => nav('/admin')}>
</button>
)}
</div>
{instances && instances.length === 0 && (
<div className="empty-state">
<div className="empty-blob">📱</div>
<div className="empty-title"></div>
<div className="empty-sub">{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}</div>
</div>
)}
<div className="inst-grid">
{instances?.map((inst) => (
<InstanceCard key={inst.id} inst={inst} isAdmin={isAdmin} onEnter={() => nav(`/desktop/${inst.id}`)} onTrigger={trigger} />
))}
</div>
<div className="list">
<button className="list-item" onClick={() => setShowPw(true)}>
<span></span>
<span className="enter-arrow"></span>
</button>
{isAdmin && (
<button className="list-item" onClick={() => nav('/admin')}>
<span></span>
<span className="enter-arrow"></span>
</button>
)}
</div>
</main>
{showPw && <ChangePassword onClose={() => setShowPw(false)} />}
</div>
);
}
function InstanceCard({
inst,
isAdmin,
onEnter,
onTrigger,
}: {
inst: InstanceWithStatus;
isAdmin?: boolean;
onEnter: () => void;
onTrigger: (inst: InstanceWithStatus, kind: 'install' | 'update') => void;
}) {
const wx = inst.wechat;
const busy = BUSY_PHASES.includes(wx.phase);
const installed = wx.installed && wx.phase !== 'downloading';
const offline = inst.runtime !== 'running';
let badge: { text: string; cls: string };
if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
else if (busy) badge = { text: '处理中', cls: 'tag-busy' };
else if (installed) badge = { text: '在线', cls: 'tag-on' };
else badge = { text: '待安装', cls: 'tag-warn' };
let sub: string;
if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试';
else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装';
else sub = '微信尚未安装';
const canEnter = !offline && installed && !busy;
return (
<div className="inst-card">
<div className="inst-head">
<span className="inst-name">{inst.name}</span>
<span className={'tag ' + badge.cls}>{badge.text}</span>
</div>
<div className="inst-sub">{sub}</div>
{busy && (
<div className="wx-progress">
<div
className={'wx-progress-bar' + (wx.percent < 0 ? ' indeterminate' : '')}
style={wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
/>
</div>
)}
<div className="inst-actions">
<button className="btn btn-primary inst-enter" disabled={!canEnter} onClick={onEnter}>
</button>
{isAdmin && !busy && !offline && (
installed ? (
<button className="btn inst-act" onClick={() => onTrigger(inst, 'update')}>
</button>
) : (
<button className="btn inst-act" onClick={() => onTrigger(inst, 'install')}>
</button>
)
)}
</div>
</div>
);
}
function ChangePassword({ onClose }: { onClose: () => void }) {
const [oldPassword, setOld] = useState('');
const [newPassword, setNew] = useState('');
const [msg, setMsg] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setMsg('');
setBusy(true);
try {
await api.changePassword(oldPassword, newPassword);
setMsg('修改成功');
setTimeout(onClose, 800);
} catch (e: any) {
setMsg(e.message || '修改失败');
} finally {
setBusy(false);
}
};
return (
<div className="modal-mask" onClick={onClose}>
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2></h2>
<input className="input" type="password" placeholder="原密码" value={oldPassword} onChange={(e) => setOld(e.target.value)} />
<input className="input" type="password" placeholder="新密码(至少 6 位)" value={newPassword} onChange={(e) => setNew(e.target.value)} />
{msg && <div className={msg === '修改成功' ? 'ok' : 'error'}>{msg}</div>}
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
</button>
<button className="btn btn-primary" disabled={busy || !oldPassword || !newPassword}>
</button>
</div>
</form>
</div>
);
}
// 已废弃:主页与改密已迁移到 ../AppShell.tsxHomeView / ChangePassword)。
export {};
+658 -10
View File
@@ -1,8 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../api';
import { useUI } from '../ui';
import { useAuth } from '../auth';
import { useInstances } from '../AppShell';
// 直接加载 KasmVNC noVNC 页面(由 kclient 静态托管)
// 反代按实例隔离:所有桌面流量走 /desktop/<id>/*,网关据 <id> 选目标容器并注入该实例凭据。
// path=desktop/<id>/websockify:让 noVNC 把 ws 连到该实例路径,网关剥前缀反代回 KasmVNC 根 /websockify。
// KasmVNC noVNC 页面;反代按实例隔离:/desktop/<id>/* → 对应容器,注入凭据
function desktopUrl(id: string) {
return (
`/desktop/${id}/vnc/index.html?autoconnect=1&path=desktop/${id}/websockify&resize=remote` +
@@ -10,19 +13,664 @@ function desktopUrl(id: string) {
);
}
export default function Desktop() {
const nav = useNavigate();
interface TFile {
name: string;
size: number;
}
function humanSize(n: number) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
const MenuIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) {
const { id } = useParams<{ id: string }>();
const nav = useNavigate();
const { user } = useAuth();
const { toast, confirm } = useUI();
const { instances, loaded, reload } = useInstances();
const isAdmin = user?.role === 'admin';
const [frameLoaded, setFrameLoaded] = useState(false);
const [dragging, setDragging] = useState(false);
const [showFiles, setShowFiles] = useState(false);
const [files, setFiles] = useState<TFile[]>([]);
const [showClip, setShowClip] = useState(false);
const [clipText, setClipText] = useState('');
const [uploading, setUploading] = useState(false);
const [starting, setStarting] = useState(false);
const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null);
const [vncNonce, setVncNonce] = useState(0);
const fileInput = useRef<HTMLInputElement>(null);
const frameRef = useRef<HTMLIFrameElement>(null);
const dragDepth = useRef(0);
const lastBeat = useRef(0);
const lastImeError = useRef(0);
const inst = instances.find((i) => i.id === id);
// 进入实例时,共享列表可能尚未同步(管理页新建/安装后),先按"探测中"显示加载态,
// 等列表刷新到该实例或超时后再判定是否真的不存在,避免从管理页跳转时误报"实例不存在"。
const [probing, setProbing] = useState(true);
const offline = inst ? inst.runtime !== 'running' : false;
const installed = !!inst && inst.wechat.installed && inst.wechat.phase !== 'downloading';
const showVnc = !!inst && !offline && installed;
// 切换实例时重置内嵌态
useEffect(() => {
setFrameLoaded(false);
setShowFiles(false);
setFiles([]);
setShowClip(false);
setClipText('');
setProbing(true);
}, [id]);
// 探测态收敛:找到实例即结束;否则给共享列表一点刷新时间(AppShell 已在导航时拉取),超时仍无则判定不存在。
useEffect(() => {
if (inst) {
setProbing(false);
return;
}
if (!probing) return;
const t = window.setTimeout(() => setProbing(false), 2500);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inst, probing, id]);
// 实例未就绪(启动中 / 安装中 / 上下文状态未刷新)时,每 3s 拉取最新状态:
// 就绪后自动进入桌面,无需手动刷新(修复"安装完进度 100% 仍提示无实例")。
useEffect(() => {
if (showVnc || !id) return;
const t = window.setInterval(() => {
if (!document.hidden) reload();
}, 3000);
return () => window.clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showVnc, id]);
// 文件拖到窗口 → 弹出落区(覆盖 iframe 接住 drop
useEffect(() => {
if (!showVnc) return;
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes('Files');
const onEnter = (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth.current++;
setDragging(true);
};
const onOver = (e: DragEvent) => hasFiles(e) && e.preventDefault();
const onLeave = (e: DragEvent) => {
if (!hasFiles(e)) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragging(false);
};
const onDropWin = (e: DragEvent) => {
if (hasFiles(e)) e.preventDefault();
dragDepth.current = 0;
setDragging(false);
};
window.addEventListener('dragenter', onEnter);
window.addEventListener('dragover', onOver);
window.addEventListener('dragleave', onLeave);
window.addEventListener('drop', onDropWin);
return () => {
window.removeEventListener('dragenter', onEnter);
window.removeEventListener('dragover', onOver);
window.removeEventListener('dragleave', onLeave);
window.removeEventListener('drop', onDropWin);
};
}, [showVnc]);
// 控制权(交互驱动的心跳软锁):每 3s 只读轮询当前操作者;超 TTL 自动释放。
useEffect(() => {
if (!showVnc || !id) {
setControl(null);
return;
}
let alive = true;
const poll = async () => {
if (document.hidden) return;
try {
const r = await api.controlStatus(id);
if (!alive) return;
setControl(r);
if (!r.free && !r.mine) frameRef.current?.blur(); // 只读:移开键盘焦点
} catch {
/* ignore */
}
};
poll();
const t = window.setInterval(poll, 3000);
return () => {
alive = false;
window.clearInterval(t);
};
}, [showVnc, id]);
// 用户在 VNC 内真实操作(鼠标/键盘/滚轮)时续约控制权(同源 iframe 可监听)。节流 2.5s。
// 只读用户的操作已被遮罩拦截/失焦,不会误续约;空闲不操作则超时自动释放。
useEffect(() => {
if (!showVnc || !id || !frameLoaded) return;
const win = frameRef.current?.contentWindow;
if (!win) return;
const onInteract = async () => {
const now = Date.now();
if (now - lastBeat.current < 2500) return;
lastBeat.current = now;
try {
const r = await api.controlBeat(id);
setControl({ free: false, mine: r.mine, holder: r.holder });
} catch {
/* ignore */
}
};
const evs = ['mousedown', 'keydown', 'wheel'] as const;
try {
evs.forEach((e) => win.addEventListener(e, onInteract, { capture: true, passive: true }));
} catch {
return;
}
return () => {
try {
evs.forEach((e) => win.removeEventListener(e, onInteract, { capture: true } as any));
} catch {
/* ignore */
}
};
}, [showVnc, id, frameLoaded]);
if (!id) {
nav('/', { replace: true });
return null;
}
const refreshFiles = async () => {
try {
const { files } = await api.listFiles(id);
setFiles(files);
} catch {
/* ignore */
}
};
const uploadFiles = async (list: FileList | File[]) => {
const arr = Array.from(list);
if (!arr.length) return;
setUploading(true);
let ok = 0;
for (const f of arr) {
try {
await api.uploadFile(id, f);
ok++;
} catch (e: any) {
toast(`${f.name}: ${e.message || '上传失败'}`, 'error');
}
}
setUploading(false);
if (ok) {
toast(`已上传 ${ok} 个文件到桌面,微信里可直接选取`, 'ok');
refreshFiles();
}
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
dragDepth.current = 0;
if (e.dataTransfer.files?.length) uploadFiles(e.dataTransfer.files);
};
const delFile = async (name: string) => {
if (!(await confirm({ title: `删除「${name}」?`, body: '将从微信桌面(~/Desktop)移除该文件。', danger: true, confirmText: '删除' }))) return;
try {
await api.deleteFile(id, name);
toast('已删除', 'ok');
refreshFiles();
} catch (e: any) {
toast(e.message || '删除失败', 'error');
}
};
// 同源 iframe:把键盘焦点交给 VNC,帮助宿主机输入法把合成的字送进去
const focusFrame = () => {
try {
frameRef.current?.focus();
frameRef.current?.contentWindow?.focus();
const ki = frameRef.current?.contentDocument?.getElementById('noVNC_keyboardinput') as HTMLElement | null;
ki?.focus();
} catch {
/* 跨域兜底(正常同源不会到这) */
}
};
// 桌面加载后给 noVNC 原生控制条注入"实心可见"样式:原生背景近纯黑半透明,叠在深色/黑屏上看不见。
// 注入后,用 KasmVNC 自带的左侧边缘手柄拉出控制条(音频/剪贴板/键盘/全屏等)时即可见。iframe 同源可直接访问。
const injectVncStyle = () => {
try {
const doc = frameRef.current?.contentDocument;
if (!doc || doc.getElementById('woc-vnc-style')) return;
const st = doc.createElement('style');
st.id = 'woc-vnc-style';
st.textContent =
'#noVNC_control_bar_anchor{z-index:2147483647!important;}' +
'#noVNC_control_bar{background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.55)!important;box-shadow:0 0 24px rgba(0,0,0,.55)!important;}' +
'#noVNC_control_bar_handle{opacity:1!important;background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.5)!important;}' +
// macOS 中文输入法需要目标元素有非零尺寸才能激活;KasmVNC 默认 0x0 导致无法切换输入法
'#noVNC_keyboardinput{width:1px!important;height:1px!important;opacity:0!important;overflow:hidden!important;}';
(doc.head || doc.documentElement).appendChild(st);
} catch {
/* 同源正常不会到这 */
}
};
// 中文 IME 输入修复:绕过 VNC XKB keysym 容量限制(~21 个 CJK 字符后 keymap 满,输入全废)。
// 根因:KasmVNC 的 Perl 补丁在 compositionend 发 CJK keysym,但紧随其后的 _handleInput
// diff 逻辑会发 Backspace 清拼音,把刚发的字也删了。必须在捕获阶段拦截,阻止 Perl 补丁执行,
// 手动重置内部状态(防止 _handleInput 发 Backspace),然后通过 API 用 xdotool 粘贴文字。
const patchVncIme = () => {
try {
const doc = frameRef.current?.contentDocument;
if (!doc || doc.getElementById('woc-ime-patch')) return;
const ta = doc.getElementById('noVNC_keyboardinput') as HTMLTextAreaElement | null;
if (!ta) return;
const win = frameRef.current?.contentWindow as any;
let imeComposing = false;
let swallowInputUntil = 0;
const keyboard = () => {
const cv = doc.querySelector('canvas') as any;
return win?.UI?.rfb?.keyboard || cv?._rfb?.keyboard || null;
};
const installKeyboardGuard = () => {
const kb = keyboard() as any;
if (!kb || kb._wocImeGuard || typeof kb._sendKeyEvent !== 'function') return;
const original = kb._sendKeyEvent.bind(kb);
kb._wocImeGuard = true;
if (typeof kb._wocImeSuppressUnicode !== 'boolean') kb._wocImeSuppressUnicode = false;
kb._sendKeyEvent = (keysym: number, ...args: any[]) => {
if (kb._wocImeSuppressUnicode && typeof keysym === 'number' && keysym >= 0x01000000) return;
return original(keysym, ...args);
};
};
const syncKeyboardInput = (value: string) => {
try {
installKeyboardGuard();
const kb = keyboard();
if (kb) {
kb._imeInProgress = false;
kb._imeHold = false;
kb._lastKeyboardInput = value;
if (kb._rfbKeyQueue) kb._rfbKeyQueue.length = 0;
}
} catch { /* ignore */ }
};
const swallowNoVncInput = (e: Event) => {
if (!imeComposing && Date.now() > swallowInputUntil) return;
e.stopImmediatePropagation();
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
};
ta.addEventListener('compositionstart', (e) => {
imeComposing = true;
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = true;
e.stopImmediatePropagation();
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
}, true);
ta.addEventListener('beforeinput', swallowNoVncInput, true);
ta.addEventListener('input', swallowNoVncInput, true);
ta.addEventListener('compositionend', (e) => {
const text = (e as CompositionEvent).data;
if (!text || !id) return;
imeComposing = false;
swallowInputUntil = Date.now() + 300;
e.stopImmediatePropagation(); // 阻止 KasmVNC 原生 IME 路径再发一遍 keysym
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = true;
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
window.setTimeout(() => {
ta.value = '';
syncKeyboardInput('');
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = false;
}, 0);
// 通过面板 API → xdotool 在容器内粘贴,完全绕过 VNC keysym
api.typeInInstance(id, text).catch((err) => {
const now = Date.now();
if (now - lastImeError.current > 3000) {
lastImeError.current = now;
toast(err?.message || '中文输入失败,请确认实例镜像包含 xclip/xdotool', 'error');
}
});
}, true); // capture:先于 Perl 补丁的 bubble handler
const mark = doc.createElement('meta');
mark.id = 'woc-ime-patch';
(doc.head || doc.documentElement).appendChild(mark);
} catch {
/* ignore */
}
};
// 跨设备剪贴板(文本):通过同源 iframe 直接喂给 KasmVNC 自带的剪贴板 textarea 并触发其发送逻辑
// (内部走 RFB.clipboardPasteFrom → clientCutText)。不依赖浏览器异步剪贴板 API,故 http/局域网 IP 下也可用,
// 规避了"非安全上下文禁用 navigator.clipboard 导致粘贴失败"的问题。文本会进入容器系统剪贴板,
// 在微信输入框按 Ctrl+V 即可粘贴。
const pushClipboardToRemote = (text: string): boolean => {
try {
const doc = frameRef.current?.contentDocument;
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
if (!doc || !ta) return false;
ta.value = text;
ta.dispatchEvent(new (frameRef.current!.contentWindow as any).Event('change', { bubbles: true }));
return true;
} catch {
return false;
}
};
const sendClip = () => {
const t = clipText;
if (!t) {
toast('请先输入要发送的文本', 'error');
return;
}
if (pushClipboardToRemote(t)) {
toast('已发送到容器剪贴板,请在微信输入框按 Ctrl+V 粘贴', 'ok');
} else {
toast('发送失败:桌面尚未连接', 'error');
}
};
// 读取容器(微信侧)当前剪贴板内容到本框,便于把容器内复制的文字带回本地
const pullClipboardFromRemote = () => {
try {
const doc = frameRef.current?.contentDocument;
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
if (ta) {
setClipText(ta.value || '');
toast('已读取容器剪贴板', 'ok');
} else {
toast('读取失败:桌面尚未连接', 'error');
}
} catch {
toast('读取失败', 'error');
}
};
const restartInstance = async () => {
const ok = await confirm({
title: '重启该实例?',
body: '会重建容器(聊天记录保留),微信重新启动,约十几秒;用于修复卡死/最小化丢失等。',
confirmText: '重启',
});
if (!ok) return;
try {
await api.instanceRestart(id);
toast('已重启,正在重连…', 'ok');
setFrameLoaded(false);
setVncNonce((n) => n + 1); // 强制 iframe 重挂、重连
await reload();
} catch (e: any) {
toast(e.message || '重启失败', 'error');
}
};
const takeControl = async () => {
try {
const r = await api.controlTake(id);
setControl({ free: false, mine: r.mine, holder: r.holder });
lastBeat.current = Date.now();
focusFrame();
} catch (e: any) {
toast(e.message || '接管失败', 'error');
}
};
const start = async () => {
setStarting(true);
try {
await api.instanceStart(id);
toast('实例已启动', 'ok');
await reload();
} catch (e: any) {
toast(e.message || '启动失败', 'error');
} finally {
setStarting(false);
}
};
const title = inst?.name || '微信实例';
return (
<div className="desktop-wrap">
<iframe className="desktop-frame" src={desktopUrl(id)} title="电脑版微信" allow="clipboard-read; clipboard-write" />
<button className="desktop-back" onClick={() => nav('/')} title="返回">
</button>
<div className="ws-page">
<header className="ws-head">
<button className="ws-menu" onClick={onOpenMenu} aria-label="菜单">
{MenuIcon}
</button>
<span className="ws-title">{title}</span>
{showVnc && (
<>
<button
className="ws-action"
title="文件传输"
onClick={() => {
setShowFiles((v) => !v);
if (!showFiles) refreshFiles();
}}
>
</button>
<button
className="ws-action"
title="把文本发送到容器剪贴板(局域网 http 下也可用)"
onClick={() => setShowClip((v) => !v)}
>
</button>
{isAdmin && (
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
</button>
)}
</>
)}
</header>
{/* —— 各种态 —— */}
{!loaded || (probing && !inst) ? (
<div className="iv-stage iv-center">
<div className="spinner" />
</div>
) : !inst ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="iv-notice-title">访</div>
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/')}>
</button>
</div>
</div>
) : offline ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="iv-notice-title">{inst.runtime === 'missing' ? '容器尚未创建' : '实例已停止'}</div>
{isAdmin ? (
<button className="btn btn-primary iv-notice-btn" disabled={starting} onClick={start}>
{starting ? '启动中…' : inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
</button>
) : (
<div className="iv-notice-sub"></div>
)}
{isAdmin && (
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
</button>
)}
</div>
</div>
) : ['downloading', 'extracting', 'installing'].includes(inst.wechat.phase) ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="spinner" />
<div className="iv-notice-title"></div>
<div className="iv-notice-sub">
{inst.wechat.message || '请稍候'}
{inst.wechat.percent >= 0 ? ` · ${inst.wechat.percent}%` : ''}
</div>
</div>
</div>
) : !installed ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? '微信安装出错' : '微信尚未安装'}</div>
<div className="iv-notice-sub">
{inst.wechat.phase === 'error'
? inst.wechat.message || '安装失败,可在「管理」重试'
: '该实例容器已就绪,但尚未安装微信'}
</div>
{isAdmin ? (
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/admin')}>
{inst.wechat.phase === 'error' ? '重试 / 更新' : '下载安装'}
</button>
) : (
<div className="iv-notice-sub"></div>
)}
{isAdmin && (
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
</button>
)}
</div>
</div>
) : (
<div className="iv-stage">
<iframe
key={`${id}:${vncNonce}`}
ref={frameRef}
className="iv-frame"
src={desktopUrl(id)}
title="电脑版微信"
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
onLoad={() => {
setFrameLoaded(true);
setTimeout(() => {
focusFrame(); // 加载完把键盘焦点交给 VNC(宿主机输入法)
injectVncStyle(); // 让原生控制条在深色背景下可见
patchVncIme(); // 修复中文 IME 吞字(绕过 VNC XKB keysym 限制)
}, 500);
}}
/>
{!frameLoaded && (
<div className="iv-loading">
<div className="spinner" />
<div className="iv-loading-text"></div>
<div className="iv-loading-sub"></div>
<div className="iv-loading-sub">/</div>
{!window.isSecureContext && (
<div className="iv-loading-warn"> HTTPS 访</div>
)}
</div>
)}
{dragging && (
<div className="iv-drop" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
<div className="drop-card">
<div className="drop-icon"></div>
<div className="drop-title"></div>
<div className="drop-sub">+ / </div>
</div>
</div>
)}
{control && !control.free && !control.mine && (
<div className="iv-lock">
<div className="iv-lock-card">
<div className="iv-lock-title">{control.holder}</div>
<div className="iv-lock-sub"></div>
<button className="btn btn-primary iv-notice-btn" onClick={takeControl}>
</button>
</div>
</div>
)}
{showFiles && (
<div className="iv-files">
<div className="files-head">
<span></span>
<button className="btn-text" onClick={() => setShowFiles(false)}>
</button>
</div>
<input
ref={fileInput}
type="file"
multiple
style={{ display: 'none' }}
onChange={(e) => {
if (e.target.files) uploadFiles(e.target.files);
e.target.value = '';
}}
/>
<button className="btn btn-primary files-upload" disabled={uploading} onClick={() => fileInput.current?.click()}>
{uploading ? '上传中…' : ' 选择文件上传'}
</button>
<div className="files-hint">~/Desktop</div>
<div className="files-list">
{files.length === 0 && (
<div className="muted small" style={{ padding: '10px 2px' }}>
</div>
)}
{files.map((f) => (
<div key={f.name} className="files-item">
<a className="files-dl" href={api.downloadFileUrl(id, f.name)} download={f.name} title="下载">
<span className="files-name">{f.name}</span>
<span className="files-size">{humanSize(f.size)} </span>
</a>
<button className="files-del" title="删除" onClick={() => delFile(f.name)}>
</button>
</div>
))}
</div>
</div>
)}
{showClip && (
<div className="iv-files">
<div className="files-head">
<span></span>
<button className="btn-text" onClick={() => setShowClip(false)}>
</button>
</div>
<textarea
className="clip-area"
value={clipText}
onChange={(e) => setClipText(e.target.value)}
placeholder="在此输入或粘贴文本,点「发送到微信」后到微信输入框按 Ctrl+V 粘贴"
rows={5}
/>
<button className="btn btn-primary files-upload" onClick={sendClip}>
</button>
<button className="btn-text" style={{ alignSelf: 'flex-start', marginTop: 6 }} onClick={pullClipboardFromRemote}>
</button>
<div className="files-hint">
http 访 Ctrl+V
</div>
</div>
)}
</div>
)}
</div>
);
}
+28 -27
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth';
import { PasswordInput } from '../ui';
export default function Login() {
const { login } = useAuth();
@@ -25,33 +26,33 @@ export default function Login() {
};
return (
<div className="center-screen">
<form className="card login-card" onSubmit={submit}>
<div className="brand">
<div className="brand-logo"><img src="/favicon.svg" alt="" /></div>
<h1></h1>
<p className="muted">访 NAS </p>
</div>
<input
className="input"
placeholder="用户名"
autoCapitalize="off"
autoCorrect="off"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
className="input"
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{err && <div className="error">{err}</div>}
<button className="btn btn-primary" disabled={busy || !username || !password}>
{busy ? '登录中…' : '登录'}
</button>
</form>
<div className="center-screen login-screen">
<div className="login-wrap">
<form className="card login-card" onSubmit={submit}>
<div className="brand">
<div className="brand-logo">
<img src="/favicon.svg" alt="" />
</div>
<h1></h1>
<p className="muted">访 NAS </p>
</div>
<input
className="input"
placeholder="用户名"
autoCapitalize="off"
autoCorrect="off"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<PasswordInput placeholder="密码" autoComplete="current-password" value={password} onChange={setPassword} />
{err && <div className="error">{err}</div>}
<button className="btn btn-primary" disabled={busy || !username || !password}>
{busy ? '登录中…' : '登录'}
</button>
</form>
<div className="login-foot"> · · / 访</div>
</div>
</div>
);
}
+909
View File
@@ -107,10 +107,30 @@ button {
}
/* 登录 */
.login-screen {
background:
radial-gradient(60% 50% at 50% 0%, rgba(var(--green-rgb) / 0.1), transparent 70%),
var(--base);
}
.login-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
max-width: 360px;
}
.login-card {
display: flex;
flex-direction: column;
gap: 14px;
padding: 28px 24px;
}
.login-foot {
font-size: 12px;
color: var(--muted);
text-align: center;
padding: 0 12px;
}
.brand {
text-align: center;
@@ -177,6 +197,33 @@ button {
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.16), 0 0 0 3px rgba(var(--green-rgb) / 0.22);
}
/* 密码框 + 显示/隐藏切换 */
.pw-field {
position: relative;
}
.pw-field .input {
padding-right: 46px;
}
.pw-toggle {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
border: none;
background: none;
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 10px;
}
.pw-toggle:active {
background: rgba(var(--shadow) / 0.06);
}
/* 按钮:浮起 blob,按下缩小 + 折痕收紧 */
.btn {
position: relative;
@@ -221,6 +268,18 @@ button {
.btn-primary:active:not(:disabled) {
background: var(--wx-green-dark);
}
/* 危险动作按钮:红底,折痕用 danger 色 */
.btn-danger {
background: var(--danger);
color: #fff;
box-shadow: 0 1px 3px rgba(var(--danger-rgb) / 0.45), 0 4px 14px rgba(var(--danger-rgb) / 0.3);
}
.btn-danger::before {
background: radial-gradient(ellipse at 50% 22%, rgba(255, 255, 255, 0.26) 0%, rgba(255, 255, 255, 0.07) 42%, transparent 72%);
}
.btn-danger:active:not(:disabled) {
background: #e84444;
}
.btn:disabled {
opacity: 0.6;
cursor: default;
@@ -287,6 +346,17 @@ button {
width: 100%;
margin: 0 auto;
}
/* PC 大屏:加宽容器,实例网格自动多列卡片 */
@media (min-width: 880px) {
.content {
max-width: 940px;
padding: 20px 28px 40px;
}
.topbar {
padding-left: 16px;
padding-right: 16px;
}
}
.hello {
font-size: 22px;
@@ -559,6 +629,166 @@ button {
transform: scale(0.94);
box-shadow: var(--crease-press);
}
/* 文件按钮:返回钮下方 */
.desktop-files-btn {
position: fixed;
top: max(12px, env(safe-area-inset-top));
left: 64px;
width: 42px;
height: 42px;
border-radius: 50%;
border: none;
background: var(--surface);
color: var(--text);
font-size: 20px;
line-height: 1;
cursor: pointer;
z-index: 10;
box-shadow: var(--crease);
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.desktop-files-btn:active {
transform: scale(0.94);
}
/* 拖拽落区 */
.drop-zone {
position: fixed;
inset: 0;
z-index: 30;
background: rgba(7, 193, 96, 0.16);
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
}
.drop-card {
background: var(--surface);
border-radius: var(--r-card);
box-shadow: var(--crease);
padding: 28px 36px;
text-align: center;
border: 2px dashed rgba(var(--green-rgb) / 0.5);
}
.drop-icon {
font-size: 36px;
color: var(--wx-green);
}
.drop-title {
margin-top: 8px;
font-size: 17px;
font-weight: 700;
}
.drop-sub {
margin-top: 4px;
font-size: 13px;
color: var(--muted);
}
/* 文件传输面板 */
.files-panel {
position: fixed;
top: max(64px, calc(env(safe-area-inset-top) + 52px));
left: 12px;
width: min(330px, calc(100vw - 24px));
max-height: 70vh;
display: flex;
flex-direction: column;
background: var(--surface);
border-radius: var(--r-card);
box-shadow: var(--crease);
z-index: 11;
padding: 16px;
gap: 10px;
}
.files-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 16px;
font-weight: 700;
}
.files-upload {
height: 42px;
font-size: 15px;
}
.clip-area {
width: 100%;
resize: vertical;
min-height: 96px;
padding: 10px 12px;
border-radius: 14px;
border: none;
background: var(--mf-trough, #edeef1);
color: var(--text, #1a1d24);
font-size: 14px;
line-height: 1.5;
font-family: inherit;
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.16);
outline: none;
box-sizing: border-box;
}
.clip-area:focus {
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.16), 0 0 0 2px rgba(7, 193, 96, 0.35);
}
.files-hint {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.files-list {
overflow-y: auto;
display: flex;
flex-direction: column;
}
.files-item {
display: flex;
align-items: center;
gap: 6px;
box-shadow: inset 0 -1px 0 rgba(var(--shadow) / 0.08);
}
.files-item:last-child {
box-shadow: none;
}
.files-dl {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 11px 4px;
text-decoration: none;
color: var(--text);
font-size: 14px;
}
.files-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.files-size {
flex: none;
color: var(--wx-green-dark);
font-size: 12px;
font-weight: 600;
}
.files-del {
flex: none;
width: 30px;
height: 30px;
border: none;
background: none;
color: var(--muted);
font-size: 14px;
cursor: pointer;
border-radius: 8px;
}
.files-del:active {
background: rgba(var(--danger-rgb) / 0.12);
color: var(--danger);
}
/* ── loading ───────────────────────────────────────────── */
.spinner {
@@ -645,6 +875,24 @@ button {
.inst-act {
flex: none;
}
.inst-act-wide {
flex: 1;
height: 42px;
font-size: 15px;
}
/* 管理卡片底部的文字操作(重命名/分配/删除) */
.inst-admin-links {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-top: 10px;
padding-top: 8px;
box-shadow: inset 0 1px 0 rgba(var(--shadow) / 0.08);
}
.inst-admin-links .btn-text {
padding: 6px 8px;
font-size: 14px;
}
/* 状态徽章配色 */
.tag-on {
@@ -730,6 +978,11 @@ button {
border-radius: inherit;
background: var(--sheen);
}
.empty-blob img {
width: 52px;
height: 52px;
position: relative;
}
.empty-title {
font-size: 16px;
font-weight: 700;
@@ -740,3 +993,659 @@ button {
font-size: 13px;
color: var(--muted);
}
/* ── 默认密码安全告警条 ──────────────────────────────────── */
.warn-banner {
position: relative;
z-index: 1;
width: 100%;
text-align: left;
display: flex;
align-items: center;
gap: 12px;
border: none;
cursor: pointer;
background: rgba(var(--danger-rgb) / 0.1);
border-radius: var(--r-blob);
padding: 14px 16px;
margin: 4px 0 14px;
box-shadow: inset 0 0 0 1.5px rgba(var(--danger-rgb) / 0.3);
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.warn-banner:active {
transform: scale(0.99);
}
.warn-icon {
flex: none;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--danger);
color: #fff;
font-weight: 800;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.warn-text {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
color: var(--danger);
}
.warn-text b {
font-size: 14px;
}
/* ── 删除实例:勾选是否连数据卷一起删 ──────────────────────── */
.purge-opt {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
padding: 12px 14px;
border-radius: var(--r-small);
background: var(--trough);
font-size: 14px;
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.14);
transition: box-shadow 0.18s;
}
.purge-opt.on {
box-shadow: inset 0 0 0 1.5px rgba(var(--danger-rgb) / 0.45);
}
.purge-check {
flex: none;
width: 20px;
height: 20px;
border-radius: 6px;
background: var(--surface);
box-shadow: var(--crease-press);
color: var(--danger);
font-weight: 800;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.purge-opt.on .purge-check {
background: var(--danger);
color: #fff;
}
/* ── Toast ──────────────────────────────────────────────── */
.toast-stack {
position: fixed;
left: 50%;
bottom: max(24px, env(safe-area-inset-bottom));
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
z-index: 50;
pointer-events: none;
}
.toast {
pointer-events: auto;
max-width: 80vw;
background: var(--surface);
color: var(--text);
font-size: 14px;
font-weight: 500;
padding: 11px 18px;
border-radius: 999px;
box-shadow: var(--crease);
animation: toast-in 0.32s cubic-bezier(0.2, 0.9, 0.2, 1);
}
.toast-ok {
color: var(--wx-green-dark);
}
.toast-error {
color: var(--danger);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ── 桌面连接 loading 遮罩 ──────────────────────────────── */
.desktop-loading {
position: fixed;
inset: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
background: var(--base);
}
.desktop-loading-text {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.desktop-loading-sub {
font-size: 13px;
color: var(--muted);
text-align: center;
padding: 0 24px;
}
.desktop-loading-warn {
margin-top: 6px;
font-size: 12px;
color: var(--danger);
text-align: center;
padding: 0 24px;
}
/* ============================================================
* 微信 PC 式布局:左侧 tab 栏 + 右侧工作区
* ============================================================ */
.shell {
display: flex;
height: 100%;
min-height: 0;
background: var(--base);
}
/* —— 左侧栏 —— */
.sidebar {
flex: none;
width: 264px;
display: flex;
flex-direction: column;
background: var(--surface);
box-shadow: 1px 0 0 rgba(var(--shadow) / 0.08), 4px 0 16px rgba(var(--shadow) / 0.05);
z-index: 20;
transition: width 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
padding-top: env(safe-area-inset-top);
}
.shell.collapsed .sidebar {
width: 64px;
}
.sb-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 14px 10px;
}
.sb-brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.sb-logo {
width: 30px;
height: 30px;
flex: none;
border-radius: 9px;
}
.sb-name {
font-size: 17px;
font-weight: 700;
white-space: nowrap;
}
.sb-collapse {
flex: none;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: var(--muted);
cursor: pointer;
border-radius: 9px;
}
.sb-collapse:active {
background: rgba(var(--shadow) / 0.06);
}
.collapsed .sb-top {
flex-direction: column;
}
.sb-nav {
padding: 4px 10px;
}
.sb-section {
padding: 12px 16px 6px;
font-size: 12px;
font-weight: 700;
color: var(--muted);
}
.sb-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.sb-empty {
padding: 10px 16px;
font-size: 13px;
color: var(--muted);
}
.sb-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
border: none;
background: none;
cursor: pointer;
padding: 9px 12px;
border-radius: 12px;
font-size: 15px;
color: var(--text);
text-align: left;
transition: background 0.15s;
}
.sb-item:hover {
background: rgba(var(--shadow) / 0.05);
}
.sb-item.on {
background: rgba(var(--green-rgb) / 0.12);
color: var(--wx-green-dark);
}
.sb-item.on .sb-ic {
color: var(--wx-green-dark);
}
.sb-ic {
flex: none;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.sb-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.collapsed .sb-item {
justify-content: center;
padding: 9px 0;
}
/* 实例头像 + 状态点 */
.sb-avatar {
position: relative;
flex: none;
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
color: #fff;
font-weight: 700;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
.sb-dot {
position: absolute;
right: -2px;
bottom: -2px;
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid var(--surface);
}
.sb-stxt {
flex: none;
font-size: 11px;
font-weight: 600;
color: var(--muted);
}
.st-on {
background: #16c060;
color: var(--wx-green-dark);
}
.st-off {
background: #c2c7d0;
color: var(--muted);
}
.st-busy {
background: #2f7ad0;
color: #2f5fd0;
}
.st-warn {
background: #e6a23c;
color: #b9770a;
}
.sb-footer {
padding: 8px 10px calc(10px + env(safe-area-inset-bottom));
box-shadow: inset 0 1px 0 rgba(var(--shadow) / 0.08);
}
.sb-user {
padding: 8px 12px 2px;
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* —— 右侧工作区 —— */
.workspace {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.ws-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.ws-head {
flex: none;
display: flex;
align-items: center;
gap: 10px;
height: calc(52px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 16px 0;
background: var(--base);
box-shadow: inset 0 -1px 0 rgba(var(--shadow) / 0.07);
}
.ws-title {
font-size: 16px;
font-weight: 700;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-menu {
display: none;
flex: none;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border: none;
background: none;
color: var(--text);
cursor: pointer;
border-radius: 9px;
margin-left: -6px;
}
.ws-action {
flex: none;
border: none;
background: var(--surface);
color: var(--wx-green-dark);
font-size: 13px;
font-weight: 600;
padding: 6px 11px;
border-radius: 999px;
cursor: pointer;
box-shadow: var(--crease);
}
.ws-action:active {
transform: scale(0.96);
}
.ws-page .content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
/* 主页实例快捷卡 */
.home-card {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
border: none;
cursor: pointer;
background: var(--surface);
border-radius: var(--r-card);
padding: 16px;
box-shadow: var(--crease);
position: relative;
overflow: hidden;
transition: transform 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.home-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--sheen);
pointer-events: none;
}
.home-card:active {
transform: scale(0.985);
}
.home-card-av {
position: relative;
flex: none;
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
color: #fff;
font-weight: 700;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
.home-card-main {
position: relative;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.home-card-name {
font-size: 16px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-card-st {
font-size: 12px;
font-weight: 600;
background: none !important;
}
/* —— 内嵌实例视图 —— */
.iv-stage {
position: relative;
flex: 1;
min-height: 0;
background: #000;
}
.iv-center {
display: flex;
align-items: center;
justify-content: center;
background: var(--base);
}
.iv-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
display: block;
}
.iv-loading,
.iv-drop {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
z-index: 5;
}
.iv-loading {
background: var(--base);
}
.iv-loading-text {
font-size: 16px;
font-weight: 600;
}
.iv-loading-sub {
font-size: 13px;
color: var(--muted);
text-align: center;
padding: 0 24px;
}
.iv-loading-warn {
margin-top: 4px;
font-size: 12px;
color: var(--danger);
text-align: center;
padding: 0 24px;
}
.iv-drop {
z-index: 30;
background: rgba(7, 193, 96, 0.16);
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
}
.iv-files {
position: absolute;
top: 14px;
right: 14px;
width: min(330px, calc(100% - 28px));
max-height: calc(100% - 28px);
display: flex;
flex-direction: column;
gap: 10px;
background: var(--surface);
border-radius: var(--r-card);
box-shadow: var(--crease), 0 8px 30px rgba(var(--shadow) / 0.18);
z-index: 12;
padding: 16px;
}
/* 只读遮罩:他人正在操作时盖住 VNC,拦截键鼠(半透,仍能看到画面) */
.iv-lock {
position: absolute;
inset: 0;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
background: rgba(26, 29, 36, 0.28);
-webkit-backdrop-filter: blur(1.5px);
backdrop-filter: blur(1.5px);
}
.iv-lock-card {
background: var(--surface);
border-radius: var(--r-card);
box-shadow: var(--crease), 0 8px 30px rgba(var(--shadow) / 0.25);
padding: 22px 26px;
text-align: center;
max-width: 320px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.iv-lock-title {
font-size: 16px;
font-weight: 700;
}
.iv-lock-sub {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.iv-notice {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 24px;
}
.iv-notice-title {
font-size: 17px;
font-weight: 700;
}
.iv-notice-sub {
font-size: 13px;
color: var(--muted);
}
.iv-notice-btn {
padding: 0 22px;
height: 44px;
}
/* —— 移动端:侧栏变抽屉 —— */
.shell-backdrop {
display: none;
}
@media (max-width: 767px) {
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: 82vw;
max-width: 320px;
transform: translateX(-100%);
transition: transform 0.25s cubic-bezier(0.2, 0.8, 0.2, 1);
box-shadow: 0 0 40px rgba(var(--shadow) / 0.35);
}
.shell.drawer-open .sidebar {
transform: none;
}
.shell.drawer-open .shell-backdrop {
display: block;
position: fixed;
inset: 0;
background: rgba(26, 29, 36, 0.4);
z-index: 15;
}
.ws-menu {
display: flex;
}
.ws-action {
padding: 7px 12px;
}
}
/* 宽屏:工作区内容容器更宽(实例网格多列) */
@media (min-width: 880px) {
.ws-page .content {
max-width: 940px;
}
}
+128
View File
@@ -0,0 +1,128 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
// ── Toast ───────────────────────────────────────────────
type ToastKind = 'ok' | 'error' | 'info';
interface ToastItem {
id: number;
text: string;
kind: ToastKind;
}
// ── Confirm ─────────────────────────────────────────────
interface ConfirmOpts {
title: string;
body?: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
}
interface UICtx {
toast: (text: string, kind?: ToastKind) => void;
confirm: (opts: ConfirmOpts) => Promise<boolean>;
}
const Ctx = createContext<UICtx>(null!);
export function UIProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [confirmState, setConfirmState] = useState<(ConfirmOpts & { resolve: (v: boolean) => void }) | null>(null);
const seq = useRef(0);
const toast = useCallback((text: string, kind: ToastKind = 'info') => {
const id = ++seq.current;
setToasts((list) => [...list, { id, text, kind }]);
setTimeout(() => setToasts((list) => list.filter((t) => t.id !== id)), 2600);
}, []);
const confirm = useCallback(
(opts: ConfirmOpts) => new Promise<boolean>((resolve) => setConfirmState({ ...opts, resolve })),
[],
);
const close = (v: boolean) => {
confirmState?.resolve(v);
setConfirmState(null);
};
return (
<Ctx.Provider value={{ toast, confirm }}>
{children}
<div className="toast-stack">
{toasts.map((t) => (
<div key={t.id} className={'toast toast-' + t.kind}>
{t.text}
</div>
))}
</div>
{confirmState && (
<div className="modal-mask" onClick={() => close(false)}>
<div className="card modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 340 }}>
<h2>{confirmState.title}</h2>
{confirmState.body && <div className="muted" style={{ fontSize: 14, lineHeight: 1.5 }}>{confirmState.body}</div>}
<div className="modal-actions">
<button type="button" className="btn" onClick={() => close(false)}>
{confirmState.cancelText || '取消'}
</button>
<button
type="button"
className={'btn ' + (confirmState.danger ? 'btn-danger' : 'btn-primary')}
onClick={() => close(true)}
>
{confirmState.confirmText || '确定'}
</button>
</div>
</div>
</div>
)}
</Ctx.Provider>
);
}
export const useUI = () => useContext(Ctx);
// ── 带"显示/隐藏"切换的密码输入框 ────────────────────────
export function PasswordInput({
value,
onChange,
placeholder,
autoComplete,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
autoComplete?: string;
}) {
const [show, setShow] = useState(false);
return (
<div className="pw-field">
<input
className="input"
type={show ? 'text' : 'password'}
placeholder={placeholder}
autoComplete={autoComplete}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<button
type="button"
className="pw-toggle"
tabIndex={-1}
aria-label={show ? '隐藏密码' : '显示密码'}
onClick={() => setShow((s) => !s)}
>
{show ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20C5 20 1 12 1 12a18.5 18.5 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19M1 1l22 22" />
<path d="M9.5 9.5a3 3 0 0 0 4.24 4.24" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</div>
);
}
+4
View File
@@ -29,6 +29,10 @@ export default defineConfig({
workbox: {
// 桌面反代与 API 不能被 SW 拦截
navigateFallbackDenylist: [/^\/desktop/, /^\/api/],
// 新版本立即接管 + 清理旧缓存,避免更新后仍跑旧代码(硬刷新绕不过 SW)
clientsClaim: true,
skipWaiting: true,
cleanupOutdatedCaches: true,
},
}),
],