From 17ba168764586f6746a410c1a508ad92ac6c051d Mon Sep 17 00:00:00 2001 From: Gloridust Date: Sun, 31 May 2026 10:56:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9F=B3=E8=A7=86=E9=A2=91/=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=BC=A0=E8=BE=93=20+=20=E4=BA=A7=E5=93=81=20UX=20?= =?UTF-8?q?=E6=94=B9=E8=BF=9B=20+=20=E9=95=9C=E5=83=8F=E6=BA=90=E5=8F=AF?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 媒体链路(生产 HTTPS 下可用,含降级) - 音频(听):PulseAudio + KasmVNC 工具条,开箱即用 - 麦克风(说):virtmic 管道源已就绪,运行时需 HTTPS(非 HTTPS 前端提示) - 摄像头(视频):docker.ts 条件化 v4l2 设备直通 + 加 video 组;无设备/无 HTTPS 时优雅降级,音频麦克风不受影响 - WOC_VIDEO_DEVICES 显式指定或经 /host-dev 自动探测 文件传输(原生拖拽) - 面板侧拖拽上传 + 下载:dockerode putArchive/getArchive 到实例 ~/Desktop(持久卷) - 纯 JS 单文件 tar 编解码(免依赖),文本/二进制均无损;全程走面板鉴权与权限校验 安全 & UX - 默认密码告警条 + mustChangePassword 追踪(兼容旧账号文件迁移) - 会话过期(401) 自动跳登录;桌面连接 loading 态 - 停止/未创建实例一键启动(新增 /api/admin/instances/:id/start) - 统一牛奶布艺弹窗 + Toast,替换 Admin 原生 alert/confirm/prompt - 密码可见切换;实例重命名;退出二次确认;空状态改用品牌终端图标 其它 - gen-icons 生成终端风格图标(此前仅空白绿块,影响 Docker/CI 产物) - 镜像源可切换 WOC_IMAGE_PREFIX(国内反代/ACR);品牌名「云微」 - 文档:.env.example / docker-compose 增加音视频(v4l2loopback/HTTPS)、镜像源、视频设备说明 Co-Authored-By: Claude Opus 4.8 --- .env.example | 16 ++ .gitignore | 1 + docker-compose.yml | 6 + panel/server/src/docker.ts | 122 +++++++++++- panel/server/src/index.ts | 78 ++++++++ panel/server/src/store.ts | 31 ++- panel/web/public/icon-180.png | Bin 6654 -> 2872 bytes panel/web/public/icon-192.png | Bin 7122 -> 3091 bytes panel/web/public/icon-512.png | Bin 19249 -> 8739 bytes panel/web/scripts/gen-icons.mjs | 87 +++++--- panel/web/src/App.tsx | 9 +- panel/web/src/api.ts | 27 ++- panel/web/src/pages/Admin.tsx | 191 +++++++++++++++--- panel/web/src/pages/Dashboard.tsx | 82 ++++++-- panel/web/src/pages/Desktop.tsx | 172 +++++++++++++++- panel/web/src/pages/Login.tsx | 9 +- panel/web/src/styles.css | 317 ++++++++++++++++++++++++++++++ panel/web/src/ui.tsx | 128 ++++++++++++ 18 files changed, 1197 insertions(+), 79 deletions(-) create mode 100644 panel/web/src/ui.tsx diff --git a/.env.example b/.env.example index 21ce3e3..dbf3c0f 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,19 @@ WOC_TZ=Asia/Shanghai # 面板对外端口(宿主侧,默认用冷门端口避免冲突;容器内固定 8080)。 # 面板是唯一对外入口;微信实例不直接对宿主暴露,由面板反向代理。 WOC_HTTP_PORT=36080 + +# ── 音频 / 麦克风 / 摄像头 ─────────────────────────────────── +# 音频(听):开箱即用,进入桌面后点 KasmVNC 左侧工具条的扬声器开启。 +# 麦克风(说) / 摄像头(视频):浏览器要求"安全上下文",即必须通过 HTTPS 访问面板 +# (或 localhost)。生产环境务必给面板套 HTTPS(反代/证书),否则浏览器会禁用麦克风与摄像头。 +# +# 摄像头还需要宿主提供一个虚拟视频设备(v4l2loopback): +# 1) 宿主安装并加载内核模块: +# Ubuntu/Debian: sudo apt install v4l2loopback-dkms && sudo modprobe v4l2loopback +# (飞牛/其它发行版若自带 v4l2loopback 同理 modprobe;没有则摄像头不可用,其余功能正常) +# 2) 确认出现了 /dev/videoN 设备。 +# 3) 二选一让实例容器拿到该设备: +# a) 自动:保留 compose 里的 `/dev:/host-dev:ro` 挂载,面板会自动探测并映射; +# b) 显式:在下面列出设备(逗号分隔),并可删掉那条 /host-dev 挂载。 +# 留空 = 不映射摄像头(音频/麦克风不受影响)。 +WOC_VIDEO_DEVICES= diff --git a/.gitignore b/.gitignore index adc939f..6ce3456 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,4 @@ dist # WechatOnCloud: 面板账号数据(含密码哈希,勿提交) /data-panel/ /.claude +.DS_Store diff --git a/docker-compose.yml b/docker-compose.yml index b2e8bc9..68ee8c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,9 @@ services: - PUID=${WOC_PUID:-1000} - PGID=${WOC_PGID:-1000} - TZ=${WOC_TZ:-Asia/Shanghai} + # 摄像头直通:逗号分隔的宿主视频设备(如 /dev/video0)。留空则自动探测(见下方 /host-dev 挂载)或禁用摄像头。 + # 启用前需在宿主加载 v4l2loopback 内核模块。详见 .env.example。 + - WOC_VIDEO_DEVICES=${WOC_VIDEO_DEVICES:-} # 面板首个管理员账号(仅首次启动、无账号文件时写入;务必改掉默认密码) - PANEL_ADMIN_USER=${WOC_USER:-admin} - PANEL_ADMIN_PASSWORD=${WOC_PASSWORD:-wechat} @@ -29,6 +32,9 @@ services: # 面板经 docker 引擎创建/启动/删除微信实例容器、exec 触发下载、读取进度。 # 注意:docker.sock 等同宿主 root 权限,故实例增删仅限管理员,docker API 绝不暴露给前端。 - /var/run/docker.sock:/var/run/docker.sock + # 摄像头自动探测(可选):把宿主 /dev 只读挂进来,面板据此发现 /dev/videoN 并映射给实例。 + # 不想自动探测就删掉此行,改用上面的 WOC_VIDEO_DEVICES 显式指定。 + - /dev:/host-dev:ro ports: - "${WOC_HTTP_PORT:-36080}:8080" # 面板 = 唯一对外入口 diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index f470a13..556077a 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -1,4 +1,5 @@ import { hostname } from 'node:os'; +import { existsSync, readdirSync } from 'node:fs'; import Docker from 'dockerode'; import type { Instance } from './store.js'; @@ -30,6 +31,32 @@ export async function ensureNetwork(): Promise { return networkName; } +// 摄像头直通:把宿主的 v4l2 视频设备映射进实例容器 +// (浏览器摄像头 → KasmVNC → 容器内 /dev/videoN(v4l2loopback) → 微信)。 +// 来源优先级: +// 1) WOC_VIDEO_DEVICES 显式指定(逗号分隔,如 /dev/video0,/dev/video1)——Ubuntu/无法自动探测时用; +// 2) 自动探测:把宿主 /dev 以只读挂到面板的 /host-dev(compose 可选),扫描其中的 videoN。 +// 一个都找不到则返回空:音频/麦克风不受影响,仅摄像头不可用(优雅降级)。 +function videoDevices(): string[] { + const explicit = (process.env.WOC_VIDEO_DEVICES || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (explicit.length) return explicit; + for (const dir of ['/host-dev', '/dev']) { + try { + if (!existsSync(dir)) continue; + const vids = readdirSync(dir) + .filter((n) => /^video\d+$/.test(n)) + .map((n) => `/dev/${n}`); // 宿主侧设备路径 + if (vids.length) return vids; + } catch { + /* 无权限/不可读,忽略 */ + } + } + return []; +} + function envList(inst: Instance): string[] { return [ `PUID=${PUID}`, @@ -62,19 +89,27 @@ export async function runInstance(inst: Instance): Promise { } catch { /* 不存在,正常 */ } + // 摄像头设备(探测不到则为空数组 → 仅摄像头不可用,音频/麦克风照常) + const vids = videoDevices(); + const hostConfig: Docker.HostConfig = { + Binds: [`${inst.volumeName}:/config`], + NetworkMode: net || undefined, + SecurityOpt: ['seccomp=unconfined'], + ShmSize: SHM_SIZE, + RestartPolicy: { Name: 'unless-stopped' }, + }; + if (vids.length) { + hostConfig.Devices = vids.map((d) => ({ PathOnHost: d, PathInContainer: d, CgroupPermissions: 'rwm' })); + hostConfig.GroupAdd = ['video']; // 让容器内 abc 用户能访问 /dev/videoN + console.log(`[docker] 实例 ${inst.id} 挂载摄像头设备: ${vids.join(', ')}`); + } const container = await docker.createContainer({ name: inst.containerName, Image: WECHAT_IMAGE, Hostname: inst.containerName, Env: envList(inst), ExposedPorts: { '3000/tcp': {} }, - HostConfig: { - Binds: [`${inst.volumeName}:/config`], - NetworkMode: net || undefined, - SecurityOpt: ['seccomp=unconfined'], - ShmSize: SHM_SIZE, - RestartPolicy: { Name: 'unless-stopped' }, - }, + HostConfig: hostConfig, }); await container.start(); } @@ -178,6 +213,79 @@ export async function pullImage(onProgress?: (line: any) => void): Promise }); } +// ---------- 文件中转(上传/下载) ---------- +// 中转目录 = abc 家目录下的 Desktop(/config 持久卷)。上传落这里,微信文件选择器可直接选到; +// 反向:把微信收到的文件另存到桌面,即可在面板里下载。 +const TRANSFER_DIR = '/config/Desktop'; + +// 极简单文件 tar 编码(putArchive 需要 tar;避免引入第三方依赖)。 +function tarSingleFile(name: string, content: Buffer): Buffer { + const h = Buffer.alloc(512, 0); + h.write(name.slice(0, 100), 0, 'utf8'); // name + h.write('0000644\0', 100); // mode + h.write('0001750\0', 108); // uid 1000(octal 1750) + h.write('0001750\0', 116); // gid 1000 + h.write(content.length.toString(8).padStart(11, '0') + '\0', 124); // size + h.write('00000000000\0', 136); // mtime + h.write(' ', 148); // checksum 占位(8 空格) + 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), Buffer.alloc(1024, 0)]); +} + +// 校验文件名为安全 basename(防路径穿越)。 +function safeName(name: string): boolean { + return !!name && name.length <= 200 && !name.includes('/') && !name.includes('\0') && name !== '.' && name !== '..'; +} + +export async function uploadToInstance(inst: Instance, name: string, content: Buffer): Promise { + if (!safeName(name)) throw new Error('文件名不合法'); + await execCapture(inst, ['sh', '-c', `mkdir -p ${TRANSFER_DIR}`]); // abc 家目录可写 + const c = docker.getContainer(inst.containerName); + await c.putArchive(tarSingleFile(name, content), { path: TRANSFER_DIR }); +} + +export interface TransferFile { + name: string; + size: number; +} +export async function listInstanceFiles(inst: Instance): Promise { + const out = await execCapture(inst, [ + 'sh', + '-c', + `find ${TRANSFER_DIR} -maxdepth 1 -type f -printf '%f\\t%s\\n' 2>/dev/null`, + ]); + return out + .split('\n') + .filter(Boolean) + .map((line) => { + const [name, size] = line.split('\t'); + return { name, size: Number(size) || 0 }; + }); +} + +export async function downloadFromInstance(inst: Instance, name: string): Promise { + if (!safeName(name)) throw new Error('文件名不合法'); + const c = docker.getContainer(inst.containerName); + const stream = (await c.getArchive({ path: `${TRANSFER_DIR}/${name}` })) as NodeJS.ReadableStream; + const chunks: Buffer[] = []; + await new Promise((resolve, reject) => { + stream.on('data', (d: Buffer) => chunks.push(d)); + stream.on('end', () => resolve()); + stream.on('error', reject); + }); + const tar = Buffer.concat(chunks); + if (tar.length < 512) return Buffer.alloc(0); + const sizeStr = tar.toString('ascii', 124, 135).replace(/\0/g, '').trim(); + const size = parseInt(sizeStr, 8) || 0; + return tar.subarray(512, 512 + size); +} + // 实例容器名(供反代构造 target)。 export function instanceTarget(inst: Instance): string { return `http://${inst.containerName}:3000`; diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 45f83e8..c61915e 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -24,6 +24,7 @@ import { userCanAccess, createInstance, removeInstance as removeInstanceRecord, + renameInstance, setInstanceUsers, publicInstance, type User, @@ -38,6 +39,9 @@ import { triggerWechat, wechatStatus, instanceTarget, + uploadToInstance, + listInstanceFiles, + downloadFromInstance, } from './docker.js'; import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js'; @@ -56,6 +60,8 @@ initStore(); const app = Fastify({ logger: true, trustProxy: true }); await app.register(cookie); +// 文件上传走原始二进制(前端以 application/octet-stream 直传 File) +app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body)); // ---------- 鉴权辅助 ---------- function currentUser(req: FastifyRequest): User | null { @@ -245,6 +251,30 @@ app.delete('/api/admin/instances/:id', async (req, reply) => { return { ok: true }; }); +// 重命名实例(仅管理员):只改显示名,不动容器/卷。 +app.post('/api/admin/instances/:id/rename', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const { name } = (req.body as any) ?? {}; + try { + return { instance: renameInstance((req.params as any).id, String(name ?? '')) }; + } 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; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + await ensureRunning(inst); + return { ok: true }; + } catch (e: any) { + return reply.code(500).send({ error: '启动失败:' + (e?.message || e) }); + } +}); + // 实例侧:设置该实例可被哪些账户访问 app.post('/api/admin/instances/:id/users', async (req, reply) => { if (!requireAdmin(req, reply)) return; @@ -258,6 +288,54 @@ app.post('/api/admin/instances/:id/users', async (req, reply) => { } }); +// ---------- 文件中转(有访问权限即可用;走面板鉴权,不额外暴露) ---------- +// 上传:原始二进制直传,落到实例 ~/Desktop,微信文件选择器可直接选到。 +app.post('/api/instances/:id/upload', { bodyLimit: 512 * 1024 * 1024 }, 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 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 uploadToInstance(findInstance(id)!, name, body); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '上传失败' }); + } +}); + +// 列出可下载的中转文件 +app.get('/api/instances/:id/files', 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: '无权访问该实例' }); + try { + return { files: await listInstanceFiles(findInstance(id)!) }; + } catch { + return { files: [] }; + } +}); + +// 下载某个中转文件 +app.get('/api/instances/:id/download', 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 name = String((req.query as any)?.name || '').trim(); + try { + const buf = await downloadFromInstance(findInstance(id)!, name); + 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 || '下载失败' }); + } +}); + // 该实例的微信安装状态(有访问权限即可看) app.get('/api/instances/:id/wechat/status', async (req, reply) => { const u = requireAuth(req, reply); diff --git a/panel/server/src/store.ts b/panel/server/src/store.ts index b0f5b35..e17a441 100644 --- a/panel/server/src/store.ts +++ b/panel/server/src/store.ts @@ -14,8 +14,13 @@ export interface User { createdAt: string; // 该账户可访问的微信实例 id 列表。admin 隐式全部,忽略此字段。 allowedInstances: string[]; + // 仍在使用初始默认密码时为 true,前端据此提示尽快改密;任意一次改密/重置后清除。 + mustChangePassword?: boolean; } +// 初始默认管理员密码;管理员仍在用它时强烈提示改密。 +const DEFAULT_ADMIN_PASSWORD = 'wechat'; + export interface Instance { id: string; // 短 id,用于容器/卷命名 name: string; // 显示名 @@ -68,9 +73,19 @@ export function initStore() { } if (!data.users.some((u) => u.role === 'admin')) { const username = process.env.PANEL_ADMIN_USER || 'admin'; - const password = process.env.PANEL_ADMIN_PASSWORD || 'wechat'; - data.users.push(makeUser(username, password, 'admin')); + const password = process.env.PANEL_ADMIN_PASSWORD || DEFAULT_ADMIN_PASSWORD; + const admin = makeUser(username, password, 'admin'); + // 用默认密码初始化时标记,提醒尽快改密 + if (password === DEFAULT_ADMIN_PASSWORD) admin.mustChangePassword = true; + data.users.push(admin); console.log(`[store] 已初始化管理员账号 '${username}'`); + } else { + // 兼容旧账号文件:管理员若仍能用默认密码登录,补打"需改密"标记 + for (const u of data.users) { + if (u.role === 'admin' && u.mustChangePassword === undefined) { + u.mustChangePassword = bcrypt.compareSync(DEFAULT_ADMIN_PASSWORD, u.passwordHash); + } + } } persist(); } @@ -84,6 +99,7 @@ export function publicUser(u: User) { disabled: u.disabled, createdAt: u.createdAt, allowedInstances: u.role === 'admin' ? [] : u.allowedInstances, + mustChangePassword: !!u.mustChangePassword, }; } @@ -128,6 +144,7 @@ export function resetPassword(id: string, password: string) { const u = findById(id); if (!u) throw new Error('用户不存在'); u.passwordHash = bcrypt.hashSync(password, 10); + u.mustChangePassword = false; // 改过密就不再提示 persist(); return publicUser(u); } @@ -205,6 +222,16 @@ export function createInstance(name: string, createdBy: string, allowedUserIds: return inst; } +export function renameInstance(id: string, name: string) { + const inst = findInstance(id); + if (!inst) throw new Error('实例不存在'); + const n = (name || '').trim(); + if (!n || n.length > 30) throw new Error('实例名称为 1-30 个字符'); + inst.name = n; + persist(); + return publicInstance(inst); +} + export function removeInstance(id: string) { const inst = findInstance(id); if (!inst) throw new Error('实例不存在'); diff --git a/panel/web/public/icon-180.png b/panel/web/public/icon-180.png index a80fd67796c6560d22cbf17765cb9b6500e2a4ad..1365a1164fff6ade61d058a467b3c389f90cd2bb 100644 GIT binary patch delta 2866 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 LHRCEn--Q1F?mTRr literal 6654 zcmVrP)mAQ*zfP47~zw{mT@fW9?d;H z-BtUCq)~UzNHZh#92(W?`^)6%FY785&|p+NE?8; z00IDf0Q|!yC_j;5ivkFO^J5@5%z?w;^ng3+-+N-kwpckUNJ?Jfo^5i|%*gowUkQSX zIq)F>YRT&%CBqQ5KvH%GI&OUOgK0g7!1y#aUB{rz`+a6rh^l5uh+*OI6n8@ zxYKoXTq#Wlwyp-q{T!H|PkI-T;ioS6s0%J4Lm`ro?-YRpNO1#6t|Q8lh_ED*Jm1Eb zALoQ_?O$^DE+^~cn06ZW{>vPw`aR%SpDV4Gst8CP)KE8MP^YROr~q^@gjoVXBaRr; za;7K1(*|L;1W&*3s1xNoBIt&FTWV--qCyEtRI2t!Fz+xEC zV=%zROByA>7yFjp^YCQLoRpyFe6ZCM3*&wOH;$D?CKQd5AN7(K(m1U#2S=Qzpm7~Z zu0vxwbTBYJW`DC72X3^4Q?QS=*8=zQ$&k^_&ZDe2~} zv+jycw2l)NbY1&bPLrtqG=L@9GbU)1eQ1_yKuPvul1xXKB@ksv#Fz$Fcn3pJ0Z6#XEq07)_*-fK8aJ}NG{Gn?3!Z-OpYkm$Z$a0$Z#k0?`X>wp4O#F| z6{ji<8Nb3POQJJ1Sisn%pqge#>bR zq0a+kru|tBbz_cFZ}F8lIJ)&Pdi6*?*$bu_jgk*Dlv>Nk2;&^bQv(RIM7Ec7OO&18 zv-I2V8h#Wb$tcq%A5e1dWDP!)5OfMrC4gX3?h zAO?-BGvfQ)iP(outaxDD@YI~sbUIhRmrcwy)SV&bI7{J()+@v;$Vr@O!&*tD?-3BR zDgo5cj4m1gEK7L2-^rElxGi+UTU&1c;Xk$C_fQq{)J9O#%cCrb4^jg##u+#l2<%W0 z%vBrUH~G~J935I;)~Rzs_;r2D?;mqUaZEwi?cF?IqVn6YO=qh_!UDC~(gBRJBs#SI zi9}ur1Rx+dRc)|z77fnup4Nj@=IBS1Nz$_5(z`pd<&}L(2LPq?ehzLC^T@vhN=RC~ z37*2SRKJ*>7EBmE+#=6co8YDj0DuyLIZ6OWv_5-7=_7`IAAq&l^2+K%2j04M4M0}g z@3+V`@R-J#HEy6o>l1EUhp zjTc5)5`FrFBm4w{ap~0~mcS)9QP3#+GSYF7uVT+OOFz&e=z!||3^?Y210yDbqDiVr zUvtjUsSU!MMwl?Cty3EW=RAGgEY%FHZnCCZ5UB6}6iiqIO*s3q{S}R}Z#dD=b*~;l zlBdf5j)hgVC3y;cMkIYr0W?UyjC2I}GEAfeUH8_l=fbwtfJBDE|3)yt&};K=1cG8= z4>fL(y_sRC1ZM@_zU8cu%k)!;<63)$4U*5YY^qO>iV#pi$uKyx^wlKL;a1w7a8$cP zYTdI9IVoqAQ+b!N-dh?(QU%tP4*yZilqjid5vT} zO-SyuVcVbCZ}U;2#+b{^1%i@bkLB>uj4j301aX~78Q~eLQOt3voM&bx5R{szkpwq& z;p_`-0f14`CKJxv>|H(c1~Cb)iCAYr*=kFjRJhG`9&&~t*f}1*i%DL8JM*=vN67mQz(F1 z^BsF4u}O@eQm|`L$rN<044}sBQb?RyTnwEV!sQTD1a>V-pmuH6P;l!RfcD)YMKF?} zQm|_=%Lb4&GHffmM`NFdGvNkA6D_C&IA<_x(XLehJ__4HS$hjDq%5cu>@9Sby{Czs zq1cg-ys@qYf^w2Wo8ky6K~*7(23jyx$VZ!U<}D#es|7Drapx%;@ad)xTeklNfa{yj$2*BGY&`NH z{u(`2rWy*Iq^diGZmQsk#hY5WuJ&ON`Bnf^3l9M2#csIp#H+fFf&=6XKWTWX38mM zm(-n}-aI>B(xR?Az3lZDmX9IhUe&rB3*9XfdYzjpcx=&4xGZpn!!;6=g6u9w6_|Pb zS)r&z#={G5#AS77l>L1OE6l34g?=y5J*n5LM8<;)zko~XmO4};K`E$OLI2<2ek82> zHWZATDtL6!CL!u6M3zorby%Ms#*ez5o%9DZDr(VDoC+qnzOZ7O`3v+>7rdrD5BgFO z7?W{GCxApKmU&vS)Z2;%*^hobI-%+5m%{JjBejb#*VQno`p87Wnuc@mMx+DpCweB8 zmtf*hP&Ar$Pl_5^#wJJ=HT5kKiqExNfQ>UQLZdQT@i^14z2_C&)A{>?c5#Zc6gC|C zK7O_27Oe2kpH$u4RKfPPn{n;o2e3QXKB>F}#iL?;XoX1l?*-T4U*~+nB4%LdsIMDl zT!39|n^B+T@R;!k8F8#V_#M0!IWp-wszk;ki#LiPy$Ysc1if|k$8kwu{LWXKXD05Q z_o;EyWJ8F0`cM($&<&CgH?&-kSA{REYr}{93kohHq{p%LKoLY;0iU0+QLBhijtlGB zEc@x@;?ls<36&EvVpx0N+s=x*e))vzBCI%85iZ9ZwZ5SAb4^Dy7fQ&8J1pu=xU62d zAC*Ey2s&Bd{&|x`t9iPf-2n}JwE9&LwX!*_d6_V;Zv90Dx!scHS#`= zxlyj2nc(U(xTv*qJ*{;GyUF=ANVolRIXpbJpyNND`YABEhMWa%a zzK*dZqPFu2NzDrzEoyC1YSE&M?ty+N-iegyjJRs2R`TGvyh0tv~Zm=Rcg_T6eh~tX=&bc2yU$P08)t~7=%>?;SwX41- zV@BXb9TKs3-va)5G`~bI3;EP`1bKXIFgSbJ3`Poa8k?#0PwJAn9#$a>nrMTm?4UXistLHDc@!*feXK&9pgqGaok3aZt?NLd-=Whc-nEJ&X`@bs##005;EA<*=B4 zB*a`4M5iz(Ld?}gb|2b)U8N1Q<6=G_#9UH@4?W7}IjeBTLLugAV_FJYh`D+hqf==$A#CHcg1OdFSD^W0VVh7fbr zGc5!y#5{ed`l;`m=X}gDF+Xs$;;1= z+}fNPzPQPzEjZ0^;qYNxM|;yVK7$o`YrO#!c<_fXI9z2%R;PbT@C=)a_}KU zjYM8)AL}el%RItV89|sT^&Ej#z>lhE_IBhr*1dTj`cmO>KZNgp@JDRgKePt0RCRkN zdUD)4RSJh=C(Hgl7GhQ1LIc2a0|&9OA1aT2Fm)r{IVNw zocCX7bvKl)4!O9#{Rh~2@|AK|BaK2NwE1SIue-HDbZZ&s6xthYp!EMJN@rbK5FPOz zq_|$zdPMX%u5bSVVuqIGt1CZ0Ilc!wPri(De_!JpGVWEq%TdX^7(9%RzxH2vr(%Z1 zfH>3epYJ}7Ee9%amM{;d^{Vem;Ell}Sn=ZRxV-snT-tOd=6RaRGMN$7V|aD&J?!Xw zse*%(Y-G`9)dnX`C3J4!sqWYDRQK!UuF8~0xEzA2qH3qU1=UDyZB}jgsGv&8p-tg( z2&xJpXhBsW1TCm4rq#|#f~gHhZf%ND4#5;6w>E{#A*d>ZpaoThIJ{mkwa5{BBwA=e zRWa>0JQqx5a%)peeG8@*V(MEkoe+W+R24$df~rC+!4gbevM<3By+XlMA&&rm(Lf8P z3i)VL_|Sr?LX1NTrWSdOLtAb!TYxDcQ)|O(LG?n3)uFI!6#$n39s}S@CxQX9#w9`^ znrJ~KKnY;hqFt*1q7*>He8;YOI>nA85L7Rct%c6AV{BLk=V5@%7`ekncCQ~sCX#5N z1r>obvbX3koN>E0LzIJB_9NApZb_k1v0Ip+GO%kgY11lzFr{E0*b|wLA#hQpSoISK z3c{>K4xZ8Ya`0m^XE+FgHRgmV!$|)cTqMh6qCijv%v#i#4sIWj<2Wb-JmdVcxRHcP zB>;d&Qb0-8ArgV0WKcrjkra$5I$>zYGIw~05|9i*M~!6qZvq0J`0c=%?QO|8keGP0!W%4M0IOopX@45Ed_!+@!B*P)syxl$#{b8m9hFaG5+M$e*tj8 z$dkB{LW(iS!{dInk{6wPitOqxs7h)jZ+e%c6gQACQuZV-VZ+Wi1porF$Nn^=$I^eR zREAd)t+b##_#~HQ$S#zMXQbiadw`K>JElKlKTRm8MJ(3=#S42HVZusb0oAI#U@DT4 zMq=z4U|7(J`kog6SWZ}s8cEB*b-n5vnl%&%N{B=#0!lj62pZE7Gcx?=u6TXV%fLtr zz4BQd9NXC@1Obev>9m_H(R!RtHQ0{*2)f*yz_H zmH?0@wFXp#lL|_NL?{{+fBM?sY)H^H7$4PcTF|L=zkCBgyE&o38TwMA8Aybp*;NOD zpcFK_YAq3Vy;?Y1gx%iMmA~9Oa$$+bBb?uDZ_E3VAuzD?b+cLn7nR}Y{}o}vZ043^ zG^sV|YX*jXEu4{#fZqiZ7C|R^0>1#m5%X(wW}quEm`+0g%yu`F`~3>5a+~F9utef_ zB?h50!3TfI)fu*toly6!#@Ma`{sKb9G!^)OS+k^fS7C%IF46xVWul!xpyMo4|7N} z0D$A&gw7wbzdmzd`O$X``IAczgOh8_w>74Ng2Fc}VgkS~doZZQL?oWT5fmz6zPH8F z5$s8ZhlYuO~n{{7~(r zvUvc`Hs4P%193w`K=Gx6u2m|dM)n}A$AwQV$P1ZBnD3jB?w0|>UO==rBPJgJ2>6e* zt9E^TygYL@shTVCPXPAW???0mj>q~eZiZV@vA{P|jD84m;+0%j=${FXq^9q2a2${I zA*^SHhXQC%GO{ICzIg>^xOc3d&nR91Fw6eTFS~K7x7o5y69-3kVi4WQL3?t608OqM z%yidVmQ*p$a4goFUCbOOs4RUXv3C2>TzTgmX{0`}b~(88XK>WppLr!$Rv$W|CvYO( zk0i@jT_Bi-xFrR%-3{=`)-|d+H_#F7$rct00E3*7i&7hQzL`%M1%=MZJJx@c622Qi z#;RF`N;t*aoZ)V8aP%g_=t&M{3oH>#14e?{np`!ORv7@IMgkwidb4%_0mK>baee)+ z=khJ9;8@UMQU4l1M%WzzL9@FSGi)+rr|`qf4uL%lXG%;zHWw@2}kR@A78hCG4d3EW!W<( zXmZt{#T6KHQGmvD45ng;>Ip=Rgc$CdqEJHMm)-D6ZqzB>G514coTED#L~k-Y-XvT* zrEGO#-4h)XsmtV+pZx54x8jd{lW^QRRuY*=Xmr(}QLPzw;)R1FZfHod6q1GxjisP- z1B`Rwdshe&p=nd80+%GiO;xxhCBrv1V{nE(EsVZoIETB*AJIJ8O<4V#*f@#C#gX+? z%Fe4kOE~`?0RLENBtp@k)}UVXi47p92}zd1fEGo6_LU}PA|T`@ZT+rCCtIdt3j{qo z*0&m9cL2CHS6Z*+LO}JQPVtJBhT-fyO(nj0qArFV$5*_{k6m>M zq5OLQ+VV;7mK69TH+-@iZb^YkWpS>{6oK=hEo~DSZ1LH&M`-b{C`a~iu zkAmR7RJ7)YxZ%OffQ?B~T!rkpNLG3pJ_V55z;R|#%Mw&NdkJut*3!4#zHG`#O4j75 zzgV{n60QaKbsU&ks&WP8g8-czWG5$Vr@rA=&zGpo60>8yBpdu6LD|KGVI>GY3_yhE zRR>9MyvzxC29!OcLw*Sxc9wKnxcpA(Z&rJisB$JamJq_*80caGehPp(Lg42degHM) zuBHG05GDi>&Jh7H$U%-#&JS@)4uRodN}_LJ!_KU+pQYme0a)v>4)k?Ru>b%707*qo IM6N<$f;`EDg#Z8m diff --git a/panel/web/public/icon-192.png b/panel/web/public/icon-192.png index fc93a6d5da517d319bc5ccb55446013cba1e3114..f3fad248c46199dea0ded2a48c79bae60955eacc 100644 GIT binary patch literal 3091 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 literal 7122 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 IM6N<$f*rtG4FCWD diff --git a/panel/web/public/icon-512.png b/panel/web/public/icon-512.png index 940b43a98426971a6665ec62a79455298917a15f..ada061ce4283230cba961f65e10fe0222a230c41 100644 GIT binary patch literal 8739 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 DO>?y% diff --git a/panel/web/scripts/gen-icons.mjs b/panel/web/scripts/gen-icons.mjs index 12e8b6c..5f512cd 100644 --- a/panel/web/scripts/gen-icons.mjs +++ b/panel/web/scripts/gen-icons.mjs @@ -1,11 +1,14 @@ -// 生成 PWA / Apple 图标:微信绿圆角方块(纯前端依赖,无需外部工具)。 -// 想换成更精致的图标,直接用设计稿替换 public/icon-*.png 即可。 +// 生成 PWA / Apple 图标:绿底 macOS 终端风格(白色 `>_` 提示符 + 左上小圆点)。 +// 纯 Node 实现,无需外部工具(rsvg/imagemagick),保证本地 / Docker / CI 构建都产出一致图标。 +// 与 public/favicon.svg 同一视觉;改图标时两边一起改(坐标基于 100×100 视图)。 import { deflateSync } from 'node:zlib'; import { writeFileSync, mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -const COLOR = [7, 193, 96]; // #07C160 +const TOP = [0x13, 0xd8, 0x73]; // 顶部亮绿 #13D873 +const BOT = [0x05, 0xa8, 0x52]; // 底部深绿 #05A852 +const WHITE = [255, 255, 255]; const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'public'); const CRC_TABLE = (() => { @@ -33,34 +36,70 @@ function chunk(type, data) { return Buffer.concat([len, typeBuf, data, crc]); } +// 点到线段距离(用于画带圆角端点的笔画) +function distToSeg(px, py, ax, ay, bx, by) { + const dx = bx - ax; + const dy = by - ay; + const len2 = dx * dx + dy * dy || 1; + let t = ((px - ax) * dx + (py - ay) * dy) / len2; + t = Math.max(0, Math.min(1, t)); + const cx = ax + t * dx; + const cy = ay + t * dy; + return Math.hypot(px - cx, py - cy); +} + +const clamp01 = (v) => Math.max(0, Math.min(1, v)); + function makePng(size) { - const radius = Math.round(size * 0.22); + const s = size / 100; // 视图 100 → 像素 + const radius = 23 * s; // 圆角,与 favicon 一致 + const stroke = (7.5 / 2) * s; // 笔画半宽 + // 提示符坐标(基于 100 视图) + const chevron = [ + [34, 30], + [48, 44], + [34, 58], + ].map(([x, y]) => [x * s, y * s]); + const underline = [ + [56, 60], + [72, 60], + ].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; + const cx = x < r ? r : x > size - r ? size - r : x; + const cy = y < r ? r : y > size - r ? size - r : y; + if (cx === x && cy === y) return 1; + const d = Math.hypot(x - cx, y - cy); + return clamp01(r - d + 0.5); // 边缘 1px 抗锯齿 + }; + const rowLen = size * 4 + 1; const raw = Buffer.alloc(rowLen * size); - const inRounded = (x, y) => { - // 圆角:四角之外的像素透明 - const corners = [ - [radius, radius], - [size - radius, radius], - [radius, size - radius], - [size - radius, size - radius], - ]; - if ((x < radius || x >= size - radius) && (y < radius || y >= size - radius)) { - const cx = x < radius ? corners[0][0] : corners[1][0]; - const cy = y < radius ? corners[0][1] : corners[2][1]; - return (x - cx) ** 2 + (y - cy) ** 2 <= radius ** 2; - } - return true; - }; for (let y = 0; y < size; y++) { raw[y * rowLen] = 0; // filter type 0 + const gy = y + 0.5; + const mix = gy / size; // 竖直渐变系数 + const bg = [ + Math.round(TOP[0] + (BOT[0] - TOP[0]) * mix), + Math.round(TOP[1] + (BOT[1] - TOP[1]) * mix), + Math.round(TOP[2] + (BOT[2] - TOP[2]) * mix), + ]; for (let x = 0; x < size; x++) { + const gx = x + 0.5; + // 白色提示符覆盖度(取各形状最大值) + let cov = 0; + 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; - const on = inRounded(x, y); - raw[o] = COLOR[0]; - raw[o + 1] = COLOR[1]; - raw[o + 2] = COLOR[2]; - raw[o + 3] = on ? 255 : 0; + raw[o] = Math.round(bg[0] + (WHITE[0] - bg[0]) * cov); + raw[o + 1] = Math.round(bg[1] + (WHITE[1] - bg[1]) * cov); + raw[o + 2] = Math.round(bg[2] + (WHITE[2] - bg[2]) * cov); + raw[o + 3] = Math.round(255 * inRounded(gx, gy)); } } const ihdr = Buffer.alloc(13); diff --git a/panel/web/src/App.tsx b/panel/web/src/App.tsx index 90d81a9..366ad54 100644 --- a/panel/web/src/App.tsx +++ b/panel/web/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { AuthProvider, useAuth } from './auth'; +import { UIProvider } from './ui'; import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; import Admin from './pages/Admin'; @@ -57,8 +58,10 @@ function Shell() { export default function App() { return ( - - - + + + + + ); } diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index 050863a..019914c 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -5,6 +5,7 @@ export interface PanelUser { disabled: boolean; createdAt: string; allowedInstances: string[]; // admin 为空数组(隐式全部) + mustChangePassword?: boolean; // 仍在用默认密码时为 true } export type WechatPhase = 'idle' | 'downloading' | 'extracting' | 'installing' | 'done' | 'error'; @@ -38,7 +39,14 @@ async function req(path: string, opts: RequestInit = {}): Promise { headers, }); const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error((data as any).error || `请求失败 (${res.status})`); + if (!res.ok) { + // 会话过期:除登录/探测接口外,任意接口收到 401 都说明 cookie 失效,直接回登录页(避免页面卡在错误态) + const isAuthProbe = path.includes('/api/auth/login') || path.includes('/api/auth/me'); + if (res.status === 401 && !isAuthProbe && location.pathname !== '/login') { + location.assign('/login'); + } + throw new Error((data as any).error || `请求失败 (${res.status})`); + } return data as T; } @@ -72,6 +80,8 @@ export const api = { method: 'POST', body: JSON.stringify({ name, allowedUserIds }), }), + renameInstance: (id: string, name: string) => + req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }), deleteInstance: (id: string, purge = false) => req(`/api/admin/instances/${id}${purge ? '?purge=1' : ''}`, { method: 'DELETE' }), setInstanceUsers: (id: string, userIds: string[]) => @@ -79,4 +89,19 @@ export const api = { instanceWechatStatus: (id: string) => req<{ status: WechatStatus }>(`/api/instances/${id}/wechat/status`), instanceWechatInstall: (id: string) => req(`/api/admin/instances/${id}/wechat/install`, { method: 'POST' }), instanceWechatUpdate: (id: string) => req(`/api/admin/instances/${id}/wechat/update`, { method: 'POST' }), + instanceStart: (id: string) => req(`/api/admin/instances/${id}/start`, { method: 'POST' }), + + // 文件中转 + listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`), + uploadFile: async (id: string, file: File) => { + const res = await fetch(`/api/instances/${id}/upload?name=${encodeURIComponent(file.name)}`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'content-type': 'application/octet-stream' }, + body: file, + }); + if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as any).error || '上传失败'); + return res.json(); + }, + downloadFileUrl: (id: string, name: string) => `/api/instances/${id}/download?name=${encodeURIComponent(name)}`, }; diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index 05b996d..1a254a9 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { api, type PanelUser, type InstanceWithStatus } from '../api'; +import { useUI, PasswordInput } from '../ui'; export default function Admin() { const nav = useNavigate(); + const { toast, confirm } = useUI(); const [users, setUsers] = useState([]); const [instances, setInstances] = useState([]); const [err, setErr] = useState(''); @@ -11,6 +13,9 @@ export default function Admin() { const [creatingInst, setCreatingInst] = useState(false); const [assignInst, setAssignInst] = useState(null); // 给实例选账户 const [assignUser, setAssignUser] = useState(null); // 给账户选实例 + const [resetTarget, setResetTarget] = useState(null); // 重置密码弹窗 + const [deleteInst, setDeleteInst] = useState(null); // 删除实例弹窗 + const [renameInst, setRenameInst] = useState(null); // 重命名实例弹窗 const subs = users.filter((u) => u.role !== 'admin'); @@ -32,31 +37,23 @@ export default function Admin() { const usersForInstance = (id: string) => subs.filter((u) => u.allowedInstances.includes(id)); const toggle = async (u: PanelUser) => { - await api.setDisabled(u.id, !u.disabled).catch((e) => alert(e.message)); - load(); - }; - const reset = async (u: PanelUser) => { - const pw = prompt(`为 ${u.username} 设置新密码(至少 6 位)`); - if (!pw) return; try { - await api.resetUser(u.id, pw); - alert('已重置'); + await api.setDisabled(u.id, !u.disabled); + toast(u.disabled ? '已启用' : '已禁用', 'ok'); } catch (e: any) { - alert(e.message); + toast(e.message, 'error'); } + load(); }; const removeUser = async (u: PanelUser) => { - if (!confirm(`确定删除子账号 ${u.username}?`)) return; - await api.deleteUser(u.id).catch((e) => alert(e.message)); - load(); - }; - const removeInst = async (inst: InstanceWithStatus) => { - if (!confirm(`删除实例「${inst.name}」?容器会被移除,但聊天记录(数据卷)会保留。`)) return; - let purge = false; - if (confirm('是否同时永久删除该实例的聊天记录(数据卷)?此操作不可恢复。\n\n确定=连数据一起删,取消=仅删容器保留数据')) { - purge = true; + const ok = await confirm({ title: `删除子账号「${u.username}」?`, body: '该账户将无法再登录。', danger: true, confirmText: '删除' }); + if (!ok) return; + try { + await api.deleteUser(u.id); + toast('已删除', 'ok'); + } catch (e: any) { + toast(e.message, 'error'); } - await api.deleteInstance(inst.id, purge).catch((e) => alert(e.message)); load(); }; @@ -88,10 +85,13 @@ export default function Admin() { 可访问账户 {usersForInstance(inst.id).length} 人
+ -
@@ -136,7 +136,7 @@ export default function Admin() { - + + + + + ); +} + +function ResetPassword({ user, onClose, onDone }: { user: PanelUser; onClose: () => void; onDone: () => void }) { + const [pw, setPw] = useState(''); + const [err, setErr] = useState(''); + const [busy, setBusy] = useState(false); + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setErr(''); + setBusy(true); + try { + await api.resetUser(user.id, pw); + onDone(); + } catch (e: any) { + setErr(e.message || '重置失败'); + } finally { + setBusy(false); + } + }; + return ( +
+
e.stopPropagation()} onSubmit={submit}> +

重置「{user.username}」的密码

+ + {err &&
{err}
} +
+ + +
+ +
+ ); +} + +function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) { + const [purge, setPurge] = useState(false); + const [err, setErr] = useState(''); + const [busy, setBusy] = useState(false); + const submit = async () => { + setErr(''); + setBusy(true); + try { + await api.deleteInstance(inst.id, purge); + onDone(); + } catch (e: any) { + setErr(e.message || '删除失败'); + setBusy(false); + } + }; + return ( +
+
e.stopPropagation()} style={{ maxWidth: 360 }}> +

删除实例「{inst.name}」?

+
+ 容器会被移除。默认保留聊天记录(数据卷),之后可重建同名实例恢复。 +
+ + {err &&
{err}
} +
+ + +
+
); } @@ -257,7 +404,7 @@ function CreateUser({ instances, onClose, onDone }: { instances: InstanceWithSta value={username} onChange={(e) => setUsername(e.target.value)} /> - setPassword(e.target.value)} /> +
可访问的微信实例
({ id: i.id, label: i.name }))} diff --git a/panel/web/src/pages/Dashboard.tsx b/panel/web/src/pages/Dashboard.tsx index 39242d3..90fe5ae 100644 --- a/panel/web/src/pages/Dashboard.tsx +++ b/panel/web/src/pages/Dashboard.tsx @@ -1,16 +1,19 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../auth'; +import { useUI, PasswordInput } from '../ui'; import { api, type InstanceWithStatus } from '../api'; const BUSY_PHASES = ['downloading', 'extracting', 'installing']; export default function Dashboard() { - const { user, logout } = useAuth(); + const { user, logout, refresh } = useAuth(); + const { toast, confirm } = useUI(); const nav = useNavigate(); const [showPw, setShowPw] = useState(false); const [instances, setInstances] = useState(null); const [err, setErr] = useState(''); + const [starting, setStarting] = useState>(new Set()); const timer = useRef(undefined); const isAdmin = user?.role === 'admin'; @@ -48,8 +51,28 @@ export default function Dashboard() { ); 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)); + 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; + }); } }; @@ -57,7 +80,12 @@ export default function Dashboard() {
云微 -
@@ -68,6 +96,16 @@ export default function Dashboard() { {isAdmin && 管理员}
+ {user?.mustChangePassword && ( + + )} + {err &&
{err}
}
@@ -81,7 +119,7 @@ export default function Dashboard() { {instances && instances.length === 0 && (
-
📱
+
还没有微信实例
{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}
@@ -89,7 +127,15 @@ export default function Dashboard() {
{instances?.map((inst) => ( - nav(`/desktop/${inst.id}`)} onTrigger={trigger} /> + nav(`/desktop/${inst.id}`)} + onTrigger={trigger} + onStart={() => start(inst)} + /> ))}
@@ -107,7 +153,7 @@ export default function Dashboard() {
- {showPw && setShowPw(false)} />} + {showPw && setShowPw(false)} onSaved={() => refresh()} />} ); } @@ -115,13 +161,17 @@ export default function Dashboard() { function InstanceCard({ inst, 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; const busy = BUSY_PHASES.includes(wx.phase); @@ -135,7 +185,8 @@ function InstanceCard({ else badge = { text: '待安装', cls: 'tag-warn' }; let sub: string; - if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…'; + if (offline) sub = inst.runtime === 'missing' ? '容器尚未创建' : '容器已停止,需先启动'; + else if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…'; else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试'; else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装'; else sub = '微信尚未安装'; @@ -160,9 +211,15 @@ function InstanceCard({ )}
- + {offline && isAdmin ? ( + + ) : ( + + )} {isAdmin && !busy && !offline && ( installed ? (