This commit is contained in:
Gloridust
2026-05-29 22:00:23 +08:00
Unverified
parent 81a50313c3
commit 5c5e9c8540
45 changed files with 12166 additions and 111 deletions
+10 -5
View File
@@ -1,16 +1,21 @@
# 复制本文件为 .env 即可覆盖默认配置。全部可选——不建 .env 也能直接 `docker compose up -d --build`。
# 复制本文件为 .env 即可覆盖默认配置。全部可选——不建 .env 也能直接 `docker compose up -d`。
# web 端登录账号 / 密码(KasmVNC 基础鉴权)。强烈建议改掉默认密码!
# 面板首个管理员账号 / 密码(仅首次启动、无账号文件时写入)。强烈建议改掉默认密码!
WOC_USER=admin
WOC_PASSWORD=wechat
# 宿主用户 uid/gid(飞牛上用 `id` 命令查看;单用户 NAS 一般是 1000)
# 镜像版本:默认 latest;上线后建议钉到具体版本(如 v1.0.0)以便可控升级。
# 同时作用于面板镜像和新建微信实例所用镜像。
WOC_VERSION=latest
# 宿主用户 uid/gid(飞牛上用 `id` 命令查看;单用户 NAS 一般是 1000)。
# 透传给每个微信实例容器,决定面板数据与微信数据卷的属主。
WOC_PUID=1000
WOC_PGID=1000
# 时区
WOC_TZ=Asia/Shanghai
# 对外端口(宿主侧,默认用冷门端口避免冲突;容器内仍是 3000/3001
# 面板对外端口(宿主侧,默认用冷门端口避免冲突;容器内固定 8080)。
# 面板是唯一对外入口;微信实例不直接对宿主暴露,由面板反向代理。
WOC_HTTP_PORT=36080
WOC_HTTPS_PORT=36443
+76
View File
@@ -0,0 +1,76 @@
name: release
# 推送 vX.Y.Z 标签或在 GitHub 上发布 Release 时,构建并推送多架构镜像到 GHCR。
# 也可手动触发(workflow_dispatch)只打 latest,便于验证流水线。
on:
push:
tags:
- 'v*.*.*'
release:
types: [published]
workflow_dispatch:
env:
REGISTRY: ghcr.io
WECHAT_IMAGE: ${{ github.repository_owner }}/wechat-on-cloud
PANEL_IMAGE: ${{ github.repository_owner }}/woc-panel
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- name: wechat
image: wechat-on-cloud
context: ./docker
dockerfile: ./docker/Dockerfile
- name: panel
image: woc-panel
context: ./panel
dockerfile: ./panel/Dockerfile
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata (tags + labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}
# 语义化标签:vX.Y.Z → X.Y.Z / X.Y / X;默认分支额外打 latest
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push (amd64 + arm64)
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
+3
View File
@@ -137,3 +137,6 @@ dist
# WechatOnCloud: 微信运行数据(登录态/消息,大且敏感,勿提交)
/data/
# WechatOnCloud: 面板账号数据(含密码哈希,勿提交)
/data-panel/
/.claude
+219 -46
View File
@@ -1,97 +1,255 @@
# WechatOnCloud
在飞牛 NAS(x86_64)上运行服务端微信多个 web 用户通过浏览器访问**同一个**微信会话,实现跨设备消息同步、多端共享。
在飞牛 NASx86_64 / arm64)上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。
> 设计与选型详见 [技术方案.md](技术方案.md)。
> 本仓库当前是 **MVP**Docker + 官方微信 Linux 原生版 + KasmVNC 串流到浏览器
> 部署形态:拉取 GHCR 预构建多架构镜像(或本地自构建),面板按需动态创建微信实例容器。不熟悉 Docker?直接看 [Docker 运行模式详解](#docker-运行模式详解新手向)
---
## 工作原理(一句话)
容器里跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器。多个浏览器连同一容器 = 共享同一个微信会话。**不修改微信客户端**。
每个微信实例 = 一个容器,里面跑 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. 镜像从哪来:两种「构建/获取」模式
容器要跑,先得有镜像。本项目的两个镜像有两条获取途径,**任选其一**([快速开始](#快速开始)对应方式 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(含 Compose 插件)。x86_64 / arm64 均可。
`docker-compose.yml` 引用的是 GHCR 上的镜像 `ghcr.io/gloridust/{woc-panel,wechat-on-cloud}`
**这两个镜像需先存在**——要么官方已发布(你能直接拉取),要么你在本地自行构建。二选一:
**方式 A · 本地自构建(官方尚未发布镜像时用这个)**
```bash
git clone <this-repo> WechatOnCloud
cd WechatOnCloud
docker compose up -d --build
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
./scripts/build-local.sh # 构建面板 + 微信实例镜像,打成 compose 用的同名标签
docker compose up -d # compose 默认优先用本地镜像,不会再去 GHCR
```
就这一条。然后浏览器访问 `http://<NAS_IP>:36080`(或 `https://<NAS_IP>:36443` 自签证书),
用默认账号 **admin / wechat** 登录 web 端,再用手机扫码登录微信即可。
**方式 B · 拉取官方镜像(已发布到 GHCR 后)**
> 宿主端口默认用冷门的 `36080/36443` 避免与其他服务冲突;容器内仍是 3000/3001。要改见 `.env`。
```bash
git clone <this-repo> WechatOnCloud
cd WechatOnCloud
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
docker compose up -d # 直接从 GHCR 拉取
```
> 首次构建会下载约 190~210MB 的微信安装包,较慢,属正常
> 报错 `error from registry: denied`?说明 GHCR 上还没有该镜像(或包是私有的)。用方式 A 本地构建,或见下方[「发布到 GHCR」](#发布到-ghcr)
无论哪种方式,都会拉起面板容器 `woc-panel`(唯一对外服务)。浏览器访问 `http://<NAS_IP>:36080`
1.`.env` 里设置的管理员账号(默认 **admin / wechat**)登录面板;
2. 管理员在面板「实例」页点「**新建微信实例**」,命名并选择哪些子账号可访问 → 面板自动 `docker run` 起一个微信实例容器(微信镜像本地没有时才会从 GHCR 拉取);
3. 进入该实例,点「**下载并安装**」微信(约 190~210MB,进度条实时显示,仅管理员可操作);
4. 装好后点「进入电脑版微信」→ 浏览器里出现微信窗口,手机扫码登录即可。
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**微信会话。
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本见 `.env`。
### 面板能做什么
| 功能 | 谁可用 | 说明 |
|------|--------|------|
| 新建 / 删除微信实例 | 管理员 | 一键创建独立微信会话容器;新建时勾选可访问的子账号。删除默认保留数据卷(聊天记录),可选彻底清除 |
| 实例权限分配 | 管理员 | 在实例上改「可访问账户」,或在账户上改「可访问实例」,双向管理 |
| 下载并安装 / 更新微信 | 管理员 | 对某实例一键下载官方微信 Linux 版到其数据卷、解压安装;带进度条;后续可一键「更新到最新版」 |
| 进入电脑版微信 | 被授权用户 | 在浏览器里操作对应实例的微信,扫码登录、收发消息 |
| 修改密码 | 所有人 | 改自己的登录密码 |
| 子账号管理 | 管理员 | 创建 / 禁用 / 重置 / 删除子账号,并分配实例访问权限 |
| 安装为 App | 所有人 | iOS Safari「添加到主屏幕」、桌面 Chrome「安装」当原生 AppPWA |
> 子账号是**访问这套面板的身份**,不是另开一个微信。管理员隐式拥有全部实例访问权;子账号只能看到被授权的实例。
> 微信本体**不打进镜像**,而是新建实例后在面板点「下载并安装」时下载到该实例的数据卷,所以镜像很小、构建快、不依赖腾讯 CDN。
### 架构自动适配
构建时会**自动检测本机 CPU 架构**Docker BuildKit 的 `TARGETARCH`),下载对应官方微信包:
镜像本身多架构(amd64/arm64);下载微信时容器内**运行时再自动检测 CPU 架构**`dpkg --print-architecture`)取对应官方包:
| 构建机器 | 架构 | 自动下载 |
| 运行机器 | 架构 | 自动下载 |
|----------|------|----------|
| Intel/AMD NAS、x86 服务器 | amd64 | `WeChatLinux_x86_64.deb` |
| ARM NAS、Apple Silicon Mac | arm64 | `WeChatLinux_arm64.deb` |
所以**在你的 Mac(M 系列)上直接 `docker compose up -d --build` 就能本地调试**,到飞牛上(无论 x64 还是 arm)同样一条命令,无需改任何架构相关配置。
> 仅当你要在 A 架构机器上为 B 架构 NAS **交叉构建**时,才需在 `docker-compose.yml` 里取消 `platforms` 注释并指定目标架构。
到飞牛上(无论 x64 还是 arm`docker compose up -d`一条命令,无需改任何架构相关配置。
### 自定义配置(可选)
默认值开箱即用,无需任何配置。要改的话,复制一份 `.env`
复制 `.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
```bash
cp .env.example .env # 然后按需修改,最该改的是 WOC_PASSWORD
git tag v1.0.0
git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1.0.0 等标签
```
可配置项见 [.env.example](.env.example)web 账号密码、PUID/PGID、时区、端口。
首次发布后还需把 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` 自构建即可,无需等待发布。
---
## 数据持久化
微信登录态与消息都写在容器内 `HOME=/config`映射到宿主 `./data`
- **不要删 `./data`**,否则要重新扫码登录、丢本地消息缓存
- 备份微信 = 备份 `./data` 目录。
- **面板数据**(用户、实例元信息、密码哈希):容器内 `/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 权限。因此:
MVP 仅依赖 KasmVNC 自带的 web 端基础鉴权(`CUSTOM_USER`/`PASSWORD`)。**生产使用务必:**
- 只在内网访问,或经飞牛远程访问 / VPN / 内网穿透,**不要直接裸暴露公网**
- 务必改掉默认密码(默认 admin / wechat):`cp .env.example .env` 后改 `WOC_PASSWORD`
- 进一步加固(独立鉴权层 Authelia、反代 TLS、陌生设备验证码)见 [技术方案.md](技术方案.md) 第 5 节,属后续迭代。
- **绝不要把面板裸暴露公网**:只在内网访问,或经飞牛远程访问 / VPN / 内网穿透;
- 务必改掉默认密码(默认 admin / wechat):`cp .env.example .env` 后改 `WOC_PASSWORD`,或登录后在「修改密码」里改;
- 实例的增删、微信安装/更新等触碰 docker 引擎的操作**仅限管理员**;docker API 绝不暴露给前端
- KasmVNC 凭据由面板服务端注入,**浏览器永远拿不到**;实例容器名由内部随机 ID 派生,避免注入
- 面板与外网之间再套一层 HTTPS 反代(飞牛自带反代 / Caddy / Nginx)获得正经 TLS
- 进一步加固(陌生设备验证码、审计日志、并发控制)见 [技术方案.md](技术方案.md) 第 5 节。
---
## 中文输入
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像已默认开启 KasmVNC 的
「IME Input Mode」:拼音联想在你本机完成,只把成品汉字发进容器。直接在微信输入框打字即可。
- 默认值只对**未存过该设置的浏览器**生效。之前手动开/关过的,浏览器 localStorage 值优先;想验证默认效果用无痕窗口。
- 已知小毛病:超长拼音串未全部转成汉字就回车,偶尔丢字([issue #97](https://github.com/linuxserver/docker-baseimage-kasmvnc/issues/97)),长句分段输入即可。
- 兜底:Chrome/Edge 下本地 `⌘C` → 远端 `Ctrl+V` 无缝粘贴;Firefox 用控制面板的 Clipboard 文本框中转。
## 常见问题
| 现象 | 排查 |
|------|------|
| 界面/消息显示成方块 | 中文字体没装好,确认镜像构建时 `fonts-noto-cjk` 安装成功 |
| 微信起不来 / 黑屏 | 看日志 `docker logs wechat-on-cloud`;确认 `security_opt: seccomp:unconfined``shm_size` 已生效。微信 deb 漏声明的运行时依赖(libatomic1、xcb 系、GTK3、Chromium 所需 X 扩展等)已在 Dockerfile 内置 |
| 排查缺哪个库 | `docker exec wechat-on-cloud ldd /opt/wechat/wechat` `.../RadiumWMPF/runtime/WeChatAppEx`,看 `not found` 的项,补进 Dockerfile 的依赖层 |
| 多人同时操作很乱 | 已知问题:单会话多端共享、键鼠会打架。MVP 未做并发控制,建议同一时刻一人操作(见技术方案 6.1) |
| 新建实例失败 | 多为面板拉不到微信镜像或连不上 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) |
| 下载 deb 失败 | 腾讯 CDN 偶发波动,重`docker compose build --no-cache`Dockerfile 已内置主/备 CDN 自动回退 |
| 架构不支持报错 | 微信仅提供 x86_64 / arm64若构建机是其他架构会直接报错退出 |
| 下载 / 更新微信失败 | 腾讯 CDN 偶发波动,重新点「下载并安装 / 更新」即可;脚本已内置主/备 CDN 自动回退 |
| 架构不支持报错 | 微信仅提供 x86_64 / arm64其他架构下载时会在面板状态里报错 |
查看运行日志:
```bash
docker logs -f wechat-on-cloud
```
查看面板日志:`docker logs -f woc-panel`;查看某实例日志:`docker logs -f woc-wx-<id>`(实例 ID 可在面板看到,或 `docker ps | grep woc-wx`)。
---
@@ -99,22 +257,37 @@ docker logs -f wechat-on-cloud
```
WechatOnCloud/
├── docker/
── Dockerfile # KasmVNC base + 中文字体 + 按架构自动下载微信原生版 deb
│ └── autostart # openbox 会话启动时拉起微信(含崩溃自重启
├── docker-compose.yml # 端口 / 数据卷 / seccomp / 鉴权(缺省值开箱即用
├── .env.example # 可选配置项(账号密码、PUID/PGID、端口、时区
├── 技术方案.md # 完整设计文档
├── .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` 中。
---
## 路线图
- [x] MVPDocker + 微信原生版 + KasmVNC,浏览器扫码登录、收发消息
- [ ] 反代 + 独立鉴权 + TLS 加固
- [x] 自研面板:cookie 鉴权 + 反代 + 子账号管理 + PWA(KasmVNC 凭据不下发前端)
- [x] 微信本体运行时下载到数据卷:面板一键「下载并安装 / 更新」,带进度条
- [x] 多实例管理 + 按账号的实例访问权限(RBAC)
- [x] 预构建多架构镜像发布到 GHCR + GitHub Actions 自动化
- [ ] 面板外层 TLS / 陌生设备验证码 / 审计日志
- [ ] 多端并发控制(控制权令牌)
- [ ] 掉登录时 web 端二维码重扫入口
- [ ] 换 Xpra 做"单窗口独立 App"观感
- [ ] 打包成飞牛原生 fpk 分发
- [~] 打包成飞牛原生 fpk 分发(工程已就绪见 [fnos/](fnos/),待真实设备验证 docker.sock 权限)
+23 -23
View File
@@ -1,35 +1,35 @@
# WechatOnCloud —— 面板为唯一服务;微信实例由面板按需动态创建(docker run)。
# 面板挂载 docker.sock 来创建/启动/删除微信实例容器,并反向代理到它们的 KasmVNC。
# 镜像全部从 GHCR 拉取,无需本地构建。要改配置:复制 .env.example 为 .env 后修改。
services:
wechat:
build:
context: ./docker
dockerfile: Dockerfile
# 不指定 platformBuildKit 默认按本机架构构建并自动下载对应微信包
# 仅当需要在 A 架构机器上为 B 架构 NAS 交叉构建时,才取消下一行注释并改成目标架构。
# platforms: ["linux/arm64"]
image: wechat-on-cloud:latest
container_name: wechat-on-cloud
panel:
image: ghcr.io/gloridust/woc-panel:${WOC_VERSION:-latest}
container_name: woc-panel
# pull_policy 用默认(missing):本地已有同名镜像就直接用,没有才去 GHCR 拉。
# 这样「发布前本地自构建」与「线上拉取」都能用同一份 compose
# 想强制更新到 GHCR 最新版:docker compose pull && docker compose up -d
# 以下全部可不填:缺省值开箱即用。要改只需复制 .env.example 为 .env 后修改。
environment:
- PORT=8080
# 新建微信实例时使用的镜像(多架构,amd64/arm64 自动匹配)
- WOC_WECHAT_IMAGE=ghcr.io/gloridust/wechat-on-cloud:${WOC_VERSION:-latest}
# 透传给每个微信实例容器(KasmVNC 基础镜像用它们降权运行)
- PUID=${WOC_PUID:-1000}
- PGID=${WOC_PGID:-1000}
- TZ=${WOC_TZ:-Asia/Shanghai}
# KasmVNC 内置 web 端基础鉴权(MVP 阶段最低限度访问控制
- CUSTOM_USER=${WOC_USER:-admin}
- PASSWORD=${WOC_PASSWORD:-wechat}
# 面板首个管理员账号(仅首次启动、无账号文件时写入;务必改掉默认密码
- PANEL_ADMIN_USER=${WOC_USER:-admin}
- PANEL_ADMIN_PASSWORD=${WOC_PASSWORD:-wechat}
- PANEL_DATA=/data/accounts.json
volumes:
# 持久化登录态与消息:微信数据写在容器内 HOME=/config 下
- ./data:/config
# 面板账号数据(用户、实例元信息、密码哈希)
- ./data-panel:/data
# 面板经 docker 引擎创建/启动/删除微信实例容器、exec 触发下载、读取进度。
# 注意:docker.sock 等同宿主 root 权限,故实例增删仅限管理员,docker API 绝不暴露给前端。
- /var/run/docker.sock:/var/run/docker.sock
ports:
- "${WOC_HTTP_PORT:-36080}:3000" # HTTP web 客户端(宿主用冷门端口避免冲突)
- "${WOC_HTTPS_PORT:-36443}:3001" # HTTPS web 客户端(自签证书)
# 微信内部沙箱需要,否则可能起不来
security_opt:
- seccomp:unconfined
# 微信/Chromium 系共享内存,过小会崩
shm_size: "1gb"
- "${WOC_HTTP_PORT:-36080}:8080" # 面板 = 唯一对外入口
restart: unless-stopped
+26 -24
View File
@@ -3,41 +3,24 @@
# 自带 Xvfb + openbox + KasmVNC + web 客户端(3000/3001)
FROM lscr.io/linuxserver/baseimage-kasmvnc:debianbookworm
# TARGETARCH 由 Docker BuildKit 自动注入:本机构建=本机架构,
# 因此在 x86 NAS / arm NAS / Apple Silicon Mac 上 `docker compose build` 都会自动选对包。
ARG TARGETARCH
# CDN 主链失败时回退备链
ARG WECHAT_CDN="https://dldir1v6.qq.com/weixin/Universal/Linux"
ARG WECHAT_CDN_FALLBACK="https://dldir1.qq.com/weixin/Universal/Linux"
# 微信本体不再在构建时下载/安装,改为运行时由面板点击下载、解压到数据卷 /config
#(见 wechat-ctl.sh)。构建时只装:中文字体、语言环境、下载工具,以及微信运行所
# 需的全部系统库(这些需 root/apt,必须 bake 进镜像)。
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
# 1) 按架构映射微信 deb 文件名
case "${TARGETARCH}" in \
amd64) WECHAT_FILE="WeChatLinux_x86_64.deb" ;; \
arm64) WECHAT_FILE="WeChatLinux_arm64.deb" ;; \
*) echo "不支持的架构: ${TARGETARCH}(微信仅提供 x86_64 / arm64" >&2; exit 1 ;; \
esac; \
echo "构建架构=${TARGETARCH}, 微信包=${WECHAT_FILE}"; \
# 2) 依赖:中文字体(否则界面/消息显示方块)+ 语言环境 + 下载工具
# 中文字体(否则界面/消息显示方块)+ 语言环境 + 下载/解压工具
apt-get update; \
apt-get install -y --no-install-recommends \
curl ca-certificates locales \
curl ca-certificates locales dpkg \
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
libnss3 libgbm1 libasound2 libxss1; \
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
locale-gen; \
# 3) 下载微信 deb(主链失败回退备链)
( curl -fSL --retry 3 -A "Mozilla/5.0" -o /tmp/wechat.deb "${WECHAT_CDN}/${WECHAT_FILE}" \
|| curl -fSL --retry 3 -A "Mozilla/5.0" -o /tmp/wechat.deb "${WECHAT_CDN_FALLBACK}/${WECHAT_FILE}" ); \
# 4) 用 apt 安装本地 deb 以自动解决依赖
apt-get install -y --no-install-recommends /tmp/wechat.deb; \
rm -f /tmp/wechat.deb; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*
# 微信 deb 未声明、但运行时需要的额外库(单独成层,避免动到上面缓存的下载层)。
# 微信运行时需要、但官方 deb 未声明的额外库(单独成层,避免动到上面缓存的安装层)。
# 微信原生版是 Qt 程序,依赖一组 xcb 平台库;libxcb-cursor0 由 Qt 动态 dlopenldd 查不到,需主动装。
RUN set -eux; \
apt-get update; \
@@ -70,7 +53,26 @@ ENV LANG=zh_CN.UTF-8 \
LC_ALL=zh_CN.UTF-8 \
LIBGL_ALWAYS_SOFTWARE=1
# openbox 会话启动时执行此脚本拉起微信
# 让 KasmVNC web 客户端默认开启 IME 输入模式:
# 用户用本地(客户端)输入法打中文,拼音联想在本地完成、只把成品汉字发进容器,无需容器内装 IME。
# 默认值仅在浏览器未存过该设置时生效,不会覆盖用户手动改过的偏好。
# 注意:实际加载的是 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" ]
# 微信下载/解压控制脚本(运行时由面板经 docker exec 触发,状态写入数据卷 /config/.woc-state
COPY wechat-ctl.sh /woc/wechat-ctl.sh
RUN chmod +x /woc/wechat-ctl.sh
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信
COPY autostart /defaults/autostart
RUN chmod +x /defaults/autostart
+20 -13
View File
@@ -1,25 +1,32 @@
#!/bin/bash
# 由 KasmVNC base 的 openbox 会话在桌面就绪后执行。
# 定位微信可执行文件并常驻拉起:用户在 web 端关掉窗口后会自动重开,避免黑屏。
# 由 KasmVNC base 的 openbox 会话在桌面就绪后执行(以 app 用户身份)
# 微信本体由面板经 docker exec 触发下载/解压到数据卷 /config/wechat(见 wechat-ctl.sh),
# 本脚本只负责:等待微信就绪 + 常驻拉起(关窗自动重开;更新后从新版本路径重启)。
set -u
# 官方 deb 安装到 /opt/wechat/wechat;同时兜底查 PATH
if [ -x /opt/wechat/wechat ]; then
WECHAT_BIN=/opt/wechat/wechat
else
WECHAT_BIN="$(command -v wechat || true)"
fi
if [ -z "${WECHAT_BIN}" ]; then
echo "[autostart] 未找到微信可执行文件,请检查 deb 是否安装成功" >&2
exit 1
fi
WECHAT_BIN=/config/wechat/opt/wechat/wechat
# 容器内无 GPU,强制软件渲染
export LIBGL_ALWAYS_SOFTWARE=1
# 1) 等待微信安装就绪(首次需在面板点「下载并安装」)
notified=0
while [ ! -x "${WECHAT_BIN}" ]; do
if [ "${notified}" -eq 0 ]; then
echo "[autostart] 微信尚未安装,等待面板触发下载…(首次使用请在面板点「下载并安装微信」)"
notified=1
fi
sleep 2
done
# 3) 常驻拉起微信
while true; do
if [ ! -x "${WECHAT_BIN}" ]; then
# 更新过程中本体被临时挪走,等就位再继续
sleep 2
continue
fi
echo "[autostart] 启动微信: ${WECHAT_BIN}"
"${WECHAT_BIN}"
echo "[autostart] 微信已退出,2 秒后重启"
+135
View File
@@ -0,0 +1,135 @@
#!/bin/bash
# 微信下载/解压控制脚本。由面板经 docker exec 触发(不再用共享卷/守护进程):
# install / update 下载官方 deb、dpkg-deb -x 解压到 /config/wechat、原子替换、pkill 让 autostart 用新版重启
# status 输出当前状态 JSON(面板轮询用)
# 用 docker exec --user abc 调用,文件归属与微信运行用户一致。
set -u
STATE_DIR="${WOC_STATE_DIR:-/config/.woc-state}"
STATUS_FILE="$STATE_DIR/status.json"
INSTALL_DIR="/config/wechat" # dpkg-deb -x 解压根;二进制在 opt/wechat/wechat
WORK_DIR="/config/.woc-dl" # 下载/解压临时区(同卷,便于原子 mv)
VERSION_FILE="$INSTALL_DIR/.woc-version"
CDN_MAIN="${WECHAT_CDN:-https://dldir1v6.qq.com/weixin/Universal/Linux}"
CDN_FALLBACK="${WECHAT_CDN_FALLBACK:-https://dldir1.qq.com/weixin/Universal/Linux}"
UA="Mozilla/5.0"
wechat_bin() { echo "$INSTALL_DIR/opt/wechat/wechat"; }
is_installed() { [ -x "$(wechat_bin)" ]; }
cur_version() { [ -f "$VERSION_FILE" ] && cat "$VERSION_FILE" || echo ""; }
deb_filename() {
case "$(dpkg --print-architecture 2>/dev/null)" in
amd64) echo "WeChatLinux_x86_64.deb" ;;
arm64) echo "WeChatLinux_arm64.deb" ;;
*) echo "" ;;
esac
}
# write_status <phase> <percent> <message>
# phase: idle|downloading|extracting|installing|done|error
write_status() {
local phase="$1" percent="$2" message="$3"
local installed=false version
is_installed && installed=true
version="$(cur_version)"
mkdir -p "$STATE_DIR"
cat > "$STATUS_FILE.tmp" <<EOF
{"phase":"$phase","percent":$percent,"installed":$installed,"version":"$version","message":"$message","updatedAt":$(date +%s)}
EOF
mv -f "$STATUS_FILE.tmp" "$STATUS_FILE"
}
print_status() {
if [ -f "$STATUS_FILE" ]; then
cat "$STATUS_FILE"
elif is_installed; then
echo "{\"phase\":\"done\",\"percent\":100,\"installed\":true,\"version\":\"$(cur_version)\",\"message\":\"已安装\",\"updatedAt\":$(date +%s)}"
else
echo "{\"phase\":\"idle\",\"percent\":0,\"installed\":false,\"version\":\"\",\"message\":\"未安装\",\"updatedAt\":$(date +%s)}"
fi
}
do_install() {
local file url tmp pid total cur pct rc=1
file="$(deb_filename)"
if [ -z "$file" ]; then
write_status error 0 "不支持的架构:微信仅提供 x86_64 / arm64"
return
fi
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
tmp="$WORK_DIR/wechat.deb"
# 取总大小用于进度(HEAD 可能失败,失败则进度走不确定值 -1)
for base in "$CDN_MAIN" "$CDN_FALLBACK"; do
total="$(curl -fsSLI -A "$UA" "$base/$file" 2>/dev/null | tr -d '\r' \
| awk 'tolower($1)=="content-length:"{v=$2} END{print v}')"
[ -n "${total:-}" ] && url="$base/$file" && break
done
: "${total:=0}" "${url:=$CDN_MAIN/$file}"
write_status downloading 0 "正在下载微信安装包"
# 后台下载 + 轮询已下字节算百分比(下载占 0~90)
for base in "$CDN_MAIN" "$CDN_FALLBACK"; do
curl -fSL --retry 3 -A "$UA" -o "$tmp" "$base/$file" & pid=$!
while kill -0 "$pid" 2>/dev/null; do
if [ "${total:-0}" -gt 0 ] 2>/dev/null; then
cur="$(stat -c%s "$tmp" 2>/dev/null || echo 0)"
pct=$(( cur * 90 / total ))
[ "$pct" -gt 90 ] && pct=90
write_status downloading "$pct" "正在下载微信安装包"
else
write_status downloading -1 "正在下载微信安装包"
fi
sleep 1
done
wait "$pid"; rc=$?
[ "$rc" -eq 0 ] && break
write_status downloading -1 "主线路失败,尝试备用线路"
done
if [ "$rc" -ne 0 ]; then
write_status error 0 "下载失败,请检查网络后重试"
rm -rf "$WORK_DIR"; return
fi
write_status extracting 92 "正在解压安装"
local newroot="$WORK_DIR/new"
rm -rf "$newroot"; mkdir -p "$newroot"
if ! dpkg-deb -x "$tmp" "$newroot" 2>/dev/null; then
write_status error 0 "解压失败,安装包可能损坏"
rm -rf "$WORK_DIR"; return
fi
local ver; ver="$(dpkg-deb -f "$tmp" Version 2>/dev/null || echo "")"
if [ ! -x "$newroot/opt/wechat/wechat" ]; then
write_status error 0 "解压后未找到微信可执行文件"
rm -rf "$WORK_DIR"; return
fi
write_status installing 96 "正在安装"
# 原子替换:先挪走旧版再就位新版,最后清理
rm -rf "$INSTALL_DIR.old"
[ -e "$INSTALL_DIR" ] && mv "$INSTALL_DIR" "$INSTALL_DIR.old"
mv "$newroot" "$INSTALL_DIR"
echo "$ver" > "$VERSION_FILE"
rm -rf "$INSTALL_DIR.old" "$WORK_DIR"
write_status done 100 "安装完成"
# 让 autostart 循环用新版本重启微信(若正在运行)
pkill -f "$INSTALL_DIR/opt/wechat/wechat" 2>/dev/null || true
}
case "${1:-status}" in
status)
print_status
;;
install|update)
do_install
;;
*)
echo "用法: $0 {install|update|status}" >&2; exit 1 ;;
esac
+39
View File
@@ -0,0 +1,39 @@
# 飞牛 fnOS 应用打包(.fpk
把 WechatOnCloud 打成飞牛应用中心可安装的 `.fpk`。本目录 `woc/` 是一个按飞牛开发文档组织的 Docker 类应用工程,应用中心安装后会直接执行 `app/docker/docker-compose.yaml` 来启停应用。
> 飞牛开发文档:<https://developer.fnnas.com/docs/guide/>fpk 结构、manifest、Docker 应用、向导等)
## 目录结构
```
woc/
├── app/docker/docker-compose.yaml # 应用中心直接执行的 compose= 仓库根 compose,挂 docker.sock
├── cmd/main # 生命周期入口:start/stop 返回 0status 看 woc-panel 容器状态
├── config/privilege # 运行身份(run-as=package
├── config/resource # 默认共享目录
├── wizard/install # 安装向导:填管理员账号/密码、端口、镜像版本(字段名即环境变量名)
├── manifest # INI 元数据(appname/version/display_name/platform=all/service_port…)
├── ICON.PNG # 64×64
└── ICON_256.PNG # 256×256
```
## 构建
需先在开发机装飞牛官方 `fnpack` CLI(见上方文档)。
```bash
cd fnos/woc
fnpack build # 产出 wechat-on-cloud-<version>.fpk
```
然后在飞牛「应用中心 → 手动安装」上传该 `.fpk`。安装向导会要求设置管理员密码与端口。
## ⚠️ 重要前提与待验证项
本工程依据公开开发文档编写,**尚未在真实 fnOS 设备上验证**,上架前请实测以下两点:
1. **docker.sock 权限(关键)**:面板需挂载宿主 `/var/run/docker.sock` 来按需创建/销毁微信实例容器,这等同宿主 root 权限。`config/privilege` 当前设为 `run-as=package`(普通应用用户);若该用户无权访问 docker.sock,新建实例会失败。届时可能需要把应用用户加入 `docker` 组,或申请飞牛 `run-as=root` 合作权限(官方文档注明 root 需官方合作)。
2. **向导环境变量注入 compose**`wizard/install` 的字段名(`WOC_USER`/`WOC_PASSWORD`/`WOC_HTTP_PORT`/`WOC_VERSION`)会成为环境变量,compose 里用 `${...}` 取用。请确认应用中心执行 compose 时这些变量确实可见(不同 fnOS 版本行为可能不同)。
镜像本身从 GHCR 拉取(多架构 amd64/arm64),与仓库根 `docker-compose.yml` 完全一致,故 fpk 内不含镜像、体积很小。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+24
View File
@@ -0,0 +1,24 @@
# fnOS 应用中心会直接执行本 compose 来启停应用。
# 与仓库根的 docker-compose.yml 等价:面板为唯一服务,挂 docker.sock 按需创建微信实例。
# 注意:面板需要访问宿主 docker.sock 来动态创建/销毁微信实例容器(等同宿主 root 权限)。
# 因此本应用必须能挂载 /var/run/docker.sock;若 fnOS 沙箱禁止,应用将无法新建实例。
services:
panel:
image: ghcr.io/gloridust/woc-panel:${WOC_VERSION:-latest}
container_name: woc-panel
pull_policy: always
environment:
- PORT=8080
- WOC_WECHAT_IMAGE=ghcr.io/gloridust/wechat-on-cloud:${WOC_VERSION:-latest}
- PUID=${WOC_PUID:-1000}
- PGID=${WOC_PGID:-1000}
- TZ=${WOC_TZ:-Asia/Shanghai}
- PANEL_ADMIN_USER=${WOC_USER:-admin}
- PANEL_ADMIN_PASSWORD=${WOC_PASSWORD:-wechat}
- PANEL_DATA=/data/accounts.json
volumes:
- ./data-panel:/data
- /var/run/docker.sock:/var/run/docker.sock
ports:
- "${WOC_HTTP_PORT:-36080}:8080"
restart: unless-stopped
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# fnOS 应用生命周期入口。Docker 类应用的启停由应用中心直接执行 compose,
# 这里 start/stop 只需返回成功;status 用首个容器的运行状态代表应用状态。
set -u
COMPOSE_FILE="$(dirname "$0")/../app/docker/docker-compose.yaml"
CONTAINER="woc-panel"
is_running() {
[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null)" = "true" ]
}
case "${1:-}" in
start)
exit 0
;;
stop)
exit 0
;;
status)
if is_running; then exit 0; else exit 3; fi
;;
*)
exit 0
;;
esac
+7
View File
@@ -0,0 +1,7 @@
{
"defaults": {
"run-as": "package"
},
"username": "wechat-on-cloud",
"groupname": "wechat-on-cloud"
}
+10
View File
@@ -0,0 +1,10 @@
{
"data-share": {
"shares": [
{
"name": "wechat-on-cloud",
"permission": "rw"
}
]
}
}
+13
View File
@@ -0,0 +1,13 @@
appname=wechat-on-cloud
version=1.0.0
display_name=云上微信
desc=在飞牛 NAS 上运行服务端微信,浏览器多端共享同一会话;支持多实例与按账号的访问权限。面板为唯一对外入口,微信实例由面板按需创建。
platform=all
source=thirdparty
maintainer=Gloridust
maintainer_url=https://github.com/Gloridust/WechatOnCloud
distributor=Gloridust
distributor_url=https://github.com/Gloridust/WechatOnCloud
os_min_version=0.9.0
service_port=8080
checkport=true
+37
View File
@@ -0,0 +1,37 @@
[
{
"stepTitle": "管理员账号",
"items": [
{
"type": "string",
"field": "WOC_USER",
"label": "管理员用户名",
"defaultValue": "admin",
"rules": [{ "required": true, "message": "请输入管理员用户名" }]
},
{
"type": "password",
"field": "WOC_PASSWORD",
"label": "管理员密码(务必修改默认值)",
"rules": [{ "required": true, "message": "请输入管理员密码" }]
}
]
},
{
"stepTitle": "运行参数",
"items": [
{
"type": "string",
"field": "WOC_HTTP_PORT",
"label": "对外访问端口",
"defaultValue": "36080"
},
{
"type": "string",
"field": "WOC_VERSION",
"label": "镜像版本(建议钉到具体版本)",
"defaultValue": "latest"
}
]
}
]
+23
View File
@@ -0,0 +1,23 @@
# 面板镜像:前端 Vite 打包 + 后端 Fastify 网关(多架构 amd64/arm64
# --- 1) 构建前端 ---
FROM node:22-slim AS web
WORKDIR /web
COPY web/package.json ./
RUN npm install
COPY web/ ./
RUN npm run build
# --- 2) 后端运行时 ---
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY server/package.json ./
RUN npm install
COPY server/ ./
COPY --from=web /web/dist ./web-dist
ENV STATIC_DIR=/app/web-dist \
PORT=8080
EXPOSE 8080
CMD ["npm", "run", "start"]
+2501
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "woc-panel-server",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts"
},
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/static": "^7.0.4",
"bcryptjs": "^2.4.3",
"dockerode": "^4.0.2",
"fastify": "^4.28.1",
"http-proxy": "^1.18.1",
"tsx": "^4.19.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/dockerode": "^3.3.31",
"@types/http-proxy": "^1.17.15",
"@types/node": "^22.7.4",
"typescript": "^5.6.2"
}
}
+186
View File
@@ -0,0 +1,186 @@
import { hostname } from 'node:os';
import Docker from 'dockerode';
import type { Instance } from './store.js';
const WECHAT_IMAGE = process.env.WOC_WECHAT_IMAGE || 'ghcr.io/gloridust/wechat-on-cloud:latest';
const PUID = process.env.PUID || '1000';
const PGID = process.env.PGID || '1000';
const TZ = process.env.TZ || 'Asia/Shanghai';
const SHM_SIZE = 1024 * 1024 * 1024; // 1gb
const docker = new Docker(); // 默认连 /var/run/docker.sock
// 面板自身所在的 docker 网络名;新实例都 attach 到它,便于按容器名互访。
let networkName: string | null = process.env.WOC_DOCKER_NETWORK || null;
export type RuntimeState = 'running' | 'stopped' | 'missing';
// 启动时探测面板自身网络(容器内 hostname = 容器短 id)。失败不致命:
// 退回 WOC_DOCKER_NETWORK 或 nullnull 时用 docker 默认 bridge,靠 IP 不靠名字会有问题,故尽量探测成功)。
export async function ensureNetwork(): Promise<string | null> {
if (networkName) return networkName;
try {
const self = docker.getContainer(hostname());
const info = await self.inspect();
const nets = Object.keys(info.NetworkSettings?.Networks || {}).filter((n) => n !== 'none' && n !== 'host');
if (nets.length > 0) networkName = nets[0];
} catch (e: any) {
console.warn('[docker] 无法探测面板网络(本地开发或缺少 docker.sock 时正常):', e?.message || e);
}
return networkName;
}
function envList(inst: Instance): string[] {
return [
`PUID=${PUID}`,
`PGID=${PGID}`,
`TZ=${TZ}`,
`CUSTOM_USER=${inst.kasmUser}`,
`PASSWORD=${inst.kasmPassword}`,
];
}
// 确保微信镜像在本地存在;缺失则从 GHCR 拉取(首次新建实例时镜像通常还没拉过)。
async function ensureImage(): Promise<void> {
try {
await docker.getImage(WECHAT_IMAGE).inspect();
return;
} catch {
/* 本地没有,下面拉取 */
}
await pullImage();
}
// 创建并启动一个微信实例容器。若同名容器已存在则先移除(仅容器,不动卷)。
export async function runInstance(inst: Instance): Promise<void> {
const net = await ensureNetwork();
await ensureImage();
try {
const existing = docker.getContainer(inst.containerName);
await existing.inspect();
await existing.remove({ force: true });
} catch {
/* 不存在,正常 */
}
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' },
},
});
await container.start();
}
// 确保实例容器在运行:缺失则按需创建(不会重建已有卷),停止则启动。
export async function ensureRunning(inst: Instance): Promise<void> {
try {
const c = docker.getContainer(inst.containerName);
const info = await c.inspect();
if (!info.State?.Running) await c.start();
} catch {
await runInstance(inst);
}
}
export async function removeInstance(inst: Instance, purgeVolume: boolean): Promise<void> {
try {
const c = docker.getContainer(inst.containerName);
await c.remove({ force: true });
} catch {
/* 容器可能已不存在 */
}
if (purgeVolume) {
try {
await docker.getVolume(inst.volumeName).remove({ force: true } as any);
} catch {
/* 卷可能不存在 */
}
}
}
export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
try {
const info = await docker.getContainer(inst.containerName).inspect();
return info.State?.Running ? 'running' : 'stopped';
} catch {
return 'missing';
}
}
// 在实例容器内执行命令,返回 stdout(demux 后只取标准输出)。
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' });
const stream = await exec.start({ hijack: true, stdin: false });
return await new Promise<string>((resolve, reject) => {
let out = '';
let err = '';
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('error', reject);
});
}
// 触发下载/安装(detached,立即返回,后台下载)。
export async function triggerWechat(inst: Instance, cmd: 'install' | 'update'): Promise<void> {
const c = docker.getContainer(inst.containerName);
const exec = await c.exec({
Cmd: ['/woc/wechat-ctl.sh', cmd === 'update' ? 'update' : 'install'],
AttachStdout: false,
AttachStderr: false,
User: 'abc',
});
await exec.start({ Detach: true });
}
export interface WechatStatus {
phase: string;
percent: number;
installed: boolean;
version: string;
message: string;
updatedAt: number;
}
const DEFAULT_STATUS: WechatStatus = { phase: 'idle', percent: 0, installed: false, version: '', message: '未安装', updatedAt: 0 };
export async function wechatStatus(inst: Instance): Promise<WechatStatus> {
try {
const raw = await execCapture(inst, ['/woc/wechat-ctl.sh', 'status']);
const json = JSON.parse(raw.trim());
return { ...DEFAULT_STATUS, ...json };
} catch {
return DEFAULT_STATUS;
}
}
// 拉取微信镜像(首次部署/更新镜像用)。返回拉取日志的最后状态。
export async function pullImage(onProgress?: (line: any) => void): Promise<void> {
return await new Promise((resolve, reject) => {
docker.pull(WECHAT_IMAGE, (err: any, stream: NodeJS.ReadableStream) => {
if (err) return reject(err);
docker.modem.followProgress(
stream,
(e: any) => (e ? reject(e) : resolve()),
(ev: any) => onProgress?.(ev),
);
});
});
}
// 实例容器名(供反代构造 target)。
export function instanceTarget(inst: Instance): string {
return `http://${inst.containerName}:3000`;
}
export { WECHAT_IMAGE };
+408
View File
@@ -0,0 +1,408 @@
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import cookie from '@fastify/cookie';
import fstatic from '@fastify/static';
import httpProxy from 'http-proxy';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import type { IncomingMessage } from 'node:http';
import type { Socket } from 'node:net';
import {
initStore,
findByUsername,
findById,
verifyPassword,
publicUser,
listUsers,
createSub,
setDisabled,
resetPassword,
deleteUser,
setUserInstances,
listInstances,
findInstance,
userInstances,
userCanAccess,
createInstance,
removeInstance as removeInstanceRecord,
setInstanceUsers,
publicInstance,
type User,
type Instance,
} from './store.js';
import {
ensureNetwork,
ensureRunning,
runInstance,
removeInstance as removeInstanceContainer,
instanceRuntime,
triggerWechat,
wechatStatus,
instanceTarget,
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 8080);
const HOST = process.env.HOST || '0.0.0.0';
const STATIC_DIR = process.env.STATIC_DIR || join(__dirname, '../../web/dist');
const COOKIE = 'woc_sess';
function basicAuth(inst: Instance) {
return 'Basic ' + Buffer.from(`${inst.kasmUser}:${inst.kasmPassword}`).toString('base64');
}
initStore();
const app = Fastify({ logger: true, trustProxy: true });
await app.register(cookie);
// ---------- 鉴权辅助 ----------
function currentUser(req: FastifyRequest): User | null {
const token = req.cookies?.[COOKIE];
const s = getSession(token);
if (!s) return null;
const u = findById(s.userId);
if (!u || u.disabled) return null;
return u;
}
function requireAuth(req: FastifyRequest, reply: FastifyReply): User | null {
const u = currentUser(req);
if (!u) {
reply.code(401).send({ error: '未登录' });
return null;
}
return u;
}
function requireAdmin(req: FastifyRequest, reply: FastifyReply): User | null {
const u = requireAuth(req, reply);
if (!u) return null;
if (u.role !== 'admin') {
reply.code(403).send({ error: '需要管理员权限' });
return null;
}
return u;
}
// ---------- 登录 / 会话 ----------
app.post('/api/auth/login', async (req, reply) => {
const { username, password } = (req.body as any) ?? {};
const u = username ? findByUsername(username) : undefined;
if (!u || u.disabled || !verifyPassword(u, password ?? '')) {
return reply.code(401).send({ error: '用户名或密码错误' });
}
const token = createSession(u.id);
reply.setCookie(COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 12,
});
return { user: publicUser(u) };
});
app.post('/api/auth/logout', async (req, reply) => {
destroySession(req.cookies?.[COOKIE]);
reply.clearCookie(COOKIE, { path: '/' });
return { ok: true };
});
app.get('/api/auth/me', async (req, reply) => {
const u = currentUser(req);
if (!u) return reply.code(401).send({ error: '未登录' });
return { user: publicUser(u) };
});
// ---------- 自助改密 ----------
app.post('/api/account/password', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const { oldPassword, newPassword } = (req.body as any) ?? {};
if (!verifyPassword(u, oldPassword ?? '')) return reply.code(400).send({ error: '原密码错误' });
if (!newPassword || String(newPassword).length < 6) return reply.code(400).send({ error: '新密码至少 6 位' });
resetPassword(u.id, newPassword);
return { ok: true };
});
// ---------- 管理员:子账号管理 ----------
app.get('/api/admin/users', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
return { users: listUsers() };
});
app.post('/api/admin/users', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const { username, password } = (req.body as any) ?? {};
if (!username || !/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
return reply.code(400).send({ error: '用户名为 3-20 位字母、数字或下划线' });
}
if (!password || String(password).length < 6) return reply.code(400).send({ error: '密码至少 6 位' });
const allowedInstances = Array.isArray((req.body as any)?.allowedInstances) ? (req.body as any).allowedInstances : [];
try {
return { user: createSub(username, password, allowedInstances) };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
// 账户侧:设置某账户可访问的实例
app.post('/api/admin/users/:id/instances', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
const instanceIds = Array.isArray((req.body as any)?.instanceIds) ? (req.body as any).instanceIds : [];
try {
return { user: setUserInstances(id, instanceIds) };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
app.post('/api/admin/users/:id/disable', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const { disabled } = (req.body as any) ?? {};
const id = (req.params as any).id;
try {
const user = setDisabled(id, !!disabled);
if (disabled) destroyUserSessions(id);
return { user };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
app.post('/api/admin/users/:id/reset', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const { newPassword } = (req.body as any) ?? {};
const id = (req.params as any).id;
if (!newPassword || String(newPassword).length < 6) return reply.code(400).send({ error: '密码至少 6 位' });
try {
const user = resetPassword(id, newPassword);
destroyUserSessions(id);
return { user };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
app.delete('/api/admin/users/:id', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
try {
deleteUser(id);
destroyUserSessions(id);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
// ---------- 微信实例管理 ----------
// 列出当前用户可见实例(含运行态 + 微信安装状态)
app.get('/api/instances', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const visible = userInstances(u);
const out = await Promise.all(
visible.map(async (pub) => {
const inst = findInstance(pub.id)!;
const [runtime, wx] = await Promise.all([instanceRuntime(inst), wechatStatus(inst)]);
return { ...pub, runtime, wechat: wx };
}),
);
return { instances: out };
});
// 新建实例(仅管理员):生成凭据 + docker run + 分配访问账户
app.post('/api/admin/instances', async (req, reply) => {
const admin = requireAdmin(req, reply);
if (!admin) return;
const { name } = (req.body as any) ?? {};
const allowedUserIds = Array.isArray((req.body as any)?.allowedUserIds) ? (req.body as any).allowedUserIds : [];
if (!name || String(name).trim().length === 0 || String(name).length > 30) {
return reply.code(400).send({ error: '实例名称为 1-30 个字符' });
}
const inst = createInstance(String(name), admin.id, allowedUserIds);
try {
await runInstance(inst);
} catch (e: any) {
removeInstanceRecord(inst.id); // 容器起不来则回滚登记
return reply.code(500).send({ error: '创建容器失败:' + (e?.message || e) });
}
return { instance: publicInstance(inst) };
});
// 删除实例(仅管理员):默认保留数据卷,?purge=1 才永久删聊天记录
app.delete('/api/admin/instances/:id', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
const purge = (req.query as any)?.purge === '1' || (req.query as any)?.purge === 'true';
const inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
await removeInstanceContainer(inst, purge);
removeInstanceRecord(id);
return { ok: true };
});
// 实例侧:设置该实例可被哪些账户访问
app.post('/api/admin/instances/:id/users', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
const userIds = Array.isArray((req.body as any)?.userIds) ? (req.body as any).userIds : [];
try {
setInstanceUsers(id, userIds);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
// 该实例的微信安装状态(有访问权限即可看)
app.get('/api/instances/:id/wechat/status', 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: '无权访问该实例' });
return { status: await wechatStatus(findInstance(id)!) };
});
// 触发该实例微信下载/更新(仅管理员)
async function triggerInstanceWechat(id: string, cmd: 'install' | 'update', reply: FastifyReply) {
const inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await triggerWechat(inst, cmd);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: '无法触发安装:' + (e?.message || e) });
}
}
app.post('/api/admin/instances/:id/wechat/install', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
return triggerInstanceWechat((req.params as any).id, 'install', reply);
});
app.post('/api/admin/instances/:id/wechat/update', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
return triggerInstanceWechat((req.params as any).id, 'update', reply);
});
// ---------- 反向代理到内网 KasmVNC(按实例注入 Basic auth,会话 + 权限把守) ----------
// 单个 proxy 实例,target 与凭据逐请求指定:凭据暂存在 req 上,proxyReq 时注入。
const proxy = httpProxy.createProxyServer({ changeOrigin: true, ws: true });
proxy.on('proxyReq', (proxyReq, req) => {
const auth = (req as any)._wocAuth;
if (auth) proxyReq.setHeader('authorization', auth);
});
proxy.on('proxyReqWs', (proxyReq, req) => {
const auth = (req as any)._wocAuth;
if (auth) proxyReq.setHeader('authorization', auth);
});
// 兜底:剥掉 KasmVNC 401 的 WWW-Authenticate 头,避免浏览器弹出原生 Basic Auth 登录框。
// 正常路径下我们已注入正确凭据(不会 401);万一凭据失配,宁可桌面加载失败也绝不把登录弹窗暴露给用户。
proxy.on('proxyRes', (proxyRes) => {
delete proxyRes.headers['www-authenticate'];
});
proxy.on('error', (_err, _req, res) => {
try {
const r = res as any;
if (r && typeof r.writeHead === 'function') {
r.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
r.end('桌面服务暂时不可用');
} else if (r && typeof r.destroy === 'function') {
r.destroy();
}
} catch {
/* ignore */
}
});
// /desktop/:id/rest → rest(剥掉前缀与实例段)。返回 null 表示 url 非法。
function parseDesktopUrl(rawUrl: string): { id: string; rest: string } | null {
const m = rawUrl.match(/^\/desktop\/([0-9a-f]{6,})(\/.*|\?.*|)?$/);
if (!m) return null;
const id = m[1];
let rest = m[2] || '/';
if (rest.startsWith('?')) rest = '/' + rest;
if (rest === '') rest = '/';
return { id, rest };
}
const desktopHandler = (req: FastifyRequest, reply: FastifyReply) => {
const u = currentUser(req);
if (!u) {
reply.code(302).header('location', '/login').send();
return;
}
const parsed = parseDesktopUrl(req.raw.url || '');
if (!parsed || !userCanAccess(u, parsed.id)) {
reply.code(403).send({ error: '无权访问该实例' });
return;
}
const inst = findInstance(parsed.id)!;
reply.hijack();
req.raw.url = parsed.rest;
(req.raw as any)._wocAuth = basicAuth(inst);
proxy.web(req.raw, reply.raw, { target: instanceTarget(inst) });
};
app.all('/desktop/:id', desktopHandler);
app.all('/desktop/:id/*', desktopHandler);
// ---------- 静态 SPA + 前端路由回退 ----------
await app.register(fstatic, { root: STATIC_DIR, wildcard: false, index: ['index.html'] });
app.setNotFoundHandler((req, reply) => {
const url = req.raw.url || '';
if (url.startsWith('/api') || url.startsWith('/desktop')) {
return reply.code(404).send({ error: 'not found' });
}
return reply.sendFile('index.html');
});
// ---------- 启动 + WebSocket 升级(同样校验会话) ----------
function parseCookies(header?: string): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(';')) {
const idx = part.indexOf('=');
if (idx === -1) continue;
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
}
return out;
}
await app.ready();
app.server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => {
const parsed = req.url ? parseDesktopUrl(req.url) : null;
if (!parsed) {
socket.destroy();
return;
}
const cookies = parseCookies(req.headers.cookie);
const s = getSession(cookies[COOKIE]);
const u = s && findById(s.userId);
if (!u || u.disabled || !userCanAccess(u, parsed.id)) {
socket.destroy();
return;
}
const inst = findInstance(parsed.id)!;
req.url = parsed.rest;
(req as any)._wocAuth = basicAuth(inst);
proxy.ws(req, socket, head, { target: instanceTarget(inst) });
});
// 探测面板网络 + 重启后把已登记实例的容器拉起来
await ensureNetwork().catch(() => {});
for (const pub of listInstances()) {
try {
await ensureRunning(findInstance(pub.id)!);
} catch (e: any) {
app.log.warn(`[instance] 启动实例 ${pub.id} 失败: ${e?.message || e}`);
}
}
await app.listen({ port: PORT, host: HOST });
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)`);
+37
View File
@@ -0,0 +1,37 @@
import { randomBytes } from 'node:crypto';
interface Session {
userId: string;
expires: number;
}
const TTL_MS = 1000 * 60 * 60 * 12; // 12 小时
const sessions = new Map<string, Session>();
export function createSession(userId: string) {
const token = randomBytes(32).toString('hex');
sessions.set(token, { userId, expires: Date.now() + TTL_MS });
return token;
}
export function getSession(token?: string) {
if (!token) return null;
const s = sessions.get(token);
if (!s) return null;
if (s.expires < Date.now()) {
sessions.delete(token);
return null;
}
return s;
}
export function destroySession(token?: string) {
if (token) sessions.delete(token);
}
// 禁用/删除账号后,立即踢掉其所有在线会话
export function destroyUserSessions(userId: string) {
for (const [token, s] of sessions) {
if (s.userId === userId) sessions.delete(token);
}
}
+249
View File
@@ -0,0 +1,249 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
import { dirname } from 'node:path';
import { randomBytes, randomUUID } from 'node:crypto';
import bcrypt from 'bcryptjs';
export type Role = 'admin' | 'sub';
export interface User {
id: string;
username: string;
role: Role;
passwordHash: string;
disabled: boolean;
createdAt: string;
// 该账户可访问的微信实例 id 列表。admin 隐式全部,忽略此字段。
allowedInstances: string[];
}
export interface Instance {
id: string; // 短 id,用于容器/卷命名
name: string; // 显示名
containerName: string; // woc-wx-<id>
volumeName: string; // woc-data-<id>
kasmUser: string; // 随机生成,服务端注入反代,永不下发前端
kasmPassword: string;
createdAt: string;
createdBy: string; // userId
}
interface Data {
users: User[];
instances: Instance[];
}
const FILE = process.env.PANEL_DATA || '/data/panel/accounts.json';
let data: Data = { users: [], instances: [] };
function persist() {
mkdirSync(dirname(FILE), { recursive: true });
const tmp = `${FILE}.tmp`;
writeFileSync(tmp, JSON.stringify(data, null, 2));
renameSync(tmp, FILE);
}
function makeUser(username: string, password: string, role: Role): User {
return {
id: randomUUID(),
username,
role,
passwordHash: bcrypt.hashSync(password, 10),
disabled: false,
createdAt: new Date().toISOString(),
allowedInstances: [],
};
}
export function initStore() {
if (existsSync(FILE)) {
data = JSON.parse(readFileSync(FILE, 'utf8'));
} else {
data = { users: [], instances: [] };
}
// 迁移:补齐新增字段,兼容旧账号文件
if (!Array.isArray(data.instances)) data.instances = [];
for (const u of data.users) {
if (!Array.isArray(u.allowedInstances)) u.allowedInstances = [];
}
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'));
console.log(`[store] 已初始化管理员账号 '${username}'`);
}
persist();
}
// ---------- 用户 ----------
export function publicUser(u: User) {
return {
id: u.id,
username: u.username,
role: u.role,
disabled: u.disabled,
createdAt: u.createdAt,
allowedInstances: u.role === 'admin' ? [] : u.allowedInstances,
};
}
export function findByUsername(username: string) {
return data.users.find((u) => u.username.toLowerCase() === username.toLowerCase());
}
export function findById(id: string) {
return data.users.find((u) => u.id === id);
}
export function listUsers() {
return data.users
.slice()
.sort((a, b) => (a.role === b.role ? a.createdAt.localeCompare(b.createdAt) : a.role === 'admin' ? -1 : 1))
.map(publicUser);
}
export function verifyPassword(u: User, password: string) {
return bcrypt.compareSync(password, u.passwordHash);
}
export function createSub(username: string, password: string, allowedInstances: string[] = []) {
if (findByUsername(username)) throw new Error('用户名已存在');
const u = makeUser(username, password, 'sub');
u.allowedInstances = sanitizeInstanceIds(allowedInstances);
data.users.push(u);
persist();
return publicUser(u);
}
export function setDisabled(id: string, disabled: boolean) {
const u = findById(id);
if (!u) throw new Error('用户不存在');
if (u.role === 'admin') throw new Error('不能禁用管理员');
u.disabled = disabled;
persist();
return publicUser(u);
}
export function resetPassword(id: string, password: string) {
const u = findById(id);
if (!u) throw new Error('用户不存在');
u.passwordHash = bcrypt.hashSync(password, 10);
persist();
return publicUser(u);
}
export function deleteUser(id: string) {
const u = findById(id);
if (!u) throw new Error('用户不存在');
if (u.role === 'admin') throw new Error('不能删除管理员');
data.users = data.users.filter((x) => x.id !== id);
persist();
}
// 设置某账户可访问的实例(账户侧编辑)
export function setUserInstances(id: string, instanceIds: string[]) {
const u = findById(id);
if (!u) throw new Error('用户不存在');
if (u.role !== 'admin') u.allowedInstances = sanitizeInstanceIds(instanceIds);
persist();
return publicUser(u);
}
// ---------- 实例 ----------
function sanitizeInstanceIds(ids: string[]): string[] {
const valid = new Set(data.instances.map((i) => i.id));
return [...new Set((ids || []).filter((x) => valid.has(x)))];
}
export function publicInstance(i: Instance) {
return { id: i.id, name: i.name, createdAt: i.createdAt, createdBy: i.createdBy };
}
export function listInstances() {
return data.instances.slice().sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
export function findInstance(id: string) {
return data.instances.find((i) => i.id === id);
}
// 当前用户可见的实例(admin 全部,sub 按 allowedInstances
export function userInstances(u: User) {
if (u.role === 'admin') return listInstances();
const allowed = new Set(u.allowedInstances);
return listInstances().filter((i) => allowed.has(i.id));
}
export function userCanAccess(u: User, instanceId: string) {
if (u.role === 'admin') return !!findInstance(instanceId);
return u.allowedInstances.includes(instanceId) && !!findInstance(instanceId);
}
export function createInstance(name: string, createdBy: string, allowedUserIds: string[] = []) {
const id = randomBytes(5).toString('hex'); // 10 hex chars
const inst: Instance = {
id,
name: name.trim() || `微信-${id.slice(0, 4)}`,
containerName: `woc-wx-${id}`,
volumeName: `woc-data-${id}`,
kasmUser: 'woc',
// 用 hex(仅 0-9a-f):容器内 init 脚本以 `openssl passwd -apr1 ${PASSWORD}` 未加引号方式生成 .htpasswd
// base64url 可能含前导 '-' 而被 openssl 当作命令行选项,导致密码哈希为空、所有鉴权失败。hex 不含任何 shell 特殊字符。
kasmPassword: randomBytes(24).toString('hex'),
createdAt: new Date().toISOString(),
createdBy,
};
data.instances.push(inst);
// 把访问权限写到选中的账户上
for (const uid of allowedUserIds || []) {
const u = findById(uid);
if (u && u.role !== 'admin' && !u.allowedInstances.includes(id)) {
u.allowedInstances.push(id);
}
}
persist();
return inst;
}
export function removeInstance(id: string) {
const inst = findInstance(id);
if (!inst) throw new Error('实例不存在');
data.instances = data.instances.filter((i) => i.id !== id);
// 从所有账户的可访问列表里移除
for (const u of data.users) {
u.allowedInstances = u.allowedInstances.filter((x) => x !== id);
}
persist();
return inst;
}
// 设置某实例可被哪些账户访问(实例侧编辑)
export function setInstanceUsers(id: string, userIds: string[]) {
const inst = findInstance(id);
if (!inst) throw new Error('实例不存在');
const allow = new Set(userIds || []);
for (const u of data.users) {
if (u.role === 'admin') continue;
const has = u.allowedInstances.includes(id);
if (allow.has(u.id) && !has) u.allowedInstances.push(id);
if (!allow.has(u.id) && has) u.allowedInstances = u.allowedInstances.filter((x) => x !== id);
}
persist();
return inst;
}
// 已登记一个实例(迁移用:复用旧 ./data 卷)。返回是否新建。
export function registerExistingInstance(opts: {
name: string;
containerName: string;
volumeName: string;
kasmUser: string;
kasmPassword: string;
createdBy: string;
}) {
const id = randomBytes(5).toString('hex');
const inst: Instance = { id, createdAt: new Date().toISOString(), ...opts };
data.instances.push(inst);
persist();
return inst;
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noImplicitAny": false,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src"]
}
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#07C160" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="云微信" />
<link rel="apple-touch-icon" href="/icon-180.png" />
<title>云微信</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6149
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "woc-panel-web",
"private": true,
"type": "module",
"scripts": {
"icons": "node scripts/gen-icons.mjs",
"dev": "vite",
"build": "npm run icons && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.2",
"vite": "^5.4.8",
"vite-plugin-pwa": "^0.20.5"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#07C160"/>
<text x="50" y="50" font-family="-apple-system, 'PingFang SC', sans-serif" font-size="56" font-weight="700" fill="#ffffff" text-anchor="middle" dominant-baseline="central"></text>
</svg>

After

Width:  |  Height:  |  Size: 338 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+83
View File
@@ -0,0 +1,83 @@
// 生成 PWA / Apple 图标:微信绿圆角方块(纯前端依赖,无需外部工具)。
// 想换成更精致的图标,直接用设计稿替换 public/icon-*.png 即可。
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 OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crc]);
}
function makePng(size) {
const radius = Math.round(size * 0.22);
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
for (let x = 0; x < size; x++) {
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;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
}
mkdirSync(OUT, { recursive: true });
for (const [name, size] of [
['icon-192.png', 192],
['icon-512.png', 512],
['icon-180.png', 180],
]) {
writeFileSync(join(OUT, name), makePng(size));
console.log('generated', name);
}
+64
View File
@@ -0,0 +1,64 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { AuthProvider, useAuth } from './auth';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Admin from './pages/Admin';
import Desktop from './pages/Desktop';
import type { ReactNode } from 'react';
function Splash() {
return (
<div className="center-screen">
<div className="spinner" />
</div>
);
}
function RequireAuth({ children, admin }: { children: ReactNode; admin?: boolean }) {
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}</>;
}
function Shell() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<RequireAuth>
<Dashboard />
</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>
);
}
+82
View File
@@ -0,0 +1,82 @@
export interface PanelUser {
id: string;
username: string;
role: 'admin' | 'sub';
disabled: boolean;
createdAt: string;
allowedInstances: string[]; // admin 为空数组(隐式全部)
}
export type WechatPhase = 'idle' | 'downloading' | 'extracting' | 'installing' | 'done' | 'error';
export interface WechatStatus {
phase: WechatPhase;
percent: number; // -1 表示进度不确定
installed: boolean;
version: string;
message: string;
updatedAt: number;
}
export type RuntimeState = 'running' | 'stopped' | 'missing';
export interface PanelInstance {
id: string;
name: string;
createdAt: string;
createdBy: string;
}
export interface InstanceWithStatus extends PanelInstance {
runtime: RuntimeState;
wechat: WechatStatus;
}
async function req<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
// 仅在有 body 时声明 JSON content-type:否则 Fastify 对「空 body + application/json」会报 400
const headers = opts.body ? { 'content-type': 'application/json', ...opts.headers } : opts.headers;
const res = await fetch(path, {
credentials: 'same-origin',
...opts,
headers,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as any).error || `请求失败 (${res.status})`);
return data as T;
}
export const api = {
me: () => req<{ user: PanelUser }>('/api/auth/me'),
login: (username: string, password: string) =>
req<{ user: PanelUser }>('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
logout: () => req('/api/auth/logout', { method: 'POST' }),
changePassword: (oldPassword: string, newPassword: string) =>
req('/api/account/password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }) }),
// 子账号
listUsers: () => req<{ users: PanelUser[] }>('/api/admin/users'),
createUser: (username: string, password: string, allowedInstances: string[] = []) =>
req<{ user: PanelUser }>('/api/admin/users', {
method: 'POST',
body: JSON.stringify({ username, password, allowedInstances }),
}),
setDisabled: (id: string, disabled: boolean) =>
req<{ user: PanelUser }>(`/api/admin/users/${id}/disable`, { method: 'POST', body: JSON.stringify({ disabled }) }),
resetUser: (id: string, newPassword: string) =>
req<{ user: PanelUser }>(`/api/admin/users/${id}/reset`, { method: 'POST', body: JSON.stringify({ newPassword }) }),
deleteUser: (id: string) => req(`/api/admin/users/${id}`, { method: 'DELETE' }),
setUserInstances: (id: string, instanceIds: string[]) =>
req<{ user: PanelUser }>(`/api/admin/users/${id}/instances`, { method: 'POST', body: JSON.stringify({ instanceIds }) }),
// 微信实例
listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'),
createInstance: (name: string, allowedUserIds: string[] = []) =>
req<{ instance: PanelInstance }>('/api/admin/instances', {
method: 'POST',
body: JSON.stringify({ name, allowedUserIds }),
}),
deleteInstance: (id: string, purge = false) =>
req(`/api/admin/instances/${id}${purge ? '?purge=1' : ''}`, { method: 'DELETE' }),
setInstanceUsers: (id: string, userIds: string[]) =>
req(`/api/admin/instances/${id}/users`, { method: 'POST', body: JSON.stringify({ userIds }) }),
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' }),
};
+46
View File
@@ -0,0 +1,46 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { api, type PanelUser } from './api';
interface AuthCtx {
user: PanelUser | null;
loading: boolean;
refresh: () => Promise<void>;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const Ctx = createContext<AuthCtx>(null!);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<PanelUser | null>(null);
const [loading, setLoading] = useState(true);
const refresh = async () => {
try {
const { user } = await api.me();
setUser(user);
} catch {
setUser(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
refresh();
}, []);
const login = async (username: string, password: string) => {
const { user } = await api.login(username, password);
setUser(user);
};
const logout = async () => {
await api.logout().catch(() => {});
setUser(null);
};
return <Ctx.Provider value={{ user, loading, refresh, login, logout }}>{children}</Ctx.Provider>;
}
export const useAuth = () => useContext(Ctx);
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+438
View File
@@ -0,0 +1,438 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, type PanelUser, type InstanceWithStatus } from '../api';
export default function Admin() {
const nav = useNavigate();
const [users, setUsers] = useState<PanelUser[]>([]);
const [instances, setInstances] = useState<InstanceWithStatus[]>([]);
const [err, setErr] = useState('');
const [creatingUser, setCreatingUser] = useState(false);
const [creatingInst, setCreatingInst] = useState(false);
const [assignInst, setAssignInst] = useState<InstanceWithStatus | null>(null); // 给实例选账户
const [assignUser, setAssignUser] = useState<PanelUser | null>(null); // 给账户选实例
const subs = users.filter((u) => u.role !== 'admin');
const load = async () => {
try {
const [{ users }, { instances }] = await Promise.all([api.listUsers(), api.listInstances()]);
setUsers(users);
setInstances(instances);
} catch (e: any) {
setErr(e.message);
}
};
useEffect(() => {
load();
}, []);
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('已重置');
} catch (e: any) {
alert(e.message);
}
};
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;
}
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('/')}>
</button>
<span className="topbar-title"></span>
<span style={{ width: 48 }} />
</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>
</div>
))}
</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>
))}
</div>
</main>
{creatingUser && (
<CreateUser
instances={instances}
onClose={() => setCreatingUser(false)}
onDone={() => {
setCreatingUser(false);
load();
}}
/>
)}
{creatingInst && (
<CreateInstance
subs={subs}
onClose={() => setCreatingInst(false)}
onDone={() => {
setCreatingInst(false);
load();
}}
/>
)}
{assignInst && (
<AssignUsers
inst={assignInst}
subs={subs}
onClose={() => setAssignInst(null)}
onDone={() => {
setAssignInst(null);
load();
}}
/>
)}
{assignUser && (
<AssignInstances
user={assignUser}
instances={instances}
onClose={() => setAssignUser(null)}
onDone={() => {
setAssignUser(null);
load();
}}
/>
)}
</div>
);
}
// 通用 chip 多选
function ChipMultiSelect({
options,
selected,
onToggle,
empty,
}: {
options: { id: string; label: string }[];
selected: Set<string>;
onToggle: (id: string) => void;
empty: string;
}) {
if (options.length === 0) return <div className="muted small">{empty}</div>;
return (
<div className="chip-row chip-row-pick">
{options.map((o) => (
<button
type="button"
key={o.id}
className={'chip chip-toggle' + (selected.has(o.id) ? ' on' : '')}
onClick={() => onToggle(o.id)}
>
{o.label}
</button>
))}
</div>
);
}
function CreateUser({ instances, onClose, onDone }: { instances: InstanceWithStatus[]; onClose: () => void; onDone: () => void }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [sel, setSel] = useState<Set<string>>(new Set());
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
setBusy(true);
try {
await api.createUser(username.trim(), password, [...sel]);
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="用户名(3-20 位字母/数字/下划线)"
autoCapitalize="off"
autoCorrect="off"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input className="input" type="password" placeholder="初始密码(至少 6 位)" value={password} onChange={(e) => setPassword(e.target.value)} />
<div className="field-label">访</div>
<ChipMultiSelect
options={instances.map((i) => ({ id: i.id, label: i.name }))}
selected={sel}
onToggle={(id) => setSel((s) => toggleSet(s, id))}
empty="暂无实例,可稍后在账户里分配"
/>
{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 || !username || !password}>
</button>
</div>
</form>
</div>
);
}
function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose: () => void; onDone: () => void }) {
const [name, setName] = useState('');
const [sel, setSel] = useState<Set<string>>(new Set());
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
setBusy(true);
try {
await api.createInstance(name.trim(), [...sel]);
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)} />
<div className="field-label">访访</div>
<ChipMultiSelect
options={subs.map((u) => ({ id: u.id, label: u.username }))}
selected={sel}
onToggle={(id) => setSel((s) => toggleSet(s, id))}
empty="暂无子账号"
/>
{err && <div className="error">{err}</div>}
<div className="muted small" style={{ marginTop: 4 }}></div>
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
</button>
<button className="btn btn-primary" disabled={busy || !name.trim()}>
</button>
</div>
</form>
</div>
);
}
function AssignUsers({
inst,
subs,
onClose,
onDone,
}: {
inst: InstanceWithStatus;
subs: PanelUser[];
onClose: () => void;
onDone: () => void;
}) {
const [sel, setSel] = useState<Set<string>>(new Set(subs.filter((u) => u.allowedInstances.includes(inst.id)).map((u) => u.id)));
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const save = async () => {
setBusy(true);
setErr('');
try {
await api.setInstanceUsers(inst.id, [...sel]);
onDone();
} catch (e: any) {
setErr(e.message || '保存失败');
} finally {
setBusy(false);
}
};
return (
<div className="modal-mask" onClick={onClose}>
<div className="card modal" onClick={(e) => e.stopPropagation()}>
<h2>{inst.name}访</h2>
<ChipMultiSelect
options={subs.map((u) => ({ id: u.id, label: u.username }))}
selected={sel}
onToggle={(id) => setSel((s) => toggleSet(s, id))}
empty="暂无子账号"
/>
{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} onClick={save}>
</button>
</div>
</div>
</div>
);
}
function AssignInstances({
user,
instances,
onClose,
onDone,
}: {
user: PanelUser;
instances: InstanceWithStatus[];
onClose: () => void;
onDone: () => void;
}) {
const [sel, setSel] = useState<Set<string>>(new Set(user.allowedInstances));
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const save = async () => {
setBusy(true);
setErr('');
try {
await api.setUserInstances(user.id, [...sel]);
onDone();
} catch (e: any) {
setErr(e.message || '保存失败');
} finally {
setBusy(false);
}
};
return (
<div className="modal-mask" onClick={onClose}>
<div className="card modal" onClick={(e) => e.stopPropagation()}>
<h2>{user.username} 访</h2>
<ChipMultiSelect
options={instances.map((i) => ({ id: i.id, label: i.name }))}
selected={sel}
onToggle={(id) => setSel((s) => toggleSet(s, id))}
empty="暂无实例"
/>
{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} onClick={save}>
</button>
</div>
</div>
</div>
);
}
function toggleSet(s: Set<string>, id: string): Set<string> {
const next = new Set(s);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
}
+221
View File
@@ -0,0 +1,221 @@
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>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { useNavigate, useParams } from 'react-router-dom';
// 直接加载 KasmVNC 的 noVNC 页面(由 kclient 静态托管)。
// 反代按实例隔离:所有桌面流量走 /desktop/<id>/*,网关据 <id> 选目标容器并注入该实例凭据。
// path=desktop/<id>/websockify:让 noVNC 把 ws 连到该实例路径,网关剥前缀反代回 KasmVNC 根 /websockify。
function desktopUrl(id: string) {
return (
`/desktop/${id}/vnc/index.html?autoconnect=1&path=desktop/${id}/websockify&resize=remote` +
'&reconnect=true&reconnect_delay=2000&clipboard_up=true&clipboard_down=true&clipboard_seamless=true'
);
}
export default function Desktop() {
const nav = useNavigate();
const { id } = useParams<{ id: string }>();
if (!id) {
nav('/', { replace: true });
return null;
}
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>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth';
export default function Login() {
const { login } = useAuth();
const nav = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
setBusy(true);
try {
await login(username.trim(), password);
nav('/', { replace: true });
} catch (e: any) {
setErr(e.message || '登录失败');
} finally {
setBusy(false);
}
};
return (
<div className="center-screen">
<form className="card login-card" onSubmit={submit}>
<div className="brand">
<div className="brand-logo"></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>
);
}
+737
View File
@@ -0,0 +1,737 @@
/* WechatOnCloud 面板 —— 牛奶布艺(soft neumorphism+ 微信绿 + iOS26 圆角极简
* 比喻:整个界面是盖在桌面上的一整块白布,组件是被布顶起的物体。
* - 高光在物体顶面(radial,中心偏上)
* - 阴影是布料折痕(小半径、紧贴边缘、冷灰蓝),不是悬浮投影
* - 组件亮度 ≥ 背景;层级靠明度,不靠边框/分割线 */
:root {
--base: #ebedf1; /* 整块布底色(立体档,91% 区间) */
--surface: #ffffff; /* 浮起表面:纯白 */
--trough: #e0e3ea; /* 凹槽:比 base 更暗(输入框/进度槽) */
--shadow: 51 66 102; /* 冷灰蓝阴影 RGB(配合 rgba() */
--wx-green: #07c160;
--wx-green-dark: #06ad56;
--green-rgb: 7 193 96;
--text: #1a1d24;
--muted: #8a9099;
--danger: #fa5151;
--danger-rgb: 250 81 81;
--r-card: 28px;
--r-blob: 22px;
--r-small: 16px;
/* 布料折痕:两层,紧贴边缘 */
--crease: 0 1px 3px rgba(var(--shadow) / 0.4), 0 2px 8px rgba(var(--shadow) / 0.22);
--crease-press: 0 1px 2px rgba(var(--shadow) / 0.28), 0 1px 3px rgba(var(--shadow) / 0.16);
--crease-accent: 0 1px 3px rgba(var(--green-rgb) / 0.45), 0 4px 14px rgba(var(--green-rgb) / 0.32);
/* 顶面高光(中心偏上的 radial),所有浮起组件共用 */
--sheen: radial-gradient(
ellipse at 50% 28%,
rgba(255, 255, 255, 0.55) 0%,
rgba(255, 255, 255, 0.16) 38%,
transparent 74%
);
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', Roboto, sans-serif;
background: var(--base);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
input,
button {
font-family: inherit;
}
.center-screen {
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
padding-top: max(24px, env(safe-area-inset-top));
padding-bottom: max(24px, env(safe-area-inset-bottom));
}
/* ── 浮起原子:surface + 折痕 + 顶面高光 ──────────────────── */
.mf-raised,
.card,
.enter-card,
.list,
.wx-state {
position: relative;
background: var(--surface);
box-shadow: var(--crease);
}
.mf-raised::before,
.card::before,
.enter-card::before,
.list::before,
.wx-state::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--sheen);
pointer-events: none;
}
.card > *,
.enter-card > *,
.list > *,
.wx-state > * {
position: relative;
z-index: 1;
}
.card {
border-radius: var(--r-card);
padding: 24px;
width: 100%;
max-width: 360px;
}
/* 登录 */
.login-card {
display: flex;
flex-direction: column;
gap: 14px;
}
.brand {
text-align: center;
margin-bottom: 8px;
}
.brand-logo {
position: relative;
width: 60px;
height: 60px;
border-radius: 18px;
background: var(--wx-green);
color: #fff;
font-size: 32px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 14px;
box-shadow: var(--crease-accent);
overflow: hidden;
}
.brand-logo::before {
content: '';
position: absolute;
inset: 0;
background: var(--sheen);
pointer-events: none;
}
.brand h1 {
font-size: 22px;
font-weight: 700;
margin: 0 0 4px;
}
.muted {
color: var(--muted);
}
.small {
font-size: 12px;
}
/* 输入框:凹陷(trough fill + 顶部内阴影),聚焦叠绿色半透环 */
.input {
width: 100%;
height: 48px;
padding: 0 16px;
border: none;
border-radius: var(--r-small);
font-size: 16px;
outline: none;
color: var(--text);
background: var(--trough);
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.18), inset 0 2px 6px rgba(var(--shadow) / 0.1);
transition: box-shadow 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.input::placeholder {
color: var(--muted);
}
.input:focus {
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.16), 0 0 0 3px rgba(var(--green-rgb) / 0.22);
}
/* 按钮:浮起 blob,按下缩小 + 折痕收紧 */
.btn {
position: relative;
height: 48px;
border: none;
border-radius: var(--r-small);
font-size: 16px;
font-weight: 500;
background: var(--surface);
color: var(--text);
cursor: pointer;
box-shadow: var(--crease);
overflow: hidden;
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.btn::before {
content: '';
position: absolute;
inset: 0;
background: var(--sheen);
pointer-events: none;
}
.btn:active:not(:disabled) {
transform: scale(0.97);
box-shadow: var(--crease-press);
}
.btn-primary {
background: var(--wx-green);
color: #fff;
box-shadow: var(--crease-accent);
}
/* 绿底上用更克制的顶面高光,避免白色 sheen 把绿冲淡 */
.btn-primary::before,
.wx-btn.btn-primary::before {
background: radial-gradient(
ellipse at 50% 22%,
rgba(255, 255, 255, 0.28) 0%,
rgba(255, 255, 255, 0.08) 42%,
transparent 72%
);
}
.btn-primary:active:not(:disabled) {
background: var(--wx-green-dark);
}
.btn:disabled {
opacity: 0.6;
cursor: default;
}
/* 文本按钮:无浮起,绿字,按下淡绿底 */
.btn-text {
background: none;
border: none;
color: var(--wx-green);
font-size: 15px;
cursor: pointer;
padding: 6px 10px;
border-radius: 10px;
transition: background 0.18s;
}
.btn-text:active {
background: rgba(var(--green-rgb) / 0.12);
}
.btn-text.danger {
color: var(--danger);
}
.btn-text.danger:active {
background: rgba(var(--danger-rgb) / 0.12);
}
.error {
color: var(--danger);
font-size: 14px;
}
.ok {
color: var(--wx-green);
font-size: 14px;
}
/* ── 页面骨架 ──────────────────────────────────────────── */
.page {
min-height: 100%;
display: flex;
flex-direction: column;
}
/* 顶栏走浅色布面(不再整条绿),标题深色、操作绿字 —— iOS26 + 牛奶布艺 */
.topbar {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
padding-top: env(safe-area-inset-top);
height: calc(52px + env(safe-area-inset-top));
background: var(--base);
}
.topbar-title {
font-size: 17px;
font-weight: 600;
color: var(--text);
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.content {
flex: 1;
padding: 16px;
max-width: 560px;
width: 100%;
margin: 0 auto;
}
.hello {
font-size: 22px;
font-weight: 700;
margin: 8px 4px 18px;
}
.tag {
display: inline-block;
font-size: 11px;
background: rgba(var(--green-rgb) / 0.14);
color: var(--wx-green-dark);
border-radius: 999px;
padding: 2px 9px;
margin-left: 8px;
vertical-align: middle;
font-weight: 600;
}
.tag-off {
background: rgba(var(--danger-rgb) / 0.14);
color: var(--danger);
}
/* 进入微信入口卡 */
.enter-card {
width: 100%;
border: none;
border-radius: var(--r-card);
padding: 20px;
display: flex;
align-items: center;
gap: 14px;
cursor: pointer;
text-align: left;
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.enter-card:active {
transform: scale(0.985);
box-shadow: var(--crease-press);
}
.enter-icon {
position: relative;
width: 46px;
height: 46px;
flex: none;
border-radius: 14px;
background: var(--wx-green);
color: #fff;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--crease-accent);
overflow: hidden;
}
.enter-icon::after {
content: '';
position: absolute;
inset: 0;
background: var(--sheen);
pointer-events: none;
}
.enter-text {
flex: 1;
}
.enter-title {
font-size: 17px;
font-weight: 600;
}
.enter-sub {
font-size: 13px;
color: var(--muted);
margin-top: 3px;
}
.enter-arrow {
color: #c2c7d0;
font-size: 22px;
}
/* 列表卡:行间用折痕细缝分隔,不用 border */
.list {
margin-top: 16px;
border-radius: var(--r-card);
overflow: hidden;
}
.list-item {
position: relative;
z-index: 1;
width: 100%;
background: none;
border: none;
box-shadow: inset 0 -1px 0 rgba(var(--shadow) / 0.08);
padding: 16px 18px;
font-size: 16px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: background 0.18s;
}
.list-item:active {
background: rgba(var(--shadow) / 0.04);
}
.list-item:last-child {
box-shadow: none;
}
/* 子账号列表 */
.user-row {
position: relative;
z-index: 1;
padding: 14px 18px;
box-shadow: inset 0 -1px 0 rgba(var(--shadow) / 0.08);
}
.user-row:last-child {
box-shadow: none;
}
.user-main {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-name {
font-size: 16px;
font-weight: 500;
}
.user-actions {
display: flex;
gap: 4px;
margin-top: 6px;
}
/* ── 微信安装/更新状态卡(启动下载 + 更新到最新版) ──────── */
.wx-state {
margin-top: 16px;
border-radius: var(--r-card);
padding: 20px;
}
.wx-state-row {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 14px;
}
.wx-state-text {
flex: 1;
min-width: 0;
}
.wx-state-title {
font-size: 16px;
font-weight: 600;
}
.wx-state-sub {
font-size: 13px;
color: var(--muted);
margin-top: 3px;
}
.wx-btn {
height: 40px;
flex: none;
padding: 0 18px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
}
/* 进度条:凹槽轨 + 绿色带光晕填充 */
.wx-progress {
position: relative;
z-index: 1;
margin-top: 16px;
height: 8px;
border-radius: 999px;
background: var(--trough);
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.2);
overflow: hidden;
}
.wx-progress-bar {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--wx-green-dark), var(--wx-green));
box-shadow: 0 0 8px rgba(var(--green-rgb) / 0.5);
transition: width 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* 进度不确定(拿不到总大小)时来回滑动 */
.wx-progress-bar.indeterminate {
width: 40%;
transition: none;
animation: wx-slide 1.1s ease-in-out infinite;
}
@keyframes wx-slide {
0% {
margin-left: -40%;
}
100% {
margin-left: 100%;
}
}
.wx-progress-text {
position: relative;
z-index: 1;
margin-top: 8px;
font-size: 12px;
color: var(--muted);
text-align: right;
}
/* ── 弹窗 ──────────────────────────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(26, 29, 36, 0.4);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 20;
}
.modal {
display: flex;
flex-direction: column;
gap: 12px;
border-radius: var(--r-card);
}
.modal h2 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 700;
}
.modal-actions {
display: flex;
gap: 10px;
}
.modal-actions .btn {
flex: 1;
}
/* ── 桌面 ──────────────────────────────────────────────── */
.desktop-wrap {
position: fixed;
inset: 0;
background: #000;
}
.desktop-frame {
width: 100%;
height: 100%;
border: none;
display: block;
}
/* 返回钮:浮起白色圆 blob */
.desktop-back {
position: fixed;
top: max(12px, env(safe-area-inset-top));
left: 12px;
width: 42px;
height: 42px;
border-radius: 50%;
border: none;
background: var(--surface);
color: var(--text);
font-size: 24px;
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), box-shadow 0.18s;
}
.desktop-back:active {
transform: scale(0.94);
box-shadow: var(--crease-press);
}
/* ── loading ───────────────────────────────────────────── */
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(var(--green-rgb) / 0.2);
border-top-color: var(--wx-green);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------- 多实例:分区标题 ---------- */
.section-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 6px 6px 12px;
}
.section-title {
font-size: 15px;
font-weight: 700;
color: var(--text);
}
/* ---------- 实例网格 ---------- */
.inst-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 22px;
}
.inst-card {
position: relative;
background: var(--surface);
border-radius: var(--r-card);
box-shadow: var(--crease);
padding: 18px;
overflow: hidden;
}
.inst-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--sheen);
pointer-events: none;
}
.inst-card > * {
position: relative;
}
.inst-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.inst-name {
font-size: 16px;
font-weight: 700;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inst-sub {
margin-top: 6px;
font-size: 13px;
color: var(--muted);
min-height: 18px;
}
.inst-actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.inst-enter {
flex: 1;
}
.inst-act {
flex: none;
}
/* 状态徽章配色 */
.tag-on {
background: rgba(var(--green-rgb) / 0.16);
color: var(--wx-green-dark);
}
.tag-busy {
background: rgba(51 102 204 / 0.16);
color: #2f5fd0;
}
.tag-warn {
background: rgba(245 158 11 / 0.18);
color: #b9770a;
}
/* ---------- chip 多选 / 展示 ---------- */
.field-label {
font-size: 13px;
font-weight: 600;
color: var(--muted);
margin: 14px 2px 8px;
}
.chip-row {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.chip-row-pick {
display: flex;
}
.chip {
font-size: 12px;
border-radius: 999px;
padding: 5px 12px;
font-weight: 600;
line-height: 1.4;
}
.chip-static {
background: rgba(var(--green-rgb) / 0.12);
color: var(--wx-green-dark);
}
.chip-toggle {
border: none;
cursor: pointer;
background: var(--trough);
color: var(--muted);
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.16);
transition: transform 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.chip-toggle:active {
transform: scale(0.95);
}
.chip-toggle.on {
background: var(--wx-green);
color: #fff;
box-shadow: var(--crease-accent);
}
/* ---------- 空状态 ---------- */
.empty-state {
text-align: center;
padding: 30px 16px 36px;
}
.empty-blob {
width: 96px;
height: 96px;
margin: 0 auto 16px;
border-radius: 50%;
background: var(--surface);
box-shadow: var(--crease);
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
position: relative;
overflow: hidden;
}
.empty-blob::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--sheen);
}
.empty-title {
font-size: 16px;
font-weight: 700;
color: var(--text);
}
.empty-sub {
margin-top: 6px;
font-size: 13px;
color: var(--muted);
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+42
View File
@@ -0,0 +1,42 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
// 开发时把 /api 与 /desktop 代理到本地后端(npm run dev 时用)
const BACKEND = process.env.BACKEND || 'http://localhost:8080';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icon-180.png'],
manifest: {
name: '云微信',
short_name: '云微信',
description: '在浏览器访问 NAS 上的微信',
lang: 'zh-CN',
theme_color: '#07C160',
background_color: '#ffffff',
display: 'standalone',
start_url: '/',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
workbox: {
// 桌面反代与 API 不能被 SW 拦截
navigateFallbackDenylist: [/^\/desktop/, /^\/api/],
},
}),
],
server: {
proxy: {
'/api': BACKEND,
'/desktop': { target: BACKEND, ws: true },
},
},
build: { outDir: 'dist' },
});
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# 本地构建面板镜像 + 微信实例镜像,打成与 docker-compose.yml 一致的 GHCR 标签。
# 用途:GHCR 尚未发布(没打 tag)时自测,或自托管者想自己构建而非拉取官方镜像。
# 构建完成后直接 `docker compose up -d` 即可(compose 默认 pull_policy=missing,会优先用本地镜像)。
#
# 用法:
# ./scripts/build-local.sh # 构建本机架构,标签 latest
# WOC_VERSION=v1.0.0 ./scripts/build-local.sh # 指定标签(需与 .env 的 WOC_VERSION 一致)
set -euo pipefail
OWNER="${WOC_IMAGE_OWNER:-gloridust}"
TAG="${WOC_VERSION:-latest}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PANEL_IMAGE="ghcr.io/${OWNER}/woc-panel:${TAG}"
WECHAT_IMAGE="ghcr.io/${OWNER}/wechat-on-cloud:${TAG}"
echo "==> 构建面板镜像 ${PANEL_IMAGE}"
docker build -t "${PANEL_IMAGE}" "${ROOT}/panel"
echo "==> 构建微信实例镜像 ${WECHAT_IMAGE}"
docker build -t "${WECHAT_IMAGE}" "${ROOT}/docker"
echo
echo "完成。本地镜像:"
# 注意:docker images 只接受一个仓库参数,故用 --filter 各列一次
docker images --filter "reference=${PANEL_IMAGE}" --format ' {{.Repository}}:{{.Tag}} {{.Size}}'
docker images --filter "reference=${WECHAT_IMAGE}" --format ' {{.Repository}}:{{.Tag}} {{.Size}}'
echo
echo "下一步:docker compose up -d (记得先把 .env 里 WOC_VERSION 设为 ${TAG}"