mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
update
This commit is contained in:
+10
-5
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -137,3 +137,6 @@ dist
|
||||
|
||||
# WechatOnCloud: 微信运行数据(登录态/消息,大且敏感,勿提交)
|
||||
/data/
|
||||
# WechatOnCloud: 面板账号数据(含密码哈希,勿提交)
|
||||
/data-panel/
|
||||
/.claude
|
||||
|
||||
@@ -1,97 +1,255 @@
|
||||
# WechatOnCloud
|
||||
|
||||
在飞牛 NAS(x86_64)上运行服务端微信,多个 web 用户通过浏览器访问**同一个**微信会话,实现跨设备消息同步、多端共享。
|
||||
在飞牛 NAS(x86_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「安装」当原生 App(PWA) |
|
||||
|
||||
> 子账号是**访问这套面板的身份**,不是另开一个微信。管理员隐式拥有全部实例访问权;子账号只能看到被授权的实例。
|
||||
|
||||
> 微信本体**不打进镜像**,而是新建实例后在面板点「下载并安装」时下载到该实例的数据卷,所以镜像很小、构建快、不依赖腾讯 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/ # Fastify:cookie 鉴权 + 账号/实例/权限 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] MVP:Docker + 微信原生版 + 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
@@ -1,35 +1,35 @@
|
||||
# WechatOnCloud —— 面板为唯一服务;微信实例由面板按需动态创建(docker run)。
|
||||
# 面板挂载 docker.sock 来创建/启动/删除微信实例容器,并反向代理到它们的 KasmVNC。
|
||||
# 镜像全部从 GHCR 拉取,无需本地构建。要改配置:复制 .env.example 为 .env 后修改。
|
||||
services:
|
||||
wechat:
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Dockerfile
|
||||
# 不指定 platform:BuildKit 默认按本机架构构建并自动下载对应微信包。
|
||||
# 仅当需要在 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
@@ -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 动态 dlopen,ldd 查不到,需主动装。
|
||||
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.js(app/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
@@ -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 秒后重启"
|
||||
|
||||
@@ -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
|
||||
@@ -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 返回 0,status 看 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 内不含镜像、体积很小。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -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
|
||||
Executable
+26
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"defaults": {
|
||||
"run-as": "package"
|
||||
},
|
||||
"username": "wechat-on-cloud",
|
||||
"groupname": "wechat-on-cloud"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"data-share": {
|
||||
"shares": [
|
||||
{
|
||||
"name": "wechat-on-cloud",
|
||||
"permission": "rw"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"]
|
||||
Generated
+2501
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 或 null(null 时用 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 };
|
||||
@@ -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} (多实例反代已就绪)`);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+6149
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' }),
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
Executable
+30
@@ -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})"
|
||||
Reference in New Issue
Block a user