6 Commits

14 changed files with 361 additions and 31 deletions
+85 -11
View File
@@ -1,9 +1,54 @@
# WechatOnCloud
<div align="center">
在飞牛 NASx86_64 / arm64)上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。
<img src="doc/img/icon-192.png" width="88" height="88" alt="云微 logo" />
> 设计与选型详见 [技术方案.md](技术方案.md)。
> 部署形态:拉取 GHCR 预构建多架构镜像(或本地自构建),面板按需动态创建微信实例容器。不熟悉 Docker?直接看 [Docker 运行模式详解](#docker-运行模式详解新手向)。
<h1>云微 · WechatOnCloud</h1>
<p><b>在自己的 NAS / 服务器上运行「服务端微信」,多端浏览器共享同一个微信会话</b></p>
<p>
<a href="https://github.com/Gloridust/WechatOnCloud/stargazers"><img src="https://img.shields.io/github/stars/Gloridust/WechatOnCloud?style=flat-square&logo=github" alt="stars" /></a>
<a href="https://github.com/Gloridust/WechatOnCloud/releases"><img src="https://img.shields.io/github/v/release/Gloridust/WechatOnCloud?style=flat-square" alt="release" /></a>
<a href="https://github.com/Gloridust/WechatOnCloud/issues"><img src="https://img.shields.io/github/issues/Gloridust/WechatOnCloud?style=flat-square" alt="issues" /></a>
<img src="https://img.shields.io/badge/arch-amd64%20%7C%20arm64-2496ED?style=flat-square&logo=docker&logoColor=white" alt="arch" />
<img src="https://img.shields.io/badge/PWA-ready-5A0FC8?style=flat-square" alt="pwa" />
</p>
<p>
<a href="#快速开始">快速开始</a> ·
<a href="#核心特性">核心特性</a> ·
<a href="#docker-运行模式详解新手向">运行原理</a> ·
<a href="#安全须知必读">安全须知</a> ·
<a href="doc/技术方案.md">技术方案</a>
</p>
<table>
<tr>
<td width="50%"><img src="doc/img/Screenshot-1.png" alt="云微 · 面板主界面" /></td>
<td width="50%"><img src="doc/img/Screenshot-2.png" alt="云微 · 实例桌面" /></td>
</tr>
</table>
</div>
在飞牛 NASx86_64 / arm64)或任意 Docker 主机上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。**不修改微信客户端。**
> 设计与选型详见 [技术方案.md](doc/技术方案.md)。不熟悉 Docker?直接看 [Docker 运行模式详解](#docker-运行模式详解新手向)。
---
## 核心特性
- 🗂️ **多实例** — 一个面板管理多个独立微信会话,每个实例独立容器 + 独立数据卷,互不干扰。
- 👥 **多端共享 + 权限** — 多浏览器 / 设备共享同一会话;子账号体系,按账号分配可访问的实例(RBAC)。
- 🖥️ **微信 PC 式界面** — 左侧实例栏 + 右侧内嵌桌面,侧栏可折叠,移动端自动转抽屉。
- 📦 **微信本体运行时下载** — 镜像不打包微信,面板一键「下载安装 / 更新」带进度条;按 CPU 架构自动取包。
- 🔁 **实例生命周期** — 启动 / 停止 / 重启 / 升级(拉新镜像重建、保留聊天记录),均在面板内一键完成。
- 📎 **文件传输** — 原生拖拽上传 + 下载 + 删除,直达微信桌面 `~/Desktop`
- 🧩 **多端协作软锁** — 同一实例多人操作时自动只读 + 申请接管,避免键鼠打架。
- 🔒 **安全优先** — 面板为唯一入口,KasmVNC 凭据服务端注入、永不下发前端;docker.sock 仅管理员可触达。
- 📱 **PWA** — iOS「添加到主屏幕」、桌面 Chrome「安装」当原生 App。
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(GHCR + GitHub Actions 自动发布)。
---
@@ -248,7 +293,9 @@ docker buildx build --platform linux/amd64,linux/arm64 \
---
## ⚠️ 安全须知(必读)
## 安全须知(必读)
> ⚠️ **这套系统暴露的是已登录的微信,请务必认真阅读本节。**
这套系统暴露的是**已登录的微信**——能登录面板的人就能看聊天记录、以你身份发消息。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
@@ -257,7 +304,7 @@ docker buildx build --platform linux/amd64,linux/arm64 \
- 实例的增删、微信安装/更新等触碰 docker 引擎的操作**仅限管理员**;docker API 绝不暴露给前端;
- KasmVNC 凭据由面板服务端注入,**浏览器永远拿不到**;实例容器名由内部随机 ID 派生,避免注入;
- 面板与外网之间再套一层 HTTPS 反代(飞牛自带反代 / Caddy / Nginx)获得正经 TLS
- 进一步加固(陌生设备验证码、审计日志、并发控制)见 [技术方案.md](技术方案.md) 第 5 节。
- 进一步加固(陌生设备验证码、审计日志、并发控制)见 [技术方案.md](doc/技术方案.md) 第 5 节。
---
@@ -321,19 +368,22 @@ WechatOnCloud/
├── .github/workflows/
│ └── release.yml # 打 tag / 发 Release 时构建多架构镜像并推送 GHCR
├── docker/ # 微信实例镜像(ghcr.io/<owner>/wechat-on-cloud
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + 默认开 IME(不打包微信本体)
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + xdotool + 默认开 IME(不打包微信本体)
│ ├── wechat-ctl.sh # 运行时下载/解压/更新微信(面板经 docker exec 触发,状态写 /config/.woc-state
── autostart # openbox 会话启动:等待微信就绪 + 常驻拉起(崩溃自重启)
── autostart # openbox 会话启动:常驻拉起微信(崩溃自重启)+ 最小化窗口自动复原看守
│ └── woc-update-autostart # 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本
├── panel/ # 自研面板(ghcr.io/<owner>/woc-panel,唯一对外入口)
│ ├── Dockerfile # 前端 Vite 打包 + 后端 Fastify 网关(多架构)
│ ├── server/ # Fastifycookie 鉴权 + 账号/实例/权限 API + dockerode 管理实例 + 反代
│ └── web/ # React + TS + PWA(牛奶布艺 + 微信绿主题)
│ ├── server/ # Fastifycookie 鉴权 + 账号/实例/权限/生命周期 API + dockerode + 反代
│ └── web/ # React + TS + PWA微信 PC 式布局,牛奶布艺 + 微信绿主题)
├── fnos/ # 飞牛 fnOS 应用打包(.fpk 工程 + 构建说明)
├── scripts/
│ └── build-local.sh # 本地构建面板+微信镜像(发布前自测 / 自托管自构建)
├── doc/ # 文档与素材
│ ├── 技术方案.md # 完整设计文档
│ └── img/ # logo 与界面截图
├── docker-compose.yml # 单服务:panel(挂 docker.sock,按需创建实例)
├── .env.example # 可选配置(账号密码、镜像版本、PUID/PGID、端口、时区)
├── 技术方案.md # 完整设计文档
└── README.md
```
@@ -352,3 +402,27 @@ WechatOnCloud/
- [x] 多端并发控制(操作控制权心跳软锁 + 只读遮罩 + 申请接管)
- [ ] 掉登录时 web 端二维码重扫入口
- [~] 打包成飞牛原生 fpk 分发(工程已就绪见 [fnos/](fnos/),待真实设备验证 docker.sock 权限)
## 致谢
创意启发自懒猫微服(原 Deepin 团队做的硬件产品),推荐有经济实力、追求稳定运营的朋友尝试!
也感谢每一位 Star / Issue / PR 的朋友——**两天突破 500 ⭐**,是继续打磨的最大动力 🙌
## Star History
<a href="https://www.star-history.com/?repos=Gloridust%2FWechatOnCloud&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&legend=top-left" />
</picture>
</a>
---
<div align="center">
<sub>如果这个项目帮到了你,欢迎点个 ⭐ Star 支持一下 ·
<a href="https://github.com/Gloridust/WechatOnCloud/issues">反馈问题</a> ·
<a href="https://github.com/Gloridust/WechatOnCloud/pulls">参与贡献</a></sub>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+2 -5
View File
@@ -1,7 +1,6 @@
# WechatOnCloud 技术方案
> 目标:在飞牛 NASx86_64)上运行一个服务端微信,多个 web 用户通过浏览器访问同一个微信会话,实现跨设备消息同步、多端共享,解决原生微信"一台电脑一个登录"的痛点。
> 参考形态:懒猫微服「云微信」。
> 目标:在飞牛 NASx86_64或任何服务器上运行服务端微信,多个 web 用户通过浏览器访问同一个微信会话,实现跨设备消息同步、多端共享,解决原生微信"一台电脑一个登录"的痛点。
---
@@ -147,7 +146,6 @@
```
WechatOnCloud/
├── 技术方案.md # 本文档
├── docker/
│ ├── Dockerfile # Debian + Xvfb + 微信deb + KasmVNC
│ ├── entrypoint.sh # 启动 Xvfb → WM → 微信 → KasmVNC
@@ -183,8 +181,7 @@ WechatOnCloud/
- [微信 Linux 官网](https://linux.weixin.qq.com/en)
- [腾讯上线 Linux 微信官网(x86/Arm/LoongArch](https://finance.sina.com.cn/tech/roll/2024-11-06/doc-incvceuc0172323.shtml)
- [懒猫微服 云微信](https://lazycat.cloud/lcmd/)
- [开源云微信部署方案(低配版懒猫远程微信)](https://linux.do/t/topic/1306818)
- [懒猫微服 云微信](https://lazycat.cloud/)
- [Xpra 官方文档](https://xpra.org/manual)
- [Xpra HTML5 客户端](https://github.com/Xpra-org/xpra-html5)
- [飞牛 fpk 框架文档](https://developer.fnnas.com/docs/core-concepts/framework)
+8 -13
View File
@@ -54,20 +54,15 @@ ENV LANG=zh_CN.UTF-8 \
LC_ALL=zh_CN.UTF-8 \
LIBGL_ALWAYS_SOFTWARE=1
# KasmVNC web 客户端默认开启 IME 输入模式
# 用户用本地(客户端)输入法打中文,拼音联想在本地完成、只把成品汉字发进容器,无需容器内装 IME。
# 默认值仅在浏览器未存过该设置时生效,不覆盖用户手动改过的偏好。
# KasmVNC web 客户端的 webpack 产物 dist/*.bundle.js
# (1) 默认开启 IME 输入模式:本地(客户端)输入法打中文,拼音联想在本地完成、只把成品汉字
# 发进容器,无需容器内装 IME。默认值仅在浏览器未存过该设置时生效,不覆盖用户手动改过的偏好。
# (2) 修复 noVNC 中文 IME 输入:原实现靠隐藏 textarea 差分逐字符重发 keysym,会泄漏中间拼音、
# 累积不 reset、退格风暴,导致大量丢字 / ~21 字卡住 / 跨浏览器不稳。改为只在 compositionend
# 用 e.data 直发成品字符串(详见 woc-www-patch.sh / woc-ime.pl)。
# 注意:实际加载的是 webpack 产物 dist/main.bundle.jsapp/ui.js 是未打包源码、运行时不加载),故必须改 bundle。
# 末尾的 grep 作为断言:若 base 镜像换了打包结构、改不到任何文件,则构建直接失败而非静默放过。
RUN set -eux; \
patched=0; \
for f in /usr/share/kasmvnc/www/dist/*.bundle.js /usr/local/share/kasmvnc/www/dist/*.bundle.js; do \
if [ -f "$f" ] && grep -q "initSetting('enable_ime', false)" "$f"; then \
sed -i "s/initSetting('enable_ime', false)/initSetting('enable_ime', true)/g" "$f"; \
patched=1; \
fi; \
done; \
[ "$patched" = "1" ]
COPY woc-www-patch.sh woc-ime.pl /woc/
RUN chmod +x /woc/woc-www-patch.sh && /woc/woc-www-patch.sh
# 微信下载/解压控制脚本(运行时由面板经 docker exec 触发,状态写入数据卷 /config/.woc-state
COPY wechat-ctl.sh /woc/wechat-ctl.sh
+45
View File
@@ -0,0 +1,45 @@
# perl -0777 -pe 补丁脚本(被 woc-www-patch.sh 引用)。
# 对 dist/*.bundle.js 里 noVNC 键盘 IME 逻辑做两处替换,全程字面匹配(\Q..\E)。
#
# 背景:noVNC 原实现靠"隐藏 textarea 差分→逐字符重发 keysym"还原 IME 输入,会在合成过程中
# 把中间拼音也发给远端、且永不 reset 导致累积+退格风暴 → 大量丢字 / 卡住 / 跨浏览器不稳。
#
# 改法:彻底不靠 textarea 差分还原中文。
# - 合成进行中(input 事件):只同步 _lastKeyboardInput、不发送(避免中间拼音泄漏 / 丢字)。
# - 提交时(compositionend):用 e.data(最终上屏字符串)逐字发 keysym,并把 _lastKeyboardInput
# 同步成当前 textarea 值。不 reset、不吞键——避免吞掉下一个词的首键、避免打断下一次合成。
# - 若个别浏览器在 compositionend 后还补发一次"提交 input":此时 isComposing/_imeHold 均为假,
# 落到非 IME 差分分支,但 newValue 与刚同步的 _lastKeyboardInput 相等 → 差分为空 → 不重复发送。
# (A) _handleCompositionEnd:提交时 e.data 直发 + 同步 _lastKeyboardInput(不 reset、不吞键)
s~\Q if (this._enableIME) {
this._imeInProgress = false;
}
if (isChromiumBased()) {
this._imeHold = false;
}\E~ if (this._enableIME) { // WOC-IME
this._imeInProgress = false;
var _wocStr = (e && typeof e.data === "string") ? e.data : "";
for (var _wocI = 0; _wocI < _wocStr.length; _wocI++) {
this._sendKeyEvent(keysymdef.lookup(_wocStr.charCodeAt(_wocI)), 'Unidentified', true);
this._sendKeyEvent(keysymdef.lookup(_wocStr.charCodeAt(_wocI)), 'Unidentified', false);
}
this._imeHold = false;
this._lastKeyboardInput = e.target.value;
return;
}
if (isChromiumBased()) {
this._imeHold = false;
}~;
# (B) _handleInput 顶部守卫:合成进行中只同步值、不发送;提交已在 compositionend 完成。
s~\Q if (this._enableIME && this._imeHold) {
Debug("IME input change, sending differential");\E~ if (this._enableIME && (e.isComposing || this._imeHold || this._imeInProgress)) { // WOC-IME
this._lastKeyboardInput = e.target.value;
return;
}
if (this._enableIME && this._imeHold) {
Debug("IME input change, sending differential");~;
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# 构建期补丁:改 KasmVNC web 客户端的 webpack 产物 dist/*.bundle.js
# (1) 默认开启 IME 输入模式(本地输入法打中文,成品汉字发进容器,容器内不装 IME)
# (2) 修复 noVNC 的中文 IME 输入:原实现靠"隐藏 textarea 差分→逐字符重发 keysym"
# 会在合成过程中把中间拼音也发给远端、且永不 reset 导致累积+退格风暴,
# 在 VNC 上表现为大量丢字、~21 字后卡住、跨浏览器不稳定。
# 改为:合成期间(input)一律不发;只在 compositionend 用 e.data(最终上屏串)逐字发 keysym
# 提交后 reset textarea,并吞掉紧随其后的提交 input 事件,避免重复发送/跨分支竞争。
# 末尾断言:若 base 镜像换了打包结构、一个文件都没改到,则构建失败而非静默放过。
set -euo pipefail
PATCH_PL="$(dirname "$0")/woc-ime.pl"
patched=0
for f in /usr/share/kasmvnc/www/dist/*.bundle.js /usr/local/share/kasmvnc/www/dist/*.bundle.js; do
[ -f "$f" ] || continue
changed=0
# (1) enable_ime 默认开启
if grep -q "initSetting('enable_ime', false)" "$f"; then
sed -i "s/initSetting('enable_ime', false)/initSetting('enable_ime', true)/g" "$f"
changed=1
fi
# (2) IME 差分逻辑修复(仅含 noVNC 键盘逻辑的 bundle
# 幂等:/usr/share/kasmvnc 是 /usr/local/share/kasmvnc 的软链,两个 glob 会命中同一 inode,
# 故已含 _imeJustCommitted 的文件直接跳过,避免重复注入守卫块。
if grep -q "IME input change, sending differential" "$f" && ! grep -q "WOC-IME" "$f"; then
perl -0777 -i -pe "$(cat "$PATCH_PL")" "$f"
after="$(grep -c "WOC-IME" "$f" || true)"
# 断言两处替换都命中(compositionend 标记 1 + _handleInput 守卫标记 1 = 2 行)
if [ "$after" -ne 2 ]; then
echo "FATAL: IME patch mismatch on $f (markers=$after, expect 2)" >&2
exit 1
fi
changed=1
fi
[ "$changed" = "1" ] && { echo "woc-www-patch: patched $f"; patched=1; }
done
[ "$patched" = "1" ] || { echo "FATAL: no bundle patched" >&2; exit 1; }
echo "woc-www-patch: done"
+16
View File
@@ -312,6 +312,22 @@ export async function downloadFromInstance(inst: Instance, name: string): Promis
return tar.subarray(512, 512 + size);
}
// 拉取实例容器日志(末尾 N 行),供前端"查看/导出日志"排错。
export async function instanceLogs(inst: Instance, tail = 600): Promise<string> {
const c = docker.getContainer(inst.containerName);
const buf = (await c.logs({ stdout: true, stderr: true, tail, timestamps: true })) as unknown as Buffer;
// docker 非 TTY 日志为多路复用流:每帧 8 字节头([stream,0,0,0,size BE]+ 负载;解出纯文本。
let out = '';
let i = 0;
while (i + 8 <= buf.length) {
const size = buf.readUInt32BE(i + 4);
if (size < 0 || i + 8 + size > buf.length) break;
out += buf.subarray(i + 8, i + 8 + size).toString('utf8');
i += 8 + size;
}
return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用
}
// 实例容器名(供反代构造 target)。
export function instanceTarget(inst: Instance): string {
return `http://${inst.containerName}:3000`;
+16
View File
@@ -45,6 +45,7 @@ import {
listInstanceFiles,
downloadFromInstance,
deleteInstanceFile,
instanceLogs,
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
@@ -437,6 +438,21 @@ app.post('/api/instances/:id/control/take', async (req, reply) => {
return { mine: true, holder: u.username };
});
// 查看实例容器日志(仅管理员):排查"无法进入/未安装/卡死"等。inline 文本,浏览器可直接看/另存。
app.get('/api/admin/instances/:id/logs', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
const text = await instanceLogs(inst);
reply.header('content-type', 'text/plain; charset=utf-8');
return reply.send(text || '(暂无日志)');
} catch (e: any) {
reply.header('content-type', 'text/plain; charset=utf-8');
return reply.send('获取日志失败:' + (e?.message || e));
}
});
// 该实例的微信安装状态(有访问权限即可看)
app.get('/api/instances/:id/wechat/status', async (req, reply) => {
const u = requireAuth(req, reply);
+1
View File
@@ -93,6 +93,7 @@ export const api = {
instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }),
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`,
// 文件中转
listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`),
+3
View File
@@ -554,6 +554,9 @@ function InstanceAdminCard({
<button className="btn-text" onClick={onAssign}>
</button>
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志(排错)">
</button>
<button className="btn-text danger" onClick={onDelete}>
</button>
+123 -2
View File
@@ -42,6 +42,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const [dragging, setDragging] = useState(false);
const [showFiles, setShowFiles] = useState(false);
const [files, setFiles] = useState<TFile[]>([]);
const [showClip, setShowClip] = useState(false);
const [clipText, setClipText] = useState('');
const [uploading, setUploading] = useState(false);
const [starting, setStarting] = useState(false);
const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null);
@@ -61,8 +63,21 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
setFrameLoaded(false);
setShowFiles(false);
setFiles([]);
setShowClip(false);
setClipText('');
}, [id]);
// 实例未就绪(启动中 / 安装中 / 上下文状态未刷新)时,每 3s 拉取最新状态:
// 就绪后自动进入桌面,无需手动刷新(修复"安装完进度 100% 仍提示无实例")。
useEffect(() => {
if (showVnc || !id) return;
const t = window.setInterval(() => {
if (!document.hidden) reload();
}, 3000);
return () => window.clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showVnc, id]);
// 文件拖到窗口 → 弹出落区(覆盖 iframe 接住 drop
useEffect(() => {
if (!showVnc) return;
@@ -236,6 +251,52 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
};
// 跨设备剪贴板(文本):通过同源 iframe 直接喂给 KasmVNC 自带的剪贴板 textarea 并触发其发送逻辑
// (内部走 RFB.clipboardPasteFrom → clientCutText)。不依赖浏览器异步剪贴板 API,故 http/局域网 IP 下也可用,
// 规避了"非安全上下文禁用 navigator.clipboard 导致粘贴失败"的问题。文本会进入容器系统剪贴板,
// 在微信输入框按 Ctrl+V 即可粘贴。
const pushClipboardToRemote = (text: string): boolean => {
try {
const doc = frameRef.current?.contentDocument;
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
if (!doc || !ta) return false;
ta.value = text;
ta.dispatchEvent(new (frameRef.current!.contentWindow as any).Event('change', { bubbles: true }));
return true;
} catch {
return false;
}
};
const sendClip = () => {
const t = clipText;
if (!t) {
toast('请先输入要发送的文本', 'error');
return;
}
if (pushClipboardToRemote(t)) {
toast('已发送到容器剪贴板,请在微信输入框按 Ctrl+V 粘贴', 'ok');
} else {
toast('发送失败:桌面尚未连接', 'error');
}
};
// 读取容器(微信侧)当前剪贴板内容到本框,便于把容器内复制的文字带回本地
const pullClipboardFromRemote = () => {
try {
const doc = frameRef.current?.contentDocument;
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
if (ta) {
setClipText(ta.value || '');
toast('已读取容器剪贴板', 'ok');
} else {
toast('读取失败:桌面尚未连接', 'error');
}
} catch {
toast('读取失败', 'error');
}
};
const restartInstance = async () => {
const ok = await confirm({
title: '重启该实例?',
@@ -299,6 +360,13 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
>
</button>
<button
className="ws-action"
title="把文本发送到容器剪贴板(局域网 http 下也可用)"
onClick={() => setShowClip((v) => !v)}
>
</button>
{isAdmin && (
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
@@ -333,19 +401,45 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
) : (
<div className="iv-notice-sub"></div>
)}
{isAdmin && (
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
</button>
)}
</div>
</div>
) : ['downloading', 'extracting', 'installing'].includes(inst.wechat.phase) ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="spinner" />
<div className="iv-notice-title"></div>
<div className="iv-notice-sub">
{inst.wechat.message || '请稍候'}
{inst.wechat.percent >= 0 ? ` · ${inst.wechat.percent}%` : ''}
</div>
</div>
</div>
) : !installed ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="iv-notice-title"></div>
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? '微信安装出错' : '微信尚未安装'}</div>
<div className="iv-notice-sub">
{inst.wechat.phase === 'error'
? inst.wechat.message || '安装失败,可在「管理」重试'
: '该实例容器已就绪,但尚未安装微信'}
</div>
{isAdmin ? (
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/admin')}>
{inst.wechat.phase === 'error' ? '重试 / 更新' : '下载安装'}
</button>
) : (
<div className="iv-notice-sub"></div>
)}
{isAdmin && (
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
</button>
)}
</div>
</div>
) : (
@@ -442,6 +536,33 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
</div>
</div>
)}
{showClip && (
<div className="iv-files">
<div className="files-head">
<span></span>
<button className="btn-text" onClick={() => setShowClip(false)}>
</button>
</div>
<textarea
className="clip-area"
value={clipText}
onChange={(e) => setClipText(e.target.value)}
placeholder="在此输入或粘贴文本,点「发送到微信」后到微信输入框按 Ctrl+V 粘贴"
rows={5}
/>
<button className="btn btn-primary files-upload" onClick={sendClip}>
</button>
<button className="btn-text" style={{ alignSelf: 'flex-start', marginTop: 6 }} onClick={pullClipboardFromRemote}>
</button>
<div className="files-hint">
http 访 Ctrl+V
</div>
</div>
)}
</div>
)}
</div>
+19
View File
@@ -713,6 +713,25 @@ button {
height: 42px;
font-size: 15px;
}
.clip-area {
width: 100%;
resize: vertical;
min-height: 96px;
padding: 10px 12px;
border-radius: 14px;
border: none;
background: var(--mf-trough, #edeef1);
color: var(--text, #1a1d24);
font-size: 14px;
line-height: 1.5;
font-family: inherit;
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.16);
outline: none;
box-sizing: border-box;
}
.clip-area:focus {
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.16), 0 0 0 2px rgba(7, 193, 96, 0.35);
}
.files-hint {
font-size: 12px;
color: var(--muted);