17 Commits

  • fix(desktop): 输入模式切换改为整页重载,修卡死 + 切换不生效
    切换模式时原先 bump vncNonce 在页内重挂 iframe,会让新旧两条 VNC ws 短暂并存,
    概率性把实例 Xvnc 卡死(需重启容器恢复、面板重启无效),且新连接常读不到新 enable_ime
    (仍是英文)。改为 window.location.reload():先卸载旧页彻底关旧 ws,再以新模式干净重连,
    正是用户实测唯一可靠的方式。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • fix(docker): typeInInstance 卡 ~2s 根因——xclip 未重定向 fd
    xclip -i 会 daemon 化常驻持有剪贴板选区,并继承 docker exec 的 stdout/stderr,
    导致 exec 要等这俩 fd 关闭、每次中文转发卡 ~2.1s。给 xclip 重定向 >/dev/null 2>&1
    后整条链路降到 ~0.08s(实测 26× 提速)。中文输入条与无感模式都受益。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • fix(desktop): 修无感输入两处 bug(切换不生效 + "你好y呀"丢字)
    1) 切到无感后当前会话仍是旧 enable_ime → 表现"还是打英文,要换页才行"。
       setMode 现同步写 enable_ime 并 bump vncNonce 重挂 iframe,让 noVNC 立即按新模式重连。
    2) "你好[空格]呀"打出"你好y呀":有序队列在中文转发期间会把下一个词的拼音首字母(y)当字面字符抢走。
       改为队列活跃时只接管【数字】(原"混数字丢字"的祸首) + 回车/退格;字母绝不接管,交给输入法合成。
       附带消除了之前"每个键都走 xdotool"导致的卡顿(字母回到原生合成快路径)。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(desktop): 输入法双模式切换(无感 / 转发)+ 深度修复无感丢字
    nav 栏可切「无感输入」(直接在微信里打中文)/「转发输入」(底部输入条,默认),偏好持久化。
    无感:enable_ime=true,compositionend 捕获中文经 xdotool 转发;并用有序队列把"转发未完成期间"
    的后续可见字符 + 回车/退格串行送出,彻底消除"中文走异步、数字走 keysym 抢跑"的"你好123→23"丢字;
    队列空闲不干预,英文/数字仍走原生 keysym 零延迟。新增 /api/instances/:id/key(xdotool 单键)。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(admin): 实例「管理」菜单改为悬浮图层展开
    绝对定位悬浮层(从按钮下方浮出),不再撑高卡片/顶走下方内容;展开时卡片 overflow:visible
    + z-index:5(盖住下方/同列卡片,仍低于弹窗);加点击外部 / 点击菜单项自动关闭。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • chore(docker): 00-woc-identity 也用显式 chmod 755(补齐 #17)
    PR #17 把脚本权限改为显式 755/644 以摆脱构建机 umask 影响,但它之后才加的
    00-woc-identity 钩子仍是 chmod +x,是唯一漏网。该钩子由 root 运行(711 也能跑、
    非功能性问题),此处仅为与其余几行统一、彻底做到与 umask 无关。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • docs(readme): 备注 NAS/飞牛首次建实例镜像拉取超时的解法
    NAS 自带的 Docker Hub 加速通常只覆盖界面手动拉取,不覆盖面板经 docker.sock 触发的拉取,
    导致首次新建实例直连 registry-1.docker.io 超时。README 快速开始加提示:先手动拉一次
    gloridust/wechat-on-cloud:latest(本地有了面板就复用不再联网),或配守护进程镜像加速 /
    换 WOC_IMAGE_PREFIX 到国内源。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • docs(readme): 方式B 改为仅凭 docker-compose.yml 部署,无需 clone 仓库
    compose 用 image: 拉官方镜像、数据落到旁边自动建的 ./data-panel,不依赖仓库其它文件
    (已在空目录验证 docker compose config 通过)。方式B 改为:命令行 curl 单文件 / 飞牛(fnOS)·
    群晖等 NAS 直接粘贴 docker-compose.yml 到 Compose 一键部署界面;并说明改密码/端口/镜像源
    可用 .env 或 NAS 环境变量,无需 clone。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(ci/telegram): 发布通知自动置顶 + 取消上一个 release 的置顶
    tg-notify.mjs:release/手动触发发完消息后,用 getChat 读群当前置顶(=上一个 release),
    pinChatMessage 置顶新消息(静音,避免二次提醒)、unpinChatMessage 取消旧的——无需持久化存储。
    issue 通知不置顶;置顶失败(机器人非管理员/无置顶权限)仅跳过、不影响通知本身。
    文档补充:需把机器人设为群管理员并开启「置顶消息」权限。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • chore: 默认镜像源切到 Docker Hub,GHCR 作为备用
    Docker Hub 国内/国际通用、免登录拉公开镜像,飞牛OS(fnOS) 等 NAS 还内置拉取加速,
    通常比 GHCR 更快更稳。docker-compose.yml 两处 :- 兜底默认与 .env.example 默认值
    由 ghcr.io/gloridust 改为 docker.io/gloridust;GHCR / 南大反代 / 阿里云等列为备用源。
    README 快速开始措辞同步更新。镜像仍同时发布到两个 registry,未改 CI。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • docs(readme): 增加 Docker Hub 镜像源切换说明(飞牛OS 等 NAS 有加速)
    官方镜像已同步发布到 Docker Hub(公开、多架构)。README 快速开始补充:
    ghcr.io 拉不动时在 .env 设 WOC_IMAGE_PREFIX=docker.io/gloridust 切到 Docker Hub;
    飞牛 OS(fnOS) 等 NAS 内置 Docker Hub 拉取加速,通常更快更稳。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • fix(panel): 修复 VolumeManager 漏闭合的 .vol-sec 标签致构建失败
    a42006e 把第 1259 行的 </div> 误回退成 </>,与 <div className="vol-sec"> 不匹配,
    导致 vite/esbuild 构建报 "Unexpected closing fragment tag" → main 上 panel 镜像构建失败
    (v1.1.10 标签镜像本身是好的,从 1c34777 构建)。改回 </div>。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(panel): 管理员数据卷管理(整卷备份/恢复 + 文件浏览器)
    管理页 → 实例「管理」→ 数据卷(仅 admin)。解决大量用户"把 PC 微信数据迁移上 docker"的诉求。
    
    - 整卷备份:流式打包 /config 为 .tar.gz 下载(大文件不入内存);恢复:上传覆盖回 /config。
      machine-id 存在卷内随包迁移 → 跨 woc 实例恢复可保留聊天记录。
    - 文件浏览器:浏览/上传/上传并解压(.tar/.tar.gz)/下载/改名/移动/删除;PC 数据打包上传解压后重启实例。
    - 全程在运行中的实例上操作(exec + docker cp,运行容器才可 exec);恢复为全量覆盖,强提示并建议重启。
    - 安全:仅 admin;路径严格限制在 /config、禁止 .. 穿越;上传落地为 abc 属主。
    - docker.ts 抽出 extractSingleFileFromTar 复用(PAX 头跳过),新增 list/mkdir/move/delete/upload/
      extract/download/backup(stream)/restore;index.ts 加 9 个 /volume 管理路由;前端 VolumeManager 弹窗
      + 线性 SVG 图标(替代渲染不一致的 emoji);新增 doc/数据卷管理.md。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • ci(telegram): render release/issue notes as Telegram HTML (not raw markdown)
    GitHub-Flavored Markdown ≠ Telegram MarkdownV2, so the old plain-text send
    showed literal ## ** | etc. New .github/scripts/tg-notify.mjs converts GFM
    → Telegram-safe HTML (<b>/<i>/<code>/<pre>/<a>; headers→bold, tables→· rows,
    lists→•, quotes→▎), escapes <>&, and falls back to plain text if Telegram
    rejects the HTML. Adds workflow_dispatch to telegram-notify so you can send
    the latest release rendered for testing without cutting a new release.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 changed files with 1151 additions and 102 deletions
+6 -5
View File
@@ -8,15 +8,16 @@ WOC_PASSWORD=wechat
# 同时作用于面板镜像和新建微信实例所用镜像。
WOC_VERSION=latest
# 镜像源前缀(registry + 命名空间)。本项目镜像同步发布到 GHCR 和 Docker Hub。
# 镜像源前缀(registry + 命名空间)。本项目镜像同步发布到 Docker Hub 和 GHCR
#
# 默认走 GHCR;如果 ghcr.io 拉不动,按需切到其它源:
# WOC_IMAGE_PREFIX=docker.io/gloridust # Docker Hub(国际网络通用、无需 PAT 即可拉公开镜像)
# 默认走 Docker Hub:国内/国际通用、无需登录即可拉公开镜像,飞牛 OS(fnOS) 等 NAS 还内置拉取加速。
# 拉不动时按需切到备用源:
# WOC_IMAGE_PREFIX=ghcr.io/gloridust # GitHub Container Registry(备用)
# WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 南京大学镜像站反代 ghcr.io(国内较稳)
# WOC_IMAGE_PREFIX=registry.cn-hangzhou.aliyuncs.com/你的命名空间 # 自己的阿里云 ACR / 腾讯 TCR / 华为 SWR
#
# 反代会把 ghcr.io/gloridust/xxx 映射为 <反代>/gloridust/xxx。
WOC_IMAGE_PREFIX=ghcr.io/gloridust
# 反代会把 docker.io/gloridust/xxx(或 ghcr.io/gloridust/xxx映射为 <反代>/gloridust/xxx。
WOC_IMAGE_PREFIX=docker.io/gloridust
# 宿主用户 uid/gid(飞牛上用 `id` 命令查看;单用户 NAS 一般是 1000)。
# 透传给每个微信实例容器,决定面板数据与微信数据卷的属主。
Binary file not shown.
+15 -38
View File
@@ -1,40 +1,39 @@
name: telegram-notify
# 通过 Telegram Bot(@WechatOnCloudBot)把「新版本发布 / 新 issue」推送到群组。
# 跑在 GitHub Actions 上,无需任何服务器。
# 新版本发布 / 新 issue 时,把内容(GitHub Markdown → Telegram HTML 渲染)推送到 Telegram 群组。
# 跑在 GitHub Actions 上,无需服务器;未配置 TELEGRAM_CHAT_ID 则自动跳过
#
# 一次性配置(未配置则自动跳过,不影响 fork):
# 1) 把 @WechatOnCloudBot 拉进目标群组(群里发言需要时设为管理员)。
# 2) 取群组 chat id:把 bot 拉进群后在群里随便发条消息,浏览器打开
# https://api.telegram.org/bot<BOT_TOKEN>/getUpdates ,找 result[].message.chat.id
# (群组通常是形如 -1001234567890 的负数)。
# 3) 仓库 Settings → Secrets and variables → Actions
# · Variables 选项卡 → 新建 TELEGRAM_CHAT_ID = 上面的 chat id
# · Secrets 选项卡 → 新建 TELEGRAM_BOT_TOKEN = @BotFather 给的 bot token
# chat id 放 Variable 是为了能在下面的 if 条件里判断是否已配置;token 必须放 Secret。)
# 一次性配置(仓库 Settings → Secrets and variables → Actions):
# · Variables → TELEGRAM_CHAT_ID = 群组 chat id(形如 -1001234567890;取法见 doc/发布到GHCR.md
# · Secrets → TELEGRAM_BOT_TOKEN = @BotFather 的 token
# 并把机器人拉进群组。
#
# 手动测试渲染效果:Actions → telegram-notify → Run workflow(会把「最新 release」渲染后发到群)。
on:
release:
types: [published]
issues:
types: [opened]
workflow_dispatch: {}
# 限制权限:只读元数据,足够本工作流所需。
permissions:
contents: read
jobs:
notify:
runs-on: ubuntu-latest
# 仅当配置了群组 chat id 时才运行(未配置的 fork 会安全跳过)
if: ${{ vars.TELEGRAM_CHAT_ID != '' }}
steps:
- name: Compose & send Telegram message
- uses: actions/checkout@v4
- name: Render & send to Telegram
env:
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TG_CHAT: ${{ vars.TELEGRAM_CHAT_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
EVENT: ${{ github.event_name }}
# 经 env 传任意文本,避免把 ${{ }} 直接拼进脚本造成命令注入
# 经 env 传任意文本,避免命令注入
R_TAG: ${{ github.event.release.tag_name }}
R_NAME: ${{ github.event.release.name }}
R_URL: ${{ github.event.release.html_url }}
@@ -44,26 +43,4 @@ jobs:
I_URL: ${{ github.event.issue.html_url }}
I_USER: ${{ github.event.issue.user.login }}
I_BODY: ${{ github.event.issue.body }}
run: |
set -uo pipefail
if [ -z "${TG_TOKEN:-}" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN 未配置,跳过"
exit 0
fi
if [ "$EVENT" = "release" ]; then
title="🚀 云微 WechatOnCloud ${R_TAG} 已发布"
if [ -n "${R_NAME:-}" ] && [ "${R_NAME}" != "${R_TAG}" ]; then
title="${title} — ${R_NAME}"
fi
# 发布说明截断到 ~3000 字符(Telegram 单条上限 4096
body="$(printf '%s' "${R_BODY:-}" | head -c 3000)"
msg="$(printf '%s\n\n%s\n\n🔗 完整说明:%s\n⬆️ 升级: docker compose pull && docker compose up -d' "$title" "$body" "$R_URL")"
else
body="$(printf '%s' "${I_BODY:-}" | head -c 600)"
msg="$(printf '🐛 新反馈 Issue #%s%s\n👤 by %s\n\n%s\n\n🔗 %s' "$I_NUM" "$I_TITLE" "$I_USER" "$body" "$I_URL")"
fi
# --data-urlencode 负责转义,纯文本发送(不用 markdown parse_mode,避免特殊字符破坏解析)
curl -sS --fail-with-body -X POST \
"https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TG_CHAT}" \
--data-urlencode "text=${msg}"
run: node .github/scripts/tg-notify.mjs
+33 -13
View File
@@ -52,7 +52,7 @@
- 🧩 **多端协作软锁** — 同一实例多人操作时自动只读 + 申请接管,避免键鼠打架。
- 🔒 **安全优先** — 面板为唯一入口,KasmVNC 凭据服务端注入、永不下发前端;docker.sock 仅管理员可触达。
- 📱 **PWA** — iOS「添加到主屏幕」、桌面 Chrome「安装」当原生 App。
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(GHCR + GitHub Actions 自动发布)。
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(Docker Hub + GHCRGitHub Actions 自动发布)。
---
@@ -63,6 +63,7 @@
| [运行原理与 Docker 指南](doc/运行原理.md) | 工作原理 + 架构图;面向 Docker 新手的逐步拆解、常用命令、架构自动适配 |
| [部署与运维](doc/部署与运维.md) | 数据持久化、常见问题排查、忘记超管密码的离线找回、目录结构 |
| [设备伪装与风控应对](doc/设备伪装.md) | 唯一 machine-id / 真实 hostname / os-release 伪装;账号被微信强制退出循环时怎么办 |
| [数据卷管理与迁移](doc/数据卷管理.md) | 管理员在面板里备份/恢复整卷、上传 PC 微信数据、浏览管理实例 /config 文件 |
| [发布到 GHCR](doc/发布到GHCR.md) | 用 GitHub Actions 或本机 buildx 把镜像发布到 GHCR |
| [技术方案](doc/技术方案.md) | 完整设计文档与选型权衡 |
@@ -72,7 +73,7 @@
> 需已安装 Docker(含 Compose 插件)。x86_64 / arm64 均可。不熟悉 Docker?先读 [运行原理与 Docker 指南](doc/运行原理.md)。
`docker-compose.yml` 引用的是 GHCR 上的镜像 `ghcr.io/gloridust/{woc-panel,wechat-on-cloud}`
`docker-compose.yml` 默认引用 **Docker Hub** 上的镜像 `docker.io/gloridust/{woc-panel,wechat-on-cloud}`(同时也发布到 GHCR 作为备用源)
**这两个镜像需先存在**——要么官方已发布(你能直接拉取),要么你在本地自行构建。二选一:
**方式 A · 本地自构建(官方尚未发布镜像时用这个)**
@@ -82,29 +83,48 @@ git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
cd WechatOnCloud
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
./scripts/build-local.sh # 构建面板 + 微信实例镜像,打成 compose 用的同名标签
docker compose up -d # compose 默认优先用本地镜像,不会再去 GHCR
docker compose up -d # compose 默认优先用本地镜像,不会再去远端拉
```
**方式 B · 拉取官方镜像(已发布到 GHCR 后**
**方式 B · 拉取官方镜像(推荐,无需 clone 整个仓库**
```bash
git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
cd WechatOnCloud
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
docker compose up -d # 直接从 GHCR 拉取
```
部署**只需要 `docker-compose.yml` 这一个文件**——它用 `image:` 直接拉官方镜像,面板数据放在该文件旁自动创建的 `./data-panel` 目录,不依赖仓库里的其它文件。
> 报错 `error from registry: denied`?说明 GHCR 上还没有该镜像(或包是私有的)。用方式 A 本地构建,或见 [发布到 GHCR](doc/发布到GHCR.md)。
- **命令行**:丢进一个空目录拉起即可
```bash
mkdir woc && cd woc
curl -fsSL https://raw.githubusercontent.com/Gloridust/WechatOnCloud/main/docker-compose.yml -o docker-compose.yml
docker compose up -d # 默认从 Docker Hub 拉取(公开、amd64/arm64 多架构)
```
> `raw.githubusercontent.com` 拉不动时,在 GitHub 网页打开根目录的 [docker-compose.yml](docker-compose.yml),复制内容自己建个同名文件即可。
- **飞牛 OSfnOS/ 群晖 等 NAS**:在 **Docker → Compose 一键部署** 界面,把根目录 [docker-compose.yml](docker-compose.yml) 的内容**直接粘贴进去**即可部署,无需命令行、无需 clone。
> **改配置(强烈建议至少改密码)**:默认管理员 **admin / wechat**。登录后在「修改密码」里改;或部署前在 `docker-compose.yml` 旁放一个 `.env`(从 [.env.example](.env.example) 下载改名),又或在 NAS 的 Compose 环境变量里填 `WOC_PASSWORD`、`WOC_HTTP_PORT`、`WOC_IMAGE_PREFIX` 等(全部可配置项见 [.env.example](.env.example))。
> **为什么默认 Docker Hub**:国内 / 国际通用、免登录即可拉公开镜像,**飞牛 OS(fnOS)等 NAS 系统还内置了 Docker Hub 拉取加速**,通常比 GHCR 更快更稳。`ghcr.io` 拉不动时设 `WOC_IMAGE_PREFIX` 切源:
>
> ```bash
> WOC_IMAGE_PREFIX=ghcr.io/gloridust # 备用:GitHub Container Registry
> WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 备用:南京大学反代 ghcr.io(国内较稳)
> ```
>
> 更多源(自建阿里云 ACR / 腾讯 TCR 等)见 [.env.example](.env.example)。报错 `denied`?说明该源上还没有镜像(或为私有),换个源或用方式 A 本地构建。
无论哪种方式,都会拉起面板容器 `woc-panel`(唯一对外服务)。浏览器访问 `http://<NAS_IP>:36080`
1. 用 `.env` 里设置的管理员账号(默认 **admin / wechat**)登录面板;
2. 管理员在面板「实例」页点「**新建微信实例**」,命名并选择哪些子账号可访问 → 面板自动 `docker run` 起一个微信实例容器(微信镜像本地没有时才会从 GHCR 拉取);
2. 管理员在面板「实例」页点「**新建微信实例**」,命名并选择哪些子账号可访问 → 面板自动 `docker run` 起一个微信实例容器(微信镜像本地没有时才会从镜像源拉取);
3. 进入该实例,点「**下载并安装**」微信(约 190~210MB,进度条实时显示,仅管理员可操作);
4. 装好后点「进入电脑版微信」→ 浏览器里出现微信窗口,手机扫码登录即可。
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**微信会话。
> **🛠️ NAS / 飞牛(fnOS) 用户必看——首次新建实例若卡住报 `创建容器失败:… registry-1.docker.io … timeout`**
> 这是 Docker **守护进程**拉取微信镜像超时。NAS 自带的「Docker Hub 加速」一般只作用于你在 NAS 界面**手动拉镜像**,不覆盖面板(经 docker.sock)触发的拉取,于是直连 `docker.io` 超时。
> **最省事的解法**:先在 NAS 的 **Docker → 镜像 → 拉取** 里手动拉一次 `gloridust/wechat-on-cloud:latest`(和你拉 `woc-panel` 同样的方式)。镜像到本地后,面板新建实例会直接复用、不再联网拉取 → 立即成功。
> 想一劳永逸:给 Docker 守护进程配「镜像加速器」(`/etc/docker/daemon.json` 的 `registry-mirrors`,改完重启 Docker),或把 `WOC_IMAGE_PREFIX` 换成国内可达源(如 `ghcr.nju.edu.cn/gloridust`)后重建面板。
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本/账号见 `.env`(可配置项见 [.env.example](.env.example))。镜像会按 CPU 架构自动适配([详见文档](doc/运行原理.md#架构自动适配))。
### 面板能做什么
@@ -174,7 +194,7 @@ docker compose up -d # 直接从 GHCR 拉取
- [x] 自研面板:cookie 鉴权 + 反代 + 子账号管理 + PWA(KasmVNC 凭据不下发前端)
- [x] 微信本体运行时下载到数据卷:面板一键「下载并安装 / 更新」,带进度条
- [x] 多实例管理 + 按账号的实例访问权限(RBAC)
- [x] 预构建多架构镜像发布到 GHCR + GitHub Actions 自动化
- [x] 预构建多架构镜像发布到 Docker Hub / GHCR + GitHub Actions 自动化
- [x] 中文输入修复 + 文本剪贴板中转 + 实例日志导出
- [ ] 面板外层 TLS / 陌生设备验证码 / 审计日志
- [x] 多端并发控制(操作控制权心跳软锁 + 只读遮罩 + 申请接管)
+4 -2
View File
@@ -81,11 +81,11 @@ docker buildx build --platform linux/amd64,linux/arm64 \
## Telegram 发布通知(可选,免服务器)
仓库内置 [.github/workflows/telegram-notify.yml](../.github/workflows/telegram-notify.yml)**新版本发布** / **新 issue** 时,通过 Telegram Bot 推送到群组。跑在 GitHub Actions 上,无需服务器;未配置则自动跳过。
仓库内置 [.github/workflows/telegram-notify.yml](../.github/workflows/telegram-notify.yml) + [.github/scripts/tg-notify.mjs](../.github/scripts/tg-notify.mjs)**新版本发布** / **新 issue** 时,把内容(GitHub Markdown 渲染为 Telegram HTML推送到群组。跑在 GitHub Actions 上,无需服务器;未配置则自动跳过。**发布通知还会自动置顶,并取消上一个 release 的置顶**(群里始终只置顶最新版本)。
一次性配置:
1. 把机器人(如 `@WechatOnCloudBot`)拉进目标 Telegram 群组;需要发言权限时设为管理员。
1. 把机器人(如 `@WechatOnCloudBot`)拉进目标 Telegram 群组。**要让"自动置顶"生效,需把机器人设为管理员并开启「置顶消息」权限**(缺权限时通知照发、仅置顶被跳过,不影响主流程)
2. 取群组 chat id:bot 进群后在群里发条消息,浏览器打开 `https://api.telegram.org/bot<BOT_TOKEN>/getUpdates`,找 `result[].message.chat.id`(群组通常是 `-100` 开头的负数)。
3. 仓库 **Settings → Secrets and variables → Actions**
- **Variables** 标签 → `TELEGRAM_CHAT_ID` = 上面的 chat id
@@ -93,6 +93,8 @@ docker buildx build --platform linux/amd64,linux/arm64 \
之后每次「发布 Release / 新建 issue」都会自动推送。想关掉 issue 推送,删掉 workflow 里 `on:` 下的 `issues:` 即可。
> **自动置顶原理**:发完新消息后,用 `getChat` 读出群当前置顶消息(即上一个 release),置顶新消息、再取消旧的——无需任何持久化存储。issue 通知不置顶。手动 **Run workflow** 也会置顶(发的是最新 release),可用来测试。
---
## Telegram 命令机器人(可选,免服务器,轮询版)
+38
View File
@@ -0,0 +1,38 @@
# 数据卷管理与数据迁移
> 返回 [← README](../README.md)
**管理员专用。** 每个实例的「数据卷」= 容器内 `/config` 持久卷,含微信全部数据(登录态、加密聊天库、配置等)。
入口:**管理页 → 实例卡片「管理」→ 数据卷**。子账号不可见(数据卷等同完整微信会话凭据)。
提供两块能力:**整卷备份 / 恢复** 与 **文件浏览器**(浏览 / 上传 / 上传并解压 / 下载 / 改名 / 移动 / 删除)。
---
## 整卷备份 / 恢复(最可靠)
- **下载整卷备份**:把 `/config` 流式打包成 `.tar.gz` 下载(大文件不进内存,边打包边下)。用于离线备份、换宿主、跨实例迁移。
- **恢复备份**:上传 `.tar.gz` 覆盖写回 `/config`;恢复后在卡片上「重启」实例以加载数据。
- **为什么能保留聊天记录**:唯一设备标识存在卷内的 `.woc-machine-id`,会随备份一起迁移 → 目标实例解密环境一致,聊天记录可还原。这也是 woc 实例之间迁移的推荐方式。详见 [设备伪装](设备伪装.md)。
> 备份大小 ≈ 卷大小(微信数据常为数百 MB ~ 数 GB),请按网络情况耐心等待。
---
## 把 PC 微信数据迁移上来
1. 在 PC 上把微信数据文件夹打包成 `.tar.gz`
2. 数据卷 → 进入目标目录 → 「**上传并解压**」(也支持 `.tar`);
3. 在卡片上「重启」实例。
> ⚠️ **能否解密取决于微信版本与设备绑定**:微信本地库通常按设备/账号加密,跨设备直接复制数据库**不保证**能在新实例打开,请自行测试。最可靠的是「本系统导出的整卷备份」在 woc 实例间恢复。
---
## 安全与注意
- **仅管理员**可用;子账号永不可见(数据卷 = 完整微信会话凭据)。管理员本就持有 `docker.sock`(宿主 root),不新增风险。
- 所有路径严格限制在 `/config` 内,禁止 `..` 穿越。
- **全程在「运行中」的实例上操作**(底层用 `docker exec` 浏览/改名/删除、用 `docker cp` 传输)。改动微信正在使用的数据后,需**重启实例**方可生效;大改动前建议先「下载整卷备份」留底。
- 实例**未运行**时:只能做整卷备份 / 恢复;文件浏览需实例运行中。
- 上传的文件落地为容器内 `abc` 属主(微信进程可读)。
+6 -6
View File
@@ -1,19 +1,19 @@
# WechatOnCloud —— 面板为唯一服务;微信实例由面板按需动态创建(docker run)。
# 面板挂载 docker.sock 来创建/启动/删除微信实例容器,并反向代理到它们的 KasmVNC。
# 镜像全部从 GHCR 拉取,无需本地构建。要改配置:复制 .env.example 为 .env 后修改。
# 镜像默认从 Docker Hub 拉取(GHCR 备用),无需本地构建。要改配置:复制 .env.example 为 .env 后修改。
services:
panel:
# 镜像源前缀默认 GHCR;大陆网络拉不动时改 .env 的 WOC_IMAGE_PREFIX 切到国内反代(见 .env.example)。
image: ${WOC_IMAGE_PREFIX:-ghcr.io/gloridust}/woc-panel:${WOC_VERSION:-latest}
# 镜像源前缀默认 Docker Hub(飞牛等 NAS 有加速);拉不动时改 .env 的 WOC_IMAGE_PREFIX 切到 GHCR/国内反代(见 .env.example)。
image: ${WOC_IMAGE_PREFIX:-docker.io/gloridust}/woc-panel:${WOC_VERSION:-latest}
container_name: woc-panel
# pull_policy 用默认(missing):本地已有同名镜像就直接用,没有才去 GHCR 拉。
# pull_policy 用默认(missing):本地已有同名镜像就直接用,没有才去镜像源拉。
# 这样「发布前本地自构建」与「线上拉取」都能用同一份 compose。
# 想强制更新到 GHCR 最新版:docker compose pull && docker compose up -d
# 想强制更新到最新版:docker compose pull && docker compose up -d
environment:
- PORT=8080
# 新建微信实例时使用的镜像(多架构,amd64/arm64 自动匹配);前缀同样跟随 WOC_IMAGE_PREFIX。
- WOC_WECHAT_IMAGE=${WOC_IMAGE_PREFIX:-ghcr.io/gloridust}/wechat-on-cloud:${WOC_VERSION:-latest}
- WOC_WECHAT_IMAGE=${WOC_IMAGE_PREFIX:-docker.io/gloridust}/wechat-on-cloud:${WOC_VERSION:-latest}
# 透传给每个微信实例容器(KasmVNC 基础镜像用它们降权运行)
- PUID=${WOC_PUID:-1000}
- PGID=${WOC_PGID:-1000}
+5 -5
View File
@@ -62,24 +62,24 @@ ENV LANG=zh_CN.UTF-8 \
# 用 e.data 直发成品字符串(详见 woc-www-patch.sh / woc-ime.pl)。
# 注意:实际加载的是 webpack 产物 dist/main.bundle.jsapp/ui.js 是未打包源码、运行时不加载),故必须改 bundle。
COPY woc-www-patch.sh woc-ime.pl /woc/
RUN chmod +x /woc/woc-www-patch.sh && /woc/woc-www-patch.sh
RUN chmod 755 /woc/woc-www-patch.sh && chmod 644 /woc/woc-ime.pl && /woc/woc-www-patch.sh
# 微信下载/解压控制脚本(运行时由面板经 docker exec 触发,状态写入数据卷 /config/.woc-state
COPY wechat-ctl.sh /woc/wechat-ctl.sh
RUN chmod +x /woc/wechat-ctl.sh
RUN chmod 755 /woc/wechat-ctl.sh
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守
COPY autostart /defaults/autostart
RUN chmod +x /defaults/autostart
RUN chmod 755 /defaults/autostart
# 启动钩子(00):给每个实例唯一且持久的 machine-id,避免所有实例共用镜像里烤死的同一个,
# 触发腾讯"设备农场"风控导致登录即被强制退出。须在 autostart(拉起微信)之前执行,故用 00 前缀。
COPY woc-identity.sh /custom-cont-init.d/00-woc-identity
RUN chmod +x /custom-cont-init.d/00-woc-identity
RUN chmod 755 /custom-cont-init.d/00-woc-identity
# 启动钩子(01):每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑)
COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart
RUN chmod +x /custom-cont-init.d/01-woc-autostart
RUN chmod 755 /custom-cont-init.d/01-woc-autostart
# 3000 = HTTP web 客户端, 3001 = HTTPS
EXPOSE 3000 3001
+145 -5
View File
@@ -1,6 +1,7 @@
import { hostname } from 'node:os';
import { existsSync, readdirSync } from 'node:fs';
import http from 'node:http';
import zlib from 'node:zlib';
import Docker from 'dockerode';
import type { Instance } from './store.js';
@@ -522,10 +523,13 @@ export async function downloadFromInstance(inst: Instance, name: string): Promis
stream.on('end', () => resolve());
stream.on('error', reject);
});
const tar = Buffer.concat(chunks);
// 解析 tar,定位真正的普通文件块。Docker(Go archive/tar) 在 mtime 含纳秒精度等情况下会先写一个
// PAX 扩展头块(typeflag 'x'),旧代码误把它当文件头、读到的是扩展记录长度 → 返回错误长度的数据
// ("大小不对")。这里跳过 PAX/全局('x'/'g')与 GNU 长名('L'/'K')等扩展头,找到普通文件('0'/NUL)再取内容。
return extractSingleFileFromTar(Buffer.concat(chunks));
}
// 从 docker getArchive 返回的 tar 中取出第一个普通文件的内容。Docker(Go archive/tar) 在 mtime 含纳秒精度等
// 情况下会先写一个 PAX 扩展头块(typeflag 'x'),把它误当文件头会读到扩展记录长度 → 返回错误长度的数据
// "大小不对")。这里跳过 PAX/全局('x'/'g')与 GNU 长名('L'/'K')等扩展头,找到普通文件('0'/NUL)再取内容。
function extractSingleFileFromTar(tar: Buffer): Buffer {
let off = 0;
while (off + 512 <= tar.length) {
const header = tar.subarray(off, off + 512);
@@ -572,12 +576,148 @@ export async function typeInInstance(inst: Instance, text: string): Promise<void
'export DISPLAY="${display:-:1}"',
'command -v xclip >/dev/null 2>&1 || { echo "xclip not installed in instance image" >&2; exit 127; }',
'command -v xdotool >/dev/null 2>&1 || { echo "xdotool not installed in instance image" >&2; exit 127; }',
`echo '${b64}' | base64 -d | xclip -selection clipboard -i`,
// xclip -i 会 daemon 化常驻持有剪贴板选区,并继承 exec 的 stdout/stderr;不重定向的话 docker exec
// 要等这俩 fd 关闭,实测每次卡 ~2s。重定向到 /dev/null 后台后,整条链路从 ~2.1s 降到 ~0.08s。
`echo '${b64}' | base64 -d | xclip -selection clipboard -i >/dev/null 2>&1`,
'xdotool key --clearmodifiers ctrl+v',
].join('; ');
await execCapture(inst, ['bash', '-c', cmd]);
}
// 通过 xdotool 在实例容器内模拟一次按键(如 Return / BackSpace)。
// 用于「无感输入」模式:中文经 xclip 转发期间,把被截下的回车/退格按序送出,保证顺序、避免抢跑。
// key 仅允许字母与下划线(xdotool keysym 名),杜绝注入。
export async function keyInInstance(inst: Instance, key: string): Promise<void> {
if (!/^[A-Za-z_]{1,20}$/.test(key)) throw new Error('按键名不合法');
const cmd = [
'set -e',
'display="${DISPLAY:-}"',
'if [ -z "$display" ]; then for x in /tmp/.X11-unix/X*; do [ -e "$x" ] || continue; display=":${x##*X}"; break; done; fi',
'export DISPLAY="${display:-:1}"',
'command -v xdotool >/dev/null 2>&1 || { echo "xdotool not installed in instance image" >&2; exit 127; }',
`xdotool key --clearmodifiers ${key}`,
].join('; ');
await execCapture(inst, ['bash', '-c', cmd]);
}
// ---------- 数据卷管理(仅管理员;路由层用 requireAdmin 限制) ----------
// 数据卷 = 容器内 /config 持久卷,含微信全部数据(登录态、加密聊天库等)。提供浏览/上传/解压/下载/
// 改名/移动/删除 + 整卷备份/恢复。主要场景:把 PC 微信数据迁移上来、跨实例迁移、离线备份。
// 路径安全:所有相对路径经 safeVolPath 归一化并严格限制在 /config 内,禁止 .. 穿越。
const VOL_ROOT = '/config';
// 把用户给的相对路径安全解析为 /config 下的绝对路径;禁止 .. 与 NUL;剥离前导 /。
function safeVolPath(rel: string): string {
const raw = (rel ?? '').replace(/\\/g, '/');
if (raw.includes('\0')) throw new Error('路径不合法');
const parts: string[] = [];
for (const seg of raw.split('/')) {
if (seg === '' || seg === '.') continue;
if (seg === '..') throw new Error('路径不合法(禁止 ..');
parts.push(seg);
}
return parts.length ? `${VOL_ROOT}/${parts.join('/')}` : VOL_ROOT;
}
const relOf = (abs: string): string => (abs === VOL_ROOT ? '' : abs.slice(VOL_ROOT.length + 1));
// gzip 魔数自动识别(用户上传可能是 .tar 或 .tar.gz;本系统备份恒为 .gz)。
const maybeGunzip = (buf: Buffer): Buffer =>
buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b ? zlib.gunzipSync(buf) : buf;
export interface VolEntry {
name: string;
type: 'dir' | 'file' | 'link' | 'other';
size: number;
mtime: number; // epoch ms
}
// 列目录(仅一层)。dirs/files 混合返回,前端排序。
export async function listVolume(inst: Instance, rel: string): Promise<{ path: string; entries: VolEntry[] }> {
const abs = safeVolPath(rel);
// GNU find -printf%y 类型(d/f/l) \t %s 大小 \t %T@ mtime(秒.纳秒) \t %f 名字。argv 直传不经 shell,名字含空格/引号也安全。
const out = await execCapture(inst, [
'find', abs, '-maxdepth', '1', '-mindepth', '1', '-printf', '%y\\t%s\\t%T@\\t%f\\n',
]);
const entries: VolEntry[] = [];
for (const line of out.split('\n')) {
if (!line) continue;
const i1 = line.indexOf('\t');
const i2 = line.indexOf('\t', i1 + 1);
const i3 = line.indexOf('\t', i2 + 1);
if (i1 < 0 || i2 < 0 || i3 < 0) continue;
const y = line.slice(0, i1);
entries.push({
type: y === 'd' ? 'dir' : y === 'f' ? 'file' : y === 'l' ? 'link' : 'other',
size: Number(line.slice(i1 + 1, i2)) || 0,
mtime: Math.round(parseFloat(line.slice(i2 + 1, i3)) * 1000) || 0,
name: line.slice(i3 + 1),
});
}
return { path: relOf(abs), entries };
}
export async function volMkdir(inst: Instance, rel: string): Promise<void> {
const abs = safeVolPath(rel);
if (abs === VOL_ROOT) throw new Error('路径不合法');
await execCapture(inst, ['mkdir', '-p', abs]);
}
export async function volMove(inst: Instance, fromRel: string, toRel: string): Promise<void> {
const from = safeVolPath(fromRel);
const to = safeVolPath(toRel);
if (from === VOL_ROOT || to === VOL_ROOT) throw new Error('不能移动数据卷根目录');
if (from === to) return;
await execCapture(inst, ['mv', '-f', from, to]);
}
export async function volDelete(inst: Instance, rel: string): Promise<void> {
const abs = safeVolPath(rel);
if (abs === VOL_ROOT) throw new Error('不能删除数据卷根目录');
await execCapture(inst, ['rm', '-rf', abs]);
}
// 上传单个文件到指定目录(tarSingleFile 写入 uid/gid 1000,落地即 abc 属主,微信可读)。
export async function volUploadFile(inst: Instance, rel: string, name: string, content: Buffer): Promise<void> {
if (!safeName(name)) throw new Error('文件名不合法');
const dir = safeVolPath(rel);
await execCapture(inst, ['mkdir', '-p', dir]);
await docker.getContainer(inst.containerName).putArchive(tarSingleFile(name, content), { path: dir });
}
// 上传压缩包并解压到指定目录(PC 微信数据迁移:用户把文件夹打成 .tar/.tar.gz 上传)。
// putArchive 把 tar 内容解到 dir 下,Docker 解包限制在 dir 内、防 .. 穿越。
export async function volExtractArchive(inst: Instance, rel: string, archive: Buffer): Promise<void> {
const dir = safeVolPath(rel);
await execCapture(inst, ['mkdir', '-p', dir]);
await docker.getContainer(inst.containerName).putArchive(maybeGunzip(archive), { path: dir });
}
export async function volDownloadFile(inst: Instance, rel: string): Promise<Buffer> {
const abs = safeVolPath(rel);
if (abs === VOL_ROOT) throw new Error('不能下载整个根目录,请用整卷备份');
const stream = (await docker.getContainer(inst.containerName).getArchive({ path: abs })) as NodeJS.ReadableStream;
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
stream.on('data', (d: Buffer) => chunks.push(d));
stream.on('end', () => resolve());
stream.on('error', reject);
});
return extractSingleFileFromTar(Buffer.concat(chunks));
}
// 整卷备份:把 /config 打成 tar 流并经 gzip 输出(路由直接 pipe 给响应,避免大文件入内存)。
// getArchive('/config') 的条目前缀为 config/,恢复时解到容器根即可落回 /config。
export async function volBackupStream(inst: Instance): Promise<NodeJS.ReadableStream> {
const tar = (await docker.getContainer(inst.containerName).getArchive({ path: VOL_ROOT })) as NodeJS.ReadableStream;
const gzip = zlib.createGzip();
tar.on('error', (e) => gzip.destroy(e as Error));
return tar.pipe(gzip);
}
// 整卷恢复:仅适用于本系统导出的备份(条目前缀 config/),解到容器根 → 落回 /config。要求实例已停止。
export async function volRestoreArchive(inst: Instance, archive: Buffer): Promise<void> {
await docker.getContainer(inst.containerName).putArchive(maybeGunzip(archive), { path: '/' });
}
// 实例容器名(供反代构造 target)。
export function instanceTarget(inst: Instance): string {
return `http://${inst.containerName}:3000`;
+164
View File
@@ -48,6 +48,7 @@ import {
deleteInstanceFile,
instanceLogs,
typeInInstance,
keyInInstance,
listOrphanVolumes,
removeVolume,
listOrphanContainers,
@@ -55,6 +56,15 @@ import {
instanceMemoryMB,
instanceHttpHealthy,
regenInstanceMachineId,
listVolume,
volMkdir,
volMove,
volDelete,
volUploadFile,
volExtractArchive,
volDownloadFile,
volBackupStream,
volRestoreArchive,
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js';
@@ -623,6 +633,22 @@ app.post('/api/instances/:id/type', async (req, reply) => {
}
});
// 模拟单个按键(无感输入模式下按序送出被截下的回车/退格,保证与中文转发的顺序)
app.post('/api/instances/:id/key', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
const { key } = (req.body as any) ?? {};
if (!key || typeof key !== 'string') return reply.code(400).send({ error: '按键名为空' });
try {
await keyInInstance(findInstance(id)!, key);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '按键失败' });
}
});
// 查看实例容器日志(仅管理员):排查"无法进入/未安装/卡死"等。inline 文本,浏览器可直接看/另存。
app.get('/api/admin/instances/:id/logs', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
@@ -638,6 +664,144 @@ app.get('/api/admin/instances/:id/logs', async (req, reply) => {
}
});
// ---------- 数据卷管理(仅管理员):浏览/上传/解压/下载/改名/移动/删除 + 整卷备份/恢复 ----------
// 数据卷 = 容器 /config,含微信完整会话与加密聊天库 → 仅 admin 可见可用(admin 本就有 docker.sock=宿主 root
// 不新增风险;子账号永不可达)。
// 全程在「运行中」的实例上操作:浏览/改名/移动/删除靠 docker exec(需容器运行),上传/解压/下载/备份靠
// getArchive/putArchive。不强制停止实例(exec 在停止容器无法运行)。整卷恢复会覆盖全部数据,前端强提示
// 并建议恢复后重启实例以加载数据。
// 浏览目录(一层)
app.get('/api/admin/instances/:id/volume', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
return await listVolume(inst, String((req.query as any)?.path || ''));
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '读取目录失败' });
}
});
// 新建文件夹
app.post('/api/admin/instances/:id/volume/mkdir', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await volMkdir(inst, String((req.body as any)?.path || ''));
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '新建失败' });
}
});
// 重命名 / 移动
app.post('/api/admin/instances/:id/volume/move', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const { from, to } = (req.body as any) ?? {};
try {
await volMove(inst, String(from || ''), String(to || ''));
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '移动失败' });
}
});
// 删除文件 / 目录
app.delete('/api/admin/instances/:id/volume', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await volDelete(inst, String((req.query as any)?.path || ''));
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '删除失败' });
}
});
// 下载单个文件
app.get('/api/admin/instances/:id/volume/download', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const path = String((req.query as any)?.path || '');
const name = path.split('/').filter(Boolean).pop() || 'file';
try {
const buf = await volDownloadFile(inst, path);
reply.header('content-type', 'application/octet-stream');
reply.header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`);
return reply.send(buf);
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '下载失败' });
}
});
// 上传单个文件到当前目录(原始二进制;落地为 abc 属主)
app.post('/api/admin/instances/:id/volume/upload', { bodyLimit: 2 * 1024 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const path = String((req.query as any)?.path || '');
const name = String((req.query as any)?.name || '').trim();
const body = req.body as Buffer;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
try {
await volUploadFile(inst, path, name, body);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '上传失败' });
}
});
// 上传压缩包并解压到当前目录(.tar / .tar.gz;PC 微信数据迁移用)
app.post('/api/admin/instances/:id/volume/extract', { bodyLimit: 3 * 1024 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const body = req.body as Buffer;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
try {
await volExtractArchive(inst, String((req.query as any)?.path || ''), body);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '解压失败(请确认是 .tar 或 .tar.gz' });
}
});
// 整卷备份:流式下载 /config 为 .tar.gz
app.get('/api/admin/instances/:id/volume/backup', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
const stream = await volBackupStream(inst);
reply.header('content-type', 'application/gzip');
reply.header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(`woc-${inst.name}-backup.tar.gz`)}`);
return reply.send(stream);
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '备份失败' });
}
});
// 整卷恢复:上传本系统导出的 .tar.gz 备份(要求实例已停止)
app.post('/api/admin/instances/:id/volume/restore', { bodyLimit: 3 * 1024 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const body = req.body as Buffer;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
try {
await volRestoreArchive(inst, body);
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);
+40
View File
@@ -41,6 +41,26 @@ export interface InstanceWithStatus extends PanelInstance {
wechat: WechatStatus;
}
export interface VolEntry {
name: string;
type: 'dir' | 'file' | 'link' | 'other';
size: number;
mtime: number; // epoch ms
}
// 原始二进制上传(File 直传 application/octet-stream),用于数据卷上传/解压/恢复
async function rawUpload(url: string, file: File): Promise<any> {
const res = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' },
body: file,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as any).error || `请求失败 (${res.status})`);
return data;
}
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;
@@ -138,9 +158,29 @@ export const api = {
downloadFileUrl: (id: string, name: string) => `/api/instances/${id}/download?name=${encodeURIComponent(name)}`,
deleteFile: (id: string, name: string) => req(`/api/instances/${id}/files?name=${encodeURIComponent(name)}`, { method: 'DELETE' }),
// 数据卷管理(仅管理员)
volumeList: (id: string, path = '') =>
req<{ path: string; entries: VolEntry[] }>(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`),
volumeMkdir: (id: string, path: string) =>
req(`/api/admin/instances/${id}/volume/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }),
volumeMove: (id: string, from: string, to: string) =>
req(`/api/admin/instances/${id}/volume/move`, { method: 'POST', body: JSON.stringify({ from, to }) }),
volumeDelete: (id: string, path: string) =>
req(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`, { method: 'DELETE' }),
volumeDownloadUrl: (id: string, path: string) =>
`/api/admin/instances/${id}/volume/download?path=${encodeURIComponent(path)}`,
volumeBackupUrl: (id: string) => `/api/admin/instances/${id}/volume/backup`,
volumeUpload: (id: string, path: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/upload?path=${encodeURIComponent(path)}&name=${encodeURIComponent(file.name)}`, file),
volumeExtract: (id: string, path: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/extract?path=${encodeURIComponent(path)}`, file),
volumeRestore: (id: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/restore`, file),
// 多端协作:操作控制权
controlStatus: (id: string) => req<{ free: boolean; mine: boolean; holder: string | null }>(`/api/instances/${id}/control`),
controlBeat: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/beat`, { method: 'POST' }),
controlTake: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/take`, { method: 'POST' }),
typeInInstance: (id: string, text: string) => req(`/api/instances/${id}/type`, { method: 'POST', body: JSON.stringify({ text }) }),
keyInInstance: (id: string, key: string) => req(`/api/instances/${id}/key`, { method: 'POST', body: JSON.stringify({ key }) }),
};
+346 -10
View File
@@ -1,11 +1,23 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, type PanelUser, type InstanceWithStatus } from '../api';
import { api, type PanelUser, type InstanceWithStatus, type VolEntry } from '../api';
import { useUI, PasswordInput } from '../ui';
import { useAuth } from '../auth';
const BUSY_PHASES = ['downloading', 'extracting', 'installing'];
function fmtBytes(n: number): string {
if (!n) return '0 B';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
return `${(n / Math.pow(1024, i)).toFixed(i ? 1 : 0)} ${u[i]}`;
}
function fmtDate(ms: number): string {
const d = new Date(ms);
const p = (x: number) => String(x).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const MenuIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 6h16M4 12h16M4 18h16" />
@@ -19,6 +31,41 @@ const CaretIcon = (
</svg>
);
// 数据卷文件浏览器用的小图标(线性 SVG,统一描边风格,替代渲染不一致的 emoji)
const svgIcon = (children: JSX.Element, size = 16) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const FolderIcon = svgIcon(<path d="M3 7a2 2 0 0 1 2-2h3.5l2 2H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />, 18);
const FileIcon = svgIcon(
<>
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
<path d="M14 3v5h5" />
</>,
18,
);
const DownloadIcon = svgIcon(
<>
<path d="M12 3v12" />
<path d="M7 11l5 5 5-5" />
<path d="M5 21h14" />
</>,
);
const EditIcon = svgIcon(
<>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z" />
</>,
);
const TrashIcon = svgIcon(
<>
<path d="M3 6h18" />
<path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
<path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
</>,
);
// 友好空状态:圆形图标 + 标题 + 说明 + 可选引导按钮(沿用首页 .empty-state 样式)
function EmptyState({ icon, title, sub, action }: { icon: string; title: string; sub?: string; action?: JSX.Element }) {
return (
@@ -47,6 +94,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
const [deleteInst, setDeleteInst] = useState<InstanceWithStatus | null>(null); // 删除实例弹窗
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
const [securityInst, setSecurityInst] = useState<InstanceWithStatus | null>(null); // 安全(内存阈值)弹窗
const [volumeInst, setVolumeInst] = useState<InstanceWithStatus | null>(null); // 数据卷管理弹窗
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
@@ -257,6 +305,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
onAssign={() => setAssignInst(inst)}
onDelete={() => setDeleteInst(inst)}
onSecurity={() => setSecurityInst(inst)}
onVolume={() => setVolumeInst(inst)}
/>
))}
</div>
@@ -479,6 +528,9 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
}}
/>
)}
{volumeInst && (
<VolumeManager inst={volumeInst} onClose={() => setVolumeInst(null)} onChanged={load} />
)}
</div>
);
}
@@ -813,6 +865,7 @@ function InstanceAdminCard({
onAssign,
onDelete,
onSecurity,
onVolume,
}: {
inst: InstanceWithStatus;
userCount: number;
@@ -827,13 +880,24 @@ function InstanceAdminCard({
onAssign: () => void;
onDelete: () => void;
onSecurity: () => void;
onVolume: () => void;
}) {
const wx = inst.wechat;
const busy = BUSY_PHASES.includes(wx.phase);
const installed = wx.installed && wx.phase !== 'downloading';
const offline = inst.runtime !== 'running';
const working = !!acting || busy; // 生命周期操作中 或 微信下载/更新中 → 锁住卡片
const [menuOpen, setMenuOpen] = useState(false); // 「管理」折叠菜单是否展开
const [menuOpen, setMenuOpen] = useState(false); // 「管理」菜单是否展开(悬浮层,不占文档流)
const menuRef = useRef<HTMLDivElement>(null);
// 悬浮下拉:点击菜单外部时关闭
useEffect(() => {
if (!menuOpen) return;
const onDocDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
};
document.addEventListener('mousedown', onDocDown);
return () => document.removeEventListener('mousedown', onDocDown);
}, [menuOpen]);
let badge: { text: string; cls: string };
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
@@ -851,7 +915,7 @@ function InstanceAdminCard({
else sub = '微信尚未安装';
return (
<div className="inst-card">
<div className={'inst-card' + (menuOpen ? ' open-menu' : '')}>
<div className="inst-head">
<span className="inst-name">{inst.name}</span>
<span className={'tag ' + badge.cls}>{badge.text}</span>
@@ -885,13 +949,14 @@ function InstanceAdminCard({
)}
</div>
<button className={'inst-menu-toggle' + (menuOpen ? ' open' : '')} onClick={() => setMenuOpen((v) => !v)}>
<span></span>
<span className="inst-menu-caret">{CaretIcon}</span>
</button>
<div className="inst-menu-wrap" ref={menuRef}>
<button className={'inst-menu-toggle' + (menuOpen ? ' open' : '')} onClick={() => setMenuOpen((v) => !v)}>
<span></span>
<span className="inst-menu-caret">{CaretIcon}</span>
</button>
{menuOpen && (
<div className="inst-menu">
{menuOpen && (
<div className="inst-menu" onClick={() => setMenuOpen(false)}>
<div className="inst-menu-group">
<div className="inst-menu-label"></div>
<div className="inst-menu-items">
@@ -930,6 +995,9 @@ function InstanceAdminCard({
<button className="btn-text" onClick={onSecurity} title="内存阈值自愈">
</button>
<button className="btn-text" onClick={onVolume} title="数据卷:备份/恢复、上传 PC 微信数据、文件管理">
</button>
</div>
</div>
<div className="inst-menu-group inst-menu-danger">
@@ -940,13 +1008,281 @@ function InstanceAdminCard({
</div>
</div>
</div>
)}
)}
</div>
</>
)}
</div>
);
}
// 数据卷管理(仅管理员):整卷备份/恢复 + 文件浏览器(浏览/上传/解压/下载/改名/移动/删除)。
// 主要场景:把 PC 微信数据迁移上来、跨实例迁移、离线备份。全程在「运行中」的实例上操作
// (浏览/改名/删除靠 docker exec,需容器运行)。整卷恢复会覆盖全部数据,强提示并建议恢复后重启实例。
function VolumeManager({ inst, onClose, onChanged }: { inst: InstanceWithStatus; onClose: () => void; onChanged: () => void }) {
const { toast, confirm } = useUI();
const [path, setPath] = useState('');
const [entries, setEntries] = useState<VolEntry[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
const [busy, setBusy] = useState(''); // 进行中操作文案;非空即禁用界面
const [mkdirOpen, setMkdirOpen] = useState(false);
const [mkdirName, setMkdirName] = useState('');
const [renaming, setRenaming] = useState<string | null>(null);
const [renameVal, setRenameVal] = useState('');
const uploadRef = useRef<HTMLInputElement>(null);
const extractRef = useRef<HTMLInputElement>(null);
const restoreRef = useRef<HTMLInputElement>(null);
const offline = inst.runtime !== 'running'; // 文件浏览需实例运行中
const join = (a: string, b: string) => (a ? a + '/' + b : b);
const load = async (p = path) => {
setLoading(true);
setErr('');
try {
const r = await api.volumeList(inst.id, p);
setEntries(r.entries);
setPath(r.path);
} catch (e: any) {
setErr(e?.message || '读取失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (offline) {
setLoading(false);
return;
}
load('');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inst.id]);
const sorted = [...entries].sort((a, b) => {
if ((a.type === 'dir') !== (b.type === 'dir')) return a.type === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name, 'zh');
});
const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
const segs = path ? path.split('/') : [];
const run = async (label: string, fn: () => Promise<any>, okMsg?: string, skipReload = false) => {
setBusy(label);
try {
await fn();
if (okMsg) toast(okMsg, 'ok');
if (!skipReload) await load();
} catch (e: any) {
toast(e?.message || '操作失败', 'error');
} finally {
setBusy('');
}
};
const doMkdir = async () => {
const name = mkdirName.trim();
if (!name) return;
await run('新建中…', () => api.volumeMkdir(inst.id, join(path, name)), '已新建文件夹');
setMkdirName('');
setMkdirOpen(false);
};
const doRename = async (oldName: string) => {
const nv = renameVal.trim();
setRenaming(null);
if (!nv || nv === oldName) return;
// 含 / → 视为相对 /config 的目标路径(移动到子目录);否则同目录改名
const to = nv.includes('/') ? nv.replace(/^\/+/, '') : join(path, nv);
await run('处理中…', () => api.volumeMove(inst.id, join(path, oldName), to), '已重命名 / 移动');
};
const doDelete = async (en: VolEntry) => {
const ok = await confirm({
title: `删除「${en.name}」?`,
body: en.type === 'dir' ? '将递归删除该文件夹下所有内容,不可恢复。' : '删除后不可恢复。',
danger: true,
confirmText: '删除',
});
if (!ok) return;
await run('删除中…', () => api.volumeDelete(inst.id, join(path, en.name)), '已删除');
};
const onPick = (kind: 'upload' | 'extract' | 'restore') => async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (kind === 'restore') {
const ok = await confirm({
title: '恢复整卷备份?',
body: `将用「${file.name}」覆盖该实例 /config 的全部数据(含登录态、聊天库),不可撤销。建议仅用于本系统导出的备份;恢复后请在卡片上「重启」实例以加载数据。`,
danger: true,
confirmText: '覆盖恢复',
});
if (!ok) return;
await run(`恢复 ${file.name}`, () => api.volumeRestore(inst.id, file), '恢复完成,请重启实例以加载数据', true);
onChanged();
return;
}
if (kind === 'upload') await run(`上传 ${file.name}`, () => api.volumeUpload(inst.id, path, file), '上传完成');
else await run(`解压 ${file.name}`, () => api.volumeExtract(inst.id, path, file), '解压完成');
};
const disabled = !!busy;
const icon = (en: VolEntry) => (en.type === 'dir' ? FolderIcon : FileIcon);
return (
<div className="modal-mask" onClick={onClose}>
<div className="card modal vol-modal" onClick={(e) => e.stopPropagation()}>
<h2> · {inst.name}</h2>
{/* 整卷备份 / 恢复(运行/停止均可用) */}
<div className="vol-sec">
<div className="vol-section-label"> / </div>
<div className="vol-topbar">
<a className="btn" href={api.volumeBackupUrl(inst.id)} target="_blank" rel="noreferrer"></a>
<button className="btn" disabled={disabled} onClick={() => restoreRef.current?.click()}></button>
<input ref={restoreRef} type="file" accept=".gz,.tgz,.tar" hidden onChange={onPick('restore')} />
</div>
<div className="vol-hint"> / 线</div>
</div>
{offline ? (
<div className="vol-warn">
/
</div>
) : (
<div className="vol-sec">
<div className="vol-section-label"></div>
{/* 面包屑 */}
<div className="vol-crumbs">
<button className="vol-crumb" disabled={disabled} onClick={() => load('')}>/config</button>
{segs.map((s, i) => (
<span key={i}>
<span className="vol-sep">/</span>
<button className="vol-crumb" disabled={disabled} onClick={() => load(segs.slice(0, i + 1).join('/'))}>
{s}
</button>
</span>
))}
</div>
{/* 工具条 */}
<div className="vol-tools">
<button className="btn-text" disabled={disabled} onClick={() => uploadRef.current?.click()}></button>
<button className="btn-text" disabled={disabled} onClick={() => extractRef.current?.click()}></button>
<button className="btn-text" disabled={disabled} onClick={() => setMkdirOpen((v) => !v)}></button>
<button className="btn-text" disabled={disabled} onClick={() => load()}></button>
<input ref={uploadRef} type="file" hidden onChange={onPick('upload')} />
<input ref={extractRef} type="file" accept=".gz,.tgz,.tar" hidden onChange={onPick('extract')} />
</div>
{mkdirOpen && (
<div className="vol-mkdir">
<input
className="input"
placeholder="文件夹名"
value={mkdirName}
autoFocus
onChange={(e) => setMkdirName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && doMkdir()}
/>
<button className="btn btn-primary" disabled={disabled || !mkdirName.trim()} onClick={doMkdir}></button>
</div>
)}
{busy && <div className="vol-busy">{busy}</div>}
{/* 文件列表 */}
<div className="vol-list">
{loading ? (
<div className="muted small" style={{ padding: 16 }}></div>
) : err ? (
<div className="error">{err}</div>
) : sorted.length === 0 ? (
<div className="muted small" style={{ padding: 16 }}>{path ? '空目录' : '(无内容)'}</div>
) : (
<>
{path && (
<button className="vol-row vol-main vol-up" disabled={disabled} onClick={() => load(parent)}>
<span className="vol-ic">{FolderIcon}</span>
<span className="vol-nm"></span>
</button>
)}
{sorted.map((en) => (
<div className="vol-row" key={en.name}>
{renaming === en.name ? (
<input
className="input vol-rename"
autoFocus
value={renameVal}
onChange={(e) => setRenameVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') doRename(en.name);
if (e.key === 'Escape') setRenaming(null);
}}
onBlur={() => doRename(en.name)}
/>
) : (
<button
className="vol-main"
disabled={disabled}
onClick={() => (en.type === 'dir' ? load(join(path, en.name)) : undefined)}
style={{ cursor: en.type === 'dir' ? 'pointer' : 'default' }}
>
<span className={'vol-ic' + (en.type === 'dir' ? ' dir' : '')}>{icon(en)}</span>
<span className="vol-nm">{en.name}</span>
<span className="vol-meta">
{en.type === 'dir' ? '' : fmtBytes(en.size)}
{en.mtime ? ` · ${fmtDate(en.mtime)}` : ''}
</span>
</button>
)}
<div className="vol-acts">
{en.type === 'file' && (
<a
className="vol-act"
title="下载"
href={api.volumeDownloadUrl(inst.id, join(path, en.name))}
target="_blank"
rel="noreferrer"
>
{DownloadIcon}
</a>
)}
<button
className="vol-act"
title="重命名 / 移动"
disabled={disabled}
onClick={() => {
setRenameVal(en.name);
setRenaming(en.name);
}}
>
{EditIcon}
</button>
<button className="vol-act danger" title="删除" disabled={disabled} onClick={() => doDelete(en)}>
{TrashIcon}
</button>
</div>
</div>
))}
</>
)}
</div>
</div>
)}
<div className="muted small" style={{ marginTop: 10, lineHeight: 1.6 }}>
PC <b>.tar.gz</b>使
</div>
<div className="modal-actions">
<button className="btn btn-primary" onClick={onClose}></button>
</div>
</div>
</div>
);
}
// 通用 chip 多选
function ChipMultiSelect({
options,
+118 -16
View File
@@ -14,6 +14,76 @@ function desktopUrl(id: string) {
);
}
// 「无感输入」钩子:装进同源 iframe,让用户直接在微信里打中文。
// - compositionend(中文提交)→ 经 xclip+xdotool 转发(绕开 VNC keysym 容量上限)。
// - 转发未完成期间(队列活跃),把后续可见字符 + 回车/退格也串进同一队列按序送出 →
// 彻底消除"中文走异步、数字走 keysym 抢跑"导致的"你好123→23"丢字。
// - 队列空闲时不干预:英文/数字仍走原生 keysym,零延迟。
// 返回清理函数(切回转发模式 / 重连 / 卸载时移除监听)。
function installSeamlessIme(win: Window, doc: Document, instId: string): () => void {
type Job = { kind: 'text'; data: string } | { kind: 'key'; data: string };
const queue: Job[] = [];
let draining = false;
const active = () => draining || queue.length > 0;
const drain = async () => {
if (draining) return;
draining = true;
while (queue.length) {
const job = queue[0];
try {
if (job.kind === 'text') await api.typeInInstance(instId, job.data);
else await api.keyInInstance(instId, job.data);
} catch {
/* 单条失败丢弃,继续后续,避免卡住队列 */
}
queue.shift();
}
draining = false;
};
const onCompositionEnd = (e: Event) => {
const txt = (e as CompositionEvent).data;
if (!txt) return;
queue.push({ kind: 'text', data: txt });
drain();
};
// 捕获阶段(iframe window 最外层)抢先拦截,赶在 noVNC 之前 → stopImmediatePropagation 阻止它发 keysym。
// 关键:队列活跃(有中文正在转发)时,只接管【数字】和回车/退格——它们不参与拼音合成、且是原"混数字丢字"的祸首;
// 字母绝不接管,否则会把下一个词的拼音首字母(如"呀"的 y)当成字面字符抢走,造成"你好y呀"。字母交给输入法合成。
const onKeyDownCapture = (ev: Event) => {
const e = ev as KeyboardEvent;
if (e.isComposing) return; // 拼音合成中,交给输入法(候选数字选词也在此放行)
if (e.ctrlKey || e.altKey || e.metaKey) return; // 快捷键放行
if (!active()) return; // 没有中文在转发 → 不接管(英文/数字走原生 keysym,零延迟)
if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
e.stopImmediatePropagation();
queue.push({ kind: 'text', data: e.key });
drain();
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopImmediatePropagation();
queue.push({ kind: 'key', data: 'Return' });
drain();
} else if (e.key === 'Backspace') {
e.preventDefault();
e.stopImmediatePropagation();
queue.push({ kind: 'key', data: 'BackSpace' });
drain();
}
// 其它非可见键(方向键/功能键等)放行
};
doc.addEventListener('compositionend', onCompositionEnd, true);
win.addEventListener('keydown', onKeyDownCapture, true);
return () => {
doc.removeEventListener('compositionend', onCompositionEnd, true);
win.removeEventListener('keydown', onKeyDownCapture, true);
};
}
interface TFile {
name: string;
size: number;
@@ -46,8 +116,27 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const [files, setFiles] = useState<TFile[]>([]);
const [showClip, setShowClip] = useState(false);
const [clipText, setClipText] = useState('');
// 中文输入条:面板里的真实 textarea(原生客户端输入法 100% 可用),回车经 xclip+xdotool 粘进微信
const [imeBar, setImeBar] = useState(true); // 默认开(直接在 VNC 里打中文不稳,给一个可靠通道)
// 中文输入模式:'forward'=底部输入条转发(默认,最稳);'seamless'=无感(直接在微信里打,提交后转发)
const [inputMode, setInputMode] = useState<'forward' | 'seamless'>(() => {
try {
return window.localStorage.getItem('woc_input_mode') === 'seamless' ? 'seamless' : 'forward';
} catch {
return 'forward';
}
});
const setMode = (m: 'forward' | 'seamless') => {
try {
window.localStorage.setItem('woc_input_mode', m);
// 同步写好 enable_ime,重载后新页面的 noVNC 连接时即读到
window.localStorage.setItem('enable_ime', m === 'seamless' ? 'true' : 'false');
} catch {
/* 隐私模式禁用 localStorage:忽略 */
}
// 整页重载切换:先卸载旧页面(彻底关闭旧 VNC ws),再以新 enable_ime 干净重连。
// 不能用页内 bump vncNonce 重挂 iframe——那会让新旧两条 ws 短暂并存,概率性把实例的 Xvnc 卡死
//(需重启容器才恢复、面板重启无效),且新连接常读不到新模式(仍是英文)。整页重载是实测唯一可靠的方式。
window.location.reload();
};
const [imeText, setImeText] = useState('');
const [imeSending, setImeSending] = useState(false);
const [uploading, setUploading] = useState(false);
@@ -203,18 +292,27 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
};
}, [showVnc, id, frameLoaded]);
// 每次进入/重连桌面前,强制把 KasmVNC 的 enable_ime 设为【关】
// 原因:开启 IME 模式后,noVNC 用隐藏 textarea + 合成事件还原中文,需要前端拦截/差分,环环相扣极脆
// 实测会"中文混数字时丢最后两个汉字+首个数字"等损坏。中文输入改由底部「中文输入条」可靠承担
// (面板真实 textarea 原生输入法 → xclip+xdotool 粘贴),故 VNC 直接打字回归纯 keysym英文/数字/
// 标点都正常、不再损坏;中文直接打不进(请用输入条)。iframe 同源共享 localStorage,加载前设好即生效。
// 进入/重连桌面前,按输入模式设 KasmVNC 的 enable_imeiframe 同源共享 localStorage,加载前设好即生效)
// 无感(seamless):enable_ime=true,启用 noVNC 合成 textarea;中文 keysym 已被容器补丁抑制
// 成品由「无感输入」钩子经 xdotool 转发(见 installSeamlessIme)。
// 转发(forward):enable_ime=falseVNC 直接打字纯 keysym英文/数字正常);中文走底部输入条。
useEffect(() => {
try {
window.localStorage.setItem('enable_ime', 'false');
window.localStorage.setItem('enable_ime', inputMode === 'seamless' ? 'true' : 'false');
} catch {
/* 隐私模式等禁用 localStorage:忽略 */
}
}, [id, vncNonce]);
}, [id, vncNonce, inputMode]);
// 无感模式:往同源 iframe 装「中文转发 + 有序队列」钩子;切回转发/重连/卸载时自动移除。
useEffect(() => {
if (inputMode !== 'seamless' || !showVnc || !frameLoaded || !id) return;
const win = frameRef.current?.contentWindow;
const doc = frameRef.current?.contentDocument;
if (!win || !doc) return;
const cleanup = installSeamlessIme(win, doc, id);
return cleanup;
}, [inputMode, showVnc, frameLoaded, id, vncNonce]);
// 音频/麦克风桥接:实例就绪即自动连接 kclient 的音频流(扬声器恒开,无需手动找工具条);
// 仅当本实例处于焦点(标签页可见且窗口聚焦)时出声/收音,失焦立即断开,避免多实例多端串音。
@@ -449,11 +547,15 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
</button>
<button
className={'ws-action' + (imeBar ? ' on' : '')}
title="底部中文输入条:用本机输入法打中文,回车送进微信(最可靠)"
onClick={() => setImeBar((v) => !v)}
className={'ws-action' + (inputMode === 'seamless' ? ' on' : '')}
title={
inputMode === 'seamless'
? '无感输入:直接在微信输入框里打中文(提交后转发,已修复混数字丢字)。点击切回「转发输入条」'
: '转发输入:用底部输入条打中文,最稳。点击切到「无感输入」(直接在微信里打)'
}
onClick={() => setMode(inputMode === 'seamless' ? 'forward' : 'seamless')}
>
{inputMode === 'seamless' ? '无感' : '转发'}
</button>
<button
className="ws-action"
@@ -552,8 +654,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
setTimeout(() => {
focusFrame(); // 加载完把键盘焦点交给 VNC
injectVncStyle(); // 让原生控制条在深色背景下可见
// 注意:不再调用 patchVncIme —— enable_ime 已关,直接打字走纯 keysym(英文/数字正常)
// 中文底部「中文输入条」承担。那套合成拦截既脆弱又会损坏混合输入,已弃用
// 无感输入模式的键盘钩子由单独的 effect(依赖 inputMode/frameLoaded)安装,不在此处
// 转发模式则 enable_ime=false,直接打字走纯 keysym(英文/数字正常),中文底部输入条
}, 500);
}}
/>
@@ -690,7 +792,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
)}
</div>
{imeBar && (
{inputMode === 'forward' && (
<div className="iv-imebar">
<textarea
className="iv-imebar-input"
+231 -2
View File
@@ -307,7 +307,6 @@ button {
/* 实例卡片操作:「管理」分类折叠菜单(默认收起,点开按运维/设置/危险分组展开文字操作) */
.inst-menu-toggle {
margin-top: 10px;
width: 100%;
height: 38px;
display: flex;
@@ -335,11 +334,24 @@ button {
.inst-menu-toggle.open .inst-menu-caret {
transform: rotate(180deg);
}
/* 「管理」菜单:绝对定位悬浮层,浮在下方内容之上,不把卡片撑高、不顶走后面的内容 */
.inst-menu-wrap {
position: relative;
margin-top: 10px;
}
.inst-menu {
margin-top: 8px;
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 2;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
background: var(--surface);
border-radius: var(--r-small);
box-shadow: 0 6px 22px rgba(var(--shadow) / 0.26), 0 2px 6px rgba(var(--shadow) / 0.18);
animation: inst-menu-in 0.16s ease;
}
@keyframes inst-menu-in {
@@ -928,6 +940,11 @@ button {
.inst-card > * {
position: relative;
}
/* 菜单展开时:放开裁剪让悬浮层露出,并抬升层级盖住下方/同列卡片(仍低于弹窗 z-index 20 */
.inst-card.open-menu {
overflow: visible;
z-index: 5;
}
.inst-head {
display: flex;
align-items: center;
@@ -1812,3 +1829,215 @@ button {
max-width: 940px;
}
}
/* ── 数据卷管理(管理员)──────────────────────────────────── */
.vol-modal {
width: 640px;
max-width: 92vw;
max-height: 86vh;
}
/* 运行中警示条:柔和琥珀,inset 凹槽质感(不是红色危险) */
.vol-warn {
background: rgba(245, 166, 35, 0.14);
color: #9a6400;
font-size: 13px;
line-height: 1.5;
padding: 9px 12px;
border-radius: var(--r-small);
}
/* 分区容器:内部元素紧凑成组(label 紧贴其内容),分区之间靠 .modal 的 12px gap 拉开 */
.vol-sec {
display: flex;
flex-direction: column;
gap: 8px;
}
.vol-section-label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.vol-hint {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.vol-topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
/* 主操作:中号浮起胶囊(比默认 48px 更克制,和弹窗比例协调) */
.vol-topbar .btn {
height: 40px;
padding: 0 18px;
font-size: 14px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.vol-crumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
font-size: 13px;
}
.vol-crumb {
border: none;
background: none;
color: var(--wx-green);
font-weight: 600;
cursor: pointer;
padding: 2px 4px;
border-radius: 8px;
}
.vol-crumb:active {
background: rgba(var(--green-rgb) / 0.12);
}
.vol-crumb:disabled {
color: var(--muted);
cursor: default;
}
.vol-sep {
color: var(--muted);
}
/* 工具栏:浮起小胶囊(绿字),和上面「扁平绿色面包屑」明显区分,读起来是"按钮"而非散落文字 */
.vol-tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.vol-tools .btn-text {
background: var(--surface);
box-shadow: var(--crease-press);
font-size: 14px;
padding: 8px 14px;
}
.vol-tools .btn-text:active {
transform: scale(0.96);
}
.vol-tools .btn-text:disabled {
opacity: 0.5;
}
.vol-mkdir {
display: flex;
gap: 8px;
}
.vol-mkdir .input {
flex: 1;
}
.vol-busy {
font-size: 13px;
color: var(--wx-green-dark);
font-weight: 600;
}
/* 列表容器:凹槽(inset),内部条目为浮起 bumphover) */
.vol-list {
background: var(--trough);
border-radius: var(--r-small);
padding: 6px;
max-height: 44vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
box-shadow: inset 0 1px 3px rgba(var(--shadow) / 0.22);
}
.vol-row {
display: flex;
align-items: center;
gap: 6px;
border-radius: 12px;
padding: 2px 4px 2px 6px;
}
.vol-row:hover {
background: var(--surface);
box-shadow: var(--crease-press);
}
.vol-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
border: none;
background: none;
text-align: left;
padding: 8px 4px;
color: var(--text);
font-size: 14px;
}
.vol-main:disabled {
opacity: 0.55;
}
.vol-up {
color: var(--muted);
}
.vol-ic {
flex: 0 0 auto;
width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.vol-ic.dir {
color: var(--wx-green);
}
.vol-ic svg {
display: block;
}
.vol-nm {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vol-meta {
flex: 0 0 auto;
color: var(--muted);
font-size: 12px;
padding-left: 8px;
}
.vol-rename {
flex: 1;
margin: 2px 0;
}
.vol-acts {
flex: 0 0 auto;
display: flex;
gap: 3px;
}
.vol-act {
border: none;
background: var(--trough);
color: var(--muted);
width: 30px;
height: 30px;
border-radius: 9px;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: var(--crease-press);
}
.vol-row:hover .vol-act {
background: var(--base);
color: var(--text);
}
.vol-act:active {
transform: scale(0.92);
}
.vol-act.danger:hover {
color: var(--danger);
background: rgba(var(--danger-rgb) / 0.12);
}
.vol-act:disabled {
opacity: 0.4;
cursor: default;
}