@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/dist/dialog.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.closeCompressDialog = closeCompressDialog;
|
|
4
|
+
exports.openCompressDialog = openCompressDialog;
|
|
5
|
+
const HOST_ID = 'fcp-dialog-host';
|
|
6
|
+
let activeDialog = null;
|
|
7
|
+
/**
|
|
8
|
+
* 关闭并卸载当前打开的压缩对话框(插件 teardown 调用)。
|
|
9
|
+
* 幂等:未打开时 no-op。
|
|
10
|
+
*/
|
|
11
|
+
function closeCompressDialog() {
|
|
12
|
+
if (!activeDialog)
|
|
13
|
+
return;
|
|
14
|
+
try {
|
|
15
|
+
activeDialog.app.unmount();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// 忽略卸载异常
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
activeDialog.host.remove();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// 忽略宿主移除异常
|
|
25
|
+
}
|
|
26
|
+
activeDialog = null;
|
|
27
|
+
}
|
|
28
|
+
function parentOf(pathStr) {
|
|
29
|
+
const parts = pathStr.split('/').filter(Boolean);
|
|
30
|
+
parts.pop();
|
|
31
|
+
return parts.join('/');
|
|
32
|
+
}
|
|
33
|
+
function displayPath(pathStr) {
|
|
34
|
+
return pathStr || '/';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 文件夹图标(内联 SVG,取 Element Plus Folder 图标的 path)。
|
|
38
|
+
* 插件上下文不暴露图标包,故自绘;颜色用主题强调色令牌随主题换肤。
|
|
39
|
+
*/
|
|
40
|
+
function renderFolderIcon(h) {
|
|
41
|
+
return h('svg', {
|
|
42
|
+
viewBox: '0 0 1024 1024',
|
|
43
|
+
width: '16',
|
|
44
|
+
height: '16',
|
|
45
|
+
'aria-hidden': 'true',
|
|
46
|
+
style: { display: 'block' },
|
|
47
|
+
}, [
|
|
48
|
+
h('path', {
|
|
49
|
+
d: 'M880 298.4H521L403.7 186.2a8.16 8.16 0 0 0-5.5-2.2H144c-17.7 0-32 14.3-32 32v592c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V330.4c0-17.7-14.3-32-32-32z',
|
|
50
|
+
fill: 'currentColor',
|
|
51
|
+
}),
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
function openCompressDialog(ctx, payload) {
|
|
55
|
+
const { createApp, defineComponent, h, ref, computed, watch } = ctx.Vue;
|
|
56
|
+
const { ElDialog, ElButton, ElInput, ElAlert, ElTag, ElMessage } = ctx.ElementPlus;
|
|
57
|
+
const formatSize = ctx.utils.formatSize;
|
|
58
|
+
const api = ctx.api.instance;
|
|
59
|
+
// 复用宿主节点,避免重复打开时叠加多个对话框(同时卸载旧实例)
|
|
60
|
+
closeCompressDialog();
|
|
61
|
+
const host = document.createElement('div');
|
|
62
|
+
host.id = HOST_ID;
|
|
63
|
+
document.body.appendChild(host);
|
|
64
|
+
const EllipsisPath = defineComponent({
|
|
65
|
+
name: 'EllipsisPath',
|
|
66
|
+
props: { path: { type: String, default: '' } },
|
|
67
|
+
setup(props) {
|
|
68
|
+
return () => h('span', { class: 'fcp-sel-name', style: { flex: 1 } }, displayPath(props.path));
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const CompressDialog = defineComponent({
|
|
72
|
+
name: 'CompressDialog',
|
|
73
|
+
setup() {
|
|
74
|
+
const visible = ref(true);
|
|
75
|
+
// 根目录时 currentPath 为 '',归一化为 '/'(与 displayPath 显示一致,后端也按根目录解析)
|
|
76
|
+
const outputDir = ref(payload.currentPath || '/');
|
|
77
|
+
const checkResult = ref(null);
|
|
78
|
+
const checking = ref(false);
|
|
79
|
+
// ─── 权限预检 ───
|
|
80
|
+
const runCheck = async () => {
|
|
81
|
+
checking.value = true;
|
|
82
|
+
checkResult.value = null;
|
|
83
|
+
try {
|
|
84
|
+
// ctx.api.instance 的 baseURL 为 '/api',路径不带 /api 前缀
|
|
85
|
+
const resp = await api.post('/plugin/compress/check', {
|
|
86
|
+
paths: payload.selected,
|
|
87
|
+
outputDir: outputDir.value,
|
|
88
|
+
});
|
|
89
|
+
checkResult.value = resp.data;
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
const msg = e?.response?.data?.message;
|
|
93
|
+
ElMessage.error(msg || '权限检查失败');
|
|
94
|
+
checkResult.value = null;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
checking.value = false;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
watch(() => outputDir.value, runCheck);
|
|
101
|
+
void runCheck();
|
|
102
|
+
const canStart = computed(() => !!checkResult.value?.ok &&
|
|
103
|
+
!checking.value &&
|
|
104
|
+
payload.selected.length > 0);
|
|
105
|
+
// ─── 刷新当前目录列表(压缩完成后触发) ───
|
|
106
|
+
const refreshCurrentDir = async () => {
|
|
107
|
+
try {
|
|
108
|
+
const resp = await api.get('/files', {
|
|
109
|
+
params: { path: ctx.stores.file.currentPath },
|
|
110
|
+
});
|
|
111
|
+
ctx.stores.file.setFiles(resp.data.files);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// 刷新失败不阻塞关闭
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
// ─── 创建压缩后台任务 ───
|
|
118
|
+
const startCompress = async () => {
|
|
119
|
+
if (!canStart.value)
|
|
120
|
+
return;
|
|
121
|
+
try {
|
|
122
|
+
// ctx.api.instance 的 baseURL 为 '/api',路径不带 /api 前缀
|
|
123
|
+
const resp = await api.post('/plugin/compress/zip', {
|
|
124
|
+
paths: payload.selected,
|
|
125
|
+
outputDir: outputDir.value,
|
|
126
|
+
});
|
|
127
|
+
const { taskId } = resp.data;
|
|
128
|
+
// 乐观插入后台任务(进度/取消/完成由主项目任务面板承接)
|
|
129
|
+
const info = {
|
|
130
|
+
id: taskId,
|
|
131
|
+
type: 'compress',
|
|
132
|
+
status: 'running',
|
|
133
|
+
phase: 'compress',
|
|
134
|
+
progress: 0,
|
|
135
|
+
speed: 0,
|
|
136
|
+
totalSize: 0,
|
|
137
|
+
startTime: Date.now(),
|
|
138
|
+
metadata: {
|
|
139
|
+
paths: payload.selected.slice(),
|
|
140
|
+
names: payload.infos.map((i) => i.name),
|
|
141
|
+
outputDir: outputDir.value,
|
|
142
|
+
targetPath: checkResult.value?.targetPath || '',
|
|
143
|
+
},
|
|
144
|
+
completedCount: 0,
|
|
145
|
+
totalCount: payload.selected.length,
|
|
146
|
+
totalItemCount: 0,
|
|
147
|
+
processedItemCount: 0,
|
|
148
|
+
};
|
|
149
|
+
ctx.stores.task.attachTask(taskId, info, makeConditionalRefresh());
|
|
150
|
+
ElMessage.success('压缩任务已推送到后台');
|
|
151
|
+
visible.value = false;
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
ElMessage.error(e?.response?.data?.message || '创建压缩任务失败');
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* 压缩完成后的条件刷新回调:zip 落在输出目录,
|
|
159
|
+
* 仅当用户当前仍停留在该目录时刷新列表(与移动任务语义一致)。
|
|
160
|
+
* 回调注册在 task store 单例中,对话框关闭后依然有效。
|
|
161
|
+
*/
|
|
162
|
+
const makeConditionalRefresh = () => {
|
|
163
|
+
const outputDirSnapshot = outputDir.value;
|
|
164
|
+
return () => {
|
|
165
|
+
// 根目录时 currentPath 为 '',与 outputDir 的 '/' 归一化对齐后再比较
|
|
166
|
+
const current = ctx.stores.file.currentPath || '/';
|
|
167
|
+
if (current === outputDirSnapshot) {
|
|
168
|
+
void refreshCurrentDir();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
// ─── 文件夹选择器 ───
|
|
173
|
+
const pickerVisible = ref(false);
|
|
174
|
+
const pickerPath = ref(payload.currentPath || '/');
|
|
175
|
+
const pickerFolders = ref([]);
|
|
176
|
+
const pickerLoading = ref(false);
|
|
177
|
+
const loadPickerFolders = async (p) => {
|
|
178
|
+
pickerLoading.value = true;
|
|
179
|
+
try {
|
|
180
|
+
const resp = await api.get('/files', { params: { path: p } });
|
|
181
|
+
pickerFolders.value = (resp.data?.files || [])
|
|
182
|
+
.filter((f) => f.isDirectory && !f.broken)
|
|
183
|
+
.map((f) => f.path);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
pickerFolders.value = [];
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
pickerLoading.value = false;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const openPicker = () => {
|
|
193
|
+
pickerPath.value = outputDir.value;
|
|
194
|
+
pickerVisible.value = true;
|
|
195
|
+
void loadPickerFolders(pickerPath.value);
|
|
196
|
+
};
|
|
197
|
+
const pickerUp = () => {
|
|
198
|
+
pickerPath.value = parentOf(pickerPath.value);
|
|
199
|
+
void loadPickerFolders(pickerPath.value);
|
|
200
|
+
};
|
|
201
|
+
const pickerEnter = (p) => {
|
|
202
|
+
pickerPath.value = p;
|
|
203
|
+
void loadPickerFolders(p);
|
|
204
|
+
};
|
|
205
|
+
const pickerConfirm = () => {
|
|
206
|
+
outputDir.value = pickerPath.value;
|
|
207
|
+
pickerVisible.value = false;
|
|
208
|
+
};
|
|
209
|
+
// ─── 渲染 ───
|
|
210
|
+
return () => {
|
|
211
|
+
const selectedRows = payload.infos.map((info) => h('div', { class: 'fcp-sel-item', key: info.path }, [
|
|
212
|
+
h('span', { class: 'fcp-sel-name' }, info.name),
|
|
213
|
+
h('span', { class: 'fcp-sel-meta' }, info.isDirectory ? '文件夹' : formatSize(info.size)),
|
|
214
|
+
]));
|
|
215
|
+
const statusChildren = [];
|
|
216
|
+
if (checkResult.value) {
|
|
217
|
+
for (const item of checkResult.value.items) {
|
|
218
|
+
const ok = item.exists && item.readable;
|
|
219
|
+
statusChildren.push(h('div', { class: 'fcp-status-row', key: item.path }, [
|
|
220
|
+
h('span', { class: 'fcp-status-name' }, `${item.name}${item.kind === 'dir' ? '(文件夹)' : ''}`),
|
|
221
|
+
h(ElTag, { type: ok ? 'success' : 'danger', size: 'small', effect: 'plain' }, () => !item.exists ? '不存在' : ok ? '可读' : '不可读'),
|
|
222
|
+
]));
|
|
223
|
+
}
|
|
224
|
+
const out = checkResult.value.output;
|
|
225
|
+
const outOk = out.exists && out.isDir && out.writable;
|
|
226
|
+
statusChildren.push(h('div', { class: 'fcp-status-row', key: '__output__' }, [
|
|
227
|
+
h('span', { class: 'fcp-status-name' }, '输出文件夹'),
|
|
228
|
+
h(ElTag, { type: outOk ? 'success' : 'danger', size: 'small', effect: 'plain' }, () => (!out.exists ? '不存在' : outOk ? '可写' : '不可写')),
|
|
229
|
+
]));
|
|
230
|
+
}
|
|
231
|
+
const forbiddenAlert = checkResult.value?.forbidden && checkResult.value.forbiddenMessage
|
|
232
|
+
? h(ElAlert, {
|
|
233
|
+
title: checkResult.value.forbiddenMessage,
|
|
234
|
+
type: 'warning',
|
|
235
|
+
showIcon: true,
|
|
236
|
+
closable: false,
|
|
237
|
+
style: { marginTop: '8px' },
|
|
238
|
+
})
|
|
239
|
+
: null;
|
|
240
|
+
const pickerDialog = h(ElDialog, {
|
|
241
|
+
modelValue: pickerVisible.value,
|
|
242
|
+
title: '选择输出文件夹',
|
|
243
|
+
width: '460px',
|
|
244
|
+
appendToBody: true,
|
|
245
|
+
'onUpdate:modelValue': (v) => {
|
|
246
|
+
pickerVisible.value = v;
|
|
247
|
+
},
|
|
248
|
+
}, {
|
|
249
|
+
default: () => [
|
|
250
|
+
h('div', { class: 'fcp-picker-path' }, [
|
|
251
|
+
h(EllipsisPath, { path: pickerPath.value }),
|
|
252
|
+
h(ElButton, {
|
|
253
|
+
size: 'small',
|
|
254
|
+
disabled: !pickerPath.value || pickerPath.value === '/',
|
|
255
|
+
onClick: pickerUp,
|
|
256
|
+
}, () => '上级'),
|
|
257
|
+
]),
|
|
258
|
+
pickerLoading.value
|
|
259
|
+
? h('div', { class: 'fcp-dim' }, '加载中…')
|
|
260
|
+
: pickerFolders.value.length === 0
|
|
261
|
+
? h('div', { class: 'fcp-picker-empty' }, '(该文件夹下没有子文件夹)')
|
|
262
|
+
: h('div', { class: 'fcp-picker-list' }, pickerFolders.value.map((p) => h('div', { class: 'fcp-picker-row', key: p, onClick: () => pickerEnter(p) }, [
|
|
263
|
+
h('span', { class: 'fcp-picker-icon' }, [renderFolderIcon(h)]),
|
|
264
|
+
h('span', { class: 'fcp-sel-name' }, p.split('/').pop() || p),
|
|
265
|
+
]))),
|
|
266
|
+
],
|
|
267
|
+
footer: () => [
|
|
268
|
+
h(ElButton, { size: 'small', onClick: () => (pickerVisible.value = false) }, () => '取消'),
|
|
269
|
+
h(ElButton, { size: 'small', type: 'primary', onClick: pickerConfirm }, () => '选择'),
|
|
270
|
+
],
|
|
271
|
+
});
|
|
272
|
+
return h(ElDialog, {
|
|
273
|
+
modelValue: visible.value,
|
|
274
|
+
title: `压缩(${payload.infos.length} 项)`,
|
|
275
|
+
width: '560px',
|
|
276
|
+
appendToBody: true,
|
|
277
|
+
closeOnClickModal: false,
|
|
278
|
+
'onUpdate:modelValue': (v) => {
|
|
279
|
+
visible.value = v;
|
|
280
|
+
if (!v)
|
|
281
|
+
close();
|
|
282
|
+
},
|
|
283
|
+
}, {
|
|
284
|
+
default: () => [
|
|
285
|
+
h('div', { class: 'fcp-dialog-body' }, [
|
|
286
|
+
h('div', { class: 'fcp-sec-title' }, '已选条目'),
|
|
287
|
+
h('div', { class: 'fcp-sel-list' }, selectedRows),
|
|
288
|
+
h('div', { class: 'fcp-sec-title' }, '输出位置'),
|
|
289
|
+
h('div', { class: 'fcp-out-row' }, [
|
|
290
|
+
h(ElInput, {
|
|
291
|
+
modelValue: displayPath(outputDir.value),
|
|
292
|
+
readonly: true,
|
|
293
|
+
placeholder: '请选择输出文件夹',
|
|
294
|
+
style: { flex: 1 },
|
|
295
|
+
}),
|
|
296
|
+
h(ElButton, { size: 'default', onClick: openPicker }, () => '选择'),
|
|
297
|
+
]),
|
|
298
|
+
checkResult.value?.targetPath
|
|
299
|
+
? h('div', { class: 'fcp-target' }, `输出文件:${checkResult.value.targetPath}`)
|
|
300
|
+
: null,
|
|
301
|
+
h('div', { class: 'fcp-sec-title' }, '权限检查'),
|
|
302
|
+
checking.value
|
|
303
|
+
? h('div', { class: 'fcp-dim' }, '正在检查权限…')
|
|
304
|
+
: checkResult.value
|
|
305
|
+
? h('div', { class: 'fcp-status-list' }, statusChildren)
|
|
306
|
+
: h('div', { class: 'fcp-dim' }, '权限检查失败'),
|
|
307
|
+
forbiddenAlert,
|
|
308
|
+
]),
|
|
309
|
+
pickerDialog,
|
|
310
|
+
],
|
|
311
|
+
footer: () => [
|
|
312
|
+
h(ElButton, { onClick: () => (visible.value = false) }, () => '关闭'),
|
|
313
|
+
h(ElButton, { type: 'primary', disabled: !canStart.value, onClick: startCompress }, () => '开始压缩'),
|
|
314
|
+
],
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
const rootApp = createApp(CompressDialog);
|
|
320
|
+
rootApp.mount(host);
|
|
321
|
+
activeDialog = { app: rootApp, host };
|
|
322
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compress 插件前端入口
|
|
3
|
+
*
|
|
4
|
+
* 向主应用批量操作注册表(window.__fm_bulk_actions,类型契约由
|
|
5
|
+
* @mqn00/file-manager/plugin/frontend 发布)注册「压缩」操作:
|
|
6
|
+
* - 任意选中(文件/文件夹均可、支持多选)时在工具栏批量操作栏显示
|
|
7
|
+
* - 点按后用 ctx.Vue.createApp 挂载压缩对话框(openCompressDialog)
|
|
8
|
+
*/
|
|
9
|
+
import type { FrontendPluginInstallFunction } from '@mqn00/file-manager/plugin/frontend';
|
|
10
|
+
export declare const install: FrontendPluginInstallFunction;
|