mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
feat(panel): 管理员数据卷管理(整卷备份/恢复 + 文件浏览器)
管理页 → 实例「管理」→ 数据卷(仅 admin)。解决大量用户"把 PC 微信数据迁移上 docker"的诉求。 - 整卷备份:流式打包 /config 为 .tar.gz 下载(大文件不入内存);恢复:上传覆盖回 /config。 machine-id 存在卷内随包迁移 → 跨 woc 实例恢复可保留聊天记录。 - 文件浏览器:浏览/上传/上传并解压(.tar/.tar.gz)/下载/改名/移动/删除;PC 数据打包上传解压后重启实例。 - 全程在运行中的实例上操作(exec + docker cp,运行容器才可 exec);恢复为全量覆盖,强提示并建议重启。 - 安全:仅 admin;路径严格限制在 /config、禁止 .. 穿越;上传落地为 abc 属主。 - docker.ts 抽出 extractSingleFileFromTar 复用(PAX 头跳过),新增 list/mkdir/move/delete/upload/ extract/download/backup(stream)/restore;index.ts 加 9 个 /volume 管理路由;前端 VolumeManager 弹窗 + 线性 SVG 图标(替代渲染不一致的 emoji);新增 doc/数据卷管理.md。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,7 @@
|
||||
| [运行原理与 Docker 指南](doc/运行原理.md) | 工作原理 + 架构图;面向 Docker 新手的逐步拆解、常用命令、架构自动适配 |
|
||||
| [部署与运维](doc/部署与运维.md) | 数据持久化、常见问题排查、忘记超管密码的离线找回、目录结构 |
|
||||
| [设备伪装与风控应对](doc/设备伪装.md) | 唯一 machine-id / 真实 hostname / os-release 伪装;账号被微信强制退出循环时怎么办 |
|
||||
| [数据卷管理与迁移](doc/数据卷管理.md) | 管理员在面板里备份/恢复整卷、上传 PC 微信数据、浏览管理实例 /config 文件 |
|
||||
| [发布到 GHCR](doc/发布到GHCR.md) | 用 GitHub Actions 或本机 buildx 把镜像发布到 GHCR |
|
||||
| [技术方案](doc/技术方案.md) | 完整设计文档与选型权衡 |
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 数据卷管理与数据迁移
|
||||
|
||||
> 返回 [← README](../README.md)
|
||||
|
||||
**管理员专用。** 每个实例的「数据卷」= 容器内 `/config` 持久卷,含微信全部数据(登录态、加密聊天库、配置等)。
|
||||
入口:**管理页 → 实例卡片「管理」→ 数据卷**。子账号不可见(数据卷等同完整微信会话凭据)。
|
||||
|
||||
提供两块能力:**整卷备份 / 恢复** 与 **文件浏览器**(浏览 / 上传 / 上传并解压 / 下载 / 改名 / 移动 / 删除)。
|
||||
|
||||
---
|
||||
|
||||
## 整卷备份 / 恢复(最可靠)
|
||||
|
||||
- **下载整卷备份**:把 `/config` 流式打包成 `.tar.gz` 下载(大文件不进内存,边打包边下)。用于离线备份、换宿主、跨实例迁移。
|
||||
- **恢复备份**:上传 `.tar.gz` 覆盖写回 `/config`;恢复后在卡片上「重启」实例以加载数据。
|
||||
- **为什么能保留聊天记录**:唯一设备标识存在卷内的 `.woc-machine-id`,会随备份一起迁移 → 目标实例解密环境一致,聊天记录可还原。这也是 woc 实例之间迁移的推荐方式。详见 [设备伪装](设备伪装.md)。
|
||||
|
||||
> 备份大小 ≈ 卷大小(微信数据常为数百 MB ~ 数 GB),请按网络情况耐心等待。
|
||||
|
||||
---
|
||||
|
||||
## 把 PC 微信数据迁移上来
|
||||
|
||||
1. 在 PC 上把微信数据文件夹打包成 `.tar.gz`;
|
||||
2. 数据卷 → 进入目标目录 → 「**上传并解压**」(也支持 `.tar`);
|
||||
3. 在卡片上「重启」实例。
|
||||
|
||||
> ⚠️ **能否解密取决于微信版本与设备绑定**:微信本地库通常按设备/账号加密,跨设备直接复制数据库**不保证**能在新实例打开,请自行测试。最可靠的是「本系统导出的整卷备份」在 woc 实例间恢复。
|
||||
|
||||
---
|
||||
|
||||
## 安全与注意
|
||||
|
||||
- **仅管理员**可用;子账号永不可见(数据卷 = 完整微信会话凭据)。管理员本就持有 `docker.sock`(宿主 root),不新增风险。
|
||||
- 所有路径严格限制在 `/config` 内,禁止 `..` 穿越。
|
||||
- **全程在「运行中」的实例上操作**(底层用 `docker exec` 浏览/改名/删除、用 `docker cp` 传输)。改动微信正在使用的数据后,需**重启实例**方可生效;大改动前建议先「下载整卷备份」留底。
|
||||
- 实例**未运行**时:只能做整卷备份 / 恢复;文件浏览需实例运行中。
|
||||
- 上传的文件落地为容器内 `abc` 属主(微信进程可读)。
|
||||
+126
-4
@@ -1,6 +1,7 @@
|
||||
import { hostname } from 'node:os';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import zlib from 'node:zlib';
|
||||
import Docker from 'dockerode';
|
||||
import type { Instance } from './store.js';
|
||||
|
||||
@@ -522,10 +523,13 @@ export async function downloadFromInstance(inst: Instance, name: string): Promis
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', reject);
|
||||
});
|
||||
const tar = Buffer.concat(chunks);
|
||||
// 解析 tar,定位真正的普通文件块。Docker(Go archive/tar) 在 mtime 含纳秒精度等情况下会先写一个
|
||||
// PAX 扩展头块(typeflag 'x'),旧代码误把它当文件头、读到的是扩展记录长度 → 返回错误长度的数据
|
||||
// ("大小不对")。这里跳过 PAX/全局('x'/'g')与 GNU 长名('L'/'K')等扩展头,找到普通文件('0'/NUL)再取内容。
|
||||
return extractSingleFileFromTar(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
// 从 docker getArchive 返回的 tar 中取出第一个普通文件的内容。Docker(Go archive/tar) 在 mtime 含纳秒精度等
|
||||
// 情况下会先写一个 PAX 扩展头块(typeflag 'x'),把它误当文件头会读到扩展记录长度 → 返回错误长度的数据
|
||||
// ("大小不对")。这里跳过 PAX/全局('x'/'g')与 GNU 长名('L'/'K')等扩展头,找到普通文件('0'/NUL)再取内容。
|
||||
function extractSingleFileFromTar(tar: Buffer): Buffer {
|
||||
let off = 0;
|
||||
while (off + 512 <= tar.length) {
|
||||
const header = tar.subarray(off, off + 512);
|
||||
@@ -578,6 +582,124 @@ export async function typeInInstance(inst: Instance, text: string): Promise<void
|
||||
await execCapture(inst, ['bash', '-c', cmd]);
|
||||
}
|
||||
|
||||
// ---------- 数据卷管理(仅管理员;路由层用 requireAdmin 限制) ----------
|
||||
// 数据卷 = 容器内 /config 持久卷,含微信全部数据(登录态、加密聊天库等)。提供浏览/上传/解压/下载/
|
||||
// 改名/移动/删除 + 整卷备份/恢复。主要场景:把 PC 微信数据迁移上来、跨实例迁移、离线备份。
|
||||
// 路径安全:所有相对路径经 safeVolPath 归一化并严格限制在 /config 内,禁止 .. 穿越。
|
||||
const VOL_ROOT = '/config';
|
||||
|
||||
// 把用户给的相对路径安全解析为 /config 下的绝对路径;禁止 .. 与 NUL;剥离前导 /。
|
||||
function safeVolPath(rel: string): string {
|
||||
const raw = (rel ?? '').replace(/\\/g, '/');
|
||||
if (raw.includes('\0')) throw new Error('路径不合法');
|
||||
const parts: string[] = [];
|
||||
for (const seg of raw.split('/')) {
|
||||
if (seg === '' || seg === '.') continue;
|
||||
if (seg === '..') throw new Error('路径不合法(禁止 ..)');
|
||||
parts.push(seg);
|
||||
}
|
||||
return parts.length ? `${VOL_ROOT}/${parts.join('/')}` : VOL_ROOT;
|
||||
}
|
||||
const relOf = (abs: string): string => (abs === VOL_ROOT ? '' : abs.slice(VOL_ROOT.length + 1));
|
||||
// gzip 魔数自动识别(用户上传可能是 .tar 或 .tar.gz;本系统备份恒为 .gz)。
|
||||
const maybeGunzip = (buf: Buffer): Buffer =>
|
||||
buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b ? zlib.gunzipSync(buf) : buf;
|
||||
|
||||
export interface VolEntry {
|
||||
name: string;
|
||||
type: 'dir' | 'file' | 'link' | 'other';
|
||||
size: number;
|
||||
mtime: number; // epoch ms
|
||||
}
|
||||
|
||||
// 列目录(仅一层)。dirs/files 混合返回,前端排序。
|
||||
export async function listVolume(inst: Instance, rel: string): Promise<{ path: string; entries: VolEntry[] }> {
|
||||
const abs = safeVolPath(rel);
|
||||
// GNU find -printf:%y 类型(d/f/l) \t %s 大小 \t %T@ mtime(秒.纳秒) \t %f 名字。argv 直传不经 shell,名字含空格/引号也安全。
|
||||
const out = await execCapture(inst, [
|
||||
'find', abs, '-maxdepth', '1', '-mindepth', '1', '-printf', '%y\\t%s\\t%T@\\t%f\\n',
|
||||
]);
|
||||
const entries: VolEntry[] = [];
|
||||
for (const line of out.split('\n')) {
|
||||
if (!line) continue;
|
||||
const i1 = line.indexOf('\t');
|
||||
const i2 = line.indexOf('\t', i1 + 1);
|
||||
const i3 = line.indexOf('\t', i2 + 1);
|
||||
if (i1 < 0 || i2 < 0 || i3 < 0) continue;
|
||||
const y = line.slice(0, i1);
|
||||
entries.push({
|
||||
type: y === 'd' ? 'dir' : y === 'f' ? 'file' : y === 'l' ? 'link' : 'other',
|
||||
size: Number(line.slice(i1 + 1, i2)) || 0,
|
||||
mtime: Math.round(parseFloat(line.slice(i2 + 1, i3)) * 1000) || 0,
|
||||
name: line.slice(i3 + 1),
|
||||
});
|
||||
}
|
||||
return { path: relOf(abs), entries };
|
||||
}
|
||||
|
||||
export async function volMkdir(inst: Instance, rel: string): Promise<void> {
|
||||
const abs = safeVolPath(rel);
|
||||
if (abs === VOL_ROOT) throw new Error('路径不合法');
|
||||
await execCapture(inst, ['mkdir', '-p', abs]);
|
||||
}
|
||||
|
||||
export async function volMove(inst: Instance, fromRel: string, toRel: string): Promise<void> {
|
||||
const from = safeVolPath(fromRel);
|
||||
const to = safeVolPath(toRel);
|
||||
if (from === VOL_ROOT || to === VOL_ROOT) throw new Error('不能移动数据卷根目录');
|
||||
if (from === to) return;
|
||||
await execCapture(inst, ['mv', '-f', from, to]);
|
||||
}
|
||||
|
||||
export async function volDelete(inst: Instance, rel: string): Promise<void> {
|
||||
const abs = safeVolPath(rel);
|
||||
if (abs === VOL_ROOT) throw new Error('不能删除数据卷根目录');
|
||||
await execCapture(inst, ['rm', '-rf', abs]);
|
||||
}
|
||||
|
||||
// 上传单个文件到指定目录(tarSingleFile 写入 uid/gid 1000,落地即 abc 属主,微信可读)。
|
||||
export async function volUploadFile(inst: Instance, rel: string, name: string, content: Buffer): Promise<void> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
const dir = safeVolPath(rel);
|
||||
await execCapture(inst, ['mkdir', '-p', dir]);
|
||||
await docker.getContainer(inst.containerName).putArchive(tarSingleFile(name, content), { path: dir });
|
||||
}
|
||||
|
||||
// 上传压缩包并解压到指定目录(PC 微信数据迁移:用户把文件夹打成 .tar/.tar.gz 上传)。
|
||||
// putArchive 把 tar 内容解到 dir 下,Docker 解包限制在 dir 内、防 .. 穿越。
|
||||
export async function volExtractArchive(inst: Instance, rel: string, archive: Buffer): Promise<void> {
|
||||
const dir = safeVolPath(rel);
|
||||
await execCapture(inst, ['mkdir', '-p', dir]);
|
||||
await docker.getContainer(inst.containerName).putArchive(maybeGunzip(archive), { path: dir });
|
||||
}
|
||||
|
||||
export async function volDownloadFile(inst: Instance, rel: string): Promise<Buffer> {
|
||||
const abs = safeVolPath(rel);
|
||||
if (abs === VOL_ROOT) throw new Error('不能下载整个根目录,请用整卷备份');
|
||||
const stream = (await docker.getContainer(inst.containerName).getArchive({ path: abs })) 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);
|
||||
});
|
||||
return extractSingleFileFromTar(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
// 整卷备份:把 /config 打成 tar 流并经 gzip 输出(路由直接 pipe 给响应,避免大文件入内存)。
|
||||
// getArchive('/config') 的条目前缀为 config/,恢复时解到容器根即可落回 /config。
|
||||
export async function volBackupStream(inst: Instance): Promise<NodeJS.ReadableStream> {
|
||||
const tar = (await docker.getContainer(inst.containerName).getArchive({ path: VOL_ROOT })) as NodeJS.ReadableStream;
|
||||
const gzip = zlib.createGzip();
|
||||
tar.on('error', (e) => gzip.destroy(e as Error));
|
||||
return tar.pipe(gzip);
|
||||
}
|
||||
|
||||
// 整卷恢复:仅适用于本系统导出的备份(条目前缀 config/),解到容器根 → 落回 /config。要求实例已停止。
|
||||
export async function volRestoreArchive(inst: Instance, archive: Buffer): Promise<void> {
|
||||
await docker.getContainer(inst.containerName).putArchive(maybeGunzip(archive), { path: '/' });
|
||||
}
|
||||
|
||||
// 实例容器名(供反代构造 target)。
|
||||
export function instanceTarget(inst: Instance): string {
|
||||
return `http://${inst.containerName}:3000`;
|
||||
|
||||
@@ -55,6 +55,15 @@ import {
|
||||
instanceMemoryMB,
|
||||
instanceHttpHealthy,
|
||||
regenInstanceMachineId,
|
||||
listVolume,
|
||||
volMkdir,
|
||||
volMove,
|
||||
volDelete,
|
||||
volUploadFile,
|
||||
volExtractArchive,
|
||||
volDownloadFile,
|
||||
volBackupStream,
|
||||
volRestoreArchive,
|
||||
} from './docker.js';
|
||||
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
|
||||
import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js';
|
||||
@@ -638,6 +647,144 @@ app.get('/api/admin/instances/:id/logs', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 数据卷管理(仅管理员):浏览/上传/解压/下载/改名/移动/删除 + 整卷备份/恢复 ----------
|
||||
// 数据卷 = 容器 /config,含微信完整会话与加密聊天库 → 仅 admin 可见可用(admin 本就有 docker.sock=宿主 root,
|
||||
// 不新增风险;子账号永不可达)。
|
||||
// 全程在「运行中」的实例上操作:浏览/改名/移动/删除靠 docker exec(需容器运行),上传/解压/下载/备份靠
|
||||
// getArchive/putArchive。不强制停止实例(exec 在停止容器无法运行)。整卷恢复会覆盖全部数据,前端强提示
|
||||
// 并建议恢复后重启实例以加载数据。
|
||||
|
||||
// 浏览目录(一层)
|
||||
app.get('/api/admin/instances/:id/volume', 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 {
|
||||
return await listVolume(inst, String((req.query as any)?.path || ''));
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '读取目录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 新建文件夹
|
||||
app.post('/api/admin/instances/:id/volume/mkdir', 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 volMkdir(inst, String((req.body as any)?.path || ''));
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '新建失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 重命名 / 移动
|
||||
app.post('/api/admin/instances/:id/volume/move', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
const { from, to } = (req.body as any) ?? {};
|
||||
try {
|
||||
await volMove(inst, String(from || ''), String(to || ''));
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '移动失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除文件 / 目录
|
||||
app.delete('/api/admin/instances/:id/volume', 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 volDelete(inst, String((req.query as any)?.path || ''));
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '删除失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 下载单个文件
|
||||
app.get('/api/admin/instances/:id/volume/download', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
const path = String((req.query as any)?.path || '');
|
||||
const name = path.split('/').filter(Boolean).pop() || 'file';
|
||||
try {
|
||||
const buf = await volDownloadFile(inst, path);
|
||||
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 || '下载失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 上传单个文件到当前目录(原始二进制;落地为 abc 属主)
|
||||
app.post('/api/admin/instances/:id/volume/upload', { bodyLimit: 2 * 1024 * 1024 * 1024 }, async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
const path = String((req.query as any)?.path || '');
|
||||
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 volUploadFile(inst, path, name, body);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 上传压缩包并解压到当前目录(.tar / .tar.gz;PC 微信数据迁移用)
|
||||
app.post('/api/admin/instances/:id/volume/extract', { bodyLimit: 3 * 1024 * 1024 * 1024 }, async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
const body = req.body as Buffer;
|
||||
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
|
||||
try {
|
||||
await volExtractArchive(inst, String((req.query as any)?.path || ''), body);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '解压失败(请确认是 .tar 或 .tar.gz)' });
|
||||
}
|
||||
});
|
||||
|
||||
// 整卷备份:流式下载 /config 为 .tar.gz
|
||||
app.get('/api/admin/instances/:id/volume/backup', 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 {
|
||||
const stream = await volBackupStream(inst);
|
||||
reply.header('content-type', 'application/gzip');
|
||||
reply.header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(`woc-${inst.name}-backup.tar.gz`)}`);
|
||||
return reply.send(stream);
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: e?.message || '备份失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 整卷恢复:上传本系统导出的 .tar.gz 备份(要求实例已停止)
|
||||
app.post('/api/admin/instances/:id/volume/restore', { bodyLimit: 3 * 1024 * 1024 * 1024 }, async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
const body = req.body as Buffer;
|
||||
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
|
||||
try {
|
||||
await volRestoreArchive(inst, body);
|
||||
return { ok: true };
|
||||
} 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);
|
||||
|
||||
@@ -41,6 +41,26 @@ export interface InstanceWithStatus extends PanelInstance {
|
||||
wechat: WechatStatus;
|
||||
}
|
||||
|
||||
export interface VolEntry {
|
||||
name: string;
|
||||
type: 'dir' | 'file' | 'link' | 'other';
|
||||
size: number;
|
||||
mtime: number; // epoch ms
|
||||
}
|
||||
|
||||
// 原始二进制上传(File 直传 application/octet-stream),用于数据卷上传/解压/恢复
|
||||
async function rawUpload(url: string, file: File): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
body: file,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as any).error || `请求失败 (${res.status})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function req<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
// 仅在有 body 时声明 JSON content-type:否则 Fastify 对「空 body + application/json」会报 400
|
||||
const headers = opts.body ? { 'content-type': 'application/json', ...opts.headers } : opts.headers;
|
||||
@@ -138,6 +158,25 @@ export const api = {
|
||||
downloadFileUrl: (id: string, name: string) => `/api/instances/${id}/download?name=${encodeURIComponent(name)}`,
|
||||
deleteFile: (id: string, name: string) => req(`/api/instances/${id}/files?name=${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
|
||||
// 数据卷管理(仅管理员)
|
||||
volumeList: (id: string, path = '') =>
|
||||
req<{ path: string; entries: VolEntry[] }>(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`),
|
||||
volumeMkdir: (id: string, path: string) =>
|
||||
req(`/api/admin/instances/${id}/volume/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }),
|
||||
volumeMove: (id: string, from: string, to: string) =>
|
||||
req(`/api/admin/instances/${id}/volume/move`, { method: 'POST', body: JSON.stringify({ from, to }) }),
|
||||
volumeDelete: (id: string, path: string) =>
|
||||
req(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`, { method: 'DELETE' }),
|
||||
volumeDownloadUrl: (id: string, path: string) =>
|
||||
`/api/admin/instances/${id}/volume/download?path=${encodeURIComponent(path)}`,
|
||||
volumeBackupUrl: (id: string) => `/api/admin/instances/${id}/volume/backup`,
|
||||
volumeUpload: (id: string, path: string, file: File) =>
|
||||
rawUpload(`/api/admin/instances/${id}/volume/upload?path=${encodeURIComponent(path)}&name=${encodeURIComponent(file.name)}`, file),
|
||||
volumeExtract: (id: string, path: string, file: File) =>
|
||||
rawUpload(`/api/admin/instances/${id}/volume/extract?path=${encodeURIComponent(path)}`, file),
|
||||
volumeRestore: (id: string, file: File) =>
|
||||
rawUpload(`/api/admin/instances/${id}/volume/restore`, file),
|
||||
|
||||
// 多端协作:操作控制权
|
||||
controlStatus: (id: string) => req<{ free: boolean; mine: boolean; holder: string | null }>(`/api/instances/${id}/control`),
|
||||
controlBeat: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/beat`, { method: 'POST' }),
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, type PanelUser, type InstanceWithStatus } from '../api';
|
||||
import { api, type PanelUser, type InstanceWithStatus, type VolEntry } from '../api';
|
||||
import { useUI, PasswordInput } from '../ui';
|
||||
import { useAuth } from '../auth';
|
||||
|
||||
const BUSY_PHASES = ['downloading', 'extracting', 'installing'];
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return '0 B';
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||||
return `${(n / Math.pow(1024, i)).toFixed(i ? 1 : 0)} ${u[i]}`;
|
||||
}
|
||||
function fmtDate(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const p = (x: number) => String(x).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const MenuIcon = (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -19,6 +31,41 @@ const CaretIcon = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// 数据卷文件浏览器用的小图标(线性 SVG,统一描边风格,替代渲染不一致的 emoji)
|
||||
const svgIcon = (children: JSX.Element, size = 16) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
const FolderIcon = svgIcon(<path d="M3 7a2 2 0 0 1 2-2h3.5l2 2H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />, 18);
|
||||
const FileIcon = svgIcon(
|
||||
<>
|
||||
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 3v5h5" />
|
||||
</>,
|
||||
18,
|
||||
);
|
||||
const DownloadIcon = svgIcon(
|
||||
<>
|
||||
<path d="M12 3v12" />
|
||||
<path d="M7 11l5 5 5-5" />
|
||||
<path d="M5 21h14" />
|
||||
</>,
|
||||
);
|
||||
const EditIcon = svgIcon(
|
||||
<>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z" />
|
||||
</>,
|
||||
);
|
||||
const TrashIcon = svgIcon(
|
||||
<>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
|
||||
<path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
|
||||
</>,
|
||||
);
|
||||
|
||||
// 友好空状态:圆形图标 + 标题 + 说明 + 可选引导按钮(沿用首页 .empty-state 样式)
|
||||
function EmptyState({ icon, title, sub, action }: { icon: string; title: string; sub?: string; action?: JSX.Element }) {
|
||||
return (
|
||||
@@ -47,6 +94,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
const [deleteInst, setDeleteInst] = useState<InstanceWithStatus | null>(null); // 删除实例弹窗
|
||||
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
|
||||
const [securityInst, setSecurityInst] = useState<InstanceWithStatus | null>(null); // 安全(内存阈值)弹窗
|
||||
const [volumeInst, setVolumeInst] = useState<InstanceWithStatus | null>(null); // 数据卷管理弹窗
|
||||
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
|
||||
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
|
||||
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
|
||||
@@ -257,6 +305,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
onAssign={() => setAssignInst(inst)}
|
||||
onDelete={() => setDeleteInst(inst)}
|
||||
onSecurity={() => setSecurityInst(inst)}
|
||||
onVolume={() => setVolumeInst(inst)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -479,6 +528,9 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{volumeInst && (
|
||||
<VolumeManager inst={volumeInst} onClose={() => setVolumeInst(null)} onChanged={load} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -813,6 +865,7 @@ function InstanceAdminCard({
|
||||
onAssign,
|
||||
onDelete,
|
||||
onSecurity,
|
||||
onVolume,
|
||||
}: {
|
||||
inst: InstanceWithStatus;
|
||||
userCount: number;
|
||||
@@ -827,6 +880,7 @@ function InstanceAdminCard({
|
||||
onAssign: () => void;
|
||||
onDelete: () => void;
|
||||
onSecurity: () => void;
|
||||
onVolume: () => void;
|
||||
}) {
|
||||
const wx = inst.wechat;
|
||||
const busy = BUSY_PHASES.includes(wx.phase);
|
||||
@@ -930,6 +984,9 @@ function InstanceAdminCard({
|
||||
<button className="btn-text" onClick={onSecurity} title="内存阈值自愈">
|
||||
安全
|
||||
</button>
|
||||
<button className="btn-text" onClick={onVolume} title="数据卷:备份/恢复、上传 PC 微信数据、文件管理">
|
||||
数据卷
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inst-menu-group inst-menu-danger">
|
||||
@@ -947,6 +1004,273 @@ function InstanceAdminCard({
|
||||
);
|
||||
}
|
||||
|
||||
// 数据卷管理(仅管理员):整卷备份/恢复 + 文件浏览器(浏览/上传/解压/下载/改名/移动/删除)。
|
||||
// 主要场景:把 PC 微信数据迁移上来、跨实例迁移、离线备份。全程在「运行中」的实例上操作
|
||||
// (浏览/改名/删除靠 docker exec,需容器运行)。整卷恢复会覆盖全部数据,强提示并建议恢复后重启实例。
|
||||
function VolumeManager({ inst, onClose, onChanged }: { inst: InstanceWithStatus; onClose: () => void; onChanged: () => void }) {
|
||||
const { toast, confirm } = useUI();
|
||||
const [path, setPath] = useState('');
|
||||
const [entries, setEntries] = useState<VolEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(''); // 进行中操作文案;非空即禁用界面
|
||||
const [mkdirOpen, setMkdirOpen] = useState(false);
|
||||
const [mkdirName, setMkdirName] = useState('');
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [renameVal, setRenameVal] = useState('');
|
||||
const uploadRef = useRef<HTMLInputElement>(null);
|
||||
const extractRef = useRef<HTMLInputElement>(null);
|
||||
const restoreRef = useRef<HTMLInputElement>(null);
|
||||
const offline = inst.runtime !== 'running'; // 文件浏览需实例运行中
|
||||
|
||||
const join = (a: string, b: string) => (a ? a + '/' + b : b);
|
||||
|
||||
const load = async (p = path) => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const r = await api.volumeList(inst.id, p);
|
||||
setEntries(r.entries);
|
||||
setPath(r.path);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '读取失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (offline) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
load('');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inst.id]);
|
||||
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
if ((a.type === 'dir') !== (b.type === 'dir')) return a.type === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, 'zh');
|
||||
});
|
||||
const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
|
||||
const segs = path ? path.split('/') : [];
|
||||
|
||||
const run = async (label: string, fn: () => Promise<any>, okMsg?: string, skipReload = false) => {
|
||||
setBusy(label);
|
||||
try {
|
||||
await fn();
|
||||
if (okMsg) toast(okMsg, 'ok');
|
||||
if (!skipReload) await load();
|
||||
} catch (e: any) {
|
||||
toast(e?.message || '操作失败', 'error');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const doMkdir = async () => {
|
||||
const name = mkdirName.trim();
|
||||
if (!name) return;
|
||||
await run('新建中…', () => api.volumeMkdir(inst.id, join(path, name)), '已新建文件夹');
|
||||
setMkdirName('');
|
||||
setMkdirOpen(false);
|
||||
};
|
||||
|
||||
const doRename = async (oldName: string) => {
|
||||
const nv = renameVal.trim();
|
||||
setRenaming(null);
|
||||
if (!nv || nv === oldName) return;
|
||||
// 含 / → 视为相对 /config 的目标路径(移动到子目录);否则同目录改名
|
||||
const to = nv.includes('/') ? nv.replace(/^\/+/, '') : join(path, nv);
|
||||
await run('处理中…', () => api.volumeMove(inst.id, join(path, oldName), to), '已重命名 / 移动');
|
||||
};
|
||||
|
||||
const doDelete = async (en: VolEntry) => {
|
||||
const ok = await confirm({
|
||||
title: `删除「${en.name}」?`,
|
||||
body: en.type === 'dir' ? '将递归删除该文件夹下所有内容,不可恢复。' : '删除后不可恢复。',
|
||||
danger: true,
|
||||
confirmText: '删除',
|
||||
});
|
||||
if (!ok) return;
|
||||
await run('删除中…', () => api.volumeDelete(inst.id, join(path, en.name)), '已删除');
|
||||
};
|
||||
|
||||
const onPick = (kind: 'upload' | 'extract' | 'restore') => async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
if (kind === 'restore') {
|
||||
const ok = await confirm({
|
||||
title: '恢复整卷备份?',
|
||||
body: `将用「${file.name}」覆盖该实例 /config 的全部数据(含登录态、聊天库),不可撤销。建议仅用于本系统导出的备份;恢复后请在卡片上「重启」实例以加载数据。`,
|
||||
danger: true,
|
||||
confirmText: '覆盖恢复',
|
||||
});
|
||||
if (!ok) return;
|
||||
await run(`恢复 ${file.name}…`, () => api.volumeRestore(inst.id, file), '恢复完成,请重启实例以加载数据', true);
|
||||
onChanged();
|
||||
return;
|
||||
}
|
||||
if (kind === 'upload') await run(`上传 ${file.name}…`, () => api.volumeUpload(inst.id, path, file), '上传完成');
|
||||
else await run(`解压 ${file.name}…`, () => api.volumeExtract(inst.id, path, file), '解压完成');
|
||||
};
|
||||
|
||||
const disabled = !!busy;
|
||||
const icon = (en: VolEntry) => (en.type === 'dir' ? FolderIcon : FileIcon);
|
||||
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<div className="card modal vol-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>数据卷 · {inst.name}</h2>
|
||||
|
||||
{/* 整卷备份 / 恢复(运行/停止均可用) */}
|
||||
<div className="vol-sec">
|
||||
<div className="vol-section-label">整卷备份 / 恢复</div>
|
||||
<div className="vol-topbar">
|
||||
<a className="btn" href={api.volumeBackupUrl(inst.id)} target="_blank" rel="noreferrer">下载整卷备份</a>
|
||||
<button className="btn" disabled={disabled} onClick={() => restoreRef.current?.click()}>恢复备份…</button>
|
||||
<input ref={restoreRef} type="file" accept=".gz,.tgz,.tar" hidden onChange={onPick('restore')} />
|
||||
</div>
|
||||
<div className="vol-hint">整卷含聊天记录,用于跨实例迁移 / 离线备份。</div>
|
||||
</div>
|
||||
|
||||
{offline ? (
|
||||
<div className="vol-warn">
|
||||
实例未运行,文件浏览不可用。可执行上方的整卷备份 / 恢复;要浏览或上传单个文件,请先在卡片上启动实例。
|
||||
</div>
|
||||
) : (
|
||||
<div className="vol-sec">
|
||||
<div className="vol-section-label">文件浏览</div>
|
||||
{/* 面包屑 */}
|
||||
<div className="vol-crumbs">
|
||||
<button className="vol-crumb" disabled={disabled} onClick={() => load('')}>/config</button>
|
||||
{segs.map((s, i) => (
|
||||
<span key={i}>
|
||||
<span className="vol-sep">/</span>
|
||||
<button className="vol-crumb" disabled={disabled} onClick={() => load(segs.slice(0, i + 1).join('/'))}>
|
||||
{s}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 工具条 */}
|
||||
<div className="vol-tools">
|
||||
<button className="btn-text" disabled={disabled} onClick={() => uploadRef.current?.click()}>上传文件</button>
|
||||
<button className="btn-text" disabled={disabled} onClick={() => extractRef.current?.click()}>上传并解压</button>
|
||||
<button className="btn-text" disabled={disabled} onClick={() => setMkdirOpen((v) => !v)}>新建文件夹</button>
|
||||
<button className="btn-text" disabled={disabled} onClick={() => load()}>刷新</button>
|
||||
<input ref={uploadRef} type="file" hidden onChange={onPick('upload')} />
|
||||
<input ref={extractRef} type="file" accept=".gz,.tgz,.tar" hidden onChange={onPick('extract')} />
|
||||
</div>
|
||||
{mkdirOpen && (
|
||||
<div className="vol-mkdir">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="文件夹名"
|
||||
value={mkdirName}
|
||||
autoFocus
|
||||
onChange={(e) => setMkdirName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && doMkdir()}
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={disabled || !mkdirName.trim()} onClick={doMkdir}>创建</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{busy && <div className="vol-busy">{busy}</div>}
|
||||
|
||||
{/* 文件列表 */}
|
||||
<div className="vol-list">
|
||||
{loading ? (
|
||||
<div className="muted small" style={{ padding: 16 }}>读取中…</div>
|
||||
) : err ? (
|
||||
<div className="error">{err}</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<div className="muted small" style={{ padding: 16 }}>{path ? '空目录' : '(无内容)'}</div>
|
||||
) : (
|
||||
<>
|
||||
{path && (
|
||||
<button className="vol-row vol-main vol-up" disabled={disabled} onClick={() => load(parent)}>
|
||||
<span className="vol-ic">{FolderIcon}</span>
|
||||
<span className="vol-nm">返回上一级</span>
|
||||
</button>
|
||||
)}
|
||||
{sorted.map((en) => (
|
||||
<div className="vol-row" key={en.name}>
|
||||
{renaming === en.name ? (
|
||||
<input
|
||||
className="input vol-rename"
|
||||
autoFocus
|
||||
value={renameVal}
|
||||
onChange={(e) => setRenameVal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') doRename(en.name);
|
||||
if (e.key === 'Escape') setRenaming(null);
|
||||
}}
|
||||
onBlur={() => doRename(en.name)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="vol-main"
|
||||
disabled={disabled}
|
||||
onClick={() => (en.type === 'dir' ? load(join(path, en.name)) : undefined)}
|
||||
style={{ cursor: en.type === 'dir' ? 'pointer' : 'default' }}
|
||||
>
|
||||
<span className={'vol-ic' + (en.type === 'dir' ? ' dir' : '')}>{icon(en)}</span>
|
||||
<span className="vol-nm">{en.name}</span>
|
||||
<span className="vol-meta">
|
||||
{en.type === 'dir' ? '' : fmtBytes(en.size)}
|
||||
{en.mtime ? ` · ${fmtDate(en.mtime)}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="vol-acts">
|
||||
{en.type === 'file' && (
|
||||
<a
|
||||
className="vol-act"
|
||||
title="下载"
|
||||
href={api.volumeDownloadUrl(inst.id, join(path, en.name))}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{DownloadIcon}
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
className="vol-act"
|
||||
title="重命名 / 移动"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setRenameVal(en.name);
|
||||
setRenaming(en.name);
|
||||
}}
|
||||
>
|
||||
{EditIcon}
|
||||
</button>
|
||||
<button className="vol-act danger" title="删除" disabled={disabled} onClick={() => doDelete(en)}>
|
||||
{TrashIcon}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="muted small" style={{ marginTop: 10, lineHeight: 1.6 }}>
|
||||
PC 微信数据迁移:把数据文件夹打包成 <b>.tar.gz</b>,用「上传并解压」放到对应目录;改动微信正在使用的数据后,重启实例方可生效。能否解密取决于微信版本与设备绑定,请自行测试。
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-primary" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 通用 chip 多选
|
||||
function ChipMultiSelect({
|
||||
options,
|
||||
|
||||
@@ -1812,3 +1812,215 @@ button {
|
||||
max-width: 940px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 数据卷管理(管理员)──────────────────────────────────── */
|
||||
.vol-modal {
|
||||
width: 640px;
|
||||
max-width: 92vw;
|
||||
max-height: 86vh;
|
||||
}
|
||||
/* 运行中警示条:柔和琥珀,inset 凹槽质感(不是红色危险) */
|
||||
.vol-warn {
|
||||
background: rgba(245, 166, 35, 0.14);
|
||||
color: #9a6400;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--r-small);
|
||||
}
|
||||
/* 分区容器:内部元素紧凑成组(label 紧贴其内容),分区之间靠 .modal 的 12px gap 拉开 */
|
||||
.vol-sec {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.vol-section-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
.vol-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.vol-topbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
/* 主操作:中号浮起胶囊(比默认 48px 更克制,和弹窗比例协调) */
|
||||
.vol-topbar .btn {
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
font-size: 14px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.vol-crumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vol-crumb {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--wx-green);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.vol-crumb:active {
|
||||
background: rgba(var(--green-rgb) / 0.12);
|
||||
}
|
||||
.vol-crumb:disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
}
|
||||
.vol-sep {
|
||||
color: var(--muted);
|
||||
}
|
||||
/* 工具栏:浮起小胶囊(绿字),和上面「扁平绿色面包屑」明显区分,读起来是"按钮"而非散落文字 */
|
||||
.vol-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.vol-tools .btn-text {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--crease-press);
|
||||
font-size: 14px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.vol-tools .btn-text:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.vol-tools .btn-text:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.vol-mkdir {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.vol-mkdir .input {
|
||||
flex: 1;
|
||||
}
|
||||
.vol-busy {
|
||||
font-size: 13px;
|
||||
color: var(--wx-green-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 列表容器:凹槽(inset),内部条目为浮起 bump(hover) */
|
||||
.vol-list {
|
||||
background: var(--trough);
|
||||
border-radius: var(--r-small);
|
||||
padding: 6px;
|
||||
max-height: 44vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
box-shadow: inset 0 1px 3px rgba(var(--shadow) / 0.22);
|
||||
}
|
||||
.vol-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 12px;
|
||||
padding: 2px 4px 2px 6px;
|
||||
}
|
||||
.vol-row:hover {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--crease-press);
|
||||
}
|
||||
.vol-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
padding: 8px 4px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.vol-main:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.vol-up {
|
||||
color: var(--muted);
|
||||
}
|
||||
.vol-ic {
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.vol-ic.dir {
|
||||
color: var(--wx-green);
|
||||
}
|
||||
.vol-ic svg {
|
||||
display: block;
|
||||
}
|
||||
.vol-nm {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vol-meta {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.vol-rename {
|
||||
flex: 1;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.vol-acts {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
.vol-act {
|
||||
border: none;
|
||||
background: var(--trough);
|
||||
color: var(--muted);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--crease-press);
|
||||
}
|
||||
.vol-row:hover .vol-act {
|
||||
background: var(--base);
|
||||
color: var(--text);
|
||||
}
|
||||
.vol-act:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
.vol-act.danger:hover {
|
||||
color: var(--danger);
|
||||
background: rgba(var(--danger-rgb) / 0.12);
|
||||
}
|
||||
.vol-act:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user