3 Commits

  • feat: 超管密码离线找回 + 改密/重置二次确认 + GHCR 手动构建文档
    - 密码找回:accounts.json 给用户加 "resetPassword": true(兼容 reset_password),
      重启面板即把其密码重置为 PANEL_ADMIN_PASSWORD(默认 wechat)、解禁并清除标记
    - 改密/重置密码弹窗新增「再次输入新密码」二次确认:两次不一致则拦截,
      避免浏览器自动填充/手误把密码静默设成非预期值导致锁死
    - README:补充方式 B(本机 buildx 手动多架构构建推送 GHCR)+ Release 的 latest 注意事项
      + 「重置超管密码(离线找回)」操作步骤
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
10 changed files with 291 additions and 71 deletions
+61 -2
View File
@@ -118,7 +118,7 @@ docker volume ls | grep woc # 看所有微信实例的数据卷
**方式 A · 本地自构建(官方尚未发布镜像时用这个)**
```bash
git clone <this-repo> WechatOnCloud
git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
cd WechatOnCloud
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
./scripts/build-local.sh # 构建面板 + 微信实例镜像,打成 compose 用的同名标签
@@ -128,7 +128,7 @@ docker compose up -d # compose 默认优先用本地镜像,不会
**方式 B · 拉取官方镜像(已发布到 GHCR 后)**
```bash
git clone <this-repo> WechatOnCloud
git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
cd WechatOnCloud
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
docker compose up -d # 直接从 GHCR 拉取
@@ -182,13 +182,47 @@ docker compose up -d # 直接从 GHCR 拉取
## 发布到 GHCR
两种方式任选其一。
### 方式 A · GitHub Actions(推荐)
仓库自带 GitHub Actions[.github/workflows/release.yml](.github/workflows/release.yml)),在你**推送 `vX.Y.Z` 标签或发布 Release** 时,自动构建多架构(amd64+arm64)镜像并推到 GHCR
```bash
git tag v1.0.0
git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1.0.0 等标签
# 或在 GitHub 上 Publish 一个 Release(会额外打 latest):
gh release create v1.0.0 --title v1.0.0 --notes "..."
```
> 注意:单纯 push tag 只产出 `X.Y.Z / X.Y / X`**不会更新 `latest`**;要更新 `latest` 请改用 **发布 Release** 或在 Actions 里手动 `workflow_dispatch`。
### 方式 B · 本机 buildx 手动构建并推送(不走 Actions)
适合想立刻出包、或不依赖 CI 的场景。需要 Docker BuildxDocker Desktop 自带;纯 Linux 跨架构需先装 QEMU:`docker run --privileged --rm tonistiigi/binfmt --install all`)。
```bash
# 1) 登录 GHCRPAT 需 write:packages 权限)
echo <YOUR_GITHUB_PAT> | docker login ghcr.io -u <github 用户名> --password-stdin
# 2) 首次创建并启用多架构构建器(已建过改用 docker buildx use woc
docker buildx create --name woc --use
# 3) 构建并推送两个镜像(amd64 + arm64)。VER 与 git tag 保持一致(不带 v)
VER=1.0.1
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/woc-panel:$VER -t ghcr.io/gloridust/woc-panel:latest \
--push ./panel
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/wechat-on-cloud:$VER -t ghcr.io/gloridust/wechat-on-cloud:latest \
--push ./docker
```
> 把 `gloridust` 换成你的 GHCR 命名空间(与 `docker-compose.yml` / `WOC_IMAGE_PREFIX` 一致)。
> 只想本机自用、不推 GHCR,用 [`./scripts/build-local.sh`](scripts/build-local.sh) 构建本机架构单架构镜像即可。
### 发布后:把包设为公开
首次发布后还需把 GHCR 包设为公开,否则别人 `docker compose up -d` 会报 `denied`
1. 打开 GitHub → 你的头像 → **Packages** → 分别进入 `woc-panel``wechat-on-cloud`
@@ -248,9 +282,34 @@ git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1
| 过段时间掉登录 | 微信桌面会话会定期失效,需手机重新扫码(见技术方案 6.2) |
| 下载 / 更新微信失败 | 腾讯 CDN 偶发波动,重新点「下载并安装 / 更新」即可;脚本已内置主/备 CDN 自动回退 |
| 架构不支持报错 | 微信仅提供 x86_64 / arm64;其他架构下载时会在面板状态里报错 |
| 忘记超管密码 | 见下方「重置超管密码」离线找回 |
查看面板日志:`docker logs -f woc-panel`;查看某实例日志:`docker logs -f woc-wx-<id>`(实例 ID 可在面板看到,或 `docker ps | grep woc-wx`)。
### 重置超管密码(离线找回)
管理员密码无法被他人重置,忘记时按以下步骤离线找回:
```bash
docker compose stop panel # 1) 先停面板,避免覆盖你的手动修改
```
2) 编辑 `./data-panel/accounts.json`,给对应用户对象加一行 `"resetPassword": true`
```json
{
"id": "...", "username": "admin", "role": "admin",
"passwordHash": "...", "disabled": false,
"resetPassword": true
}
```
```bash
docker compose up -d # 3) 重启,面板启动时会重置该账号
```
重启后该账号密码被重置为 `PANEL_ADMIN_PASSWORD`(即 `.env``WOC_PASSWORD`,默认 `wechat`),并自动**解禁**、清除该标记;用此密码登录后请立即在「修改密码」改掉。日志会打印 `[store] 已重置用户 '<用户名>' 的密码`
---
## 目录结构
+17
View File
@@ -16,6 +16,10 @@ export interface User {
allowedInstances: string[];
// 仍在使用初始默认密码时为 true,前端据此提示尽快改密;任意一次改密/重置后清除。
mustChangePassword?: boolean;
// 离线密码找回:在 accounts.json 手动把某用户置为 true,重启面板即重置其密码并清除此标记。
// 兼容下划线写法 reset_password。
resetPassword?: boolean;
reset_password?: boolean;
}
// 初始默认管理员密码;管理员仍在用它时强烈提示改密。
@@ -87,6 +91,19 @@ export function initStore() {
}
}
}
// 离线密码找回:忘记超管密码时,停掉面板 → 在 accounts.json 给该用户加 "resetPassword": true
// → 重启面板。这里把其密码重置为 PANEL_ADMIN_PASSWORD(默认 wechat)、解禁,并清除标记。
for (const u of data.users) {
if ((u as any).resetPassword === true || (u as any).reset_password === true) {
const pw = process.env.PANEL_ADMIN_PASSWORD || DEFAULT_ADMIN_PASSWORD;
u.passwordHash = bcrypt.hashSync(pw, 10);
u.mustChangePassword = pw === DEFAULT_ADMIN_PASSWORD; // 重置成默认密码则提示尽快改密
u.disabled = false;
delete (u as any).resetPassword;
delete (u as any).reset_password;
console.log(`[store] 已重置用户 '${u.username}' 的密码(resetPassword 标记,密码=PANEL_ADMIN_PASSWORD 或默认 wechat`);
}
}
persist();
}
+2 -3
View File
@@ -7,9 +7,8 @@
</defs>
<rect width="100" height="100" rx="23" fill="url(#bg)"/>
<rect x="3.5" y="3.5" width="93" height="93" rx="20" fill="none" stroke="#ffffff" stroke-opacity="0.18" stroke-width="2"/>
<circle cx="22" cy="36" r="3.4" fill="#ffffff" fill-opacity="0.92"/>
<g fill="none" stroke="#ffffff" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M34 30 L48 44 L34 58"/>
<path d="M56 60 L72 60"/>
<path d="M28 24 L42 38 L28 52"/>
<path d="M50 54 L66 54"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 715 B

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

+5 -7
View File
@@ -56,15 +56,14 @@ function makePng(size) {
const stroke = (7.5 / 2) * s; // 笔画半宽
// 提示符坐标(基于 100 视图)
const chevron = [
[34, 30],
[48, 44],
[34, 58],
[28, 24],
[42, 38],
[28, 52],
].map(([x, y]) => [x * s, y * s]);
const underline = [
[56, 60],
[72, 60],
[50, 54],
[66, 54],
].map(([x, y]) => [x * s, y * s]);
const dot = [22 * s, 36 * s, 3.4 * s]; // cx, cy, r
const inRounded = (x, y) => {
const r = radius;
@@ -93,7 +92,6 @@ function makePng(size) {
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, chevron[0][0], chevron[0][1], chevron[1][0], chevron[1][1]) + 0.5));
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, chevron[1][0], chevron[1][1], chevron[2][0], chevron[2][1]) + 0.5));
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, underline[0][0], underline[0][1], underline[1][0], underline[1][1]) + 0.5));
cov = Math.max(cov, clamp01(dot[2] - Math.hypot(gx - dot[0], gy - dot[1]) + 0.5));
const o = y * rowLen + 1 + x * 4;
raw[o] = Math.round(bg[0] + (WHITE[0] - bg[0]) * cov);
+166 -25
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, type PanelUser, type InstanceWithStatus } from '../api';
import { useUI, PasswordInput } from '../ui';
const BUSY_PHASES = ['downloading', 'extracting', 'installing'];
export default function Admin() {
const nav = useNavigate();
const { toast, confirm } = useUI();
@@ -16,8 +18,10 @@ export default function Admin() {
const [resetTarget, setResetTarget] = useState<PanelUser | null>(null); // 重置密码弹窗
const [deleteInst, setDeleteInst] = useState<InstanceWithStatus | null>(null); // 删除实例弹窗
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
const [starting, setStarting] = useState<Set<string>>(new Set());
const subs = users.filter((u) => u.role !== 'admin');
const timer = useRef<number | undefined>(undefined);
const load = async () => {
try {
@@ -31,8 +35,49 @@ export default function Admin() {
useEffect(() => {
load();
return () => window.clearTimeout(timer.current);
}, []);
// 安装/更新进行中时轮询进度
useEffect(() => {
window.clearTimeout(timer.current);
if (instances.some((i) => BUSY_PHASES.includes(i.wechat.phase))) timer.current = window.setTimeout(load, 1500);
return () => window.clearTimeout(timer.current);
}, [instances]);
const trigger = async (inst: InstanceWithStatus, kind: 'install' | 'update') => {
try {
await (kind === 'install' ? api.instanceWechatInstall(inst.id) : api.instanceWechatUpdate(inst.id));
setInstances((list) =>
list.map((i) =>
i.id === inst.id ? { ...i, wechat: { ...i.wechat, phase: 'downloading', percent: -1, message: '正在准备…' } } : i,
),
);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(load, 1000);
toast(kind === 'install' ? '已开始下载微信' : '已开始更新', 'ok');
} catch (e: any) {
toast(e.message || '操作失败', 'error');
}
};
const start = async (inst: InstanceWithStatus) => {
setStarting((s) => new Set(s).add(inst.id));
try {
await api.instanceStart(inst.id);
toast('实例已启动', 'ok');
await load();
} catch (e: any) {
toast(e.message || '启动失败', 'error');
} finally {
setStarting((s) => {
const n = new Set(s);
n.delete(inst.id);
return n;
});
}
};
const instName = (id: string) => instances.find((i) => i.id === id)?.name || id;
const usersForInstance = (id: string) => subs.filter((u) => u.allowedInstances.includes(id));
@@ -76,28 +121,27 @@ export default function Admin() {
+
</button>
</div>
<div className="list">
{instances.length === 0 && <div className="muted small" style={{ padding: '14px 16px' }}></div>}
{instances.map((inst) => (
<div key={inst.id} className="user-row">
<div className="user-main">
<span className="user-name">{inst.name}</span>
<span className="muted small">访 {usersForInstance(inst.id).length} </span>
</div>
<div className="user-actions">
<button className="btn-text" onClick={() => setRenameInst(inst)}>
</button>
<button className="btn-text" onClick={() => setAssignInst(inst)}>
</button>
<button className="btn-text danger" onClick={() => setDeleteInst(inst)}>
</button>
</div>
</div>
))}
</div>
{instances.length === 0 ? (
<div className="list">
<div className="muted small" style={{ padding: '14px 16px' }}></div>
</div>
) : (
<div className="inst-grid">
{instances.map((inst) => (
<InstanceAdminCard
key={inst.id}
inst={inst}
userCount={usersForInstance(inst.id).length}
starting={starting.has(inst.id)}
onTrigger={trigger}
onStart={() => start(inst)}
onRename={() => setRenameInst(inst)}
onAssign={() => setAssignInst(inst)}
onDelete={() => setDeleteInst(inst)}
/>
))}
</div>
)}
<div className="section-row" style={{ marginTop: 22 }}>
<span className="section-title"></span>
@@ -265,11 +309,17 @@ function RenameInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; o
function ResetPassword({ user, onClose, onDone }: { user: PanelUser; onClose: () => void; onDone: () => void }) {
const [pw, setPw] = useState('');
const [confirm, setConfirm] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const mismatch = confirm.length > 0 && pw !== confirm;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
if (pw !== confirm) {
setErr('两次输入的新密码不一致');
return;
}
setBusy(true);
try {
await api.resetUser(user.id, pw);
@@ -285,12 +335,13 @@ function ResetPassword({ user, onClose, onDone }: { user: PanelUser; onClose: ()
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2>{user.username}</h2>
<PasswordInput placeholder="新密码(至少 6 位)" autoComplete="new-password" value={pw} onChange={setPw} />
{err && <div className="error">{err}</div>}
<PasswordInput placeholder="再次输入新密码" autoComplete="new-password" value={confirm} onChange={setConfirm} />
{(mismatch || err) && <div className="error">{mismatch ? '两次输入的新密码不一致' : err}</div>}
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
</button>
<button className="btn btn-primary" disabled={busy || pw.length < 6}>
<button className="btn btn-primary" disabled={busy || pw.length < 6 || pw !== confirm}>
</button>
</div>
@@ -342,6 +393,96 @@ function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; o
);
}
// 管理页的实例卡片:含微信版本管理(下载/更新)+ 重命名/分配/删除
function InstanceAdminCard({
inst,
userCount,
starting,
onTrigger,
onStart,
onRename,
onAssign,
onDelete,
}: {
inst: InstanceWithStatus;
userCount: number;
starting?: boolean;
onTrigger: (inst: InstanceWithStatus, kind: 'install' | 'update') => void;
onStart: () => void;
onRename: () => void;
onAssign: () => void;
onDelete: () => void;
}) {
const wx = inst.wechat;
const busy = BUSY_PHASES.includes(wx.phase);
const installed = wx.installed && wx.phase !== 'downloading';
const offline = inst.runtime !== 'running';
let badge: { text: string; cls: string };
if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
else if (busy) badge = { text: '处理中', cls: 'tag-busy' };
else if (installed) badge = { text: '在线', cls: 'tag-on' };
else badge = { text: '待安装', cls: 'tag-warn' };
let sub: string;
if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试';
else if (offline) sub = inst.runtime === 'missing' ? '容器尚未创建' : '容器已停止';
else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装';
else sub = '微信尚未安装';
return (
<div className="inst-card">
<div className="inst-head">
<span className="inst-name">{inst.name}</span>
<span className={'tag ' + badge.cls}>{badge.text}</span>
</div>
<div className="inst-sub">
{sub} · 访 {userCount}
</div>
{busy && (
<div className="wx-progress">
<div
className={'wx-progress-bar' + (wx.percent < 0 ? ' indeterminate' : '')}
style={wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
/>
</div>
)}
{!busy && (
<div className="inst-actions">
{offline ? (
<button className="btn btn-primary inst-act-wide" disabled={starting} onClick={onStart}>
{starting ? '启动中…' : inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
</button>
) : installed ? (
<button className="btn btn-primary inst-act-wide" onClick={() => onTrigger(inst, 'update')}>
</button>
) : (
<button className="btn btn-primary inst-act-wide" onClick={() => onTrigger(inst, 'install')}>
</button>
)}
</div>
)}
<div className="inst-admin-links">
<button className="btn-text" onClick={onRename}>
</button>
<button className="btn-text" onClick={onAssign}>
</button>
<button className="btn-text danger" onClick={onDelete}>
</button>
</div>
</div>
);
}
// 通用 chip 多选
function ChipMultiSelect({
options,
+11 -34
View File
@@ -39,25 +39,6 @@ export default function Dashboard() {
return () => window.clearTimeout(timer.current);
}, [instances]);
const trigger = async (inst: InstanceWithStatus, kind: 'install' | 'update') => {
setErr('');
try {
await (kind === 'install' ? api.instanceWechatInstall(inst.id) : api.instanceWechatUpdate(inst.id));
setInstances(
(list) =>
list?.map((i) =>
i.id === inst.id ? { ...i, wechat: { ...i.wechat, phase: 'downloading', percent: -1, message: '正在准备…' } } : i,
) ?? list,
);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(load, 1000);
toast(kind === 'install' ? '已开始下载微信' : '已开始更新', 'ok');
} catch (e: any) {
setErr(e.message || '操作失败');
toast(e.message || '操作失败', 'error');
}
};
const start = async (inst: InstanceWithStatus) => {
setErr('');
setStarting((s) => new Set(s).add(inst.id));
@@ -133,7 +114,6 @@ export default function Dashboard() {
isAdmin={isAdmin}
starting={starting.has(inst.id)}
onEnter={() => nav(`/desktop/${inst.id}`)}
onTrigger={trigger}
onStart={() => start(inst)}
/>
))}
@@ -163,14 +143,12 @@ function InstanceCard({
isAdmin,
starting,
onEnter,
onTrigger,
onStart,
}: {
inst: InstanceWithStatus;
isAdmin?: boolean;
starting?: boolean;
onEnter: () => void;
onTrigger: (inst: InstanceWithStatus, kind: 'install' | 'update') => void;
onStart: () => void;
}) {
const wx = inst.wechat;
@@ -220,17 +198,6 @@ function InstanceCard({
</button>
)}
{isAdmin && !busy && !offline && (
installed ? (
<button className="btn inst-act" onClick={() => onTrigger(inst, 'update')}>
</button>
) : (
<button className="btn inst-act" onClick={() => onTrigger(inst, 'install')}>
</button>
)
)}
</div>
</div>
);
@@ -239,12 +206,20 @@ function InstanceCard({
function ChangePassword({ onClose, onSaved }: { onClose: () => void; onSaved?: () => void }) {
const [oldPassword, setOld] = useState('');
const [newPassword, setNew] = useState('');
const [confirm, setConfirm] = useState('');
const [msg, setMsg] = useState('');
const [busy, setBusy] = useState(false);
const mismatch = confirm.length > 0 && newPassword !== confirm;
const canSubmit = !busy && !!oldPassword && newPassword.length >= 6 && newPassword === confirm;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setMsg('');
if (newPassword !== confirm) {
setMsg('两次输入的新密码不一致');
return;
}
setBusy(true);
try {
await api.changePassword(oldPassword, newPassword);
@@ -264,12 +239,14 @@ function ChangePassword({ onClose, onSaved }: { onClose: () => void; onSaved?: (
<h2></h2>
<PasswordInput placeholder="原密码" autoComplete="current-password" value={oldPassword} onChange={setOld} />
<PasswordInput placeholder="新密码(至少 6 位)" autoComplete="new-password" value={newPassword} onChange={setNew} />
<PasswordInput placeholder="再次输入新密码" autoComplete="new-password" value={confirm} onChange={setConfirm} />
{mismatch && <div className="error"></div>}
{msg && <div className={msg === '修改成功' ? 'ok' : 'error'}>{msg}</div>}
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
</button>
<button className="btn btn-primary" disabled={busy || !oldPassword || !newPassword}>
<button className="btn btn-primary" disabled={!canSubmit}>
</button>
</div>
+29
View File
@@ -326,6 +326,17 @@ button {
width: 100%;
margin: 0 auto;
}
/* PC 大屏:加宽容器,实例网格自动多列卡片 */
@media (min-width: 880px) {
.content {
max-width: 940px;
padding: 20px 28px 40px;
}
.topbar {
padding-left: 16px;
padding-right: 16px;
}
}
.hello {
font-size: 22px;
@@ -803,6 +814,24 @@ button {
.inst-act {
flex: none;
}
.inst-act-wide {
flex: 1;
height: 42px;
font-size: 15px;
}
/* 管理卡片底部的文字操作(重命名/分配/删除) */
.inst-admin-links {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-top: 10px;
padding-top: 8px;
box-shadow: inset 0 1px 0 rgba(var(--shadow) / 0.08);
}
.inst-admin-links .btn-text {
padding: 6px 8px;
font-size: 14px;
}
/* 状态徽章配色 */
.tag-on {