mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
Compare commits
38 Commits
+10
-5
@@ -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)。
|
||||
# 透传给每个微信实例容器,决定面板数据与微信数据卷的属主。
|
||||
@@ -103,8 +104,12 @@ WOC_SPOOF_OS=1
|
||||
# WOC_INSTANCE_MEM_SOFT_MB soft 阈值(MiB),默认 1500
|
||||
# WOC_INSTANCE_MEM_HARD_MB hard 阈值(MiB),默认 2500(兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB)
|
||||
# WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒,默认 300(5 分钟);最小 60;0 = 关闭整个 watchdog
|
||||
# WOC_WATCHDOG_HEALTH_FAILS VNC 响应性探测:连续无响应几次才重启实例;默认 0 = 关闭该探测(仅保留内存自愈)。
|
||||
# 健康实例探测约 1ms,但宿主级 CPU/IO 偶发争用会让探测超时被误判为卡死而重启正常实例,故默认关闭;
|
||||
# 想要"卡在正在连接桌面"时自动兜底,可设为正整数(如 3 = 连续 3 次≈15 分钟无响应才重启)。
|
||||
#
|
||||
# 调参建议:日常活跃单实例约 1500 MiB;soft 应略高于此(如 2000);hard 远低于宿主可用内存。
|
||||
WOC_INSTANCE_MEM_SOFT_MB=1500
|
||||
WOC_INSTANCE_MEM_HARD_MB=2500
|
||||
WOC_WATCHDOG_INTERVAL_SEC=300
|
||||
# WOC_WATCHDOG_HEALTH_FAILS=0
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Telegram 命令机器人(轮询版)——跑在 GitHub Actions cron 上,无需任何服务器。
|
||||
//
|
||||
// 工作方式:每次运行调用 getUpdates 拉取「自上次确认以来」的待处理更新,逐条处理命令并回复,
|
||||
// 最后用 offset=最后一条+1 再调一次 getUpdates 向 Telegram「确认」(这些更新随即被服务端清掉,
|
||||
// 下次不再返回)。Telegram 自己保存 offset 状态(未确认的更新保留 24h),因此本脚本无需任何持久化存储。
|
||||
//
|
||||
// 支持私聊与群组(群组里以 /命令 形式发送即可,命令不受 bot 隐私模式影响)。
|
||||
// 命令:/help /releases /release <tag> /issues /issue <编号>
|
||||
//
|
||||
// 局限:受 GitHub cron 最小 5 分钟间隔限制,命令有延迟(非实时)。要实时需改用 webhook(需 serverless 端点)。
|
||||
|
||||
const TG = process.env.TG_TOKEN;
|
||||
const GH = process.env.GH_TOKEN || '';
|
||||
const REPO = process.env.REPO; // owner/repo
|
||||
if (!TG) {
|
||||
console.log('TELEGRAM_BOT_TOKEN 未配置,跳过');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const tgUrl = (method) => `https://api.telegram.org/bot${TG}/${method}`;
|
||||
const ghHeaders = {
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'woc-telegram-bot',
|
||||
...(GH ? { authorization: `Bearer ${GH}` } : {}),
|
||||
};
|
||||
|
||||
async function tg(method, params) {
|
||||
const r = await fetch(tgUrl(method), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
async function gh(path) {
|
||||
const r = await fetch(`https://api.github.com/repos/${REPO}${path}`, { headers: ghHeaders });
|
||||
if (!r.ok) throw new Error(`GitHub ${path} → ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
const trunc = (s, n) => (s && s.length > n ? s.slice(0, n) + '…' : s || '');
|
||||
const send = (chatId, text) => tg('sendMessage', { chat_id: chatId, text, disable_web_page_preview: true });
|
||||
|
||||
const HELP = [
|
||||
'🤖 云微 WechatOnCloud 机器人命令:',
|
||||
'',
|
||||
'/releases — 最近发布列表',
|
||||
'/release <tag> — 某版本详情(省略 = 最新)',
|
||||
'/issues — 打开中的 issue 列表',
|
||||
'/issue <编号> — issue 详情',
|
||||
'/help — 显示本帮助',
|
||||
'',
|
||||
'(轮询版,命令可能有几分钟延迟)',
|
||||
].join('\n');
|
||||
|
||||
async function handle(cmd, arg, chatId) {
|
||||
switch (cmd) {
|
||||
case '/start':
|
||||
case '/help':
|
||||
return send(chatId, HELP);
|
||||
|
||||
case '/releases': {
|
||||
const rels = await gh('/releases?per_page=8');
|
||||
if (!rels.length) return send(chatId, '暂无 release');
|
||||
const lines = rels.map(
|
||||
(r) =>
|
||||
`• ${r.tag_name}${r.name && r.name !== r.tag_name ? ' — ' + r.name : ''} (${(r.published_at || '').slice(0, 10)})`,
|
||||
);
|
||||
return send(chatId, '📦 最近发布:\n' + lines.join('\n') + '\n\n用 /release <tag> 看某版详情');
|
||||
}
|
||||
|
||||
case '/release': {
|
||||
const rel = arg ? await gh(`/releases/tags/${encodeURIComponent(arg)}`) : await gh('/releases/latest');
|
||||
const title = `${rel.tag_name}${rel.name && rel.name !== rel.tag_name ? ' · ' + rel.name : ''}`;
|
||||
return send(
|
||||
chatId,
|
||||
`📦 ${title}\n发布于 ${(rel.published_at || '').slice(0, 10)}\n\n${trunc(rel.body, 2500)}\n\n🔗 ${rel.html_url}`,
|
||||
);
|
||||
}
|
||||
|
||||
case '/issues': {
|
||||
const items = (await gh('/issues?state=open&per_page=10&sort=updated')).filter((i) => !i.pull_request);
|
||||
if (!items.length) return send(chatId, '🎉 当前没有打开的 issue');
|
||||
const lines = items.map((i) => `• #${i.number} ${trunc(i.title, 60)}`);
|
||||
return send(chatId, `🐛 打开中的 issue(${items.length}):\n` + lines.join('\n') + '\n\n用 /issue <编号> 看详情');
|
||||
}
|
||||
|
||||
case '/issue': {
|
||||
if (!arg) return send(chatId, '用法:/issue <编号>');
|
||||
const i = await gh(`/issues/${encodeURIComponent(arg)}`);
|
||||
if (i.pull_request) return send(chatId, `#${arg} 是个 PR,不是 issue`);
|
||||
return send(
|
||||
chatId,
|
||||
`🐛 #${i.number} ${i.title}\n状态:${i.state} · by ${i.user?.login}\n\n${trunc(i.body, 2500)}\n\n🔗 ${i.html_url}`,
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return; // 未知命令静默忽略,避免群里刷屏
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// 短轮询拉取待处理更新(只要 message)
|
||||
const res = await (
|
||||
await fetch(tgUrl('getUpdates') + '?timeout=0&allowed_updates=' + encodeURIComponent('["message"]'))
|
||||
).json();
|
||||
if (!res.ok) {
|
||||
console.error('getUpdates 失败:', JSON.stringify(res));
|
||||
process.exit(res.error_code === 409 ? 0 : 1); // 409 = 设了 webhook,与轮询冲突,直接退出
|
||||
}
|
||||
const updates = res.result || [];
|
||||
let maxId = 0;
|
||||
for (const u of updates) {
|
||||
maxId = Math.max(maxId, u.update_id);
|
||||
const m = u.message;
|
||||
if (!m || !m.text) continue;
|
||||
const text = m.text.trim();
|
||||
if (!text.startsWith('/')) continue;
|
||||
const parts = text.split(/\s+/);
|
||||
const cmd = parts[0].split('@')[0].toLowerCase(); // 去掉 @botname 后缀
|
||||
const arg = parts.slice(1).join(' ').trim();
|
||||
try {
|
||||
await handle(cmd, arg, m.chat.id);
|
||||
} catch (e) {
|
||||
await send(m.chat.id, '⚠️ 出错了:' + (e?.message || e));
|
||||
}
|
||||
}
|
||||
// 向 Telegram 确认已处理(清掉这些更新,下次不再返回)
|
||||
if (maxId) {
|
||||
await fetch(tgUrl('getUpdates') + `?offset=${maxId + 1}&timeout=0`);
|
||||
}
|
||||
console.log(`processed ${updates.length} update(s)`);
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Binary file not shown.
@@ -62,6 +62,16 @@ jobs:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# 解析烤进面板镜像的版本号:打 tag / 发 Release 时用 tag 名(vX.Y.Z),手动触发则用 dev-<短SHA>。
|
||||
- name: Resolve build version
|
||||
id: ver
|
||||
run: |
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "version=dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Docker metadata (tags + labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -85,6 +95,8 @@ jobs:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# 仅面板镜像消费 WOC_VERSION(实例镜像不展示版本号),避免「未使用的 build-arg」告警。
|
||||
build-args: ${{ matrix.name == 'panel' && format('WOC_VERSION={0}', steps.ver.outputs.version) || '' }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: telegram-bot
|
||||
|
||||
# Telegram 命令机器人(轮询版)——无需服务器,仅靠 GitHub Actions cron。
|
||||
# 私聊 / 群组都可用命令查询:/help /releases /release <tag> /issues /issue <编号>。
|
||||
#
|
||||
# 启用(一次性):
|
||||
# 1) 已配置 telegram-notify 用到的 TELEGRAM_BOT_TOKEN(Secret)即可复用;
|
||||
# 2) 仓库 Settings → Secrets and variables → Actions → Variables 新建
|
||||
# TELEGRAM_BOT_ENABLED = true (未设为 true 则本工作流不运行,避免空跑)
|
||||
# 3) 把机器人拉进群组 / 在私聊里 /start。
|
||||
#
|
||||
# 局限:cron 最小 5 分钟且可能再延后 → 命令非实时;GitHub 会在仓库 60 天无活动时暂停定时任务。
|
||||
# 想立即处理一次:Actions → telegram-bot → Run workflow(workflow_dispatch)。
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
# 避免轮询任务并发重叠重复处理;新触发取消正在跑的(残留未确认更新下次重处理,无副作用)
|
||||
concurrency:
|
||||
group: telegram-bot
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
poll:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ vars.TELEGRAM_BOT_ENABLED == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Poll Telegram & handle commands
|
||||
env:
|
||||
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: node .github/scripts/telegram-bot.mjs
|
||||
@@ -0,0 +1,55 @@
|
||||
name: telegram-notify
|
||||
|
||||
# 新版本发布 / 新 issue 时,把内容(GitHub Markdown → Telegram HTML 渲染)推送到 Telegram 群组。
|
||||
# 跑在 GitHub Actions 上,无需服务器;未配置 TELEGRAM_CHAT_ID 则自动跳过。
|
||||
#
|
||||
# 一次性配置(仓库 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]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ vars.TELEGRAM_CHAT_ID != '' }}
|
||||
steps:
|
||||
- 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 传任意文本,避免命令注入
|
||||
R_TAG: ${{ github.event.release.tag_name }}
|
||||
R_NAME: ${{ github.event.release.name }}
|
||||
R_URL: ${{ github.event.release.html_url }}
|
||||
R_BODY: ${{ github.event.release.body }}
|
||||
I_NUM: ${{ github.event.issue.number }}
|
||||
I_TITLE: ${{ github.event.issue.title }}
|
||||
I_URL: ${{ github.event.issue.html_url }}
|
||||
I_USER: ${{ github.event.issue.user.login }}
|
||||
I_BODY: ${{ github.event.issue.body }}
|
||||
# issue_comment 事件:issue 下的新回复(PR 评论也走此事件,脚本里据 C_PR 跳过)
|
||||
C_NUM: ${{ github.event.issue.number }}
|
||||
C_TITLE: ${{ github.event.issue.title }}
|
||||
C_URL: ${{ github.event.comment.html_url }}
|
||||
C_USER: ${{ github.event.comment.user.login }}
|
||||
C_BODY: ${{ github.event.comment.body }}
|
||||
C_PR: ${{ github.event.issue.pull_request.html_url }}
|
||||
run: node .github/scripts/tg-notify.mjs
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
<h1>云微 · WechatOnCloud</h1>
|
||||
|
||||
<p><b>在自己的 NAS / 服务器上运行「服务端微信」,多端浏览器共享同一个微信会话</b></p>
|
||||
<p><b>在自己的 NAS / 服务器上运行「服务端微信」,多端浏览器共享同一会话</b></p>
|
||||
|
||||
<p>不止微信——还能开 <b>Chromium 浏览器实例</b>,登录 Telegram / X / Instagram 等网页版社媒,常驻云端、多端同步</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>
|
||||
@@ -19,6 +21,7 @@
|
||||
<p>
|
||||
<a href="#快速开始">快速开始</a> ·
|
||||
<a href="#核心特性">核心特性</a> ·
|
||||
<a href="#浏览器实例登录网页版社媒">浏览器实例</a> ·
|
||||
<a href="doc/运行原理.md">运行原理</a> ·
|
||||
<a href="#安全须知必读">安全须知</a> ·
|
||||
<a href="doc/技术方案.md">技术方案</a>
|
||||
@@ -33,9 +36,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
在飞牛 NAS(x86_64 / arm64)或任意 Docker 主机上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。**不修改微信客户端。**
|
||||
在飞牛 NAS(x86_64 / arm64)或任意 Docker 主机上运行服务端微信:面板可管理**多个**实例,每个实例都是一个独立容器——可以是一个**微信**会话,也可以是一个 **Chromium 浏览器**(用来登录 Telegram / X / Instagram 等网页版应用)。多个 web 用户通过浏览器访问被授权的实例,实现跨设备同步、多端共享。**不修改微信客户端。**
|
||||
|
||||
**一句话原理**:每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器;同一实例被多个浏览器连 = 共享同一个微信会话。前面一层自研**面板**是唯一对外入口,经 docker.sock 按需创建/销毁实例并反向代理。
|
||||
**一句话原理**:每个实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 一个应用(官方原版微信,或 Chromium 浏览器),KasmVNC 把画面串到浏览器;同一实例被多个浏览器连 = 共享同一个会话。前面一层自研**面板**是唯一对外入口,经 docker.sock 按需创建/销毁实例并反向代理。
|
||||
|
||||
交流群: [@WechatOnCloud](https://t.me/WechatOnCloud)
|
||||
|
||||
@@ -43,16 +46,17 @@
|
||||
|
||||
## 核心特性
|
||||
|
||||
- 🗂️ **多实例** — 一个面板管理多个独立微信会话,每个实例独立容器 + 独立数据卷,互不干扰。
|
||||
- 🗂️ **多实例** — 一个面板管理多个独立实例,每个实例独立容器 + 独立数据卷,互不干扰。
|
||||
- 🌐 **多应用(微信 + 浏览器)** — 新建实例时可选**微信**或 **Chromium 浏览器**;浏览器实例用来登录 Telegram / X / Instagram 等网页版社媒,登录态写进数据卷、常驻云端、多端共享。
|
||||
- 👥 **多端共享 + 权限** — 多浏览器 / 设备共享同一会话;子账号体系,按账号分配可访问的实例(RBAC)。
|
||||
- 🖥️ **微信 PC 式界面** — 左侧实例栏 + 右侧内嵌桌面,侧栏可折叠,移动端自动转抽屉。
|
||||
- 📦 **微信本体运行时下载** — 镜像不打包微信,面板一键「下载安装 / 更新」带进度条;按 CPU 架构自动取包。
|
||||
- 🖥️ **PC 式界面** — 左侧实例栏 + 右侧内嵌桌面,侧栏可折叠,移动端自动转抽屉;实例图标可自定义(内置图标 / 上传裁剪)。
|
||||
- 📦 **微信按需下载 · 浏览器开箱即用** — 镜像不打包微信,面板一键「下载安装 / 更新」带进度条、按架构取包;Chromium 已烤进镜像,创建即用、无需下载。
|
||||
- 🔁 **实例生命周期** — 启动 / 停止 / 重启 / 升级(拉新镜像重建、保留聊天记录),均在面板内一键完成。
|
||||
- 📎 **文件传输 + 文本剪贴板** — 拖拽上传 + 下载 + 删除,直达微信桌面 `~/Desktop`;文本可经剪贴板中转送进微信(局域网 http 下也可用)。
|
||||
- 📎 **文件传输 + 文本剪贴板** — 拖拽上传 + 下载 + 删除,直达实例桌面 `~/Desktop`;文本可经剪贴板中转送进实例(局域网 http 下也可用)。
|
||||
- 🧩 **多端协作软锁** — 同一实例多人操作时自动只读 + 申请接管,避免键鼠打架。
|
||||
- 🔒 **安全优先** — 面板为唯一入口,KasmVNC 凭据服务端注入、永不下发前端;docker.sock 仅管理员可触达。
|
||||
- 📱 **PWA** — iOS「添加到主屏幕」、桌面 Chrome「安装」当原生 App。
|
||||
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(GHCR + GitHub Actions 自动发布)。
|
||||
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(Docker Hub + GHCR,GitHub Actions 自动发布)。
|
||||
|
||||
---
|
||||
|
||||
@@ -63,6 +67,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 +77,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,46 +87,77 @@ 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),复制内容自己建个同名文件即可。
|
||||
|
||||
- **飞牛 OS(fnOS)/ 群晖 等 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(国内外通用、免登录,**飞牛等 NAS 还内置了 Docker Hub 加速**,通常比 GHCR 更稳)。拉不动时设 `WOC_IMAGE_PREFIX` 切到备用源 `ghcr.io/gloridust` 或国内反代 `ghcr.nju.edu.cn/gloridust`(更多源见 [.env.example](.env.example))。报错 `denied` = 该源上还没有镜像,换源或用方式 A 本地构建。
|
||||
|
||||
无论哪种方式,都会拉起面板容器 `woc-panel`(唯一对外服务)。浏览器访问 `http://<NAS_IP>:36080`:
|
||||
|
||||
1. 用 `.env` 里设置的管理员账号(默认 **admin / wechat**)登录面板;
|
||||
2. 管理员在面板「实例」页点「**新建微信实例**」,命名并选择哪些子账号可访问 → 面板自动 `docker run` 起一个微信实例容器(微信镜像本地没有时才会从 GHCR 拉取);
|
||||
3. 进入该实例,点「**下载并安装**」微信(约 190~210MB,进度条实时显示,仅管理员可操作);
|
||||
4. 装好后点「进入电脑版微信」→ 浏览器里出现微信窗口,手机扫码登录即可。
|
||||
2. 在「实例」页点「**新建实例**」,选应用类型(**微信** 或 **Chromium 浏览器**)、命名、勾选可访问的子账号 → 面板自动 `docker run` 起一个实例容器(镜像本地没有时才会从镜像源拉取);
|
||||
3. **微信实例**:进入后点「**下载并安装**」微信(约 190~210MB,带进度条,仅管理员);**浏览器实例**:随镜像就绪,跳过这步;
|
||||
4. 点「**进入实例**」→ 微信扫码登录即可收发消息;浏览器则直接打开网页登录 Telegram / X / Instagram 等。
|
||||
|
||||
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**微信会话。
|
||||
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**会话。
|
||||
|
||||
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本/账号见 `.env`(可配置项见 [.env.example](.env.example))。镜像会按 CPU 架构自动适配([详见文档](doc/运行原理.md#架构自动适配))。
|
||||
> **🛠️ 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#架构自动适配))。
|
||||
|
||||
### 面板能做什么
|
||||
|
||||
| 功能 | 谁可用 | 说明 |
|
||||
|------|--------|------|
|
||||
| 新建 / 删除微信实例 | 管理员 | 一键创建独立微信会话容器;新建时勾选可访问的子账号。删除默认保留数据卷(聊天记录),可选彻底清除 |
|
||||
| 新建 / 删除实例 | 管理员 | 一键创建独立实例容器(微信 / Chromium 浏览器);新建时勾选可访问的子账号、可自定义图标。删除默认保留数据卷,可选彻底清除 |
|
||||
| 实例权限分配 | 管理员 | 在实例上改「可访问账户」,或在账户上改「可访问实例」,双向管理 |
|
||||
| 下载并安装 / 更新微信 | 管理员 | 对某实例一键下载官方微信 Linux 版到其数据卷、解压安装;带进度条;后续可一键「更新到最新版」 |
|
||||
| 进入电脑版微信 | 被授权用户 | 在浏览器里操作对应实例的微信,扫码登录、收发消息 |
|
||||
| 文件 / 文本传输 | 被授权用户 | 拖拽上传 / 下载文件;文本经剪贴板中转送进微信 |
|
||||
| 下载并安装 / 更新微信 | 管理员 | 微信实例一键下载官方 Linux 版到数据卷、解压安装、带进度条,后续可「更新到最新版」(浏览器实例无需此步) |
|
||||
| 进入实例 | 被授权用户 | 在浏览器里操作对应实例:微信扫码收发消息,或在 Chromium 里登录网页应用 |
|
||||
| 文件 / 文本传输 | 被授权用户 | 拖拽上传 / 下载文件;文本经剪贴板中转送入实例 |
|
||||
| 实例日志 | 管理员 | 查看实例日志,含**持久化历史**(重启原因 + 上一容器日志快照,跨容器重建保留) |
|
||||
| 修改密码 | 所有人 | 改自己的登录密码 |
|
||||
| 子账号管理 | 管理员 | 创建 / 禁用 / 重置 / 删除子账号,并分配实例访问权限 |
|
||||
| 安装为 App | 所有人 | iOS Safari「添加到主屏幕」、桌面 Chrome「安装」当原生 App(PWA) |
|
||||
|
||||
> 子账号是**访问这套面板的身份**,不是另开一个微信。管理员隐式拥有全部实例访问权;子账号只能看到被授权的实例。
|
||||
> 微信本体**不打进镜像**,而是新建实例后在面板点「下载并安装」时下载到该实例的数据卷,所以镜像很小、构建快。
|
||||
> 子账号是**访问这套面板的身份**,不是另开一个微信 / 账号。管理员隐式拥有全部实例访问权;子账号只能看到被授权的实例。
|
||||
> 微信本体**不打进镜像**,新建微信实例后在面板点「下载并安装」时才下载到该实例的数据卷(浏览器实例则已随镜像就绪),所以镜像小、构建快。
|
||||
|
||||
---
|
||||
|
||||
## 浏览器实例(登录网页版社媒)
|
||||
|
||||
云微是个**多应用**平台:除了微信,新建实例时还可以选 **Chromium 浏览器**——相当于一台**常驻云端、多端共享的浏览器**,专门用来登录各种**网页版**应用:
|
||||
|
||||
- **社媒 / IM**:Telegram Web、X(Twitter)、Instagram、WhatsApp Web、Discord、Slack、微博、知乎…… 凡是有网页版的都行;
|
||||
- **邮箱 / 后台 / 工具**:Gmail、各类管理后台、需要长期保持登录的网页应用。
|
||||
|
||||
和微信实例同一套体验与好处:
|
||||
|
||||
- **随镜像就绪、免下载** — Chromium 已烤进镜像,创建后点「进入实例」直接用(amd64 / arm64 均可)。
|
||||
- **登录态常驻、重启不掉** — 浏览器配置与 Cookie 写在实例数据卷 `/config`,容器重启 / 升级都保留登录。
|
||||
- **多端共享 + 同步** — 多设备打开同一实例看到的是**同一个**浏览器画面,跨设备无缝接力;多人操作有软锁保护。
|
||||
- **中文输入 / 文件 / 剪贴板** — 与微信实例共用一套:本地输入法直接打字,工具栏拖拽传文件、剪贴板中转文本。
|
||||
|
||||
> ⚠️ 浏览器实例登录着你的社媒账号,同样受[安全须知](#安全须知必读)约束——**切勿把面板暴露公网**。
|
||||
|
||||
---
|
||||
|
||||
@@ -139,15 +175,15 @@ docker compose up -d # 直接从 GHCR 拉取
|
||||
- 估算:**面板 ≈ 0.15 GiB 常驻;每个微信实例按 1 vCPU + 1.5 GiB 内存预留**较从容(轻度使用可更低)。
|
||||
- 参考容量:**2 核 / 2 GiB** 跑 1 个实例(轻度);**4 核 / 8 GiB** 跑 3–4 个实例;视频通话等重负载需再加预留。
|
||||
|
||||
> 内存是主要瓶颈,CPU 多为短时突发。实例越多越吃内存,按上表线性叠加即可估算。
|
||||
> 内存是主要瓶颈,CPU 多为短时突发。实例越多越吃内存,按上表线性叠加即可估算。**Chromium 浏览器实例**的占用与微信实例同量级(取决于开的标签页数),可套用上表。
|
||||
|
||||
---
|
||||
|
||||
## 安全须知(必读)
|
||||
|
||||
> ⚠️ **这套系统暴露的是已登录的微信,请务必认真阅读本节。**
|
||||
> ⚠️ **这套系统暴露的是已登录的微信 / 社媒账号,请务必认真阅读本节。**
|
||||
|
||||
能登录面板的人就能看聊天记录、以你身份发消息。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
|
||||
能登录面板的人就能看你的聊天记录、以你身份发消息(浏览器实例则能用你登录的 Telegram / X / Instagram 等账号)。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
|
||||
|
||||
- **绝不要把面板裸暴露公网**:只在内网访问,或经飞牛远程访问 / VPN / 内网穿透;
|
||||
- 务必改掉默认密码(默认 admin / wechat):`cp .env.example .env` 后改 `WOC_PASSWORD`,或登录后在「修改密码」里改;
|
||||
@@ -160,10 +196,10 @@ docker compose up -d # 直接从 GHCR 拉取
|
||||
|
||||
## 中文输入
|
||||
|
||||
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像默认开启 KasmVNC 的「IME Input Mode」,并对 noVNC 的 IME 合成逻辑做了修复——**只在输入法「上屏」那一刻把成品汉字整串发进容器**,规避了原生实现逐字符差分带来的丢字 / 卡顿。直接在微信输入框打字即可。
|
||||
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像默认开启 KasmVNC 的「IME Input Mode」,并对 noVNC 的 IME 合成逻辑做了修复——**只在输入法「上屏」那一刻把成品汉字整串发进容器**,规避了原生实现逐字符差分带来的丢字 / 卡顿。在微信或浏览器的输入框直接打字即可(对所有实例通用)。
|
||||
|
||||
- 默认值只对**未存过该设置的浏览器**生效。之前手动开/关过的,浏览器 localStorage 值优先;想验证默认效果用无痕窗口。
|
||||
- **跨设备文本**:实例工具栏的「剪贴板」可把文本送入容器剪贴板,再在微信里 `Ctrl+V` 粘贴——不依赖浏览器异步剪贴板 API,**局域网 http 访问下也可用**。
|
||||
- **跨设备文本**:实例工具栏的「剪贴板」可把文本送入容器剪贴板,再在微信 / 网页里 `Ctrl+V` 粘贴——不依赖浏览器异步剪贴板 API,**局域网 http 访问下也可用**。
|
||||
- **文件**:用工具栏「文件」拖拽上传,微信收到的文件另存到桌面即可在此下载。
|
||||
|
||||
---
|
||||
@@ -174,8 +210,9 @@ docker compose up -d # 直接从 GHCR 拉取
|
||||
- [x] 自研面板:cookie 鉴权 + 反代 + 子账号管理 + PWA(KasmVNC 凭据不下发前端)
|
||||
- [x] 微信本体运行时下载到数据卷:面板一键「下载并安装 / 更新」,带进度条
|
||||
- [x] 多实例管理 + 按账号的实例访问权限(RBAC)
|
||||
- [x] 预构建多架构镜像发布到 GHCR + GitHub Actions 自动化
|
||||
- [x] 中文输入修复 + 文本剪贴板中转 + 实例日志导出
|
||||
- [x] 多应用平台:微信 + Chromium 浏览器实例(登录 Telegram / X / Instagram 等网页版社媒)+ 自定义实例图标
|
||||
- [x] 预构建多架构镜像发布到 Docker Hub / GHCR + GitHub Actions 自动化
|
||||
- [x] 中文输入修复 + 文本剪贴板中转 + 实例日志持久化(跨容器重建保留重启原因与日志快照)
|
||||
- [ ] 面板外层 TLS / 陌生设备验证码 / 审计日志
|
||||
- [x] 多端并发控制(操作控制权心跳软锁 + 只读遮罩 + 申请接管)
|
||||
- [ ] 掉登录时 web 端二维码重扫入口
|
||||
|
||||
@@ -76,3 +76,38 @@ docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
|
||||
> 若想保持私有,则使用者需先 `docker login ghcr.io`(用具备 `read:packages` 的 PAT)才能拉取。
|
||||
> 在镜像发布之前,本地用 [`./scripts/build-local.sh`](../scripts/build-local.sh) 自构建即可,无需等待发布。
|
||||
|
||||
---
|
||||
|
||||
## Telegram 发布通知(可选,免服务器)
|
||||
|
||||
仓库内置 [.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 群组。**要让"自动置顶"生效,需把机器人设为群管理员并开启「置顶消息」权限**(缺权限时通知照发、仅置顶被跳过,不影响主流程)。
|
||||
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;
|
||||
- **Secrets** 标签 → `TELEGRAM_BOT_TOKEN` = [@BotFather](https://t.me/BotFather) 给的 token。
|
||||
|
||||
之后每次「发布 Release / 新建 issue」都会自动推送。想关掉 issue 推送,删掉 workflow 里 `on:` 下的 `issues:` 即可。
|
||||
|
||||
> **自动置顶原理**:发完新消息后,用 `getChat` 读出群当前置顶消息(即上一个 release),置顶新消息、再取消旧的——无需任何持久化存储。issue 通知不置顶。手动 **Run workflow** 也会置顶(发的是最新 release),可用来测试。
|
||||
|
||||
---
|
||||
|
||||
## Telegram 命令机器人(可选,免服务器,轮询版)
|
||||
|
||||
除了发布通知,仓库还内置 [.github/workflows/telegram-bot.yml](../.github/workflows/telegram-bot.yml) + [.github/scripts/telegram-bot.mjs](../.github/scripts/telegram-bot.mjs):让机器人在**私聊 / 群组**里响应命令,跑在 GitHub Actions cron 上,**不需要服务器**。
|
||||
|
||||
命令:`/help`、`/releases`、`/release <tag>`、`/issues`、`/issue <编号>`。
|
||||
|
||||
启用:
|
||||
|
||||
1. 复用上面的 `TELEGRAM_BOT_TOKEN`(Secret);
|
||||
2. 仓库 **Settings → Secrets and variables → Actions → Variables** 新建 `TELEGRAM_BOT_ENABLED = true`(不设为 `true` 则该工作流不运行);
|
||||
3. 把机器人拉进群组,或在私聊里 `/start`。
|
||||
|
||||
> **原理**:每 5 分钟(cron 最小间隔)调用 `getUpdates` 拉取待处理命令、回复、再用 `offset` 向 Telegram 确认(Telegram 自己保存 offset,**无需任何持久化存储**)。
|
||||
> **局限**:命令**非实时**——受 cron 最小 5 分钟 + GitHub 排队延迟影响;且 GitHub 会在仓库 60 天无活动时暂停定时任务(去 Actions 页手动重启即可)。想立即处理一次:Actions → telegram-bot → **Run workflow**。要真正实时,得改用 webhook(需一个 serverless 端点)。
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
+17
-5
@@ -15,6 +15,8 @@ RUN set -eux; \
|
||||
curl ca-certificates locales dpkg \
|
||||
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
|
||||
libnss3 libgbm1 libasound2 libxss1 \
|
||||
xz-utils \
|
||||
chromium \
|
||||
xdotool xclip; \
|
||||
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
|
||||
locale-gen; \
|
||||
@@ -62,24 +64,34 @@ ENV LANG=zh_CN.UTF-8 \
|
||||
# 用 e.data 直发成品字符串(详见 woc-www-patch.sh / woc-ime.pl)。
|
||||
# 注意:实际加载的是 webpack 产物 dist/main.bundle.js(app/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
|
||||
|
||||
# v1.2.0 多应用:应用定义(app-defs.sh,被 autostart/app-ctl 引用)+ 通用安装/状态控制(app-ctl.sh,
|
||||
# 微信委托回 wechat-ctl.sh、其它应用各自实现)。app-defs.sh 是被 source 的、不需可执行位。
|
||||
COPY app-defs.sh app-ctl.sh /woc/
|
||||
RUN chmod 644 /woc/app-defs.sh && chmod 755 /woc/app-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
|
||||
|
||||
# 启动钩子(02):把容器环境 WOC_APP_TYPE 写入 /config/.woc-app,供 autostart 选择启动哪个应用。
|
||||
# 须在 autostart 之前执行;缺 WOC_APP_TYPE 则不写 → autostart 回退微信(向后兼容)。
|
||||
COPY woc-app-init.sh /custom-cont-init.d/02-woc-app
|
||||
RUN chmod 755 /custom-cont-init.d/02-woc-app
|
||||
|
||||
# 3000 = HTTP web 客户端, 3001 = HTTPS
|
||||
EXPOSE 3000 3001
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# 多应用安装/状态控制(面板经 docker exec --user abc 调用):
|
||||
# app-ctl.sh <appType> <install|update|status>
|
||||
# 设计:微信完全委托给原 wechat-ctl.sh(逻辑零改动);其它应用各自实现,状态 JSON 复用同一格式与文件,
|
||||
# 故面板的轮询逻辑无需区分应用类型。状态文件:/config/.woc-state/status.json。
|
||||
set -u
|
||||
|
||||
APP="${1:-wechat}"
|
||||
ACTION="${2:-status}"
|
||||
|
||||
# 微信:原样委托,保持既有行为不变(向后兼容老实例与旧面板调用路径)
|
||||
if [ "$APP" = "wechat" ]; then exec /woc/wechat-ctl.sh "$ACTION"; fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. /woc/app-defs.sh
|
||||
woc_app_def "$APP"
|
||||
|
||||
STATE_DIR="${WOC_STATE_DIR:-/config/.woc-state}"
|
||||
STATUS_FILE="$STATE_DIR/status.json"
|
||||
|
||||
is_installed() { [ -n "${APP_BIN:-}" ] && [ -x "$APP_BIN" ]; }
|
||||
|
||||
write_status() {
|
||||
local phase="$1" percent="$2" message="$3" installed=false
|
||||
is_installed && installed=true
|
||||
mkdir -p "$STATE_DIR"
|
||||
cat > "$STATUS_FILE.tmp" <<EOF
|
||||
{"phase":"$phase","percent":$percent,"installed":$installed,"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\":\"\",\"message\":\"已就绪\",\"updatedAt\":$(date +%s)}"
|
||||
else
|
||||
echo "{\"phase\":\"idle\",\"percent\":0,\"installed\":false,\"version\":\"\",\"message\":\"未安装\",\"updatedAt\":$(date +%s)}"
|
||||
fi
|
||||
}
|
||||
|
||||
install_telegram() {
|
||||
case "$(dpkg --print-architecture 2>/dev/null)" in
|
||||
amd64) ;;
|
||||
*) write_status error 0 "Telegram 官方仅提供 x86_64 版本,当前架构($(dpkg --print-architecture 2>/dev/null))不支持"; return ;;
|
||||
esac
|
||||
local work=/config/.woc-dl tmp
|
||||
tmp="$work/tg.tar.xz"
|
||||
rm -rf "$work"; mkdir -p "$work"
|
||||
write_status downloading -1 "正在下载 Telegram"
|
||||
if ! curl -fSL --retry 3 -A "Mozilla/5.0" -o "$tmp" "https://telegram.org/dl/desktop/linux"; then
|
||||
write_status error 0 "下载失败,请检查网络后重试"; rm -rf "$work"; return
|
||||
fi
|
||||
write_status extracting 92 "正在解压安装"
|
||||
local newdir="$work/x"; mkdir -p "$newdir"
|
||||
# 官方包内顶层是 Telegram/ 目录,strip 掉一层 → newdir 下直接是 Telegram + Updater
|
||||
if ! tar -xJf "$tmp" -C "$newdir" --strip-components=1 2>/dev/null; then
|
||||
write_status error 0 "解压失败,安装包可能损坏"; rm -rf "$work"; return
|
||||
fi
|
||||
if [ ! -x "$newdir/Telegram" ]; then
|
||||
write_status error 0 "解压后未找到 Telegram 可执行文件"; rm -rf "$work"; return
|
||||
fi
|
||||
write_status installing 96 "正在安装"
|
||||
rm -rf /config/telegram.old
|
||||
[ -e /config/telegram ] && mv /config/telegram /config/telegram.old
|
||||
mv "$newdir" /config/telegram
|
||||
rm -rf /config/telegram.old "$work"
|
||||
write_status done 100 "安装完成"
|
||||
pkill -f "/config/telegram/Telegram" 2>/dev/null || true
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
status) print_status ;;
|
||||
install | update)
|
||||
case "$APP" in
|
||||
telegram) install_telegram ;;
|
||||
chromium) write_status done 100 "Chromium 随镜像就绪" ;; # 后续:apt 烤进镜像后即就绪
|
||||
custom)
|
||||
if is_installed; then write_status done 100 "就绪"; else write_status error 0 "请先在「数据卷」上传并配置自定义应用"; fi ;;
|
||||
*) echo "未知应用: $APP" >&2; exit 1 ;;
|
||||
esac ;;
|
||||
*) echo "用法: $0 <appType> {install|update|status}" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,34 @@
|
||||
# 应用定义(被 autostart 与 app-ctl.sh source)。给定应用类型,输出该应用的:
|
||||
# APP_BIN — 可执行文件路径(autostart 据此判断"是否就绪/已安装")
|
||||
# APP_LAUNCH — 启动命令(可带参数;autostart 以 word-split 方式执行,参数勿含空格)
|
||||
# APP_NAME — 显示名(日志用)
|
||||
# 缺省/未知类型一律回退微信,保证老实例零改动。v1.2.0 多应用平台。
|
||||
woc_app_def() {
|
||||
case "${1:-wechat}" in
|
||||
telegram)
|
||||
APP_BIN=/config/telegram/Telegram
|
||||
APP_LAUNCH="$APP_BIN"
|
||||
APP_NAME=Telegram
|
||||
;;
|
||||
chromium)
|
||||
# 容器内无 user namespace / GPU:--no-sandbox + 软件渲染;--password-store=basic 免 keyring 弹窗。
|
||||
# --disable-background-networking:关掉 Chromium 后台 phone-home(GCM 推送 / 组件更新 / 变体下载),
|
||||
# 在受限网络(NAS / 被墙)下这些会反复失败刷屏 "gcm ConnectionHandler failed net error: -2"。
|
||||
# 只影响后台流量,不影响前台网页加载与真实网络错误提示。
|
||||
APP_BIN=/usr/bin/chromium
|
||||
APP_LAUNCH="$APP_BIN --no-sandbox --no-first-run --no-default-browser-check --start-maximized --password-store=basic --disable-gpu --disable-background-networking --user-data-dir=/config/chromium"
|
||||
APP_NAME=Chromium
|
||||
;;
|
||||
custom)
|
||||
# 自定义:启动命令由面板写入 .woc-app 的 WOC_CUSTOM_LAUNCH(用户上传安装包后设定)
|
||||
APP_LAUNCH="${WOC_CUSTOM_LAUNCH:-}"
|
||||
APP_BIN="${WOC_CUSTOM_BIN:-}"
|
||||
APP_NAME="自定义应用"
|
||||
;;
|
||||
*)
|
||||
APP_BIN=/config/wechat/opt/wechat/wechat
|
||||
APP_LAUNCH="$APP_BIN"
|
||||
APP_NAME=微信
|
||||
;;
|
||||
esac
|
||||
}
|
||||
+29
-14
@@ -1,17 +1,25 @@
|
||||
#!/bin/bash
|
||||
# 由 KasmVNC base 的 openbox 会话在桌面就绪后执行(以 app 用户身份)。
|
||||
# 微信本体由面板经 docker exec 触发下载/解压到数据卷 /config/wechat(见 wechat-ctl.sh),
|
||||
# 本脚本只负责:等待微信就绪 + 常驻拉起(关窗自动重开;更新后从新版本路径重启)。
|
||||
# v1.2.0 多应用:实例承载的应用(微信/Telegram/Chromium/自定义)由面板写入容器环境 WOC_APP_TYPE,
|
||||
# 再由 02-woc-app 钩子落到数据卷 /config/.woc-app。本脚本据此等待对应应用就绪并常驻拉起
|
||||
# (关窗自动重开;更新后从新版本路径重启)。读不到类型 → 回退微信,老实例零改动。
|
||||
|
||||
set -u
|
||||
|
||||
WECHAT_BIN=/config/wechat/opt/wechat/wechat
|
||||
|
||||
# 容器内无 GPU,强制软件渲染
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
|
||||
# 防“最小化后丢失”:本桌面(openbox)无任务栏,微信被最小化就无处恢复 → 黑屏。
|
||||
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。
|
||||
# 解析本实例应用类型与启动信息(APP_BIN / APP_LAUNCH / APP_NAME)
|
||||
APP_TYPE=wechat
|
||||
# shellcheck source=/dev/null
|
||||
[ -f /config/.woc-app ] && . /config/.woc-app 2>/dev/null || true
|
||||
APP_TYPE="${WOC_APP_TYPE:-wechat}"
|
||||
# shellcheck source=/dev/null
|
||||
. /woc/app-defs.sh
|
||||
woc_app_def "$APP_TYPE"
|
||||
|
||||
# 防“最小化后丢失”:本桌面(openbox)无任务栏,窗口被最小化就无处恢复 → 黑屏。
|
||||
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。对任意应用通用。
|
||||
(
|
||||
export DISPLAY="${DISPLAY:-:1}"
|
||||
while sleep 2; do
|
||||
@@ -23,25 +31,32 @@ export LIBGL_ALWAYS_SOFTWARE=1
|
||||
done
|
||||
) &
|
||||
|
||||
# 1) 等待微信安装就绪(首次需在面板点「下载并安装」)
|
||||
# 自定义应用若未配置启动命令,给出提示并退出(避免空等)
|
||||
if [ "$APP_TYPE" = "custom" ] && [ -z "${APP_LAUNCH:-}" ]; then
|
||||
echo "[autostart] 自定义应用尚未配置启动命令,请在面板「数据卷」上传安装包并设定后重启实例"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 1) 等待应用安装就绪(首次需在面板点「下载并安装」)
|
||||
notified=0
|
||||
while [ ! -x "${WECHAT_BIN}" ]; do
|
||||
while [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; do
|
||||
if [ "${notified}" -eq 0 ]; then
|
||||
echo "[autostart] 微信尚未安装,等待面板触发下载…(首次使用请在面板点「下载并安装微信」)"
|
||||
echo "[autostart] ${APP_NAME} 尚未安装,等待面板触发下载…(首次使用请在面板点「下载并安装」)"
|
||||
notified=1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 3) 常驻拉起微信
|
||||
# 3) 常驻拉起应用
|
||||
while true; do
|
||||
if [ ! -x "${WECHAT_BIN}" ]; then
|
||||
if [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; then
|
||||
# 更新过程中本体被临时挪走,等就位再继续
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
echo "[autostart] 启动微信: ${WECHAT_BIN}"
|
||||
"${WECHAT_BIN}"
|
||||
echo "[autostart] 微信已退出,2 秒后重启"
|
||||
echo "[autostart] 启动 ${APP_NAME}: ${APP_LAUNCH}"
|
||||
# APP_LAUNCH 可带参数,按 word-split 执行(各应用参数均不含空格,见 app-defs.sh)
|
||||
${APP_LAUNCH}
|
||||
echo "[autostart] ${APP_NAME} 已退出,2 秒后重启"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# /custom-cont-init.d 钩子(02):把容器环境里的应用类型写入数据卷 /config/.woc-app,
|
||||
# 供 autostart(以 abc 身份的桌面会话)读取。由 s6 在 autostart 之前以 root 运行。
|
||||
# 缺 WOC_APP_TYPE(老实例/老面板)则不写文件 → autostart 回退微信,完全向后兼容。
|
||||
APP_TYPE="${WOC_APP_TYPE:-}"
|
||||
[ -z "$APP_TYPE" ] && exit 0
|
||||
|
||||
# 仅允许已知的简单标识,杜绝写入异常内容
|
||||
case "$APP_TYPE" in
|
||||
wechat | telegram | chromium | custom) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
TMP=/config/.woc-app.tmp
|
||||
{
|
||||
echo "WOC_APP_TYPE='${APP_TYPE}'"
|
||||
# 自定义应用的启动命令由面板经环境传入(admin 设定);用单引号包裹,转义内部单引号
|
||||
if [ -n "${WOC_CUSTOM_LAUNCH:-}" ]; then
|
||||
esc=${WOC_CUSTOM_LAUNCH//\'/\'\\\'\'}
|
||||
echo "WOC_CUSTOM_LAUNCH='${esc}'"
|
||||
fi
|
||||
} > "$TMP"
|
||||
mv -f "$TMP" /config/.woc-app
|
||||
chown abc:abc /config/.woc-app 2>/dev/null || true
|
||||
echo "[woc-app] 实例应用类型 = ${APP_TYPE}"
|
||||
+5
-1
@@ -17,7 +17,11 @@ RUN npm install
|
||||
COPY server/ ./
|
||||
COPY --from=web /web/dist ./web-dist
|
||||
|
||||
ENV STATIC_DIR=/app/web-dist \
|
||||
# 构建版本号:CI 用 git tag 注入(vX.Y.Z),本地构建默认 dev。烤进镜像 → 面板运行时显示真实版本并据此检测更新。
|
||||
# 放在末尾:改版本号不会破坏上面的依赖安装缓存。
|
||||
ARG WOC_VERSION=dev
|
||||
ENV WOC_VERSION=${WOC_VERSION} \
|
||||
STATIC_DIR=/app/web-dist \
|
||||
PORT=8080
|
||||
EXPOSE 8080
|
||||
CMD ["npm", "run", "start"]
|
||||
|
||||
+318
-10
@@ -1,8 +1,10 @@
|
||||
import { hostname } from 'node:os';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { appendInstanceLog, deleteInstanceLog, appendPanelLog, readInstanceLog, readPanelLog, filterSince } from './logs.js';
|
||||
import http from 'node:http';
|
||||
import zlib from 'node:zlib';
|
||||
import Docker from 'dockerode';
|
||||
import type { Instance } from './store.js';
|
||||
import { instanceAppType, 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';
|
||||
@@ -113,6 +115,11 @@ function envList(inst: Instance): string[] {
|
||||
if (!ENABLE_GPU) env.push('DISABLE_DRI=1');
|
||||
// 透传 os 伪装开关给容器内的 00-woc-identity 钩子(决定是否把 /etc/os-release 改成 deepin)。
|
||||
env.push(`WOC_SPOOF_OS=${SPOOF_OS ? '1' : '0'}`);
|
||||
// v1.2.0 多应用:透传应用类型给 02-woc-app 钩子(写入 /config/.woc-app,autostart 据此启动)。
|
||||
// 老实例无 appType → instanceAppType 回退 wechat;自定义应用额外透传启动命令。
|
||||
const appType = instanceAppType(inst);
|
||||
env.push(`WOC_APP_TYPE=${appType}`);
|
||||
if (appType === 'custom' && inst.customLaunch) env.push(`WOC_CUSTOM_LAUNCH=${inst.customLaunch}`);
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -124,7 +131,17 @@ async function ensureImage(): Promise<void> {
|
||||
} catch {
|
||||
/* 本地没有,下面拉取 */
|
||||
}
|
||||
await pullImage();
|
||||
// 首次新建实例常卡在这一步(NAS 直连 docker.io 拉取超时,见 README)。这里前后都打日志:
|
||||
// 若诊断包里只见"开始拉取"而无"完成/失败",即可定位为拉取卡死。
|
||||
appendPanelLog('INFO', `本地无实例镜像 ${WECHAT_IMAGE},开始拉取(首次较慢;NAS 直连 docker.io 可能超时)…`);
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await pullImage();
|
||||
appendPanelLog('INFO', `实例镜像拉取完成 ${WECHAT_IMAGE}(耗时 ${Math.round((Date.now() - t0) / 1000)}s)`);
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `实例镜像拉取失败 ${WECHAT_IMAGE}(耗时 ${Math.round((Date.now() - t0) / 1000)}s):${e?.message || e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建并启动一个微信实例容器。若同名容器已存在则先移除(仅容器,不动卷)。
|
||||
@@ -134,6 +151,8 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
try {
|
||||
const existing = docker.getContainer(inst.containerName);
|
||||
await existing.inspect();
|
||||
// 删除前先把旧容器最后日志快照进持久日志,否则随容器删除就看不到"上次为何停/崩"。
|
||||
await snapshotContainerLog(inst, '容器重建(重启/升级/自愈),保留上一容器最后日志');
|
||||
await existing.remove({ force: true });
|
||||
} catch {
|
||||
/* 不存在,正常 */
|
||||
@@ -177,6 +196,7 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
const container = await docker.createContainer(createOpts);
|
||||
try {
|
||||
await container.start();
|
||||
appendInstanceLog(inst.id, '容器已启动');
|
||||
} catch (e) {
|
||||
// 启动失败但容器已被创建出来(Created 状态),不清理的话会成为"幽灵容器"——
|
||||
// 它仍占着卷名 woc-data-<id>,让后续删卷报 409。修复 #23 时发现 4 个此类残留。
|
||||
@@ -235,6 +255,7 @@ export async function regenInstanceMachineId(inst: Instance): Promise<void> {
|
||||
export async function stopInstance(inst: Instance): Promise<void> {
|
||||
try {
|
||||
await docker.getContainer(inst.containerName).stop({ t: 5 } as any);
|
||||
appendInstanceLog(inst.id, '容器已停止');
|
||||
} catch {
|
||||
/* 已停止或不存在 */
|
||||
}
|
||||
@@ -253,6 +274,7 @@ export async function removeInstance(inst: Instance, purgeVolume: boolean): Prom
|
||||
} catch {
|
||||
/* 卷可能不存在 */
|
||||
}
|
||||
deleteInstanceLog(inst.id); // 彻底删除时一并清掉持久日志
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,11 +425,15 @@ async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
// 触发下载/安装(detached,立即返回,后台下载)。
|
||||
// 触发下载/安装(detached,立即返回,后台下载)。按实例 appType 分发:app-ctl.sh wechat → 委托回
|
||||
// wechat-ctl.sh;telegram 等各自实现。兼容旧容器(升级前镜像里没有 /woc/app-ctl.sh):有则用之,无则
|
||||
// 回退老的 wechat-ctl.sh(旧实例都是微信)。appType 取值受 instanceAppType 约束,可安全内插进 shell。
|
||||
export async function triggerWechat(inst: Instance, cmd: 'install' | 'update'): Promise<void> {
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
const at = instanceAppType(inst);
|
||||
const action = cmd === 'update' ? 'update' : 'install';
|
||||
const exec = await c.exec({
|
||||
Cmd: ['/woc/wechat-ctl.sh', cmd === 'update' ? 'update' : 'install'],
|
||||
Cmd: ['bash', '-c', `if [ -x /woc/app-ctl.sh ]; then /woc/app-ctl.sh ${at} ${action}; else /woc/wechat-ctl.sh ${action}; fi`],
|
||||
AttachStdout: false,
|
||||
AttachStderr: false,
|
||||
User: 'abc',
|
||||
@@ -428,7 +454,13 @@ const DEFAULT_STATUS: WechatStatus = { phase: 'idle', percent: 0, installed: fal
|
||||
|
||||
export async function wechatStatus(inst: Instance): Promise<WechatStatus> {
|
||||
try {
|
||||
const raw = await execCapture(inst, ['/woc/wechat-ctl.sh', 'status']);
|
||||
// 兼容旧容器(无 /woc/app-ctl.sh):有则按 appType 取状态,无则回退老的 wechat-ctl.sh(旧实例皆微信)。
|
||||
const at = instanceAppType(inst);
|
||||
const raw = await execCapture(inst, [
|
||||
'bash',
|
||||
'-c',
|
||||
`if [ -x /woc/app-ctl.sh ]; then /woc/app-ctl.sh ${at} status; else /woc/wechat-ctl.sh status; fi`,
|
||||
]);
|
||||
const json = JSON.parse(raw.trim());
|
||||
return { ...DEFAULT_STATUS, ...json };
|
||||
} catch {
|
||||
@@ -475,6 +507,129 @@ function tarSingleFile(name: string, content: Buffer): Buffer {
|
||||
return Buffer.concat([h, content, Buffer.alloc(pad, 0), Buffer.alloc(1024, 0)]);
|
||||
}
|
||||
|
||||
// ---------- 诊断包 ----------
|
||||
// 单个 tar entry(USTAR header + 内容 + 512 对齐填充),复用与 tarSingleFile 相同的格式。
|
||||
function tarEntry(name: string, content: Buffer): Buffer {
|
||||
const h = Buffer.alloc(512, 0);
|
||||
h.write(name.slice(0, 100), 0, 'utf8');
|
||||
h.write('0000644\0', 100);
|
||||
h.write('0001750\0', 108);
|
||||
h.write('0001750\0', 116);
|
||||
h.write(content.length.toString(8).padStart(11, '0') + '\0', 124);
|
||||
h.write('00000000000\0', 136);
|
||||
h.write(' ', 148); // checksum 占位
|
||||
h.write('0', 156); // typeflag 普通文件
|
||||
h.write('ustar\0', 257);
|
||||
h.write('00', 263);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 512; i++) sum += h[i];
|
||||
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
|
||||
const pad = (512 - (content.length % 512)) % 512;
|
||||
return Buffer.concat([h, content, Buffer.alloc(pad, 0)]);
|
||||
}
|
||||
|
||||
// 多文件 tar.gz(内存构建;诊断包通常仅数 MB)。文件名用 ASCII 路径避免 utf8 超 100 字节。
|
||||
function buildTarGz(entries: { name: string; content: string | Buffer }[]): Buffer {
|
||||
const parts = entries.map((e) => tarEntry(e.name, Buffer.isBuffer(e.content) ? e.content : Buffer.from(e.content, 'utf8')));
|
||||
parts.push(Buffer.alloc(1024, 0)); // 两个空块标记归档结束
|
||||
return zlib.gzipSync(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
// 汇总诊断包:系统信息 + 面板全局日志 + 每个实例(容器状态 + 持久日志 + 实时日志)+ 全部 woc-* 容器清单。
|
||||
// 日志按 sinceMs 时间裁剪。给排查"首个实例创建卡死 / 打开实例黑屏不可用 / 升级失败"等问题用。
|
||||
export async function buildDiagnostics(instances: Instance[], sinceMs: number, meta: Record<string, string>): Promise<Buffer> {
|
||||
const entries: { name: string; content: string | Buffer }[] = [];
|
||||
const stamp = new Date().toISOString();
|
||||
|
||||
entries.push({
|
||||
name: 'README.txt',
|
||||
content: [
|
||||
'云微 · WechatOnCloud 诊断包',
|
||||
`生成时间: ${stamp}`,
|
||||
`时间范围: 最近 ${meta.range || '24h'}`,
|
||||
'',
|
||||
'内容:',
|
||||
' system.txt 系统/Docker/镜像信息',
|
||||
' panel.log 面板全局运维日志(创建/删除/升级/启停/镜像拉取/错误)',
|
||||
' containers.txt 所有 woc-* 容器清单(含残留/未登记)',
|
||||
' instances/<id>.log 每个实例:容器状态 + 持久日志 + 实时容器日志',
|
||||
'',
|
||||
'把本压缩包发给维护者即可协助排查(不含密码/密钥等敏感信息)。',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
// 系统信息
|
||||
let sys = `生成时间: ${stamp}\n时间范围: 最近 ${meta.range || '24h'}\n\n`;
|
||||
for (const [k, v] of Object.entries(meta)) sys += `${k}: ${v}\n`;
|
||||
try {
|
||||
const ver: any = await docker.version();
|
||||
sys += `\nDocker 版本: ${ver.Version} (API ${ver.ApiVersion}, ${ver.Os}/${ver.Arch})\n`;
|
||||
} catch (e: any) {
|
||||
sys += `\nDocker 版本: 获取失败 ${e?.message || e}\n`;
|
||||
}
|
||||
try {
|
||||
const info: any = await docker.info();
|
||||
sys += `容器: ${info.Containers}(运行 ${info.ContainersRunning}) · 镜像: ${info.Images}\n`;
|
||||
sys += `内核: ${info.KernelVersion} · OS: ${info.OperatingSystem} · 架构: ${info.Architecture}\n`;
|
||||
sys += `CPU: ${info.NCPU} 核 · 内存: ${(info.MemTotal / 1073741824).toFixed(1)} GiB\n`;
|
||||
if (Array.isArray(info.Warnings) && info.Warnings.length) sys += `Docker 警告: ${info.Warnings.join('; ')}\n`;
|
||||
} catch (e: any) {
|
||||
sys += `Docker info: 获取失败 ${e?.message || e}\n`;
|
||||
}
|
||||
try {
|
||||
const img: any = await docker.getImage(WECHAT_IMAGE).inspect();
|
||||
sys += `\n实例镜像 ${WECHAT_IMAGE}: ${String(img.Id).slice(0, 19)} · 创建 ${img.Created}\n`;
|
||||
} catch {
|
||||
sys += `\n实例镜像 ${WECHAT_IMAGE}: 本地不存在(首次新建实例需联网拉取,可能在此卡住)\n`;
|
||||
}
|
||||
sys += `\n实例数: ${instances.length}\n`;
|
||||
entries.push({ name: 'system.txt', content: sys });
|
||||
|
||||
// 面板全局日志(按范围裁剪)
|
||||
entries.push({ name: 'panel.log', content: filterSince(readPanelLog(), sinceMs) || '(无面板日志)' });
|
||||
|
||||
// 每个实例
|
||||
for (const inst of instances) {
|
||||
let c = `实例: ${inst.name}\nID: ${inst.id}\n容器: ${inst.containerName}\n类型: ${instanceAppType(inst)}\n数据卷: ${inst.volumeName}\n创建: ${inst.createdAt}\n\n`;
|
||||
try {
|
||||
const info: any = await docker.getContainer(inst.containerName).inspect();
|
||||
const s = info.State || {};
|
||||
c += `===== 容器状态 =====\n运行: ${s.Running} · 状态: ${s.Status} · 退出码: ${s.ExitCode}\n`;
|
||||
c += `OOMKilled: ${s.OOMKilled} · 重启次数: ${info.RestartCount} · 启动于: ${s.StartedAt}\n`;
|
||||
if (s.Error) c += `错误: ${s.Error}\n`;
|
||||
c += `镜像: ${String(info.Image).slice(0, 19)} · 健康: ${s.Health?.Status ?? 'n/a'}\n\n`;
|
||||
} catch (e: any) {
|
||||
c += `===== 容器状态 =====\n无法读取(容器可能未创建/已删除):${e?.message || e}\n\n`;
|
||||
}
|
||||
c += `===== 持久化日志(最近 ${meta.range || '24h'}) =====\n${filterSince(readInstanceLog(inst.id), sinceMs) || '(无)'}\n\n`;
|
||||
try {
|
||||
c += `===== 本次容器日志(实时 tail 300) =====\n${(await instanceLogs(inst, 300)).trimEnd() || '(无)'}\n`;
|
||||
} catch (e: any) {
|
||||
c += `===== 本次容器日志 =====\n获取失败:${e?.message || e}\n`;
|
||||
}
|
||||
entries.push({ name: `instances/${inst.id}.log`, content: c });
|
||||
}
|
||||
|
||||
// 全部 woc-* 容器清单(含未登记/残留,用于诊断"首次创建失败遗留")
|
||||
try {
|
||||
const all = await docker.listContainers({ all: true });
|
||||
const known = new Set(instances.map((i) => i.containerName));
|
||||
let txt = '所有 woc-* 容器:\n\n';
|
||||
for (const ct of all) {
|
||||
const names = (ct.Names || []).map((n: string) => n.replace(/^\//, ''));
|
||||
if (!names.some((n) => n.startsWith('woc-'))) continue;
|
||||
const nm = names.join(',');
|
||||
const tag = nm.includes('woc-panel') ? '面板' : known.has(nm) ? '已登记实例' : '未登记/残留';
|
||||
txt += `[${tag}] ${nm} · ${ct.State}/${ct.Status} · ${ct.Image}\n`;
|
||||
}
|
||||
entries.push({ name: 'containers.txt', content: txt });
|
||||
} catch (e: any) {
|
||||
entries.push({ name: 'containers.txt', content: '获取失败:' + (e?.message || e) });
|
||||
}
|
||||
|
||||
return buildTarGz(entries);
|
||||
}
|
||||
|
||||
// 校验文件名为安全 basename(防路径穿越)。
|
||||
function safeName(name: string): boolean {
|
||||
return !!name && name.length <= 200 && !name.includes('/') && !name.includes('\0') && name !== '.' && name !== '..';
|
||||
@@ -522,10 +677,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);
|
||||
@@ -561,6 +719,20 @@ export async function instanceLogs(inst: Instance, tail = 600): Promise<string>
|
||||
return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用
|
||||
}
|
||||
|
||||
// ---------- 持久化日志 ----------
|
||||
// 日志原语(appendInstanceLog / readInstanceLog / deleteInstanceLog / appendPanelLog 等)已抽到 logs.ts
|
||||
// (无 docker 依赖,避免循环)。这里只保留需要 docker 的快照能力。
|
||||
|
||||
// 把"即将被删/重建"的容器最后日志快照进持久日志(否则随容器删除丢失)。
|
||||
export async function snapshotContainerLog(inst: Instance, reason: string): Promise<void> {
|
||||
try {
|
||||
const logs = (await instanceLogs(inst, 200)).trimEnd();
|
||||
appendInstanceLog(inst.id, `──── ${reason} ────\n${logs}\n──── 上一容器日志快照结束 ────`);
|
||||
} catch {
|
||||
/* 容器可能已不可读,忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// 通过 xdotool 在实例容器内输入文字(绕过 VNC keysym 限制,解决中文 IME 吞字问题)。
|
||||
// 用 base64 传递文本避免 shell 转义问题,xclip 写入剪贴板后 xdotool 模拟 Ctrl+V 粘贴。
|
||||
export async function typeInInstance(inst: Instance, text: string): Promise<void> {
|
||||
@@ -572,12 +744,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`;
|
||||
|
||||
+287
-22
@@ -26,8 +26,11 @@ import {
|
||||
createInstance,
|
||||
removeInstance as removeInstanceRecord,
|
||||
renameInstance,
|
||||
setInstanceIcon,
|
||||
setInstanceUsers,
|
||||
publicInstance,
|
||||
APP_TYPES,
|
||||
type AppType,
|
||||
type User,
|
||||
type Instance,
|
||||
} from './store.js';
|
||||
@@ -47,7 +50,9 @@ import {
|
||||
downloadFromInstance,
|
||||
deleteInstanceFile,
|
||||
instanceLogs,
|
||||
buildDiagnostics,
|
||||
typeInInstance,
|
||||
keyInInstance,
|
||||
listOrphanVolumes,
|
||||
removeVolume,
|
||||
listOrphanContainers,
|
||||
@@ -55,9 +60,20 @@ 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';
|
||||
import { CURRENT_VERSION, versionInfo, ensureChecked, checkForUpdate, startUpdateChecker } from './version.js';
|
||||
import { appendInstanceLog, readInstanceLog, appendPanelLog, readPanelLog, pruneOldLogs, filterSince, rangeToMs, DIAG_RANGES } from './logs.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -157,6 +173,19 @@ app.get('/api/auth/me', async (req, reply) => {
|
||||
return { user: publicUser(u) };
|
||||
});
|
||||
|
||||
// ---------- 版本与更新检测 ----------
|
||||
// 当前构建版本 + 缓存的「最新版」检测结果(后台每 6h 查一次 Docker Hub/GHCR)。任何登录用户可读。
|
||||
app.get('/api/version', async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
ensureChecked(); // 刚启动还没首检时,触发一次后台检查(不阻塞本次响应)
|
||||
return versionInfo();
|
||||
});
|
||||
// 立即重新检查(管理员,用于「检查更新」按钮)。
|
||||
app.post('/api/admin/version/check', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
return await checkForUpdate();
|
||||
});
|
||||
|
||||
// ---------- 自助改密 ----------
|
||||
app.post('/api/account/password', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
@@ -260,11 +289,12 @@ app.get('/api/instances', async (req, reply) => {
|
||||
app.post('/api/admin/instances', async (req, reply) => {
|
||||
const admin = requireAdmin(req, reply);
|
||||
if (!admin) return;
|
||||
const { name, reuseVolume } = (req.body as any) ?? {};
|
||||
const { name, reuseVolume, appType } = (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 type: AppType = APP_TYPES.includes(appType) ? appType : 'wechat';
|
||||
// 复用卷:必须以 woc-data- 开头,且不能被现存实例占用。后端先校验,避免坏名穿透到 docker run。
|
||||
let reuseVolumeName: string | undefined;
|
||||
if (reuseVolume) {
|
||||
@@ -276,13 +306,20 @@ app.post('/api/admin/instances', async (req, reply) => {
|
||||
}
|
||||
reuseVolumeName = reuseVolume;
|
||||
}
|
||||
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName);
|
||||
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName, type);
|
||||
appendPanelLog(
|
||||
'INFO',
|
||||
`创建实例「${inst.name}」(${type}, id=${inst.id}) by ${admin.username}${reuseVolumeName ? ` · 复用卷 ${reuseVolumeName}` : ''} → 开始创建容器(镜像缺失会自动拉取,首次较慢)`,
|
||||
);
|
||||
appendInstanceLog(inst.id, `实例创建(${type})by ${admin.username}`);
|
||||
try {
|
||||
await runInstance(inst);
|
||||
} catch (e: any) {
|
||||
removeInstanceRecord(inst.id); // 容器起不来则回滚登记
|
||||
appendPanelLog('ERROR', `创建实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '创建容器失败:' + (e?.message || e) });
|
||||
}
|
||||
appendPanelLog('INFO', `创建实例「${inst.name}」(id=${inst.id}) 成功`);
|
||||
return { instance: publicInstance(inst) };
|
||||
});
|
||||
|
||||
@@ -419,6 +456,7 @@ app.delete('/api/admin/instances/:id', async (req, reply) => {
|
||||
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: '实例不存在' });
|
||||
appendPanelLog('INFO', `删除实例「${inst.name}」(id=${id})${purge ? ' · 同时清除数据卷' : ' · 保留数据卷'}`);
|
||||
await removeInstanceContainer(inst, purge);
|
||||
removeInstanceRecord(id);
|
||||
controlHolders.delete(id);
|
||||
@@ -436,6 +474,17 @@ app.post('/api/admin/instances/:id/rename', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 设置实例自定义图标(仅管理员):icon = builtin:<key> / data:image 图片 / 空串(恢复默认)。
|
||||
app.post('/api/admin/instances/:id/icon', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const { icon } = (req.body as any) ?? {};
|
||||
try {
|
||||
return { instance: setInstanceIcon((req.params as any).id, typeof icon === 'string' ? icon : null) };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '设置图标失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 启动实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。
|
||||
app.post('/api/admin/instances/:id/start', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
@@ -443,8 +492,10 @@ app.post('/api/admin/instances/:id/start', async (req, reply) => {
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await ensureRunning(inst);
|
||||
appendPanelLog('INFO', `启动实例「${inst.name}」(id=${inst.id})`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `启动实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '启动失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -456,8 +507,10 @@ app.post('/api/admin/instances/:id/stop', async (req, reply) => {
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
appendPanelLog('INFO', `停止实例「${inst.name}」(id=${inst.id})`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `停止实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '停止失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -468,9 +521,11 @@ app.post('/api/admin/instances/:id/restart', async (req, reply) => {
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
appendPanelLog('INFO', `重启实例「${inst.name}」(id=${inst.id})`);
|
||||
await runInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `重启实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '重启失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -482,9 +537,12 @@ app.post('/api/admin/instances/:id/upgrade', async (req, reply) => {
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}):拉取最新镜像后重建`);
|
||||
await upgradeInstance(inst);
|
||||
appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}) 完成`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '升级失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -623,18 +681,209 @@ 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;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
// 持久化历史(重启原因 + 上一容器日志快照,跨重建保留)+ 本次容器实时日志。
|
||||
const history = readInstanceLog(inst.id).trimEnd();
|
||||
let live = '';
|
||||
try {
|
||||
const text = await instanceLogs(inst);
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
return reply.send(text || '(暂无日志)');
|
||||
live = (await instanceLogs(inst)).trimEnd();
|
||||
} catch (e: any) {
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
return reply.send('获取日志失败:' + (e?.message || e));
|
||||
live = '获取本次容器日志失败:' + (e?.message || e);
|
||||
}
|
||||
if (!history && !live) return reply.send('(暂无日志)');
|
||||
if (!history) return reply.send(live);
|
||||
return reply.send(
|
||||
`═══ 历史日志(持久化 · 跨重启保留)═══\n${history}\n\n═══ 本次容器日志(实时)═══\n${live || '(本次容器暂无日志)'}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------- 全局日志 / 诊断包(仅管理员)----------
|
||||
// 面板全局运维日志(创建/删除/升级/启停/镜像拉取/错误等跨实例事件),可按时间范围裁剪。
|
||||
app.get('/api/admin/panel-log', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
const since = Date.now() - rangeToMs((req.query as any)?.range);
|
||||
const text = filterSince(readPanelLog(), since).trimEnd();
|
||||
return reply.send(text || '(暂无面板日志)');
|
||||
});
|
||||
|
||||
// 一键导出诊断包(tar.gz):系统信息 + 面板日志 + 各实例容器状态/持久日志/实时日志 + 全部容器清单。
|
||||
// 单实例日志只记录"实例内单次日志",这里把全局 + 全部实例 + 容器层面的信息打包,便于排查
|
||||
// 首个实例创建卡死 / 打开实例黑屏不可用 / 升级失败等问题。range:24h(默认)/7d/30d/1y。
|
||||
app.get('/api/admin/diagnostics', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const range = ((req.query as any)?.range as string) || '24h';
|
||||
if (!DIAG_RANGES[range]) return reply.code(400).send({ error: '时间范围非法(24h/7d/30d/1y)' });
|
||||
const since = Date.now() - rangeToMs(range);
|
||||
try {
|
||||
const buf = await buildDiagnostics(listInstances(), since, { range, 面板版本: CURRENT_VERSION });
|
||||
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
|
||||
reply.header('content-type', 'application/gzip');
|
||||
reply.header('content-disposition', `attachment; filename="woc-diag-${range}-${stamp}.tar.gz"`);
|
||||
appendPanelLog('INFO', `导出诊断包(范围 ${range},${buf.length} 字节)`);
|
||||
return reply.send(buf);
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `导出诊断包失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '生成诊断包失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 数据卷管理(仅管理员):浏览/上传/解压/下载/改名/移动/删除 + 整卷备份/恢复 ----------
|
||||
// 数据卷 = 容器 /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 || '恢复失败' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -653,8 +902,10 @@ async function triggerInstanceWechat(id: string, cmd: 'install' | 'update', repl
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await triggerWechat(inst, cmd);
|
||||
appendPanelLog('INFO', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '下载安装' : '更新'}应用`);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '安装' : '更新'}失败:${e?.message || e}`);
|
||||
return reply.code(500).send({ error: '无法触发安装:' + (e?.message || e) });
|
||||
}
|
||||
}
|
||||
@@ -800,12 +1051,16 @@ for (const pub of listInstances()) {
|
||||
// WOC_INSTANCE_MEM_SOFT_MB soft 阈值;默认 1500
|
||||
// WOC_INSTANCE_MEM_HARD_MB hard 阈值;默认 2500(也兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB)
|
||||
// WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒;默认 300(5 分钟),最小 60;0 关闭整个 watchdog
|
||||
// WOC_WATCHDOG_HEALTH_FAILS VNC 响应性探测:连续无响应几次才重启;默认 0=关闭该探测(仅保留内存自愈)
|
||||
const DEFAULT_SOFT_MB = Math.max(0, Number(process.env.WOC_INSTANCE_MEM_SOFT_MB ?? 1500));
|
||||
const DEFAULT_HARD_MB = Math.max(
|
||||
0,
|
||||
Number(process.env.WOC_INSTANCE_MEM_HARD_MB ?? process.env.WOC_INSTANCE_MEM_LIMIT_MB ?? 2500),
|
||||
);
|
||||
const WATCHDOG_INTERVAL_SEC = Math.max(60, Number(process.env.WOC_WATCHDOG_INTERVAL_SEC ?? 300));
|
||||
// VNC 响应性探测默认关闭(=0)。实测健康实例 ~1ms 响应,但偶发宿主级 CPU/IO 争用(如同机重 docker build)
|
||||
// 会让探测超时被误判为 stall 而重启正常实例,故默认不启用;需要时设为正整数 N(连续 N 次无响应才重启)开启。
|
||||
const HEALTH_FAIL_LIMIT = Math.max(0, Number(process.env.WOC_WATCHDOG_HEALTH_FAILS ?? 0));
|
||||
const WATCHDOG_ENABLED = WATCHDOG_INTERVAL_SEC > 0 && (DEFAULT_SOFT_MB > 0 || DEFAULT_HARD_MB > 0);
|
||||
|
||||
// 单实例生效阈值:per-instance 覆盖优先;为 undefined 则用 env 默认。
|
||||
@@ -826,18 +1081,20 @@ function hasActiveSession(id: string): boolean {
|
||||
|
||||
if (WATCHDOG_ENABLED) {
|
||||
const recovering = new Set<string>(); // 防重入:自愈期间跳过本实例
|
||||
const healthFails = new Map<string, number>(); // id → 连续无响应次数
|
||||
const HEALTH_FAIL_LIMIT = 2; // 连续 N 次无响应才重启,避免误杀刚启动/瞬时抖动
|
||||
const healthFails = new Map<string, number>(); // id → 连续无响应次数(仅 HEALTH_FAIL_LIMIT>0 时启用)
|
||||
|
||||
const recover = async (inst: Instance, reason: string, detail: string) => {
|
||||
recovering.add(inst.id);
|
||||
app.log.warn(`[watchdog] ${inst.containerName} ${detail}`);
|
||||
appendInstanceLog(inst.id, `[看门狗] 自愈重启(${reason}):${detail}`);
|
||||
appendPanelLog('WARN', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈重启(${reason}):${detail}`);
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
await runInstance(inst);
|
||||
healthFails.delete(inst.id);
|
||||
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason})`);
|
||||
} catch (e: any) {
|
||||
appendPanelLog('ERROR', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈失败(${reason}):${e?.message || e}`);
|
||||
app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}): ${e?.message || e}`);
|
||||
} finally {
|
||||
recovering.delete(inst.id);
|
||||
@@ -870,18 +1127,21 @@ if (WATCHDOG_ENABLED) {
|
||||
app.log.info(`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 但用户在使用,延后`);
|
||||
}
|
||||
}
|
||||
// 2) 响应性自愈(新):探测 VNC 是否还能提供页面;连续 N 次无响应 → 重启
|
||||
// 2) 响应性自愈:探测 VNC 是否还能提供页面;连续 N 次无响应 → 重启。
|
||||
// 应对"进程没死、显示在线,但 I/O/服务 stall 读不出 VNC 文件、永远卡在正在连接桌面"。
|
||||
const healthy = await instanceHttpHealthy(inst);
|
||||
if (healthy) {
|
||||
healthFails.delete(inst.id);
|
||||
continue;
|
||||
}
|
||||
const fails = (healthFails.get(inst.id) || 0) + 1;
|
||||
healthFails.set(inst.id, fails);
|
||||
app.log.warn(`[watchdog] ${inst.containerName} VNC 无响应(连续 ${fails}/${HEALTH_FAIL_LIMIT})`);
|
||||
if (fails >= HEALTH_FAIL_LIMIT) {
|
||||
await recover(inst, 'unresponsive', `VNC 连续 ${fails} 次无响应(疑似 I/O/服务 stall),自愈重启`);
|
||||
// 默认关闭(HEALTH_FAIL_LIMIT=0):偶发宿主级争用会误判健康实例为 stall;需要时用 env 开启。
|
||||
if (HEALTH_FAIL_LIMIT > 0) {
|
||||
const healthy = await instanceHttpHealthy(inst);
|
||||
if (healthy) {
|
||||
healthFails.delete(inst.id);
|
||||
continue;
|
||||
}
|
||||
const fails = (healthFails.get(inst.id) || 0) + 1;
|
||||
healthFails.set(inst.id, fails);
|
||||
app.log.warn(`[watchdog] ${inst.containerName} VNC 无响应(连续 ${fails}/${HEALTH_FAIL_LIMIT})`);
|
||||
if (fails >= HEALTH_FAIL_LIMIT) {
|
||||
await recover(inst, 'unresponsive', `VNC 连续 ${fails} 次无响应(疑似 I/O/服务 stall),自愈重启`);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
app.log.warn(`[watchdog] ${pub.id} 检查异常: ${e?.message || e}`);
|
||||
@@ -890,9 +1150,14 @@ if (WATCHDOG_ENABLED) {
|
||||
};
|
||||
setInterval(() => void tick(), WATCHDOG_INTERVAL_SEC * 1000).unref();
|
||||
console.log(
|
||||
`[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s · 含响应性探测`,
|
||||
`[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s · VNC响应性探测=${HEALTH_FAIL_LIMIT > 0 ? `连续${HEALTH_FAIL_LIMIT}次` : '关闭'}`,
|
||||
);
|
||||
}
|
||||
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)`);
|
||||
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)· 版本 ${CURRENT_VERSION}`);
|
||||
appendPanelLog('INFO', `面板启动 · 版本 ${CURRENT_VERSION} · 监听 ${HOST}:${PORT}`);
|
||||
startUpdateChecker(); // 后台检测新版(best-effort,失败静默)
|
||||
// 日志保留期清理:启动后跑一次 + 每 24h 一次,删除超过一年的日志行(unref 不阻止退出)。
|
||||
pruneOldLogs();
|
||||
setInterval(() => pruneOldLogs(), 24 * 60 * 60 * 1000).unref();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// 全局持久化日志(存在面板数据卷 /…/logs/,宿主 ./data-panel 持久保留,跨容器/面板重建不丢)。
|
||||
// 两类日志:
|
||||
// _panel.log 面板级运维事件(实例创建/删除/升级/启停、镜像拉取、错误等跨实例的全局动作)
|
||||
// <实例id>.log 单实例生命周期 + 重启原因 + 重建前容器日志快照
|
||||
// 单文件按大小封顶(超限截掉前半保留最近),并按「一年保留」定期清理过期行。
|
||||
// 本模块不依赖 docker,避免与 docker.ts 形成循环依赖(docker.ts/index.ts 反过来引用本模块)。
|
||||
|
||||
import { appendFileSync, mkdirSync, statSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
// 与 store.ts 的 accounts.json 同目录。fallback 须与 store.ts 一致。
|
||||
export const LOG_DIR = `${dirname(process.env.PANEL_DATA || '/data/panel/accounts.json')}/logs`;
|
||||
const PER_FILE_CAP = 512 * 1024; // 单文件 ~512KB,超限截掉前半保留最近
|
||||
const RETENTION_MS = 365 * 24 * 60 * 60 * 1000; // 日志保留一年,更早的自动清理
|
||||
const PANEL_LOG = `${LOG_DIR}/_panel.log`;
|
||||
const INSTANCE_ID_RE = /^[0-9a-f]{1,32}$/; // 实例 id 为十六进制;校验防路径注入
|
||||
|
||||
export type LogLevel = 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function appendTo(file: string, line: string): void {
|
||||
try {
|
||||
mkdirSync(LOG_DIR, { recursive: true });
|
||||
appendFileSync(file, line.endsWith('\n') ? line : line + '\n');
|
||||
const sz = statSync(file).size;
|
||||
if (sz > PER_FILE_CAP) writeFileSync(file, readFileSync(file).subarray(sz - Math.floor(PER_FILE_CAP / 2)));
|
||||
} catch {
|
||||
/* 写日志失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
function instanceLogPath(id: string): string {
|
||||
return `${LOG_DIR}/${id}.log`;
|
||||
}
|
||||
|
||||
// ---------- 单实例日志 ----------
|
||||
export function appendInstanceLog(id: string, line: string): void {
|
||||
if (!INSTANCE_ID_RE.test(id)) return;
|
||||
appendTo(instanceLogPath(id), `[${nowIso()}] ${line}`);
|
||||
}
|
||||
|
||||
export function readInstanceLog(id: string): string {
|
||||
if (!INSTANCE_ID_RE.test(id)) return '';
|
||||
try {
|
||||
const p = instanceLogPath(id);
|
||||
return existsSync(p) ? readFileSync(p, 'utf8') : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 实例彻底删除(连数据卷一并清除)时,顺手删掉它的持久日志文件,避免遗留孤儿。
|
||||
export function deleteInstanceLog(id: string): void {
|
||||
if (!INSTANCE_ID_RE.test(id)) return;
|
||||
try {
|
||||
rmSync(instanceLogPath(id), { force: true });
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 面板级全局日志 ----------
|
||||
// 同时写入持久文件并回显 stdout(docker logs woc-panel 仍可见)。运维动作统一走这里,便于诊断包汇总。
|
||||
export function appendPanelLog(level: LogLevel, message: string): void {
|
||||
appendTo(PANEL_LOG, `[${nowIso()}] [${level}] ${message}`);
|
||||
const c = level === 'ERROR' ? console.error : level === 'WARN' ? console.warn : console.log;
|
||||
c(`[panel] ${message}`);
|
||||
}
|
||||
|
||||
export function readPanelLog(): string {
|
||||
try {
|
||||
return existsSync(PANEL_LOG) ? readFileSync(PANEL_LOG, 'utf8') : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 时间裁剪 / 保留期清理 ----------
|
||||
// 保留行首 [ISO时间] >= sinceMs 的行;无法解析时间戳的行跟随上一条的保留状态(多行块整体保留)。
|
||||
export function filterSince(text: string, sinceMs: number): string {
|
||||
if (!text) return '';
|
||||
const out: string[] = [];
|
||||
let keeping = false;
|
||||
for (const ln of text.split('\n')) {
|
||||
const m = /^\[(\d{4}-\d\d-\d\dT[\d:.]+Z)\]/.exec(ln);
|
||||
if (m) {
|
||||
const t = Date.parse(m[1]);
|
||||
if (Number.isFinite(t)) keeping = t >= sinceMs;
|
||||
}
|
||||
if (keeping) out.push(ln);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// 清理所有日志文件中早于一年的行;整文件均过期则删除。每日定时调用。
|
||||
export function pruneOldLogs(): void {
|
||||
const cutoff = Date.now() - RETENTION_MS;
|
||||
try {
|
||||
if (!existsSync(LOG_DIR)) return;
|
||||
for (const f of readdirSync(LOG_DIR)) {
|
||||
if (!f.endsWith('.log')) continue;
|
||||
const p = `${LOG_DIR}/${f}`;
|
||||
try {
|
||||
const kept = filterSince(readFileSync(p, 'utf8'), cutoff);
|
||||
if (kept.trim()) writeFileSync(p, kept.endsWith('\n') ? kept : kept + '\n');
|
||||
else rmSync(p, { force: true });
|
||||
} catch {
|
||||
/* 单文件失败不影响其它 */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// 诊断包可选时间范围(→ 毫秒)。默认 24h。
|
||||
export const DIAG_RANGES: Record<string, number> = {
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||
'1y': 365 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
export function rangeToMs(range: string | undefined): number {
|
||||
return DIAG_RANGES[range ?? '24h'] ?? DIAG_RANGES['24h'];
|
||||
}
|
||||
@@ -25,15 +25,33 @@ export interface User {
|
||||
// 初始默认管理员密码;管理员仍在用它时强烈提示改密。
|
||||
const DEFAULT_ADMIN_PASSWORD = 'wechat';
|
||||
|
||||
// v1.2.0:实例可承载多种应用(不止微信)。同一镜像运行时按 appType 安装/启动对应应用。
|
||||
export type AppType = 'wechat' | 'telegram' | 'chromium' | 'custom';
|
||||
export const APP_TYPES: AppType[] = ['wechat', 'telegram', 'chromium', 'custom'];
|
||||
export const APP_LABELS: Record<AppType, string> = {
|
||||
wechat: '微信',
|
||||
telegram: 'Telegram',
|
||||
chromium: '浏览器',
|
||||
custom: '自定义应用',
|
||||
};
|
||||
// 向后兼容:v1.2.0 之前创建的实例没有 appType 字段,一律视为微信。
|
||||
export function instanceAppType(i: Instance): AppType {
|
||||
return i.appType && APP_TYPES.includes(i.appType) ? i.appType : 'wechat';
|
||||
}
|
||||
|
||||
export interface Instance {
|
||||
id: string; // 短 id,用于容器/卷命名
|
||||
name: string; // 显示名
|
||||
appType?: AppType; // 承载的应用类型;缺省(老实例)= wechat(见 instanceAppType)
|
||||
icon?: string; // 自定义图标:data: 图片(base64) 或 builtin:<key>;缺省按 appType 取默认图标
|
||||
containerName: string; // woc-wx-<id>
|
||||
volumeName: string; // woc-data-<id>
|
||||
kasmUser: string; // 随机生成,服务端注入反代,永不下发前端
|
||||
kasmPassword: string;
|
||||
createdAt: string;
|
||||
createdBy: string; // userId
|
||||
// 自定义应用(appType=custom):用户上传的安装包信息,autostart 据此启动。
|
||||
customLaunch?: string; // 启动命令(容器内绝对路径或命令)
|
||||
// 自愈 watchdog 的"安全阀",per-instance 覆盖全局默认;缺省时使用 env / 内置默认。
|
||||
// soft:内存超此值时,仅在"当前没有用户在远程会话"才主动重启(柔和自愈);
|
||||
// hard:内存超此值时,无论是否有人在会话都重启(防止 OOM 拖垮宿主)。
|
||||
@@ -198,6 +216,8 @@ export function publicInstance(i: Instance) {
|
||||
return {
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
appType: instanceAppType(i), // 老实例无字段时回退 wechat
|
||||
icon: i.icon,
|
||||
createdAt: i.createdAt,
|
||||
createdBy: i.createdBy,
|
||||
memSoftLimitMB: i.memSoftLimitMB,
|
||||
@@ -263,7 +283,9 @@ export function createInstance(
|
||||
createdBy: string,
|
||||
allowedUserIds: string[] = [],
|
||||
reuseVolumeName?: string,
|
||||
appType: AppType = 'wechat',
|
||||
) {
|
||||
const type: AppType = APP_TYPES.includes(appType) ? appType : 'wechat';
|
||||
let id = randomBytes(5).toString('hex'); // 10 hex chars
|
||||
let volumeName = `woc-data-${id}`;
|
||||
if (reuseVolumeName) {
|
||||
@@ -275,7 +297,8 @@ export function createInstance(
|
||||
}
|
||||
const inst: Instance = {
|
||||
id,
|
||||
name: name.trim() || `微信-${id.slice(0, 4)}`,
|
||||
name: name.trim() || `${APP_LABELS[type]}-${id.slice(0, 4)}`,
|
||||
appType: type,
|
||||
containerName: `woc-wx-${id}`,
|
||||
volumeName,
|
||||
kasmUser: 'woc',
|
||||
@@ -307,6 +330,23 @@ export function renameInstance(id: string, name: string) {
|
||||
return publicInstance(inst);
|
||||
}
|
||||
|
||||
// 设置/清除实例自定义图标。传空 → 恢复按 appType 的默认图标。
|
||||
// 仅允许 builtin:<key> 或 data:image/...(裁剪后约 128px,限 ~225KB,防滥用撑大 accounts.json)。
|
||||
export function setInstanceIcon(id: string, icon: string | null) {
|
||||
const inst = findInstance(id);
|
||||
if (!inst) throw new Error('实例不存在');
|
||||
const v = (icon ?? '').trim();
|
||||
if (!v) {
|
||||
delete inst.icon;
|
||||
} else if (/^builtin:[a-z0-9_-]{1,32}$/.test(v) || (v.startsWith('data:image/') && v.length <= 300000)) {
|
||||
inst.icon = v;
|
||||
} else {
|
||||
throw new Error('图标格式不合法或过大');
|
||||
}
|
||||
persist();
|
||||
return publicInstance(inst);
|
||||
}
|
||||
|
||||
export function removeInstance(id: string) {
|
||||
const inst = findInstance(id);
|
||||
if (!inst) throw new Error('实例不存在');
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// 面板版本与更新检测。
|
||||
// 构建时把版本号烤进镜像(Dockerfile: ARG/ENV WOC_VERSION,由 CI 用 git tag 注入,本地构建为 dev);
|
||||
// 运行时查询 Docker Hub 与 GHCR 上 woc-panel 的最新语义化标签,比对后给前端「有新版」红点。
|
||||
// 全程 best-effort:离线 / 被墙 / 私有源拉取失败时不报错、不打扰,仅不显示红点(记 error 供「上次检查」提示)。
|
||||
|
||||
export const CURRENT_VERSION = (process.env.WOC_VERSION || 'dev').trim();
|
||||
|
||||
export interface VersionInfo {
|
||||
current: string; // 当前构建版本(如 v1.2.0 / dev)
|
||||
latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null
|
||||
hasUpdate: boolean; // 当前能解析为语义化版本且 latest > current 时为 true
|
||||
checkedAt: number; // 上次检查时间戳(ms);0 = 尚未检查
|
||||
source: string | null; // 数据来源:dockerhub / ghcr / dockerhub+ghcr
|
||||
error: string | null; // 检查失败原因(两个源都拉不到时)
|
||||
}
|
||||
|
||||
// 镜像命名空间:从 WOC_WECHAT_IMAGE 推断(面板与实例镜像同账号)。
|
||||
// 例 docker.io/gloridust/wechat-on-cloud:latest → gloridust;ghcr.io/gloridust/... 同理。
|
||||
function imageOwner(): string {
|
||||
const img = (process.env.WOC_WECHAT_IMAGE || 'docker.io/gloridust/wechat-on-cloud').split('@')[0];
|
||||
const segs = img.split('/'); // [registry?, owner, image[:tag]]
|
||||
return segs.length >= 2 ? segs[segs.length - 2] : 'gloridust';
|
||||
}
|
||||
const PANEL_REPO = process.env.WOC_PANEL_REPO || 'woc-panel';
|
||||
|
||||
function parseSemver(s: string): [number, number, number] | null {
|
||||
const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(s.trim());
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
}
|
||||
function cmpSemver(a: [number, number, number], b: [number, number, number]): number {
|
||||
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
||||
}
|
||||
// 从一堆标签里挑出最大的 x.y.z(忽略 latest / x / x.y 等非完整语义化标签)。
|
||||
function maxSemver(tags: string[]): string | null {
|
||||
let best: [number, number, number] | null = null;
|
||||
for (const t of tags) {
|
||||
const v = parseSemver(t);
|
||||
if (v && (!best || cmpSemver(v, best) > 0)) best = v;
|
||||
}
|
||||
return best ? `${best[0]}.${best[1]}.${best[2]}` : null;
|
||||
}
|
||||
|
||||
async function getJson(url: string, headers: Record<string, string>, timeoutMs: number): Promise<any> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { headers: { 'user-agent': 'woc-panel-update-check', ...headers }, signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Docker Hub 公共 API:免鉴权列标签。
|
||||
async function dockerHubTags(owner: string): Promise<string[]> {
|
||||
const d = await getJson(`https://hub.docker.com/v2/repositories/${owner}/${PANEL_REPO}/tags?page_size=100`, {}, 8000);
|
||||
return Array.isArray(d?.results) ? d.results.map((r: any) => String(r?.name || '')) : [];
|
||||
}
|
||||
// GHCR 公共镜像:先取匿名 pull token,再走 registry v2 tags/list。
|
||||
async function ghcrTags(owner: string): Promise<string[]> {
|
||||
const tok = await getJson(`https://ghcr.io/token?scope=repository:${owner}/${PANEL_REPO}:pull&service=ghcr.io`, {}, 8000);
|
||||
const d = await getJson(`https://ghcr.io/v2/${owner}/${PANEL_REPO}/tags/list`, { authorization: `Bearer ${tok?.token || ''}` }, 8000);
|
||||
return Array.isArray(d?.tags) ? d.tags.map((t: any) => String(t)) : [];
|
||||
}
|
||||
|
||||
let cache: VersionInfo = { current: CURRENT_VERSION, latest: null, hasUpdate: false, checkedAt: 0, source: null, error: null };
|
||||
let inflight: Promise<VersionInfo> | null = null;
|
||||
|
||||
export function versionInfo(): VersionInfo {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// 查询两个仓库(并行、互不阻塞),取全局最大语义化版本与当前版本比对。失败静默写入 error。
|
||||
export function checkForUpdate(): Promise<VersionInfo> {
|
||||
if (inflight) return inflight; // 合并并发请求,避免重复外呼
|
||||
inflight = (async () => {
|
||||
const owner = imageOwner();
|
||||
const sources: string[] = [];
|
||||
const tags: string[] = [];
|
||||
const [hub, ghcr] = await Promise.allSettled([dockerHubTags(owner), ghcrTags(owner)]);
|
||||
if (hub.status === 'fulfilled' && hub.value.length) {
|
||||
tags.push(...hub.value);
|
||||
sources.push('dockerhub');
|
||||
}
|
||||
if (ghcr.status === 'fulfilled' && ghcr.value.length) {
|
||||
tags.push(...ghcr.value);
|
||||
sources.push('ghcr');
|
||||
}
|
||||
const latestBare = maxSemver(tags);
|
||||
const cur = parseSemver(CURRENT_VERSION);
|
||||
const latestV = latestBare ? parseSemver(latestBare) : null;
|
||||
const hasUpdate = !!(latestV && cur && cmpSemver(latestV, cur) > 0);
|
||||
cache = {
|
||||
current: CURRENT_VERSION,
|
||||
latest: latestBare ? `v${latestBare}` : null,
|
||||
hasUpdate,
|
||||
checkedAt: Date.now(),
|
||||
source: sources.join('+') || null,
|
||||
error: tags.length ? null : '无法连接镜像仓库(Docker Hub / GHCR)',
|
||||
};
|
||||
return cache;
|
||||
})().finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
// 尚未检查过则触发一次后台检查(不等待)。GET /api/version 调用,保证刚启动也能尽快填上缓存。
|
||||
export function ensureChecked(): void {
|
||||
if (!cache.checkedAt && !inflight) void checkForUpdate().catch(() => {});
|
||||
}
|
||||
|
||||
// 启动后延迟首检(让监听就绪)+ 每 6 小时复检;定时器 unref,不阻止进程退出。
|
||||
export function startUpdateChecker(): void {
|
||||
setTimeout(() => void checkForUpdate().catch(() => {}), 4_000).unref();
|
||||
setInterval(() => void checkForUpdate().catch(() => {}), 6 * 60 * 60 * 1000).unref();
|
||||
}
|
||||
Generated
+20
@@ -8,6 +8,7 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -4515,6 +4516,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -4735,6 +4742,19 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.0.2.tgz",
|
||||
"integrity": "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { AppType } from './api';
|
||||
|
||||
// 实例图标。支持三种来源(优先级从高到低):
|
||||
// 1) 自定义上传/裁剪的图片 → inst.icon = "data:image/...;base64,..."
|
||||
// 2) 内置图标 → inst.icon = "builtin:<key>"(如 builtin:xiaohongshu)
|
||||
// 3) 缺省:按 appType 给默认图标(微信 / Chromium / Telegram / 通用)
|
||||
// 内置图标用简洁 SVG(彩色圆角块 + 白色字形),风格统一、无需联网抓取。后续可往 BUILTIN 里加更多平台。
|
||||
|
||||
type Glyph = { bg: string; el: JSX.Element };
|
||||
const G = (bg: string, el: JSX.Element): Glyph => ({ bg, el });
|
||||
|
||||
// 白色字形(viewBox 0 0 48 48,置于彩色圆角块上)
|
||||
const chat = (
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M19 12c-6.6 0-12 4.2-12 9.5 0 3 1.8 5.7 4.6 7.4l-1.1 3.9 4.4-2.3c1.3.3 2.7.5 4.1.5 6.6 0 12-4.2 12-9.5S25.6 12 19 12zm-4 8.2a1.6 1.6 0 110-3.2 1.6 1.6 0 010 3.2zm8 0a1.6 1.6 0 110-3.2 1.6 1.6 0 010 3.2z"
|
||||
/>
|
||||
);
|
||||
const globe = (
|
||||
<g fill="none" stroke="#fff" strokeWidth="2.4">
|
||||
<circle cx="24" cy="24" r="13" />
|
||||
<ellipse cx="24" cy="24" rx="5.5" ry="13" />
|
||||
<path d="M11.5 20h25M11.5 28h25" />
|
||||
</g>
|
||||
);
|
||||
const plane = <path fill="#fff" d="M35 14L13 23.2l6.1 2.2 2.3 7.2 3.3-3.9 5.6 4.1L35 14zm-12.4 12.6l9-7.2-6.7 8.1-.1 3.6-2.2-4.5z" />;
|
||||
const dots = (
|
||||
<g fill="#fff">
|
||||
<circle cx="16" cy="24" r="2.6" />
|
||||
<circle cx="24" cy="24" r="2.6" />
|
||||
<circle cx="32" cy="24" r="2.6" />
|
||||
</g>
|
||||
);
|
||||
|
||||
// 文字字形(品牌色块 + 白字),用于没有简单标志的平台
|
||||
const txt = (s: string, fs = 22) => (
|
||||
<text x="24" y="25" fill="#fff" fontSize={fs} fontWeight="700" textAnchor="middle" dominantBaseline="central" fontFamily="-apple-system, system-ui, sans-serif">
|
||||
{s}
|
||||
</text>
|
||||
);
|
||||
const play = <path fill="#fff" d="M20 17l12 7-12 7z" />;
|
||||
|
||||
// key → 字形。default-by-appType 与「内置图标选择器」共用同一张表。
|
||||
export const BUILTIN_ICONS: Record<string, Glyph> = {
|
||||
wechat: G('#07c160', chat),
|
||||
chromium: G('#4285f4', globe),
|
||||
telegram: G('#2aabee', plane),
|
||||
xiaohongshu: G('#ff2442', txt('书')),
|
||||
douyin: G('#111111', txt('抖')),
|
||||
bilibili: G('#fb7299', txt('B', 26)),
|
||||
weibo: G('#e6162d', txt('微')),
|
||||
zhihu: G('#0084ff', txt('知')),
|
||||
youtube: G('#ff0000', play),
|
||||
globe: G('#5b8def', globe),
|
||||
app: G('#8a9099', dots),
|
||||
};
|
||||
// 「内置图标」选择器里展示的可选项(顺序即展示顺序)
|
||||
export const ICON_CHOICES: { key: string; label: string }[] = [
|
||||
{ key: 'wechat', label: '微信' },
|
||||
{ key: 'chromium', label: 'Chromium' },
|
||||
{ key: 'telegram', label: 'Telegram' },
|
||||
{ key: 'xiaohongshu', label: '小红书' },
|
||||
{ key: 'douyin', label: '抖音' },
|
||||
{ key: 'bilibili', label: 'B站' },
|
||||
{ key: 'weibo', label: '微博' },
|
||||
{ key: 'zhihu', label: '知乎' },
|
||||
{ key: 'youtube', label: 'YouTube' },
|
||||
{ key: 'globe', label: '通用' },
|
||||
];
|
||||
const DEFAULT_BY_APP: Record<AppType, string> = {
|
||||
wechat: 'wechat',
|
||||
chromium: 'chromium',
|
||||
telegram: 'telegram',
|
||||
custom: 'app',
|
||||
};
|
||||
|
||||
export function InstanceIcon({
|
||||
icon,
|
||||
appType,
|
||||
size = 36,
|
||||
radius = 12,
|
||||
}: {
|
||||
icon?: string;
|
||||
appType?: AppType;
|
||||
size?: number;
|
||||
radius?: number;
|
||||
}) {
|
||||
// 1) 自定义图片
|
||||
if (icon && icon.startsWith('data:')) {
|
||||
return <img src={icon} width={size} height={size} alt="" style={{ borderRadius: radius, objectFit: 'cover', display: 'block' }} />;
|
||||
}
|
||||
// 2) 内置 / 3) 默认
|
||||
const key = icon && icon.startsWith('builtin:') ? icon.slice(8) : DEFAULT_BY_APP[appType ?? 'wechat'] ?? 'app';
|
||||
const g = BUILTIN_ICONS[key] ?? BUILTIN_ICONS.app;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 48 48" style={{ display: 'block' }} aria-hidden="true">
|
||||
<rect width="48" height="48" rx={(radius / size) * 48} fill={g.bg} />
|
||||
{g.el}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+41
-10
@@ -2,7 +2,8 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from './auth';
|
||||
import { useUI, PasswordInput } from './ui';
|
||||
import { api, type InstanceWithStatus } from './api';
|
||||
import { api, appProfile, type InstanceWithStatus } from './api';
|
||||
import { InstanceIcon } from './AppIcon';
|
||||
import InstanceView from './pages/Desktop';
|
||||
import Admin from './pages/Admin';
|
||||
|
||||
@@ -156,6 +157,17 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const go = (p: string) => nav(p);
|
||||
|
||||
// 有新版时在「管理」入口点个红点(仅管理员,因为升级面板需管理员在宿主操作)。
|
||||
// 依赖 loc.pathname:导航时复查一次(服务端有缓存、开销极小),保证刚启动时首检完成后红点能及时出现。
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
api
|
||||
.getVersion()
|
||||
.then((v) => setHasUpdate(!!v.hasUpdate))
|
||||
.catch(() => {});
|
||||
}, [isAdmin, loc.pathname]);
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sb-top">
|
||||
@@ -175,7 +187,7 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{!collapsed && <div className="sb-section">微信实例</div>}
|
||||
{!collapsed && <div className="sb-section">实例</div>}
|
||||
<div className="sb-list">
|
||||
{instances.length === 0 && !collapsed && <div className="sb-empty">暂无可用实例</div>}
|
||||
{instances.map((inst) => {
|
||||
@@ -184,7 +196,7 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
|
||||
return (
|
||||
<button key={inst.id} className={'sb-item sb-inst' + (on ? ' on' : '')} onClick={() => go(`/i/${inst.id}`)} title={inst.name}>
|
||||
<span className="sb-avatar">
|
||||
{inst.name.slice(0, 1)}
|
||||
<InstanceIcon icon={inst.icon} appType={inst.appType} size={34} radius={10} />
|
||||
<span className={'sb-dot ' + st.cls} />
|
||||
</span>
|
||||
{!collapsed && <span className="sb-label">{inst.name}</span>}
|
||||
@@ -195,9 +207,17 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
|
||||
</div>
|
||||
|
||||
<div className="sb-footer">
|
||||
<button className={'sb-item' + (loc.pathname === '/admin' ? ' on' : '')} onClick={() => go('/admin')} title={isAdmin ? '管理' : '设置'}>
|
||||
<span className="sb-ic">{Icon.gear}</span>
|
||||
<button
|
||||
className={'sb-item' + (loc.pathname === '/admin' ? ' on' : '')}
|
||||
onClick={() => go('/admin')}
|
||||
title={isAdmin && hasUpdate ? '管理 · 有新版本可用' : isAdmin ? '管理' : '设置'}
|
||||
>
|
||||
<span className="sb-ic">
|
||||
{Icon.gear}
|
||||
{isAdmin && hasUpdate && <span className="sb-updot" />}
|
||||
</span>
|
||||
{!collapsed && <span className="sb-label">{isAdmin ? '管理' : '设置'}</span>}
|
||||
{!collapsed && isAdmin && hasUpdate && <span className="sb-updot-text">新版</span>}
|
||||
</button>
|
||||
<button
|
||||
className="sb-item"
|
||||
@@ -252,7 +272,7 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
|
||||
)}
|
||||
|
||||
<div className="section-row">
|
||||
<span className="section-title">我的微信实例</span>
|
||||
<span className="section-title">我的实例</span>
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => nav('/admin')}>
|
||||
管理 ›
|
||||
@@ -265,19 +285,30 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
|
||||
<div className="empty-blob">
|
||||
<img src="/favicon.svg" alt="" />
|
||||
</div>
|
||||
<div className="empty-title">还没有微信实例</div>
|
||||
<div className="empty-sub">{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}</div>
|
||||
<div className="empty-title">还没有实例</div>
|
||||
<div className="empty-sub">{isAdmin ? '去「管理」新建一个实例' : '请联系管理员为你分配实例'}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inst-grid">
|
||||
{instances.map((inst) => {
|
||||
const st = statusOf(inst);
|
||||
const prof = appProfile(inst.appType);
|
||||
const meta = inst.wechat.installed
|
||||
? `${prof.label} ${inst.wechat.version || ''}`.trim()
|
||||
: inst.runtime === 'running' && prof.needsInstall
|
||||
? `待下载安装${prof.label}`
|
||||
: '';
|
||||
return (
|
||||
<button key={inst.id} className="home-card" onClick={() => nav(`/i/${inst.id}`)}>
|
||||
<span className="home-card-av">{inst.name.slice(0, 1)}</span>
|
||||
<span className="home-card-av">
|
||||
<InstanceIcon icon={inst.icon} appType={inst.appType} size={42} radius={12} />
|
||||
</span>
|
||||
<span className="home-card-main">
|
||||
<span className="home-card-name">{inst.name}</span>
|
||||
<span className={'home-card-st ' + st.cls}>● {st.text}</span>
|
||||
<span className="home-card-meta">
|
||||
<span className={'home-card-st ' + st.cls}>● {st.text}</span>
|
||||
{meta && <span className="home-card-ver">{meta}</span>}
|
||||
</span>
|
||||
</span>
|
||||
<span className="enter-arrow">›</span>
|
||||
</button>
|
||||
|
||||
+87
-2
@@ -19,9 +19,36 @@ export interface WechatStatus {
|
||||
}
|
||||
|
||||
export type RuntimeState = 'running' | 'stopped' | 'missing';
|
||||
export type AppType = 'wechat' | 'telegram' | 'chromium' | 'custom';
|
||||
export const APP_LABELS: Record<AppType, string> = {
|
||||
wechat: '微信',
|
||||
telegram: 'Telegram',
|
||||
chromium: 'Chromium',
|
||||
custom: '自定义应用',
|
||||
};
|
||||
|
||||
// 各应用的 UI 画像,供卡片/桌面页按类型显示正确文案(避免到处写死「微信」)。
|
||||
// needsInstall: 是否需要运行时下载安装(微信/Telegram 是;Chromium 已烤进镜像、即创建即就绪)。
|
||||
// enterHint: 首次进入实例的提示。
|
||||
// updateLabel: 「管理」菜单里的更新按钮文案(needsInstall=false 时不显示)。
|
||||
export interface AppProfile {
|
||||
label: string;
|
||||
needsInstall: boolean;
|
||||
enterHint: string;
|
||||
updateLabel: string;
|
||||
}
|
||||
export const APP_PROFILES: Record<AppType, AppProfile> = {
|
||||
wechat: { label: '微信', needsInstall: true, enterHint: '首次进入请扫码登录微信', updateLabel: '更新微信' },
|
||||
telegram: { label: 'Telegram', needsInstall: true, enterHint: '首次进入请登录 Telegram', updateLabel: '更新 Telegram' },
|
||||
chromium: { label: 'Chromium', needsInstall: false, enterHint: '浏览器已就绪,直接使用即可', updateLabel: '' },
|
||||
custom: { label: '自定义应用', needsInstall: true, enterHint: '', updateLabel: '更新' },
|
||||
};
|
||||
export const appProfile = (t?: AppType): AppProfile => APP_PROFILES[t ?? 'wechat'] ?? APP_PROFILES.wechat;
|
||||
export interface PanelInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
appType?: AppType; // 缺省(老实例)= wechat
|
||||
icon?: string; // 自定义图标:data: 图片 / builtin:<key>;缺省按 appType 取默认图标
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
memSoftLimitMB?: number;
|
||||
@@ -41,6 +68,35 @@ export interface InstanceWithStatus extends PanelInstance {
|
||||
wechat: WechatStatus;
|
||||
}
|
||||
|
||||
export interface VolEntry {
|
||||
name: string;
|
||||
type: 'dir' | 'file' | 'link' | 'other';
|
||||
size: number;
|
||||
mtime: number; // epoch ms
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
current: string; // 当前构建版本(如 v1.2.0 / dev)
|
||||
latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null
|
||||
hasUpdate: boolean; // 有更高的语义化版本可用
|
||||
checkedAt: number; // 上次检查时间戳(ms);0=尚未检查
|
||||
source: string | null; // 数据来源:dockerhub / ghcr / dockerhub+ghcr
|
||||
error: string | null; // 检查失败原因
|
||||
}
|
||||
|
||||
// 原始二进制上传(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;
|
||||
@@ -69,6 +125,10 @@ export const api = {
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
req('/api/account/password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }) }),
|
||||
|
||||
// 版本与更新检测
|
||||
getVersion: () => req<VersionInfo>('/api/version'),
|
||||
checkUpdate: () => req<VersionInfo>('/api/admin/version/check', { method: 'POST' }),
|
||||
|
||||
// 子账号
|
||||
listUsers: () => req<{ users: PanelUser[] }>('/api/admin/users'),
|
||||
createUser: (username: string, password: string, allowedInstances: string[] = []) =>
|
||||
@@ -86,10 +146,10 @@ export const api = {
|
||||
|
||||
// 微信实例
|
||||
listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'),
|
||||
createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string) =>
|
||||
createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string, appType: AppType = 'wechat') =>
|
||||
req<{ instance: PanelInstance }>('/api/admin/instances', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined }),
|
||||
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined, appType }),
|
||||
}),
|
||||
regenMachineId: (id: string) =>
|
||||
req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }),
|
||||
@@ -108,6 +168,8 @@ export const api = {
|
||||
req<{ containers: { id: string; name: string; status: string; volumeName?: string }[] }>('/api/admin/orphan-containers'),
|
||||
deleteOrphanContainer: (idOrName: string) =>
|
||||
req(`/api/admin/orphan-containers/${encodeURIComponent(idOrName)}`, { method: 'DELETE' }),
|
||||
setInstanceIcon: (id: string, icon: string | null) =>
|
||||
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/icon`, { method: 'POST', body: JSON.stringify({ icon }) }),
|
||||
renameInstance: (id: string, name: string) =>
|
||||
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
deleteInstance: (id: string, purge = false) =>
|
||||
@@ -122,6 +184,9 @@ export const api = {
|
||||
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`,
|
||||
// 全局日志 / 诊断包(范围 24h/7d/30d/1y)
|
||||
diagnosticsUrl: (range: string) => `/api/admin/diagnostics?range=${encodeURIComponent(range)}`,
|
||||
panelLogUrl: (range: string) => `/api/admin/panel-log?range=${encodeURIComponent(range)}`,
|
||||
|
||||
// 文件中转
|
||||
listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`),
|
||||
@@ -138,9 +203,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 }) }),
|
||||
};
|
||||
|
||||
+758
-49
@@ -1,17 +1,224 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, type PanelUser, type InstanceWithStatus } from '../api';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { api, APP_LABELS, appProfile, type PanelUser, type InstanceWithStatus, type VolEntry, type AppType, type VersionInfo } from '../api';
|
||||
import { InstanceIcon, ICON_CHOICES } from '../AppIcon';
|
||||
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" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// 折叠菜单的展开箭头
|
||||
const CaretIcon = (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</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 (
|
||||
<div className="empty-state">
|
||||
<div className="empty-blob">{icon}</div>
|
||||
<div className="empty-title">{title}</div>
|
||||
{sub && <div className="empty-sub">{sub}</div>}
|
||||
{action && <div className="empty-action">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RELEASES_URL = 'https://github.com/Gloridust/WechatOnCloud/releases';
|
||||
|
||||
const DIAG_RANGE_OPTIONS = [
|
||||
{ key: '24h', label: '24 小时' },
|
||||
{ key: '7d', label: '7 天' },
|
||||
{ key: '30d', label: '30 天' },
|
||||
{ key: '1y', label: '1 年' },
|
||||
];
|
||||
|
||||
// 「诊断与日志」(仅管理员):单实例「日志」只记录该实例日志;这里一键打包全局——系统信息 +
|
||||
// 面板运维日志 + 全部实例容器状态/日志 + 容器清单,便于排查部署/创建卡死/黑屏不可用等问题。
|
||||
function DiagnosticsSection() {
|
||||
const [range, setRange] = useState('24h');
|
||||
const exportBundle = () => {
|
||||
// tar.gz 带 content-disposition: attachment,用隐藏 <a> 触发下载(带同源 cookie),不离开页面。
|
||||
const a = document.createElement('a');
|
||||
a.href = api.diagnosticsUrl(range);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="section-row" style={{ marginTop: 22 }}>
|
||||
<span className="section-title">诊断与日志</span>
|
||||
</div>
|
||||
<div className="settings-block">
|
||||
<p className="s-desc">打包系统/Docker 信息 + 面板全局日志 + 各实例容器状态与日志 + 容器清单,用于排查部署、创建卡死、黑屏不可用、升级失败等问题。</p>
|
||||
<div className="s-field">
|
||||
<span className="field-label">时间范围</span>
|
||||
<div className="chip-row">
|
||||
{DIAG_RANGE_OPTIONS.map((r) => (
|
||||
<button key={r.key} className={'chip chip-toggle' + (range === r.key ? ' on' : '')} onClick={() => setRange(r.key)}>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-actions">
|
||||
<button className="btn btn-primary s-btn" onClick={exportBundle}>
|
||||
导出诊断包
|
||||
</button>
|
||||
<a className="btn-text" href={api.panelLogUrl(range)} target="_blank" rel="noreferrer">
|
||||
查看面板日志 ›
|
||||
</a>
|
||||
</div>
|
||||
<p className="s-foot">导出当前选定范围内的日志(.tar.gz)。超过一年的日志自动清理;诊断包不含密码 / 密钥等敏感信息。</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 「关于」:显示真实构建版本号 + 检测新版(后台已每 6h 查 Docker Hub/GHCR;这里读缓存并可手动重查)。
|
||||
function AboutSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { toast } = useUI();
|
||||
const [info, setInfo] = useState<VersionInfo | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getVersion().then(setInfo).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 当前版本是否为正式发布版(语义化 vX.Y.Z)。dev / dev-<sha> 等本地构建无法与发布版比较,
|
||||
// 既不显示「已是最新」也不显示红点,只把最新发布版作为信息展示。
|
||||
const isRelease = !!info && /^v?\d+\.\d+\.\d+$/.test(info.current);
|
||||
|
||||
const check = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const r = await api.checkUpdate();
|
||||
setInfo(r);
|
||||
const rel = /^v?\d+\.\d+\.\d+$/.test(r.current);
|
||||
if (r.error) toast('检查失败:' + r.error, 'error');
|
||||
else if (r.hasUpdate) toast(`发现新版本 ${r.latest}`, 'ok');
|
||||
else if (!rel) toast(`最新发布 ${r.latest ?? '未知'}(当前为开发版)`, 'ok');
|
||||
else toast('已是最新版本', 'ok');
|
||||
} catch (e: any) {
|
||||
toast(e.message || '检查失败', 'error');
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-row" style={{ marginTop: 22 }}>
|
||||
<span className="section-title">关于</span>
|
||||
</div>
|
||||
<div className="settings-block">
|
||||
<div className="s-title-row">
|
||||
<span className="s-app">云微 · WechatOnCloud</span>
|
||||
{info?.hasUpdate ? <span className="tag tag-warn">有新版</span> : info && !isRelease ? <span className="tag">开发版</span> : null}
|
||||
</div>
|
||||
<p className="s-line">
|
||||
当前版本 <b>{info?.current ?? '…'}</b>
|
||||
{info?.hasUpdate && info.latest && (
|
||||
<>
|
||||
{' · '}最新 <b>{info.latest}</b>
|
||||
</>
|
||||
)}
|
||||
{isRelease && info && !info.hasUpdate && info.latest && !info.error && <>{' · '}已是最新</>}
|
||||
{!isRelease && info?.latest && !info.error && (
|
||||
<>
|
||||
{' · '}最新发布 <b>{info.latest}</b>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{info?.hasUpdate && (
|
||||
<div className="ver-hint">
|
||||
在宿主执行 <code>docker compose pull && docker compose up -d</code> 升级面板;各实例镜像可在「管理 → 升级」单独更新。
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-actions">
|
||||
{info?.hasUpdate && (
|
||||
<a className="btn btn-primary s-btn" href={RELEASES_URL + '/latest'} target="_blank" rel="noreferrer">
|
||||
查看新版
|
||||
</a>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button className="btn-text" disabled={checking} onClick={check}>
|
||||
{checking ? '检查中…' : '检查更新'}
|
||||
</button>
|
||||
)}
|
||||
<a className="btn-text" href={RELEASES_URL} target="_blank" rel="noreferrer">
|
||||
发布日志 ›
|
||||
</a>
|
||||
</div>
|
||||
{info && (
|
||||
<p className="s-foot">
|
||||
{info.checkedAt ? `上次检查 ${fmtDate(info.checkedAt)}` : '尚未检查'}
|
||||
{info.source && ` · 来源 ${info.source}`}
|
||||
{info.error && ` · ${info.error}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
|
||||
const nav = useNavigate();
|
||||
const { user } = useAuth();
|
||||
@@ -28,6 +235,8 @@ 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 [iconInst, setIconInst] = useState<InstanceWithStatus | null>(null); // 图标编辑弹窗
|
||||
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
|
||||
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
|
||||
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
|
||||
@@ -204,15 +413,22 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="section-row">
|
||||
<span className="section-title">微信实例</span>
|
||||
<span className="section-title">实例</span>
|
||||
<button className="btn-text" onClick={() => setCreatingInst(true)}>
|
||||
+ 新建实例
|
||||
</button>
|
||||
</div>
|
||||
{instances.length === 0 ? (
|
||||
<div className="list">
|
||||
<div className="muted small" style={{ padding: '14px 16px' }}>暂无实例</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="🖥️"
|
||||
title="还没有实例"
|
||||
sub="新建一个实例(微信 / Chromium 浏览器),进入后即可在浏览器里使用"
|
||||
action={
|
||||
<button className="btn btn-primary" onClick={() => setCreatingInst(true)}>
|
||||
+ 新建实例
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="inst-grid">
|
||||
{instances.map((inst) => (
|
||||
@@ -231,6 +447,8 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
onAssign={() => setAssignInst(inst)}
|
||||
onDelete={() => setDeleteInst(inst)}
|
||||
onSecurity={() => setSecurityInst(inst)}
|
||||
onVolume={() => setVolumeInst(inst)}
|
||||
onIcon={() => setIconInst(inst)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -243,9 +461,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
</button>
|
||||
</div>
|
||||
{subs.length === 0 ? (
|
||||
<div className="list">
|
||||
<div className="muted small" style={{ padding: '14px 16px' }}>暂无子账号</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="👥"
|
||||
title="还没有子账号"
|
||||
sub="子账号是登录这套面板的身份,可按账号分配能访问哪些实例"
|
||||
action={
|
||||
<button className="btn btn-primary" onClick={() => setCreatingUser(true)}>
|
||||
+ 新建子账号
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="inst-grid">
|
||||
{subs.map((u) => (
|
||||
@@ -360,6 +585,9 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && <DiagnosticsSection />}
|
||||
<AboutSection isAdmin={isAdmin} />
|
||||
</main>
|
||||
|
||||
{creatingUser && (
|
||||
@@ -446,6 +674,19 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{volumeInst && (
|
||||
<VolumeManager inst={volumeInst} onClose={() => setVolumeInst(null)} onChanged={load} />
|
||||
)}
|
||||
{iconInst && (
|
||||
<InstanceIconEditor
|
||||
inst={iconInst}
|
||||
onClose={() => setIconInst(null)}
|
||||
onDone={() => {
|
||||
toast('已更新图标', 'ok');
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -780,6 +1021,8 @@ function InstanceAdminCard({
|
||||
onAssign,
|
||||
onDelete,
|
||||
onSecurity,
|
||||
onVolume,
|
||||
onIcon,
|
||||
}: {
|
||||
inst: InstanceWithStatus;
|
||||
userCount: number;
|
||||
@@ -794,12 +1037,27 @@ function InstanceAdminCard({
|
||||
onAssign: () => void;
|
||||
onDelete: () => void;
|
||||
onSecurity: () => void;
|
||||
onVolume: () => void;
|
||||
onIcon: () => 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 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]);
|
||||
|
||||
const profile = appProfile(inst.appType);
|
||||
|
||||
let badge: { text: string; cls: string };
|
||||
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
|
||||
@@ -813,11 +1071,11 @@ function InstanceAdminCard({
|
||||
else if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
|
||||
else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试';
|
||||
else if (offline) sub = inst.runtime === 'missing' ? '容器尚未创建' : '容器已停止';
|
||||
else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装';
|
||||
else sub = '微信尚未安装';
|
||||
else if (installed) sub = wx.version ? `${profile.label} ${wx.version}` : `${profile.label}已就绪`;
|
||||
else sub = `${profile.label}尚未安装`;
|
||||
|
||||
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>
|
||||
@@ -845,46 +1103,75 @@ function InstanceAdminCard({
|
||||
{inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
|
||||
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装' + profile.label}>
|
||||
进入实例
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="inst-admin-links">
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
|
||||
{installed ? '更新微信' : '下载安装'}
|
||||
</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" onClick={() => setMenuOpen(false)}>
|
||||
<div className="inst-menu-group">
|
||||
<div className="inst-menu-label">运维</div>
|
||||
<div className="inst-menu-items">
|
||||
{!offline && profile.needsInstall && (
|
||||
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
|
||||
{installed ? profile.updateLabel : '下载安装'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录)">
|
||||
升级实例
|
||||
</button>
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={onRestart}>
|
||||
重启
|
||||
</button>
|
||||
)}
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={onStop}>
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="inst-menu-group">
|
||||
<div className="inst-menu-label">设置</div>
|
||||
<div className="inst-menu-items">
|
||||
<button className="btn-text" onClick={onRename}>
|
||||
重命名
|
||||
</button>
|
||||
<button className="btn-text" onClick={onAssign}>
|
||||
分配账户
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例日志(含历史:重启原因 + 上一容器日志快照,跨重启保留)">
|
||||
日志
|
||||
</button>
|
||||
<button className="btn-text" onClick={onSecurity} title="内存阈值自愈">
|
||||
安全
|
||||
</button>
|
||||
<button className="btn-text" onClick={onIcon} title="设置实例图标:内置图标 / 上传图片裁剪">
|
||||
图标
|
||||
</button>
|
||||
<button className="btn-text" onClick={onVolume} title="数据卷:备份/恢复、上传 PC 微信数据、文件管理">
|
||||
数据卷
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inst-menu-group inst-menu-danger">
|
||||
<div className="inst-menu-items">
|
||||
<button className="btn-text danger" onClick={onDelete}>
|
||||
删除实例
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录),把实例更新到新版">
|
||||
升级实例
|
||||
</button>
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={onRestart}>
|
||||
重启
|
||||
</button>
|
||||
)}
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={onStop}>
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-text" onClick={onRename}>
|
||||
重命名
|
||||
</button>
|
||||
<button className="btn-text" onClick={onAssign}>
|
||||
分配账户
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志(排错)">
|
||||
日志
|
||||
</button>
|
||||
<button className="btn-text" onClick={onSecurity} title="设置内存阈值:超过 soft 且无人会话时柔和重启;超过 hard 强制重启">
|
||||
安全
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={onDelete}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -892,6 +1179,399 @@ function InstanceAdminCard({
|
||||
);
|
||||
}
|
||||
|
||||
// 把裁剪区域画到 128px 画布并导出 PNG dataURL(存进 inst.icon)
|
||||
async function cropToDataUrl(src: string, area: { x: number; y: number; width: number; height: number }): Promise<string> {
|
||||
const img = await new Promise<HTMLImageElement>((res, rej) => {
|
||||
const i = new Image();
|
||||
i.onload = () => res(i);
|
||||
i.onerror = rej;
|
||||
i.src = src;
|
||||
});
|
||||
const SIZE = 128;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = SIZE;
|
||||
c.height = SIZE;
|
||||
c.getContext('2d')!.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, SIZE, SIZE);
|
||||
return c.toDataURL('image/png');
|
||||
}
|
||||
|
||||
// 实例图标编辑:选内置图标 / 上传图片裁剪 / 恢复默认。
|
||||
function InstanceIconEditor({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
|
||||
const { toast } = useUI();
|
||||
const [sel, setSel] = useState<string>(inst.icon || ''); // '' = 按应用默认
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [cropSrc, setCropSrc] = useState(''); // 非空 = 裁剪态
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [area, setArea] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!f) return;
|
||||
if (!f.type.startsWith('image/')) return toast('请选择图片文件', 'error');
|
||||
if (f.size > 8 * 1024 * 1024) return toast('图片过大(>8MB)', 'error');
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
setCropSrc(String(r.result));
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
};
|
||||
r.readAsDataURL(f);
|
||||
};
|
||||
|
||||
const confirmCrop = async () => {
|
||||
if (!cropSrc || !area) return;
|
||||
try {
|
||||
setSel(await cropToDataUrl(cropSrc, area));
|
||||
setCropSrc('');
|
||||
} catch {
|
||||
toast('裁剪失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.setInstanceIcon(inst.id, sel || null);
|
||||
onDone();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
toast(e?.message || '保存失败', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<div className="card modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 460 }}>
|
||||
<h2>图标 · {inst.name}</h2>
|
||||
{cropSrc ? (
|
||||
<>
|
||||
<div className="icon-crop">
|
||||
<Cropper
|
||||
image={cropSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={(_, a) => setArea(a)}
|
||||
/>
|
||||
</div>
|
||||
<input type="range" min={1} max={3} step={0.01} value={zoom} onChange={(e) => setZoom(Number(e.target.value))} />
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={() => setCropSrc('')}>返回</button>
|
||||
<button type="button" className="btn btn-primary" onClick={confirmCrop}>裁剪并使用</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="icon-edit-top">
|
||||
<InstanceIcon icon={sel || undefined} appType={inst.appType} size={56} radius={14} />
|
||||
<div className="muted small">预览({sel.startsWith('data:') ? '自定义图片' : sel.startsWith('builtin:') ? '内置图标' : '按应用默认'})</div>
|
||||
</div>
|
||||
<div className="field-label">内置图标</div>
|
||||
<div className="icon-grid">
|
||||
<button type="button" className={'icon-pick' + (sel === '' ? ' sel' : '')} onClick={() => setSel('')}>
|
||||
<InstanceIcon appType={inst.appType} size={38} radius={11} />
|
||||
<span>默认</span>
|
||||
</button>
|
||||
{ICON_CHOICES.map((c) => (
|
||||
<button
|
||||
type="button"
|
||||
key={c.key}
|
||||
className={'icon-pick' + (sel === `builtin:${c.key}` ? ' sel' : '')}
|
||||
onClick={() => setSel(`builtin:${c.key}`)}
|
||||
>
|
||||
<InstanceIcon icon={`builtin:${c.key}`} size={38} radius={11} />
|
||||
<span>{c.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="btn" onClick={() => fileRef.current?.click()}>上传图片并裁剪…</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPickFile} />
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose} disabled={busy}>取消</button>
|
||||
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>保存</button>
|
||||
</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,
|
||||
@@ -976,8 +1656,16 @@ function CreateUser({ instances, onClose, onDone }: { instances: InstanceWithSta
|
||||
);
|
||||
}
|
||||
|
||||
// 可创建的应用类型。ready=false 的暂时禁用(即将支持)。Telegram(仅 x86_64)与其它应用暂缓。
|
||||
const APP_OPTIONS: { type: AppType; desc: string; ready: boolean }[] = [
|
||||
{ type: 'wechat', desc: '默认', ready: true },
|
||||
{ type: 'chromium', desc: '浏览器', ready: true },
|
||||
{ type: 'custom', desc: '即将支持', ready: false },
|
||||
];
|
||||
|
||||
function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose: () => void; onDone: () => void }) {
|
||||
const [name, setName] = useState('');
|
||||
const [appType, setAppType] = useState<AppType>('wechat');
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -1003,7 +1691,7 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
|
||||
setErr('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.createInstance(name.trim(), [...sel], reuse || undefined);
|
||||
await api.createInstance(name.trim(), [...sel], reuse || undefined, appType);
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '创建失败');
|
||||
@@ -1015,8 +1703,27 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
|
||||
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)} />
|
||||
<h2>新建实例</h2>
|
||||
<div className="field-label">应用类型</div>
|
||||
<div className="app-picker">
|
||||
{APP_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.type}
|
||||
type="button"
|
||||
className={'app-pick' + (appType === o.type ? ' sel' : '')}
|
||||
disabled={!o.ready}
|
||||
title={o.ready ? '' : '即将支持'}
|
||||
onClick={() => o.ready && setAppType(o.type)}
|
||||
>
|
||||
<span className="app-pick-name">{APP_LABELS[o.type]}</span>
|
||||
<span className="app-pick-desc">{o.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input className="input" placeholder="实例名称(留空自动命名)" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
{appType === 'chromium' && (
|
||||
<div className="muted small">Chromium 浏览器随镜像就绪,创建后直接「进入实例」即可(无需下载安装)。</div>
|
||||
)}
|
||||
<div className="field-label">允许访问的子账号(管理员默认可访问全部)</div>
|
||||
<ChipMultiSelect
|
||||
options={subs.map((u) => ({ id: u.id, label: u.username }))}
|
||||
@@ -1042,7 +1749,9 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
|
||||
</>
|
||||
)}
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="muted small" style={{ marginTop: 4 }}>创建后会拉起一个新的微信容器,进入后扫码登录。</div>
|
||||
<div className="muted small" style={{ marginTop: 4 }}>
|
||||
创建后拉起一个新的 {APP_LABELS[appType]} 容器;进入实例后点「下载并安装」,再登录即可。
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
取消
|
||||
|
||||
+139
-35
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { api, appProfile } from '../api';
|
||||
import { useUI } from '../ui';
|
||||
import { useAuth } from '../auth';
|
||||
import { useInstances } from '../AppShell';
|
||||
@@ -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);
|
||||
@@ -61,6 +150,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
const audioRef = useRef<VncAudio | null>(null);
|
||||
|
||||
const inst = instances.find((i) => i.id === id);
|
||||
const profile = appProfile(inst?.appType); // 按应用类型显示正确文案(微信/Chromium…)
|
||||
const appLabel = profile.label;
|
||||
// 进入实例时,共享列表可能尚未同步(管理页新建/安装后),先按"探测中"显示加载态,
|
||||
// 等列表刷新到该实例或超时后再判定是否真的不存在,避免从管理页跳转时误报"实例不存在"。
|
||||
const [probing, setProbing] = useState(true);
|
||||
@@ -203,18 +294,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_ime(iframe 同源共享 localStorage,加载前设好即生效)。
|
||||
// 无感(seamless):enable_ime=true,启用 noVNC 合成 textarea;中文 keysym 已被容器补丁抑制,
|
||||
// 成品由「无感输入」钩子经 xdotool 转发(见 installSeamlessIme)。
|
||||
// 转发(forward):enable_ime=false,VNC 直接打字纯 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 的音频流(扬声器恒开,无需手动找工具条);
|
||||
// 仅当本实例处于焦点(标签页可见且窗口聚焦)时出声/收音,失焦立即断开,避免多实例多端串音。
|
||||
@@ -268,7 +368,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
}
|
||||
setUploading(false);
|
||||
if (ok) {
|
||||
toast(`已上传 ${ok} 个文件到桌面,微信里可直接选取`, 'ok');
|
||||
toast(`已上传 ${ok} 个文件到桌面,应用里可直接取用`, 'ok');
|
||||
refreshFiles();
|
||||
}
|
||||
};
|
||||
@@ -281,7 +381,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
};
|
||||
|
||||
const delFile = async (name: string) => {
|
||||
if (!(await confirm({ title: `删除「${name}」?`, body: '将从微信桌面(~/Desktop)移除该文件。', danger: true, confirmText: '删除' }))) return;
|
||||
if (!(await confirm({ title: `删除「${name}」?`, body: '将从桌面(~/Desktop)移除该文件。', danger: true, confirmText: '删除' }))) return;
|
||||
try {
|
||||
await api.deleteFile(id, name);
|
||||
toast('已删除', 'ok');
|
||||
@@ -347,7 +447,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
return;
|
||||
}
|
||||
if (pushClipboardToRemote(t)) {
|
||||
toast('已发送到容器剪贴板,请在微信输入框按 Ctrl+V 粘贴', 'ok');
|
||||
toast('已发送到容器剪贴板,请在应用输入框按 Ctrl+V 粘贴', 'ok');
|
||||
} else {
|
||||
toast('发送失败:桌面尚未连接', 'error');
|
||||
}
|
||||
@@ -388,7 +488,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
const restartInstance = async () => {
|
||||
const ok = await confirm({
|
||||
title: '重启该实例?',
|
||||
body: '会重建容器(聊天记录保留),微信重新启动,约十几秒;用于修复卡死/最小化丢失等。',
|
||||
body: `会重建容器(数据保留),${appLabel}重新启动,约十几秒;用于修复卡死/最小化丢失等。`,
|
||||
confirmText: '重启',
|
||||
});
|
||||
if (!ok) return;
|
||||
@@ -427,7 +527,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
}
|
||||
};
|
||||
|
||||
const title = inst?.name || '微信实例';
|
||||
const title = inst?.name || '实例';
|
||||
|
||||
return (
|
||||
<div className="ws-page">
|
||||
@@ -449,11 +549,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"
|
||||
@@ -507,7 +611,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="spinner" />
|
||||
<div className="iv-notice-title">微信安装中…</div>
|
||||
<div className="iv-notice-title">{appLabel}安装中…</div>
|
||||
<div className="iv-notice-sub">
|
||||
{inst.wechat.message || '请稍候'}
|
||||
{inst.wechat.percent >= 0 ? ` · ${inst.wechat.percent}%` : ''} ——完成后自动进入,无需刷新
|
||||
@@ -517,18 +621,18 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
) : !installed ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? '微信安装出错' : '微信尚未安装'}</div>
|
||||
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? `${appLabel}安装出错` : `${appLabel}尚未安装`}</div>
|
||||
<div className="iv-notice-sub">
|
||||
{inst.wechat.phase === 'error'
|
||||
? inst.wechat.message || '安装失败,可在「管理」重试'
|
||||
: '该实例容器已就绪,但尚未安装微信'}
|
||||
: `该实例容器已就绪,但尚未安装${appLabel}`}
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/admin')}>
|
||||
去「管理」{inst.wechat.phase === 'error' ? '重试 / 更新' : '下载安装'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="iv-notice-sub">请联系管理员在「管理」中下载安装微信</div>
|
||||
<div className="iv-notice-sub">请联系管理员在「管理」中下载安装{appLabel}</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
|
||||
@@ -545,15 +649,15 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
ref={frameRef}
|
||||
className="iv-frame"
|
||||
src={desktopUrl(id)}
|
||||
title="电脑版微信"
|
||||
title={`${appLabel} · 实例桌面`}
|
||||
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
|
||||
onLoad={() => {
|
||||
setFrameLoaded(true);
|
||||
setTimeout(() => {
|
||||
focusFrame(); // 加载完把键盘焦点交给 VNC
|
||||
injectVncStyle(); // 让原生控制条在深色背景下可见
|
||||
// 注意:不再调用 patchVncIme —— enable_ime 已关,直接打字走纯 keysym(英文/数字正常);
|
||||
// 中文由底部「中文输入条」承担。那套合成拦截既脆弱又会损坏混合输入,已弃用。
|
||||
// 无感输入模式的键盘钩子由单独的 effect(依赖 inputMode/frameLoaded)安装,不在此处;
|
||||
// 转发模式则 enable_ime=false,直接打字走纯 keysym(英文/数字正常),中文用底部输入条。
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
@@ -562,7 +666,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<div className="iv-loading">
|
||||
<div className="spinner" />
|
||||
<div className="iv-loading-text">正在连接桌面…</div>
|
||||
<div className="iv-loading-sub">首次进入请扫码登录微信</div>
|
||||
<div className="iv-loading-sub">{profile.enterHint}</div>
|
||||
<div className="iv-loading-sub">拖文件到此处即可上传;声音自动开启,点一下画面即可出声</div>
|
||||
{!window.isSecureContext && (
|
||||
<div className="iv-loading-warn">当前非 HTTPS 访问,浏览器将禁用麦克风与摄像头(音频播放不受影响)</div>
|
||||
@@ -601,8 +705,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<div className="iv-drop" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
<div className="drop-card">
|
||||
<div className="drop-icon">⬇</div>
|
||||
<div className="drop-title">松开上传到微信桌面</div>
|
||||
<div className="drop-sub">上传后在微信里「+ / 文件」选择即可</div>
|
||||
<div className="drop-title">松开上传到桌面</div>
|
||||
<div className="drop-sub">上传后在应用里「+ / 文件」选择即可</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -640,7 +744,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<button className="btn btn-primary files-upload" disabled={uploading} onClick={() => fileInput.current?.click()}>
|
||||
{uploading ? '上传中…' : '+ 选择文件上传'}
|
||||
</button>
|
||||
<div className="files-hint">也可直接把文件拖进来。下方为桌面(~/Desktop)里的文件,微信收到的文件另存到桌面即可在此下载。</div>
|
||||
<div className="files-hint">也可直接把文件拖进来。下方为桌面(~/Desktop)里的文件,应用收到的文件另存到桌面即可在此下载。</div>
|
||||
<div className="files-list">
|
||||
{files.length === 0 && (
|
||||
<div className="muted small" style={{ padding: '10px 2px' }}>
|
||||
@@ -674,23 +778,23 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
className="clip-area"
|
||||
value={clipText}
|
||||
onChange={(e) => setClipText(e.target.value)}
|
||||
placeholder="在此输入或粘贴文本,点「发送到微信」后到微信输入框按 Ctrl+V 粘贴"
|
||||
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。
|
||||
局域网 http 访问时浏览器会禁用系统级剪贴板同步,故用此框中转:文本→容器剪贴板,再在应用里 Ctrl+V。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{imeBar && (
|
||||
{inputMode === 'forward' && (
|
||||
<div className="iv-imebar">
|
||||
<textarea
|
||||
className="iv-imebar-input"
|
||||
@@ -702,7 +806,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
sendImeText();
|
||||
}
|
||||
}}
|
||||
placeholder="中文输入这里 → 回车送进微信(先点好微信的输入框)。Shift+回车换行。"
|
||||
placeholder="中文输入这里 → 回车送进应用(先点好应用的输入框)。Shift+回车换行。"
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
|
||||
+512
-18
@@ -305,6 +305,79 @@ button {
|
||||
background: rgba(var(--danger-rgb) / 0.12);
|
||||
}
|
||||
|
||||
/* 实例卡片操作:「管理」分类折叠菜单(默认收起,点开按运维/设置/危险分组展开文字操作) */
|
||||
.inst-menu-toggle {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--trough);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.inst-menu-toggle:hover {
|
||||
background: var(--base);
|
||||
}
|
||||
.inst-menu-caret {
|
||||
display: inline-flex;
|
||||
color: var(--muted);
|
||||
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.inst-menu-toggle.open .inst-menu-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
/* 「管理」菜单:绝对定位悬浮层,浮在下方内容之上,不把卡片撑高、不顶走后面的内容 */
|
||||
.inst-menu-wrap {
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.inst-menu {
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
.inst-menu-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
margin: 2px 4px 2px;
|
||||
}
|
||||
.inst-menu-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
.inst-menu-danger {
|
||||
margin-top: 2px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(var(--shadow) / 0.08);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 14px;
|
||||
@@ -845,6 +918,8 @@ button {
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
/* 各卡片按自身内容高度,避免某张展开「管理」菜单时同行其它卡片被拉等高(显得也展开了) */
|
||||
align-items: start;
|
||||
}
|
||||
.inst-card {
|
||||
position: relative;
|
||||
@@ -865,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;
|
||||
@@ -890,6 +970,31 @@ button {
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
/* 「关于」版本卡:有新版时的升级提示 + 上次检查脚注 */
|
||||
.ver-hint {
|
||||
margin-top: 10px;
|
||||
padding: 9px 11px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
background: rgba(245 158 11 / 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ver-hint code {
|
||||
font-size: 12px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
background: rgba(var(--shadow) / 0.08);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ver-checked {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.inst-actions a.btn {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.inst-enter {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -942,6 +1047,68 @@ button {
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* 扁平设置区块(诊断与日志 / 关于):无卡片,直接排在分区标题下,避免单卡片的厚重感 */
|
||||
.settings-block {
|
||||
padding: 2px 2px 4px;
|
||||
}
|
||||
.settings-block .s-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--muted);
|
||||
max-width: 660px;
|
||||
}
|
||||
.s-field {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.s-field .field-label {
|
||||
display: block;
|
||||
margin: 0 2px 8px;
|
||||
}
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
/* 实心主按钮:固定内边距 + 不换行(之前作为 flex 项被压窄导致按钮内文字断行) */
|
||||
.settings-actions .s-btn {
|
||||
height: 42px;
|
||||
padding: 0 22px;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.settings-actions .btn-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.s-foot {
|
||||
margin: 14px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
max-width: 660px;
|
||||
}
|
||||
.s-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.s-app {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.s-line {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.s-line b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip-row-pick {
|
||||
display: flex;
|
||||
}
|
||||
@@ -978,6 +1145,9 @@ button {
|
||||
text-align: center;
|
||||
padding: 30px 16px 36px;
|
||||
}
|
||||
.empty-action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.empty-blob {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
@@ -1288,6 +1458,7 @@ button {
|
||||
color: var(--wx-green-dark);
|
||||
}
|
||||
.sb-ic {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
@@ -1295,6 +1466,23 @@ button {
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
/* 「有新版」红点:贴在「管理」齿轮图标右上角,折叠/展开都可见 */
|
||||
.sb-updot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -1px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #f5483b;
|
||||
border: 2px solid var(--surface);
|
||||
}
|
||||
.sb-updot-text {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #f5483b;
|
||||
}
|
||||
.sb-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -1314,15 +1502,6 @@ button {
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sb-dot {
|
||||
position: absolute;
|
||||
@@ -1467,6 +1646,10 @@ button {
|
||||
background: var(--sheen);
|
||||
pointer-events: none;
|
||||
}
|
||||
.home-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--crease-accent);
|
||||
}
|
||||
.home-card:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
@@ -1475,15 +1658,6 @@ button {
|
||||
flex: none;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.home-card-main {
|
||||
position: relative;
|
||||
@@ -1500,10 +1674,29 @@ button {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.home-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.home-card-st {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: none !important;
|
||||
flex: none;
|
||||
}
|
||||
.home-card-ver {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.home-card-ver::before {
|
||||
content: '·';
|
||||
margin-right: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* —— 内嵌实例视图 —— */
|
||||
@@ -1723,3 +1916,304 @@ 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),内部条目为浮起 bump(hover) */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ── 新建实例·应用类型选择 ─────────────────────────────── */
|
||||
.app-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.app-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 10px 6px;
|
||||
border: none;
|
||||
border-radius: var(--r-small);
|
||||
background: var(--trough);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
.app-pick:hover:not(:disabled):not(.sel) {
|
||||
background: var(--base);
|
||||
}
|
||||
.app-pick.sel {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--crease-accent);
|
||||
}
|
||||
.app-pick.sel .app-pick-name {
|
||||
color: var(--wx-green-dark);
|
||||
}
|
||||
.app-pick:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.app-pick-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.app-pick-desc {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── 实例图标编辑 ─────────────────────────────────────── */
|
||||
.icon-edit-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.icon-crop {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
border-radius: var(--r-small);
|
||||
overflow: hidden;
|
||||
background: #1a1d24;
|
||||
}
|
||||
.icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(62px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.icon-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
border: none;
|
||||
border-radius: var(--r-small);
|
||||
background: var(--trough);
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.12s;
|
||||
}
|
||||
.icon-pick span {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.icon-pick.sel {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--crease-accent);
|
||||
}
|
||||
.icon-pick.sel span {
|
||||
color: var(--wx-green-dark);
|
||||
}
|
||||
.icon-pick:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,15 @@ set -euo pipefail
|
||||
OWNER="${WOC_IMAGE_OWNER:-gloridust}"
|
||||
TAG="${WOC_VERSION:-latest}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# 烤进面板镜像的版本号:设了 WOC_VERSION 就用它(如 v1.2.0),否则用 dev-<短SHA>(本地构建标识)。
|
||||
# 开发版不是正式发布版,面板「关于」会标「开发版」、不会触发「有新版」红点。
|
||||
VER="${WOC_VERSION:-dev-$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo local)}"
|
||||
|
||||
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 "==> 构建面板镜像 ${PANEL_IMAGE} (版本号 ${VER})"
|
||||
docker build --build-arg "WOC_VERSION=${VER}" -t "${PANEL_IMAGE}" "${ROOT}/panel"
|
||||
|
||||
echo "==> 构建微信实例镜像 ${WECHAT_IMAGE}"
|
||||
docker build -t "${WECHAT_IMAGE}" "${ROOT}/docker"
|
||||
|
||||
Reference in New Issue
Block a user