Compare commits
3 Commits
@@ -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 Buildx(Docker Desktop 自带;纯 Linux 跨架构需先装 QEMU:`docker run --privileged --rm tonistiigi/binfmt --install all`)。
|
||||
|
||||
```bash
|
||||
# 1) 登录 GHCR(PAT 需 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] 已重置用户 '<用户名>' 的密码`。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||