@mqn00/file-manager-plugin-compress 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -0
- package/dist/backend.d.ts +10 -0
- package/dist/backend.js +102 -0
- package/dist/compress.d.ts +82 -0
- package/dist/compress.js +350 -0
- package/dist/dialog.d.ts +20 -0
- package/dist/dialog.js +322 -0
- package/dist/frontend.d.ts +10 -0
- package/dist/frontend.js +405 -0
- package/dist/style.d.ts +6 -0
- package/dist/style.js +61 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# file-manager-plugin-compress
|
|
2
|
+
|
|
3
|
+
File Manager 压缩插件:将内置压缩功能提取为独立插件并增强。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- **多选压缩**:支持同时选择多个**文件**与**文件夹**压缩为单个 zip(原有内置压缩仅支持单选文件夹)
|
|
8
|
+
- **指定输出文件夹**:压缩结果可放入任意已存在文件夹,默认输出到**当前浏览文件夹**
|
|
9
|
+
- **压缩前权限预检**:
|
|
10
|
+
- 确认每个选中条目的**读取权限**(`R_OK`)
|
|
11
|
+
- 确认输出文件夹的**写入权限**(`W_OK`)且必须存在
|
|
12
|
+
- 禁止输出文件夹位于某个选中文件夹内部(避免把 zip 压进自己)
|
|
13
|
+
- **后台任务**:压缩任务推入**主项目后台任务系统**——后台任务面板(TaskPanel)展示进度/速度/当前文件并可取消;任务持久化,页面刷新后可继续跟踪;取消/失败自动清理半成品
|
|
14
|
+
- **命名规则**:单项 `<名称>.zip`;多项 `<首项名称> 等 N 项.zip`;目标已存在自动追加 ` (1)`、` (2)`… 后缀,不覆盖
|
|
15
|
+
|
|
16
|
+
## 使用
|
|
17
|
+
|
|
18
|
+
安装/启用插件后,文件浏览器**多选任意文件/文件夹**(复选框)→ 工具栏批量操作区出现「压缩」按钮 →
|
|
19
|
+
点击弹出对话框:确认默认输出文件夹(可点「选择」更换)→ 权限预检通过后「开始压缩」→
|
|
20
|
+
任务推送到后台(对话框关闭),在**后台任务面板**查看进度/取消;完成后输出目录自动刷新。
|
|
21
|
+
|
|
22
|
+
## 接口
|
|
23
|
+
|
|
24
|
+
均需登录,挂在 `/api/plugin/compress` 下(详见主项目 `API.md`「4. 压缩(compress 插件)」):
|
|
25
|
+
|
|
26
|
+
| 接口 | 说明 |
|
|
27
|
+
|------|------|
|
|
28
|
+
| `POST /api/plugin/compress/check` | 权限预检(源读取 + 输出目录写入 + 目录包含防护),返回逐项状态与目标 zip 路径 |
|
|
29
|
+
| `POST /api/plugin/compress/zip` | **创建压缩后台任务**,返回 `taskId`;进度/取消/完成走主项目任务系统(`GET /api/tasks`、`GET /api/tasks/:id/stream`、`POST /api/tasks/:id/cancel`) |
|
|
30
|
+
|
|
31
|
+
## 构建与测试
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install --registry=https://registry.npmmirror.com # 安装依赖(archiver 等)
|
|
35
|
+
npm run build # tsc 编译后端 + esbuild 打包前端
|
|
36
|
+
npm test # vitest 单元测试(命名/权限/条目收集/压缩/取消)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 依赖
|
|
40
|
+
|
|
41
|
+
- 运行时:`archiver`
|
|
42
|
+
- 主项目:`file-manager`(peerDependencies,类型与平台能力:`ctx.utils.path.safe`、`ctx.services.task` 外部任务(createExternal/updateProgress/finalize)、日志等;工具栏操作经主应用 `window.__fm_bulk_actions` 注册表挂载)
|
|
43
|
+
|
|
44
|
+
## 任务系统接入示例
|
|
45
|
+
|
|
46
|
+
本插件演示如何将耗时操作接入主项目后台任务系统:
|
|
47
|
+
|
|
48
|
+
**后端**(创建任务 + 执行 + 进度上报):
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// 1. 创建任务条目
|
|
52
|
+
const task = ctx.services.task.createExternal(
|
|
53
|
+
'compress',
|
|
54
|
+
{ paths, names, outputDir, targetPath },
|
|
55
|
+
{ phase: 'compress', totalCount: paths.length }
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// 2. 获取取消信号
|
|
59
|
+
const signal = ctx.services.task.signal(task.id)
|
|
60
|
+
|
|
61
|
+
// 3. 异步执行(不阻塞响应)
|
|
62
|
+
void (async () => {
|
|
63
|
+
try {
|
|
64
|
+
// ... 执行压缩 ...
|
|
65
|
+
ctx.services.task.updateProgress(task.id, { progress: 50, currentFile: 'xxx.txt' })
|
|
66
|
+
ctx.services.task.finalize(task.id, 'completed')
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (e?.message === 'CANCELLED') {
|
|
69
|
+
ctx.services.task.finalize(task.id, 'cancelled', { message: '已取消' })
|
|
70
|
+
} else {
|
|
71
|
+
ctx.services.task.finalize(task.id, 'failed', { error: e.message })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
})()
|
|
75
|
+
|
|
76
|
+
// 4. 立即返回 taskId
|
|
77
|
+
res.json({ taskId: task.id })
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**前端**(挂载任务到 TaskPanel):
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
export const install: FrontendPluginInstallFunction = (ctx) => {
|
|
84
|
+
// 注册批量操作
|
|
85
|
+
const api = window.__fm_bulk_actions
|
|
86
|
+
api?.register({
|
|
87
|
+
id: 'compress',
|
|
88
|
+
label: '压缩',
|
|
89
|
+
visible: (p) => p.count > 0,
|
|
90
|
+
run: (payload) => openCompressDialog(ctx, payload),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 对话框中创建任务后:
|
|
95
|
+
const { taskId } = await ctx.api.instance.post('/plugin/compress/zip', { paths, outputDir })
|
|
96
|
+
ctx.stores.task.attachTask(taskId, taskInfo, () => {
|
|
97
|
+
// 完成回调:刷新文件列表等
|
|
98
|
+
})
|
|
99
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 压缩插件后端入口
|
|
3
|
+
*
|
|
4
|
+
* 注册路由(均需登录):
|
|
5
|
+
* - POST /api/plugin/compress/check :压缩前预检(源读取权限 + 输出目录写入权限 + 目录包含防护)
|
|
6
|
+
* - POST /api/plugin/compress/zip :创建压缩后台任务(推入主项目任务系统,返回 taskId;
|
|
7
|
+
* 进度/取消/完成由主项目 /api/tasks 系列端点承载)
|
|
8
|
+
*/
|
|
9
|
+
import type { BackendPluginContext, PluginInstallFunction } from '@mqn00/file-manager/plugin';
|
|
10
|
+
export declare const install: PluginInstallFunction<BackendPluginContext>;
|
package/dist/backend.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.install = void 0;
|
|
4
|
+
const compress_js_1 = require("./compress.js");
|
|
5
|
+
const install = (ctx) => {
|
|
6
|
+
const service = (0, compress_js_1.createCompressService)({
|
|
7
|
+
safePath: ctx.utils.path.safe,
|
|
8
|
+
getStorageRoot: ctx.utils.path.getStorageRoot,
|
|
9
|
+
log: (level, tag, message) => ctx.utils.logger.log(level, tag, message),
|
|
10
|
+
});
|
|
11
|
+
const toStrArray = (v) => Array.isArray(v)
|
|
12
|
+
? v.filter((x) => typeof x === 'string' && x.length > 0)
|
|
13
|
+
: [];
|
|
14
|
+
const toStr = (v) => (typeof v === 'string' ? v : '');
|
|
15
|
+
const router = ctx.express.Router();
|
|
16
|
+
// ---- 权限预检 ----
|
|
17
|
+
router.post('/check', ctx.middleware.auth, async (req, res) => {
|
|
18
|
+
const { paths, outputDir } = (req.body ?? {});
|
|
19
|
+
try {
|
|
20
|
+
if (!outputDir) {
|
|
21
|
+
res.status(400).json({ message: '缺少输出文件夹' });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const result = await service.checkPermissions(toStrArray(paths), toStr(outputDir));
|
|
25
|
+
res.json(result);
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
ctx.utils.logger.log('ERROR', 'compress', `权限预检失败: ${e?.message || '未知错误'}`);
|
|
29
|
+
res.status(400).json({ message: e?.message || '权限预检失败' });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
// ---- 创建压缩后台任务 ----
|
|
33
|
+
router.post('/zip', ctx.middleware.auth, async (req, res) => {
|
|
34
|
+
const { paths, outputDir } = (req.body ?? {});
|
|
35
|
+
const pathsArr = toStrArray(paths);
|
|
36
|
+
const output = toStr(outputDir);
|
|
37
|
+
if (!output) {
|
|
38
|
+
res.status(400).json({ message: '缺少输出文件夹' });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!pathsArr.length) {
|
|
42
|
+
res.status(400).json({ message: '未选择任何文件/文件夹' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
// 预计算输出 zip 目标(与 runJob 内部幂等一致:命名只依赖首项名称与数量)
|
|
47
|
+
const sources = pathsArr.map((p) => service.resolveSource(p));
|
|
48
|
+
const target = service.computeTarget(sources, output);
|
|
49
|
+
// 任务条目注册进主项目任务系统(冲突检测失败抛 TASK_CONFLICT)
|
|
50
|
+
const task = ctx.services.task.createExternal('compress', {
|
|
51
|
+
paths: pathsArr,
|
|
52
|
+
names: sources.map((s) => s.name),
|
|
53
|
+
outputDir: output,
|
|
54
|
+
targetPath: target.relativePath,
|
|
55
|
+
}, { phase: 'compress', totalCount: pathsArr.length });
|
|
56
|
+
// 异步执行压缩,进度/终态经任务系统广播(不阻塞创建响应)
|
|
57
|
+
const signal = ctx.services.task.signal(task.id);
|
|
58
|
+
void (async () => {
|
|
59
|
+
try {
|
|
60
|
+
const result = await service.runJob({
|
|
61
|
+
paths: pathsArr,
|
|
62
|
+
outputDir: output,
|
|
63
|
+
signal,
|
|
64
|
+
onProgress: (percent, _processedBytes, totalBytes) => {
|
|
65
|
+
ctx.services.task.updateProgress(task.id, {
|
|
66
|
+
progress: percent,
|
|
67
|
+
totalSize: totalBytes,
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
// 完成:回写最终目标路径后收尾
|
|
72
|
+
ctx.services.task.updateProgress(task.id, {
|
|
73
|
+
progress: 100,
|
|
74
|
+
metadata: { ...task.metadata, targetPath: result.target.relativePath },
|
|
75
|
+
});
|
|
76
|
+
ctx.services.task.finalize(task.id, 'completed');
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
if (e?.message === 'CANCELLED') {
|
|
80
|
+
ctx.services.task.finalize(task.id, 'cancelled', { message: '压缩已取消' });
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const msg = e instanceof compress_js_1.ZCompressError ? e.message : `压缩失败: ${e?.message || '未知错误'}`;
|
|
84
|
+
ctx.utils.logger.log('ERROR', 'compress', `压缩任务失败: ${msg}`);
|
|
85
|
+
ctx.services.task.finalize(task.id, 'failed', { error: msg });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
89
|
+
res.json({ taskId: task.id });
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
if (e?.code === 'TASK_CONFLICT') {
|
|
93
|
+
res.status(409).json({ message: e.message });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
ctx.utils.logger.log('ERROR', 'compress', `创建压缩任务失败: ${e?.message || '未知错误'}`);
|
|
97
|
+
res.status(400).json({ message: e?.message || '创建压缩任务失败' });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
ctx.app.use('/api/plugin/compress', router);
|
|
101
|
+
};
|
|
102
|
+
exports.install = install;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export interface ServiceDeps {
|
|
2
|
+
/** 安全路径解析:相对路径 → 存储根内的绝对路径(越界抛错) */
|
|
3
|
+
safePath(userPath: string): string;
|
|
4
|
+
/** 存储根绝对路径 */
|
|
5
|
+
getStorageRoot(): string;
|
|
6
|
+
/** 日志 */
|
|
7
|
+
log(level: 'INFO' | 'WARNING' | 'ERROR', tag: string, message: string): void;
|
|
8
|
+
}
|
|
9
|
+
export interface SourceItem {
|
|
10
|
+
/** 相对路径(用户输入) */
|
|
11
|
+
path: string;
|
|
12
|
+
/** 存取根内的绝对路径 */
|
|
13
|
+
fullPath: string;
|
|
14
|
+
name: string;
|
|
15
|
+
kind: 'file' | 'dir';
|
|
16
|
+
}
|
|
17
|
+
export interface SourceCheck {
|
|
18
|
+
path: string;
|
|
19
|
+
name: string;
|
|
20
|
+
kind: 'file' | 'dir';
|
|
21
|
+
exists: boolean;
|
|
22
|
+
readable: boolean;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface OutputCheck {
|
|
26
|
+
path: string;
|
|
27
|
+
exists: boolean;
|
|
28
|
+
isDir: boolean;
|
|
29
|
+
writable: boolean;
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface CheckResult {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
items: SourceCheck[];
|
|
35
|
+
output: OutputCheck;
|
|
36
|
+
/** 计划生成的 zip 相对路径(冲突已考虑后缀),输出目录非法时为空 */
|
|
37
|
+
targetPath: string;
|
|
38
|
+
/** 输出目录是否落在某个选中文件夹内部(禁止) */
|
|
39
|
+
forbidden: boolean;
|
|
40
|
+
forbiddenMessage?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface ZipEntry {
|
|
43
|
+
filePath: string;
|
|
44
|
+
zipName: string;
|
|
45
|
+
size: number;
|
|
46
|
+
}
|
|
47
|
+
export interface TargetInfo {
|
|
48
|
+
fullPath: string;
|
|
49
|
+
relativePath: string;
|
|
50
|
+
}
|
|
51
|
+
export declare function createCompressService(deps: ServiceDeps): {
|
|
52
|
+
resolveSource: (userPath: string) => SourceItem;
|
|
53
|
+
computeTarget: (sources: SourceItem[], outputDirUserPath: string) => TargetInfo;
|
|
54
|
+
targetBaseName: (sources: SourceItem[]) => string;
|
|
55
|
+
isForbiddenOutput: (sources: SourceItem[], outputDirFull: string) => string | null;
|
|
56
|
+
checkPermissions: (paths: string[], outputDir: string) => Promise<CheckResult>;
|
|
57
|
+
collectEntries: (sources: SourceItem[]) => Promise<{
|
|
58
|
+
entries: ZipEntry[];
|
|
59
|
+
totalBytes: number;
|
|
60
|
+
}>;
|
|
61
|
+
createArchive: (opts: {
|
|
62
|
+
entries: ZipEntry[];
|
|
63
|
+
targetFull: string;
|
|
64
|
+
totalBytes: number;
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
onProgress?: (percent: number, processedBytes: number, totalBytes: number) => void;
|
|
67
|
+
}) => Promise<TargetInfo>;
|
|
68
|
+
runJob: (opts: {
|
|
69
|
+
paths: string[];
|
|
70
|
+
outputDir: string;
|
|
71
|
+
signal?: AbortSignal;
|
|
72
|
+
onProgress?: (percent: number, processedBytes: number, totalBytes: number) => void;
|
|
73
|
+
}) => Promise<{
|
|
74
|
+
target: TargetInfo;
|
|
75
|
+
entries: number;
|
|
76
|
+
}>;
|
|
77
|
+
};
|
|
78
|
+
/** 用户可读的压缩业务错误(路由层转为 SSE error / HTTP 400) */
|
|
79
|
+
export declare class ZCompressError extends Error {
|
|
80
|
+
constructor(message: string);
|
|
81
|
+
}
|
|
82
|
+
export type CompressService = ReturnType<typeof createCompressService>;
|
package/dist/compress.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ZCompressError = void 0;
|
|
7
|
+
exports.createCompressService = createCompressService;
|
|
8
|
+
/**
|
|
9
|
+
* 压缩插件后端服务
|
|
10
|
+
*
|
|
11
|
+
* 多源(文件 + 文件夹)压缩为单个 zip:
|
|
12
|
+
* - `computeTarget`:确定输出 zip 路径(命名 + 冲突自动加 (n) 后缀)
|
|
13
|
+
* - `checkPermissions`:压缩前预检(源读取权限 R_OK、输出目录写入权限 W_OK)
|
|
14
|
+
* - `collectEntries`:收集全部待压缩条目并汇总字节数
|
|
15
|
+
* - `createArchive`:以 archiver 流式压缩(支持 AbortSignal 取消,取消/失败清理半成品)
|
|
16
|
+
*
|
|
17
|
+
* 路径解析依赖注入(来自主应用 ctx.utils.path.safe / getStorageRoot),
|
|
18
|
+
* 单测可通过假 ctx(临时 storageRoot)直接构造服务。
|
|
19
|
+
*/
|
|
20
|
+
const fs_1 = require("fs");
|
|
21
|
+
const path_1 = __importDefault(require("path"));
|
|
22
|
+
const archiver_1 = __importDefault(require("archiver"));
|
|
23
|
+
const VALID_LEVELS = ['INFO', 'WARNING', 'ERROR'];
|
|
24
|
+
function isLogLevel(v) {
|
|
25
|
+
return typeof v === 'string' && VALID_LEVELS.includes(v);
|
|
26
|
+
}
|
|
27
|
+
function createCompressService(deps) {
|
|
28
|
+
const { safePath, getStorageRoot, log } = deps;
|
|
29
|
+
const logSafe = (level, tag, message) => {
|
|
30
|
+
try {
|
|
31
|
+
log(level, tag, message);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// 日志失败不影响主流程
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/** 解析用户相对路径为绝对路径(越界/非法抛 AppError) */
|
|
38
|
+
function resolveSource(userPath) {
|
|
39
|
+
const fullPath = safePath(userPath);
|
|
40
|
+
const name = path_1.default.basename(userPath) || userPath;
|
|
41
|
+
return { path: userPath, fullPath, name, kind: 'file' };
|
|
42
|
+
}
|
|
43
|
+
/** 相对路径转 zip 文件名(单项 / 多项目命名规则) */
|
|
44
|
+
function targetBaseName(sources) {
|
|
45
|
+
const firstName = sources[0].name;
|
|
46
|
+
if (sources.length === 1)
|
|
47
|
+
return `${firstName}.zip`;
|
|
48
|
+
return `${firstName} 等 ${sources.length} 项.zip`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 计算输出 zip 目标路径:outputDir/<name>.zip;
|
|
52
|
+
* 已存在时依次尝试 `name (1).zip`、`name (2).zip`… 避免覆盖。
|
|
53
|
+
*/
|
|
54
|
+
function computeTarget(sources, outputDirUserPath) {
|
|
55
|
+
const outputDirFull = safePath(outputDirUserPath);
|
|
56
|
+
const base = targetBaseName(sources);
|
|
57
|
+
let candidate = path_1.default.join(outputDirFull, base);
|
|
58
|
+
let n = 1;
|
|
59
|
+
while ((0, fs_1.existsSync)(candidate)) {
|
|
60
|
+
const stem = base.replace(/\.zip$/, '');
|
|
61
|
+
candidate = path_1.default.join(outputDirFull, `${stem} (${n}).zip`);
|
|
62
|
+
n += 1;
|
|
63
|
+
}
|
|
64
|
+
const relativePath = path_1.default.relative(getStorageRoot(), candidate).replace(/\\/g, '/');
|
|
65
|
+
return { fullPath: candidate, relativePath };
|
|
66
|
+
}
|
|
67
|
+
/** 输出目录是否等于或位于某个选中文件夹内部(禁止项) */
|
|
68
|
+
function isForbiddenOutput(sources, outputDirFull) {
|
|
69
|
+
for (const s of sources) {
|
|
70
|
+
if (s.kind !== 'dir')
|
|
71
|
+
continue;
|
|
72
|
+
if (outputDirFull === s.fullPath || outputDirFull.startsWith(s.fullPath + path_1.default.sep)) {
|
|
73
|
+
return `输出目录不能位于待压缩文件夹「${s.name}」内部`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 压缩前权限预检:逐项读取权限 + 输出目录写入权限 + 目录包含防护。
|
|
80
|
+
*/
|
|
81
|
+
async function checkPermissions(paths, outputDir) {
|
|
82
|
+
const items = [];
|
|
83
|
+
let sources = [];
|
|
84
|
+
for (const p of paths) {
|
|
85
|
+
const src = resolveSource(p);
|
|
86
|
+
try {
|
|
87
|
+
const st = await fs_1.promises.lstat(src.fullPath);
|
|
88
|
+
src.kind = st.isDirectory() ? 'dir' : 'file';
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
items.push({
|
|
92
|
+
path: p,
|
|
93
|
+
name: src.name,
|
|
94
|
+
kind: 'file',
|
|
95
|
+
exists: false,
|
|
96
|
+
readable: false,
|
|
97
|
+
error: e?.message || '不存在或无访问权限',
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
let readable = false;
|
|
102
|
+
let error;
|
|
103
|
+
try {
|
|
104
|
+
await fs_1.promises.access(src.fullPath, fs_1.promises.constants.R_OK);
|
|
105
|
+
readable = true;
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
error = e?.message || '无读取权限';
|
|
109
|
+
}
|
|
110
|
+
sources.push(src);
|
|
111
|
+
items.push({ path: p, name: src.name, kind: src.kind, exists: true, readable, error });
|
|
112
|
+
}
|
|
113
|
+
let output = { path: outputDir, exists: false, isDir: false, writable: false };
|
|
114
|
+
let forbidden = null;
|
|
115
|
+
try {
|
|
116
|
+
const st = await fs_1.promises.stat(safePath(outputDir));
|
|
117
|
+
output = { path: outputDir, exists: true, isDir: st.isDirectory(), writable: false };
|
|
118
|
+
if (!st.isDirectory()) {
|
|
119
|
+
output.error = '输出位置不是文件夹';
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
try {
|
|
123
|
+
await fs_1.promises.access(safePath(outputDir), fs_1.promises.constants.W_OK);
|
|
124
|
+
output.writable = true;
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
output.error = e?.message || '无写入权限';
|
|
128
|
+
}
|
|
129
|
+
forbidden = isForbiddenOutput(sources, safePath(outputDir));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
output.error = e?.message || '输出文件夹不存在或无访问权限';
|
|
134
|
+
}
|
|
135
|
+
const targetPath = items.every((i) => i.readable) && output.exists && output.isDir && output.writable && !forbidden
|
|
136
|
+
? computeTarget(sources, outputDir).relativePath
|
|
137
|
+
: '';
|
|
138
|
+
return {
|
|
139
|
+
ok: items.every((i) => i.readable) &&
|
|
140
|
+
output.exists &&
|
|
141
|
+
output.isDir &&
|
|
142
|
+
output.writable &&
|
|
143
|
+
!forbidden,
|
|
144
|
+
items,
|
|
145
|
+
output,
|
|
146
|
+
targetPath,
|
|
147
|
+
forbidden: !!forbidden,
|
|
148
|
+
forbiddenMessage: forbidden ?? undefined,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 收集全部待压缩条目(文件直接一项;文件夹递归,zipName = 文件夹名/…)。
|
|
153
|
+
* 不可读文件跳过(readdir 阶段已有 R_OK 预检,这里是兜底)。
|
|
154
|
+
*/
|
|
155
|
+
async function collectEntries(sources) {
|
|
156
|
+
const entries = [];
|
|
157
|
+
let totalBytes = 0;
|
|
158
|
+
const walk = async (dirFull, zipPrefix) => {
|
|
159
|
+
let names;
|
|
160
|
+
try {
|
|
161
|
+
names = await fs_1.promises.readdir(dirFull);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
for (const name of names) {
|
|
167
|
+
const childFull = path_1.default.join(dirFull, name);
|
|
168
|
+
const zipName = `${zipPrefix}/${name}`;
|
|
169
|
+
try {
|
|
170
|
+
const st = await fs_1.promises.lstat(childFull);
|
|
171
|
+
if (st.isDirectory()) {
|
|
172
|
+
await walk(childFull, zipName);
|
|
173
|
+
}
|
|
174
|
+
else if (st.isFile()) {
|
|
175
|
+
entries.push({ filePath: childFull, zipName, size: st.size });
|
|
176
|
+
totalBytes += st.size;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// 跳过不可读条目
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
for (const src of sources) {
|
|
185
|
+
if (src.kind === 'file') {
|
|
186
|
+
try {
|
|
187
|
+
const st = await fs_1.promises.stat(src.fullPath);
|
|
188
|
+
entries.push({ filePath: src.fullPath, zipName: src.name, size: st.size });
|
|
189
|
+
totalBytes += st.size;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// 源文件已不可读:跳过(压缩结果缺少该项)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
await walk(src.fullPath, src.name);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { entries, totalBytes };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* 流式压缩(Per-entry 进度回调)。
|
|
203
|
+
* 成功 resolve({ relativePath });取消 reject(new Error('CANCELLED')) 并清理半成品;
|
|
204
|
+
* 出错 reject 并清理半成品。
|
|
205
|
+
*/
|
|
206
|
+
function createArchive(opts) {
|
|
207
|
+
const { entries, targetFull, totalBytes, signal, onProgress } = opts;
|
|
208
|
+
const relativePath = path_1.default.relative(getStorageRoot(), targetFull).replace(/\\/g, '/');
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
const cleanup = () => {
|
|
211
|
+
try {
|
|
212
|
+
if ((0, fs_1.existsSync)(targetFull))
|
|
213
|
+
(0, fs_1.unlinkSync)(targetFull);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// 忽略清理失败
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const cleanupAbort = () => {
|
|
220
|
+
if (signal?.aborted) {
|
|
221
|
+
cleanup();
|
|
222
|
+
reject(new Error('CANCELLED'));
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const output = (0, fs_1.createWriteStream)(targetFull);
|
|
226
|
+
const archive = archiver_1.default.create('zip', { zlib: { level: 9 } });
|
|
227
|
+
let processedBytes = 0;
|
|
228
|
+
let settled = false;
|
|
229
|
+
const finish = (fn) => {
|
|
230
|
+
if (settled)
|
|
231
|
+
return;
|
|
232
|
+
settled = true;
|
|
233
|
+
fn();
|
|
234
|
+
};
|
|
235
|
+
const onAbort = () => {
|
|
236
|
+
if (settled)
|
|
237
|
+
return;
|
|
238
|
+
settled = true;
|
|
239
|
+
cleanup();
|
|
240
|
+
archive.abort();
|
|
241
|
+
reject(new Error('CANCELLED'));
|
|
242
|
+
};
|
|
243
|
+
if (signal?.aborted) {
|
|
244
|
+
onAbort();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
248
|
+
archive.on('entry', (entry) => {
|
|
249
|
+
if (entry.stats && !entry.stats.isDirectory()) {
|
|
250
|
+
processedBytes += entry.stats.size;
|
|
251
|
+
const denom = totalBytes > 0 ? totalBytes : 1;
|
|
252
|
+
const percent = Math.min(100, Math.round((processedBytes / denom) * 100));
|
|
253
|
+
onProgress?.(percent, processedBytes, totalBytes);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
output.on('close', () => {
|
|
257
|
+
finish(() => {
|
|
258
|
+
signal?.removeEventListener('abort', onAbort);
|
|
259
|
+
resolve({ fullPath: targetFull, relativePath });
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
archive.on('error', (err) => {
|
|
263
|
+
finish(() => {
|
|
264
|
+
cleanupAbort();
|
|
265
|
+
signal?.removeEventListener('abort', onAbort);
|
|
266
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
output.on('error', (err) => {
|
|
270
|
+
finish(() => {
|
|
271
|
+
cleanup();
|
|
272
|
+
signal?.removeEventListener('abort', onAbort);
|
|
273
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
archive.pipe(output);
|
|
277
|
+
for (const entry of entries) {
|
|
278
|
+
// 条目可能已消失(压缩开始后源被删除):archiver 对不存在文件会抛错,由 error 分支兜底
|
|
279
|
+
archive.file(entry.filePath, { name: entry.zipName });
|
|
280
|
+
}
|
|
281
|
+
archive.finalize();
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 一次压缩任务(路由层调用,返回结构化结果而非直接写 SSE,便于测试)。
|
|
286
|
+
* - ZCompressError 携带用户可读错误(含校验失败)
|
|
287
|
+
* - CANCELLED 表示被取消
|
|
288
|
+
*/
|
|
289
|
+
async function runJob(opts) {
|
|
290
|
+
// 去重(多选列表理论上不重复,防呆)
|
|
291
|
+
const paths = [...new Set(opts.paths)];
|
|
292
|
+
const { outputDir } = opts;
|
|
293
|
+
if (!paths.length) {
|
|
294
|
+
throw new ZCompressError('未选择任何文件/文件夹');
|
|
295
|
+
}
|
|
296
|
+
// 预检:存在性 + 读取/写入权限 + 目录包含防护
|
|
297
|
+
const check = await checkPermissions(paths, outputDir);
|
|
298
|
+
if (!check.ok) {
|
|
299
|
+
const detail = [...check.items.map((i) => `${i.name}: ${i.error || (i.readable ? '可读' : '不可读')}`)]
|
|
300
|
+
.concat(check.output.error ? [`输出目录: ${check.output.error}`] : [])
|
|
301
|
+
.concat(check.forbiddenMessage ? [check.forbiddenMessage] : []);
|
|
302
|
+
throw new ZCompressError(detail.join(';'));
|
|
303
|
+
}
|
|
304
|
+
const sources = check.items.map((item) => resolveSource(item.path));
|
|
305
|
+
sources.forEach((s, i) => (s.kind = check.items[i].kind));
|
|
306
|
+
const target = computeTarget(sources, outputDir);
|
|
307
|
+
const { entries, totalBytes } = await collectEntries(sources);
|
|
308
|
+
if (!entries.length) {
|
|
309
|
+
throw new ZCompressError('没有可压缩的文件');
|
|
310
|
+
}
|
|
311
|
+
logSafe('INFO', 'compress', `开始压缩 ${paths.length} 项 → ${target.relativePath}(${entries.length} 个文件,${totalBytes} 字节)`);
|
|
312
|
+
try {
|
|
313
|
+
await createArchive({
|
|
314
|
+
entries,
|
|
315
|
+
targetFull: target.fullPath,
|
|
316
|
+
totalBytes,
|
|
317
|
+
signal: opts.signal,
|
|
318
|
+
onProgress: opts.onProgress,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
if (e?.message === 'CANCELLED') {
|
|
323
|
+
logSafe('INFO', 'compress', `压缩已取消:${target.relativePath}`);
|
|
324
|
+
throw e;
|
|
325
|
+
}
|
|
326
|
+
logSafe('ERROR', 'compress', `压缩失败 ${target.relativePath}: ${e?.message || '未知错误'}`);
|
|
327
|
+
throw e;
|
|
328
|
+
}
|
|
329
|
+
logSafe('INFO', 'compress', `压缩完成:${target.relativePath}`);
|
|
330
|
+
return { target, entries: entries.length };
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
resolveSource,
|
|
334
|
+
computeTarget,
|
|
335
|
+
targetBaseName,
|
|
336
|
+
isForbiddenOutput,
|
|
337
|
+
checkPermissions,
|
|
338
|
+
collectEntries,
|
|
339
|
+
createArchive,
|
|
340
|
+
runJob,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/** 用户可读的压缩业务错误(路由层转为 SSE error / HTTP 400) */
|
|
344
|
+
class ZCompressError extends Error {
|
|
345
|
+
constructor(message) {
|
|
346
|
+
super(message);
|
|
347
|
+
this.name = 'ZCompressError';
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
exports.ZCompressError = ZCompressError;
|
package/dist/dialog.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compress 插件压缩对话框
|
|
3
|
+
*
|
|
4
|
+
* 由工具栏「压缩」操作触发(openCompressDialog),用 ctx.Vue.createApp 挂载到
|
|
5
|
+
* document.body(插件无法 import 主应用 .vue 组件,故自建对话框与文件夹选择器)。
|
|
6
|
+
*
|
|
7
|
+
* 流程:选择输出文件夹(默认当前浏览文件夹)→ 权限预检(POST /check,
|
|
8
|
+
* 源读取权限 + 输出目录写入权限 + 目录包含防护)→ 全部通过才可「开始压缩」→
|
|
9
|
+
* POST /zip 创建后台任务(推入主项目任务系统)→ 对话框关闭,任务卡片出现在
|
|
10
|
+
* 后台任务面板(进度/取消/完成由主项目 TaskPanel 承接)→ 完成后条件刷新输出目录。
|
|
11
|
+
*/
|
|
12
|
+
import type { FrontendPluginContext, BulkActionContext } from '@mqn00/file-manager/plugin/frontend';
|
|
13
|
+
/** 压缩对话框载荷:主应用批量操作点按上下文(类型入口发布,与 BulkActionContext 同一契约) */
|
|
14
|
+
export type CompressPayload = BulkActionContext;
|
|
15
|
+
/**
|
|
16
|
+
* 关闭并卸载当前打开的压缩对话框(插件 teardown 调用)。
|
|
17
|
+
* 幂等:未打开时 no-op。
|
|
18
|
+
*/
|
|
19
|
+
export declare function closeCompressDialog(): void;
|
|
20
|
+
export declare function openCompressDialog(ctx: FrontendPluginContext, payload: CompressPayload): void;
|