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 a80fd67..1365a11 100644 Binary files a/panel/web/public/icon-180.png and b/panel/web/public/icon-180.png differ diff --git a/panel/web/public/icon-192.png b/panel/web/public/icon-192.png index fc93a6d..f3fad24 100644 Binary files a/panel/web/public/icon-192.png and b/panel/web/public/icon-192.png differ diff --git a/panel/web/public/icon-512.png b/panel/web/public/icon-512.png index 940b43a..ada061c 100644 Binary files a/panel/web/public/icon-512.png and b/panel/web/public/icon-512.png differ 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 ? (