Compare commits
5 Commits
@@ -4,10 +4,18 @@
|
||||
WOC_USER=admin
|
||||
WOC_PASSWORD=wechat
|
||||
|
||||
# 镜像版本:默认 latest;上线后建议钉到具体版本(如 v1.0.0)以便可控升级。
|
||||
# 镜像版本:默认 latest;上线后建议钉到具体版本(如 1.0.0,注意不带 v)以便可控升级。
|
||||
# 同时作用于面板镜像和新建微信实例所用镜像。
|
||||
WOC_VERSION=latest
|
||||
|
||||
# 镜像源前缀(registry + 命名空间)。默认 GHCR 官方。
|
||||
# 中国大陆网络拉 ghcr.io 常 TLS 超时,可改用 GHCR 反代镜像(前提:GHCR 包已设为 Public):
|
||||
# WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 南京大学镜像站反代 ghcr.io(较稳定)
|
||||
# 反代会把 ghcr.io/gloridust/xxx 映射为 <反代>/gloridust/xxx。
|
||||
# 若用自己的国内仓库(阿里云 ACR / 腾讯 TCR / 华为 SWR),填该仓库的完整前缀即可,例如:
|
||||
# WOC_IMAGE_PREFIX=registry.cn-hangzhou.aliyuncs.com/你的命名空间
|
||||
WOC_IMAGE_PREFIX=ghcr.io/gloridust
|
||||
|
||||
# 宿主用户 uid/gid(飞牛上用 `id` 命令查看;单用户 NAS 一般是 1000)。
|
||||
# 透传给每个微信实例容器,决定面板数据与微信数据卷的属主。
|
||||
WOC_PUID=1000
|
||||
@@ -19,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=
|
||||
|
||||
@@ -140,3 +140,4 @@ dist
|
||||
# WechatOnCloud: 面板账号数据(含密码哈希,勿提交)
|
||||
/data-panel/
|
||||
/.claude
|
||||
.DS_Store
|
||||
|
||||
@@ -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] 已重置用户 '<用户名>' 的密码`。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# 镜像全部从 GHCR 拉取,无需本地构建。要改配置:复制 .env.example 为 .env 后修改。
|
||||
services:
|
||||
panel:
|
||||
image: ghcr.io/gloridust/woc-panel:${WOC_VERSION:-latest}
|
||||
# 镜像源前缀默认 GHCR;大陆网络拉不动时改 .env 的 WOC_IMAGE_PREFIX 切到国内反代(见 .env.example)。
|
||||
image: ${WOC_IMAGE_PREFIX:-ghcr.io/gloridust}/woc-panel:${WOC_VERSION:-latest}
|
||||
container_name: woc-panel
|
||||
# pull_policy 用默认(missing):本地已有同名镜像就直接用,没有才去 GHCR 拉。
|
||||
# 这样「发布前本地自构建」与「线上拉取」都能用同一份 compose。
|
||||
@@ -11,12 +12,15 @@ services:
|
||||
|
||||
environment:
|
||||
- PORT=8080
|
||||
# 新建微信实例时使用的镜像(多架构,amd64/arm64 自动匹配)
|
||||
- WOC_WECHAT_IMAGE=ghcr.io/gloridust/wechat-on-cloud:${WOC_VERSION:-latest}
|
||||
# 新建微信实例时使用的镜像(多架构,amd64/arm64 自动匹配);前缀同样跟随 WOC_IMAGE_PREFIX。
|
||||
- WOC_WECHAT_IMAGE=${WOC_IMAGE_PREFIX:-ghcr.io/gloridust}/wechat-on-cloud:${WOC_VERSION:-latest}
|
||||
# 透传给每个微信实例容器(KasmVNC 基础镜像用它们降权运行)
|
||||
- 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}
|
||||
@@ -28,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" # 面板 = 唯一对外入口
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<void> {
|
||||
} 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<void>
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 文件中转(上传/下载) ----------
|
||||
// 中转目录 = 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<void> {
|
||||
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<TransferFile[]> {
|
||||
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<Buffer> {
|
||||
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<void>((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`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,8 +14,17 @@ export interface User {
|
||||
createdAt: string;
|
||||
// 该账户可访问的微信实例 id 列表。admin 隐式全部,忽略此字段。
|
||||
allowedInstances: string[];
|
||||
// 仍在使用初始默认密码时为 true,前端据此提示尽快改密;任意一次改密/重置后清除。
|
||||
mustChangePassword?: boolean;
|
||||
// 离线密码找回:在 accounts.json 手动把某用户置为 true,重启面板即重置其密码并清除此标记。
|
||||
// 兼容下划线写法 reset_password。
|
||||
resetPassword?: boolean;
|
||||
reset_password?: boolean;
|
||||
}
|
||||
|
||||
// 初始默认管理员密码;管理员仍在用它时强烈提示改密。
|
||||
const DEFAULT_ADMIN_PASSWORD = 'wechat';
|
||||
|
||||
export interface Instance {
|
||||
id: string; // 短 id,用于容器/卷命名
|
||||
name: string; // 显示名
|
||||
@@ -68,9 +77,32 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 离线密码找回:忘记超管密码时,停掉面板 → 在 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();
|
||||
}
|
||||
@@ -84,6 +116,7 @@ export function publicUser(u: User) {
|
||||
disabled: u.disabled,
|
||||
createdAt: u.createdAt,
|
||||
allowedInstances: u.role === 'admin' ? [] : u.allowedInstances,
|
||||
mustChangePassword: !!u.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +161,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 +239,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('实例不存在');
|
||||
|
||||
@@ -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: 6.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -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,68 @@ 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 = [
|
||||
[28, 24],
|
||||
[42, 38],
|
||||
[28, 52],
|
||||
].map(([x, y]) => [x * s, y * s]);
|
||||
const underline = [
|
||||
[50, 54],
|
||||
[66, 54],
|
||||
].map(([x, y]) => [x * s, y * s]);
|
||||
|
||||
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));
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 (
|
||||
<AuthProvider>
|
||||
<Shell />
|
||||
</AuthProvider>
|
||||
<UIProvider>
|
||||
<AuthProvider>
|
||||
<Shell />
|
||||
</AuthProvider>
|
||||
</UIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
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)}`,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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();
|
||||
const [users, setUsers] = useState<PanelUser[]>([]);
|
||||
const [instances, setInstances] = useState<InstanceWithStatus[]>([]);
|
||||
const [err, setErr] = useState('');
|
||||
@@ -11,8 +15,13 @@ export default function Admin() {
|
||||
const [creatingInst, setCreatingInst] = useState(false);
|
||||
const [assignInst, setAssignInst] = useState<InstanceWithStatus | null>(null); // 给实例选账户
|
||||
const [assignUser, setAssignUser] = useState<PanelUser | null>(null); // 给账户选实例
|
||||
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 {
|
||||
@@ -26,37 +35,70 @@ 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));
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -79,25 +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={() => setAssignInst(inst)}>
|
||||
分配账户
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={() => removeInst(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>
|
||||
@@ -136,7 +180,7 @@ export default function Admin() {
|
||||
<button className="btn-text" onClick={() => toggle(u)}>
|
||||
{u.disabled ? '启用' : '禁用'}
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => reset(u)}>
|
||||
<button className="btn-text" onClick={() => setResetTarget(u)}>
|
||||
重置密码
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={() => removeUser(u)}>
|
||||
@@ -191,6 +235,250 @@ export default function Admin() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{resetTarget && (
|
||||
<ResetPassword
|
||||
user={resetTarget}
|
||||
onClose={() => setResetTarget(null)}
|
||||
onDone={() => {
|
||||
setResetTarget(null);
|
||||
toast('密码已重置', 'ok');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{deleteInst && (
|
||||
<DeleteInstance
|
||||
inst={deleteInst}
|
||||
onClose={() => setDeleteInst(null)}
|
||||
onDone={() => {
|
||||
setDeleteInst(null);
|
||||
toast('实例已删除', 'ok');
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{renameInst && (
|
||||
<RenameInstance
|
||||
inst={renameInst}
|
||||
onClose={() => setRenameInst(null)}
|
||||
onDone={() => {
|
||||
setRenameInst(null);
|
||||
toast('已重命名', 'ok');
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenameInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
|
||||
const [name, setName] = useState(inst.name);
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.renameInstance(inst.id, name.trim());
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '重命名失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>重命名实例</h2>
|
||||
<input className="input" placeholder="实例名称" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={busy || !name.trim() || name.trim() === inst.name}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '重置失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="modal-mask" onClick={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} />
|
||||
<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 || pw !== confirm}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<div className="card modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 360 }}>
|
||||
<h2>删除实例「{inst.name}」?</h2>
|
||||
<div className="muted" style={{ fontSize: 14, lineHeight: 1.5 }}>
|
||||
容器会被移除。默认保留聊天记录(数据卷),之后可重建同名实例恢复。
|
||||
</div>
|
||||
<label className={'purge-opt' + (purge ? ' on' : '')} onClick={() => setPurge((v) => !v)}>
|
||||
<span className="purge-check">{purge ? '✓' : ''}</span>
|
||||
<span>
|
||||
同时永久删除聊天记录(数据卷)
|
||||
<span className="muted small" style={{ display: 'block' }}>不可恢复,请谨慎勾选</span>
|
||||
</span>
|
||||
</label>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger" disabled={busy} onClick={submit}>
|
||||
{purge ? '连数据一起删除' : '删除实例'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 管理页的实例卡片:含微信版本管理(下载/更新)+ 重命名/分配/删除
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -257,7 +545,7 @@ function CreateUser({ instances, onClose, onDone }: { instances: InstanceWithSta
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input className="input" type="password" placeholder="初始密码(至少 6 位)" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<PasswordInput placeholder="初始密码(至少 6 位)" autoComplete="new-password" value={password} onChange={setPassword} />
|
||||
<div className="field-label">可访问的微信实例</div>
|
||||
<ChipMultiSelect
|
||||
options={instances.map((i) => ({ id: i.id, label: i.name }))}
|
||||
|
||||
@@ -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<InstanceWithStatus[] | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
const [starting, setStarting] = useState<Set<string>>(new Set());
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
@@ -36,20 +39,21 @@ export default function Dashboard() {
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, [instances]);
|
||||
|
||||
const trigger = async (inst: InstanceWithStatus, kind: 'install' | 'update') => {
|
||||
const start = async (inst: InstanceWithStatus) => {
|
||||
setErr('');
|
||||
setStarting((s) => new Set(s).add(inst.id));
|
||||
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);
|
||||
await api.instanceStart(inst.id);
|
||||
toast('实例已启动', 'ok');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '操作失败');
|
||||
toast(e.message || '启动失败', 'error');
|
||||
} finally {
|
||||
setStarting((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(inst.id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,7 +61,12 @@ export default function Dashboard() {
|
||||
<div className="page">
|
||||
<header className="topbar">
|
||||
<span className="topbar-title">云微</span>
|
||||
<button className="btn-text" onClick={() => logout()}>
|
||||
<button
|
||||
className="btn-text"
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '退出登录?', confirmText: '退出' })) logout();
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</header>
|
||||
@@ -68,6 +77,16 @@ export default function Dashboard() {
|
||||
{isAdmin && <span className="tag">管理员</span>}
|
||||
</div>
|
||||
|
||||
{user?.mustChangePassword && (
|
||||
<button className="warn-banner" onClick={() => setShowPw(true)}>
|
||||
<span className="warn-icon">!</span>
|
||||
<span className="warn-text">
|
||||
<b>你还在使用默认密码</b>
|
||||
<span>该系统登录着你的微信,请立即修改密码 ›</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{err && <div className="error">{err}</div>}
|
||||
|
||||
<div className="section-row">
|
||||
@@ -81,7 +100,7 @@ export default function Dashboard() {
|
||||
|
||||
{instances && instances.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-blob">📱</div>
|
||||
<div className="empty-blob"><img src="/favicon.svg" alt="" /></div>
|
||||
<div className="empty-title">还没有微信实例</div>
|
||||
<div className="empty-sub">{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}</div>
|
||||
</div>
|
||||
@@ -89,7 +108,14 @@ export default function Dashboard() {
|
||||
|
||||
<div className="inst-grid">
|
||||
{instances?.map((inst) => (
|
||||
<InstanceCard key={inst.id} inst={inst} isAdmin={isAdmin} onEnter={() => nav(`/desktop/${inst.id}`)} onTrigger={trigger} />
|
||||
<InstanceCard
|
||||
key={inst.id}
|
||||
inst={inst}
|
||||
isAdmin={isAdmin}
|
||||
starting={starting.has(inst.id)}
|
||||
onEnter={() => nav(`/desktop/${inst.id}`)}
|
||||
onStart={() => start(inst)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -107,7 +133,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showPw && <ChangePassword onClose={() => setShowPw(false)} />}
|
||||
{showPw && <ChangePassword onClose={() => setShowPw(false)} onSaved={() => refresh()} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -115,13 +141,15 @@ 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 +163,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,38 +189,42 @@ function InstanceCard({
|
||||
)}
|
||||
|
||||
<div className="inst-actions">
|
||||
<button className="btn btn-primary inst-enter" disabled={!canEnter} onClick={onEnter}>
|
||||
进入微信
|
||||
</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>
|
||||
)
|
||||
{offline && isAdmin ? (
|
||||
<button className="btn btn-primary inst-enter" disabled={starting} onClick={onStart}>
|
||||
{starting ? '启动中…' : inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary inst-enter" disabled={!canEnter} onClick={onEnter}>
|
||||
进入微信
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangePassword({ onClose }: { onClose: () => void }) {
|
||||
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);
|
||||
setMsg('修改成功');
|
||||
onSaved?.(); // 刷新当前用户,清除「默认密码」提示
|
||||
setTimeout(onClose, 800);
|
||||
} catch (e: any) {
|
||||
setMsg(e.message || '修改失败');
|
||||
@@ -204,14 +237,16 @@ function ChangePassword({ onClose }: { onClose: () => void }) {
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>修改密码</h2>
|
||||
<input className="input" type="password" placeholder="原密码" value={oldPassword} onChange={(e) => setOld(e.target.value)} />
|
||||
<input className="input" type="password" placeholder="新密码(至少 6 位)" value={newPassword} onChange={(e) => setNew(e.target.value)} />
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { useUI } from '../ui';
|
||||
|
||||
// 直接加载 KasmVNC 的 noVNC 页面(由 kclient 静态托管)。
|
||||
// 反代按实例隔离:所有桌面流量走 /desktop/<id>/*,网关据 <id> 选目标容器并注入该实例凭据。
|
||||
@@ -10,19 +13,186 @@ function desktopUrl(id: string) {
|
||||
);
|
||||
}
|
||||
|
||||
interface TFile {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
function humanSize(n: number) {
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
|
||||
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
|
||||
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
export default function Desktop() {
|
||||
const nav = useNavigate();
|
||||
const { toast } = useUI();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const [files, setFiles] = useState<TFile[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const dragDepth = useRef(0);
|
||||
|
||||
// 文件拖到窗口任意位置时,弹出落区(覆盖 iframe,否则 drop 会被 iframe 吞掉)
|
||||
useEffect(() => {
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes('Files');
|
||||
const onEnter = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current++;
|
||||
setDragging(true);
|
||||
};
|
||||
const onOver = (e: DragEvent) => {
|
||||
if (hasFiles(e)) e.preventDefault();
|
||||
};
|
||||
const onLeave = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDragging(false);
|
||||
};
|
||||
const onDropWin = (e: DragEvent) => {
|
||||
if (hasFiles(e)) e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDragging(false);
|
||||
};
|
||||
window.addEventListener('dragenter', onEnter);
|
||||
window.addEventListener('dragover', onOver);
|
||||
window.addEventListener('dragleave', onLeave);
|
||||
window.addEventListener('drop', onDropWin);
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', onEnter);
|
||||
window.removeEventListener('dragover', onOver);
|
||||
window.removeEventListener('dragleave', onLeave);
|
||||
window.removeEventListener('drop', onDropWin);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!id) {
|
||||
nav('/', { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshFiles = async () => {
|
||||
try {
|
||||
const { files } = await api.listFiles(id);
|
||||
setFiles(files);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (list: FileList | File[]) => {
|
||||
const arr = Array.from(list);
|
||||
if (!arr.length) return;
|
||||
setUploading(true);
|
||||
let ok = 0;
|
||||
for (const f of arr) {
|
||||
try {
|
||||
await api.uploadFile(id, f);
|
||||
ok++;
|
||||
} catch (e: any) {
|
||||
toast(`${f.name}: ${e.message || '上传失败'}`, 'error');
|
||||
}
|
||||
}
|
||||
setUploading(false);
|
||||
if (ok) {
|
||||
toast(`已上传 ${ok} 个文件到桌面,微信里可直接选取`, 'ok');
|
||||
refreshFiles();
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
dragDepth.current = 0;
|
||||
if (e.dataTransfer.files?.length) uploadFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="desktop-wrap">
|
||||
<iframe className="desktop-frame" src={desktopUrl(id)} title="电脑版微信" allow="clipboard-read; clipboard-write" />
|
||||
<iframe
|
||||
className="desktop-frame"
|
||||
src={desktopUrl(id)}
|
||||
title="电脑版微信"
|
||||
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
|
||||
{!loaded && (
|
||||
<div className="desktop-loading">
|
||||
<div className="spinner" />
|
||||
<div className="desktop-loading-text">正在连接桌面…</div>
|
||||
<div className="desktop-loading-sub">首次进入请扫码登录微信</div>
|
||||
<div className="desktop-loading-sub">拖文件到窗口即可上传;语音/视频在左侧工具条开启</div>
|
||||
{!window.isSecureContext && (
|
||||
<div className="desktop-loading-warn">当前非 HTTPS 访问,浏览器将禁用麦克风与摄像头(音频播放不受影响)</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拖拽落区:仅拖入文件时出现,覆盖 iframe 接住 drop */}
|
||||
{dragging && (
|
||||
<div className="drop-zone" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
<div className="drop-card">
|
||||
<div className="drop-icon">⬇</div>
|
||||
<div className="drop-title">松开上传到微信桌面</div>
|
||||
<div className="drop-sub">上传后在微信里「+ / 文件」选择即可</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="desktop-back" onClick={() => nav('/')} title="返回">
|
||||
‹
|
||||
</button>
|
||||
|
||||
{/* 文件按钮 */}
|
||||
<button
|
||||
className="desktop-files-btn"
|
||||
title="文件传输"
|
||||
onClick={() => {
|
||||
setShowFiles((v) => !v);
|
||||
if (!showFiles) refreshFiles();
|
||||
}}
|
||||
>
|
||||
⇅
|
||||
</button>
|
||||
|
||||
{showFiles && (
|
||||
<div className="files-panel">
|
||||
<div className="files-head">
|
||||
<span>文件传输</span>
|
||||
<button className="btn-text" onClick={() => setShowFiles(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) uploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary files-upload" disabled={uploading} onClick={() => fileInput.current?.click()}>
|
||||
{uploading ? '上传中…' : '+ 选择文件上传'}
|
||||
</button>
|
||||
<div className="files-hint">也可直接把文件拖到窗口。下方为桌面(~/Desktop)里的文件,微信收到的文件另存到桌面即可在此下载。</div>
|
||||
<div className="files-list">
|
||||
{files.length === 0 && <div className="muted small" style={{ padding: '10px 2px' }}>暂无文件</div>}
|
||||
{files.map((f) => (
|
||||
<a key={f.name} className="files-item" href={api.downloadFileUrl(id, f.name)} download={f.name}>
|
||||
<span className="files-name">{f.name}</span>
|
||||
<span className="files-size">{humanSize(f.size)} ↓</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth';
|
||||
import { PasswordInput } from '../ui';
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
@@ -40,13 +41,7 @@ export default function Login() {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<PasswordInput placeholder="密码" autoComplete="current-password" value={password} onChange={setPassword} />
|
||||
{err && <div className="error">{err}</div>}
|
||||
<button className="btn btn-primary" disabled={busy || !username || !password}>
|
||||
{busy ? '登录中…' : '登录'}
|
||||
|
||||
@@ -177,6 +177,33 @@ button {
|
||||
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.16), 0 0 0 3px rgba(var(--green-rgb) / 0.22);
|
||||
}
|
||||
|
||||
/* 密码框 + 显示/隐藏切换 */
|
||||
.pw-field {
|
||||
position: relative;
|
||||
}
|
||||
.pw-field .input {
|
||||
padding-right: 46px;
|
||||
}
|
||||
.pw-toggle {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.pw-toggle:active {
|
||||
background: rgba(var(--shadow) / 0.06);
|
||||
}
|
||||
|
||||
/* 按钮:浮起 blob,按下缩小 + 折痕收紧 */
|
||||
.btn {
|
||||
position: relative;
|
||||
@@ -221,6 +248,18 @@ button {
|
||||
.btn-primary:active:not(:disabled) {
|
||||
background: var(--wx-green-dark);
|
||||
}
|
||||
/* 危险动作按钮:红底,折痕用 danger 色 */
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(var(--danger-rgb) / 0.45), 0 4px 14px rgba(var(--danger-rgb) / 0.3);
|
||||
}
|
||||
.btn-danger::before {
|
||||
background: radial-gradient(ellipse at 50% 22%, rgba(255, 255, 255, 0.26) 0%, rgba(255, 255, 255, 0.07) 42%, transparent 72%);
|
||||
}
|
||||
.btn-danger:active:not(:disabled) {
|
||||
background: #e84444;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
@@ -287,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;
|
||||
@@ -559,6 +609,125 @@ button {
|
||||
transform: scale(0.94);
|
||||
box-shadow: var(--crease-press);
|
||||
}
|
||||
/* 文件按钮:返回钮下方 */
|
||||
.desktop-files-btn {
|
||||
position: fixed;
|
||||
top: max(12px, env(safe-area-inset-top));
|
||||
left: 64px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
box-shadow: var(--crease);
|
||||
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.desktop-files-btn:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* 拖拽落区 */
|
||||
.drop-zone {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
background: rgba(7, 193, 96, 0.16);
|
||||
-webkit-backdrop-filter: blur(3px);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.drop-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--crease);
|
||||
padding: 28px 36px;
|
||||
text-align: center;
|
||||
border: 2px dashed rgba(var(--green-rgb) / 0.5);
|
||||
}
|
||||
.drop-icon {
|
||||
font-size: 36px;
|
||||
color: var(--wx-green);
|
||||
}
|
||||
.drop-title {
|
||||
margin-top: 8px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.drop-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 文件传输面板 */
|
||||
.files-panel {
|
||||
position: fixed;
|
||||
top: max(64px, calc(env(safe-area-inset-top) + 52px));
|
||||
left: 12px;
|
||||
width: min(330px, calc(100vw - 24px));
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--crease);
|
||||
z-index: 11;
|
||||
padding: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
.files-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.files-upload {
|
||||
height: 42px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.files-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.files-list {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.files-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 11px 4px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
box-shadow: inset 0 -1px 0 rgba(var(--shadow) / 0.08);
|
||||
}
|
||||
.files-item:last-child {
|
||||
box-shadow: none;
|
||||
}
|
||||
.files-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.files-size {
|
||||
flex: none;
|
||||
color: var(--wx-green-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── loading ───────────────────────────────────────────── */
|
||||
.spinner {
|
||||
@@ -645,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 {
|
||||
@@ -730,6 +917,11 @@ button {
|
||||
border-radius: inherit;
|
||||
background: var(--sheen);
|
||||
}
|
||||
.empty-blob img {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
position: relative;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
@@ -740,3 +932,157 @@ button {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── 默认密码安全告警条 ──────────────────────────────────── */
|
||||
.warn-banner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: rgba(var(--danger-rgb) / 0.1);
|
||||
border-radius: var(--r-blob);
|
||||
padding: 14px 16px;
|
||||
margin: 4px 0 14px;
|
||||
box-shadow: inset 0 0 0 1.5px rgba(var(--danger-rgb) / 0.3);
|
||||
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.warn-banner:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
.warn-icon {
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.warn-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
}
|
||||
.warn-text b {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── 删除实例:勾选是否连数据卷一起删 ──────────────────────── */
|
||||
.purge-opt {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--r-small);
|
||||
background: var(--trough);
|
||||
font-size: 14px;
|
||||
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.14);
|
||||
transition: box-shadow 0.18s;
|
||||
}
|
||||
.purge-opt.on {
|
||||
box-shadow: inset 0 0 0 1.5px rgba(var(--danger-rgb) / 0.45);
|
||||
}
|
||||
.purge-check {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--crease-press);
|
||||
color: var(--danger);
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.purge-opt.on .purge-check {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Toast ──────────────────────────────────────────────── */
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: max(24px, env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
max-width: 80vw;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 11px 18px;
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--crease);
|
||||
animation: toast-in 0.32s cubic-bezier(0.2, 0.9, 0.2, 1);
|
||||
}
|
||||
.toast-ok {
|
||||
color: var(--wx-green-dark);
|
||||
}
|
||||
.toast-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 桌面连接 loading 遮罩 ──────────────────────────────── */
|
||||
.desktop-loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
background: var(--base);
|
||||
}
|
||||
.desktop-loading-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.desktop-loading-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.desktop-loading-warn {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
text-align: center;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
// ── Toast ───────────────────────────────────────────────
|
||||
type ToastKind = 'ok' | 'error' | 'info';
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
text: string;
|
||||
kind: ToastKind;
|
||||
}
|
||||
|
||||
// ── Confirm ─────────────────────────────────────────────
|
||||
interface ConfirmOpts {
|
||||
title: string;
|
||||
body?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface UICtx {
|
||||
toast: (text: string, kind?: ToastKind) => void;
|
||||
confirm: (opts: ConfirmOpts) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<UICtx>(null!);
|
||||
|
||||
export function UIProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const [confirmState, setConfirmState] = useState<(ConfirmOpts & { resolve: (v: boolean) => void }) | null>(null);
|
||||
const seq = useRef(0);
|
||||
|
||||
const toast = useCallback((text: string, kind: ToastKind = 'info') => {
|
||||
const id = ++seq.current;
|
||||
setToasts((list) => [...list, { id, text, kind }]);
|
||||
setTimeout(() => setToasts((list) => list.filter((t) => t.id !== id)), 2600);
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback(
|
||||
(opts: ConfirmOpts) => new Promise<boolean>((resolve) => setConfirmState({ ...opts, resolve })),
|
||||
[],
|
||||
);
|
||||
|
||||
const close = (v: boolean) => {
|
||||
confirmState?.resolve(v);
|
||||
setConfirmState(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ toast, confirm }}>
|
||||
{children}
|
||||
<div className="toast-stack">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={'toast toast-' + t.kind}>
|
||||
{t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{confirmState && (
|
||||
<div className="modal-mask" onClick={() => close(false)}>
|
||||
<div className="card modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 340 }}>
|
||||
<h2>{confirmState.title}</h2>
|
||||
{confirmState.body && <div className="muted" style={{ fontSize: 14, lineHeight: 1.5 }}>{confirmState.body}</div>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={() => close(false)}>
|
||||
{confirmState.cancelText || '取消'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'btn ' + (confirmState.danger ? 'btn-danger' : 'btn-primary')}
|
||||
onClick={() => close(true)}
|
||||
>
|
||||
{confirmState.confirmText || '确定'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useUI = () => useContext(Ctx);
|
||||
|
||||
// ── 带"显示/隐藏"切换的密码输入框 ────────────────────────
|
||||
export function PasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="pw-field">
|
||||
<input
|
||||
className="input"
|
||||
type={show ? 'text' : 'password'}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="pw-toggle"
|
||||
tabIndex={-1}
|
||||
aria-label={show ? '隐藏密码' : '显示密码'}
|
||||
onClick={() => setShow((s) => !s)}
|
||||
>
|
||||
{show ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20C5 20 1 12 1 12a18.5 18.5 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19M1 1l22 22" />
|
||||
<path d="M9.5 9.5a3 3 0 0 0 4.24 4.24" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||