dsh-plugin-pack 0.0.0-stage → 1.0.3
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/LICENSE +21 -0
- package/README.md +537 -3
- package/bin/dsh-plugin-pack.js +200 -0
- package/client.js +2337 -0
- package/cordis.patch.yml +24 -0
- package/hotload.js +230 -0
- package/lib/export.js +452 -0
- package/lib/home.js +75 -0
- package/lib/import-plugin.js +356 -0
- package/lib/import-tgz.js +115 -0
- package/lib/import.js +831 -0
- package/lib/inbox.js +194 -0
- package/lib/index.js +725 -0
- package/lib/jobs.js +208 -0
- package/lib/patch-text.js +672 -0
- package/lib/scan.js +488 -0
- package/lib/server.js +926 -0
- package/lib/tar.js +241 -0
- package/lib/util.js +333 -0
- package/lib/zip-stream.js +166 -0
- package/lib/zip.js +514 -0
- package/package.json +72 -6
package/client.js
ADDED
|
@@ -0,0 +1,2337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-pack 客户端界面。
|
|
3
|
+
*
|
|
4
|
+
* 两个挂载点:
|
|
5
|
+
* 1. plugins.row.config —— 插件页「已安装」列表里点开 dsh-plugin-pack 这一行(主入口)
|
|
6
|
+
* summary view:列表里那一行的一行简介
|
|
7
|
+
* page view :点进去的详情页,完整面板
|
|
8
|
+
* 2. settings.section —— 「设置 → 插件整合包」整页,同一个面板
|
|
9
|
+
*
|
|
10
|
+
* 不再往会话界面塞常驻浮动按钮(shell.overlay):入口就在插件页里,不占屏幕。
|
|
11
|
+
*
|
|
12
|
+
* 所有文件操作都走宿主的 /plugin-pack/* 接口,客户端不碰文件系统。
|
|
13
|
+
*
|
|
14
|
+
* 这段代码由宿主的客户端模块加载器执行,通过 require('react') 拿 React,
|
|
15
|
+
* 不依赖 JSX 编译产物,所以整个文件就是一个 factory。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-plugin-pack/client
|
|
18
|
+
*/
|
|
19
|
+
window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
20
|
+
const module = { exports: {} };
|
|
21
|
+
const exports = module.exports;
|
|
22
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
23
|
+
|
|
24
|
+
const react = require('react');
|
|
25
|
+
const h = react.createElement;
|
|
26
|
+
const { useState, useEffect, useCallback, useRef, useMemo } = react;
|
|
27
|
+
|
|
28
|
+
const name = 'dsh-plugin-pack';
|
|
29
|
+
// 只声明真正用到的服务:slots 用来挂界面,locale 用来做中英文案。
|
|
30
|
+
// 多声明一个不存在的服务会让 cordis 一直等它,导致整页不渲染。
|
|
31
|
+
const inject = ['slots', 'locale'];
|
|
32
|
+
|
|
33
|
+
const NS = 'dsh-plugin-pack';
|
|
34
|
+
|
|
35
|
+
/* ---------------------------------- 文案 --------------------------------- */
|
|
36
|
+
|
|
37
|
+
const zh = {
|
|
38
|
+
'nav': '插件整合包',
|
|
39
|
+
'summary': '点开可一键导出/导入全部插件',
|
|
40
|
+
'title': '插件整合包',
|
|
41
|
+
'hotload': '热加载界面',
|
|
42
|
+
'hotload.running': '正在热加载…',
|
|
43
|
+
'hotload.done': '界面已热加载,刷新页面即可看到',
|
|
44
|
+
'hotload.failed': '热加载失败',
|
|
45
|
+
'hotload.why': '改完插件配置或 client 代码后,点这里不用重启 dsh。',
|
|
46
|
+
'subtitle': '把当前 profile 的所有第三方插件连依赖和配置打成一个 zip,换机一键还原。',
|
|
47
|
+
'tab.export': '导出',
|
|
48
|
+
'tab.import': '导入',
|
|
49
|
+
'tab.backup': '备份与回滚',
|
|
50
|
+
'tab.help': '怎么用',
|
|
51
|
+
'tab.navImport': '导入',
|
|
52
|
+
'tab.navExport': '导出',
|
|
53
|
+
'refresh': '刷新',
|
|
54
|
+
'loading': '读取中…',
|
|
55
|
+
'failed': '读取失败',
|
|
56
|
+
'retry': '重试',
|
|
57
|
+
'profile': '当前 profile',
|
|
58
|
+
'profileDir': '目录',
|
|
59
|
+
'dshHome': 'DSH_HOME',
|
|
60
|
+
'plugins': '插件',
|
|
61
|
+
'total': '合计',
|
|
62
|
+
'selectAll': '全选',
|
|
63
|
+
'selectNone': '全不选',
|
|
64
|
+
'selected': '已选',
|
|
65
|
+
'files': '文件',
|
|
66
|
+
'deps': '依赖',
|
|
67
|
+
'patch': '配置行',
|
|
68
|
+
'data': '数据目录',
|
|
69
|
+
'notPlugin': '非插件',
|
|
70
|
+
'warning': '提示',
|
|
71
|
+
'export.title': '导出整合包',
|
|
72
|
+
'export.desc': '勾选要带走的插件,下面选好存到哪,点「开始导出」。',
|
|
73
|
+
'export.out': '保存到',
|
|
74
|
+
'export.outHint': '留空就用默认目录;写目录会自动加文件名',
|
|
75
|
+
'export.includeData': '连插件数据目录一起打包',
|
|
76
|
+
'export.includeDataHint': '比如 lark-plus 的登录态、purge 的规则。可能含敏感信息',
|
|
77
|
+
'export.includeSecrets': '连 .env / 密钥文件一起打包',
|
|
78
|
+
'export.includeSecretsHint': '默认剔除。要发给别人时务必保持关闭',
|
|
79
|
+
'export.dry': '预演(只算体积)',
|
|
80
|
+
'export.maxMb': '体积上限',
|
|
81
|
+
'export.maxMbHint': '0 = 不限制。带原生二进制的插件(如 dsh-ops、dsh-pet)单个体积就很大。',
|
|
82
|
+
'export.maxMbFit': '按当前选择自动调大',
|
|
83
|
+
'export.maxMbNone': '不限制',
|
|
84
|
+
'export.overLimit': '所选插件超过上限,导出会失败',
|
|
85
|
+
'export.run': '开始导出',
|
|
86
|
+
'export.running': '正在打包…',
|
|
87
|
+
'export.done': '导出完成',
|
|
88
|
+
'export.empty': '先勾选至少一个插件',
|
|
89
|
+
'export.size': '包体积',
|
|
90
|
+
'export.raw': '未压缩',
|
|
91
|
+
'export.entries': '条目数',
|
|
92
|
+
'import.title': '导入整合包',
|
|
93
|
+
'import.desc': '选一个 zip,先「查看内容」确认是你要的,再「开始导入」。导入前会自动备份现有配置。',
|
|
94
|
+
'import.pick': '选择文件',
|
|
95
|
+
'import.pathHint': '也可以直接把 zip 路径粘到这里',
|
|
96
|
+
'import.inspect': '查看内容',
|
|
97
|
+
'import.run': '开始导入',
|
|
98
|
+
'import.running': '正在导入…',
|
|
99
|
+
'import.dry': '预演(不写任何文件)',
|
|
100
|
+
'import.mode': '目标已存在时',
|
|
101
|
+
'import.modeMerge': '覆盖',
|
|
102
|
+
'import.modeSkip': '跳过',
|
|
103
|
+
'import.restoreData': '还原数据目录',
|
|
104
|
+
'import.link': '建立 node_modules 链接',
|
|
105
|
+
'import.done': '导入完成',
|
|
106
|
+
'import.restart': '重启 dsh 后生效',
|
|
107
|
+
'import.empty': '先选一个 zip 文件',
|
|
108
|
+
'import.source.bundle': '整合包',
|
|
109
|
+
'import.source.plugin': '本地插件',
|
|
110
|
+
'import.notBundle': 'zip 不是整合包',
|
|
111
|
+
'drop.title': '把 zip / tgz 或插件文件夹拖到这里',
|
|
112
|
+
'drop.release': '松手就导入',
|
|
113
|
+
'drop.running': '正在导入…',
|
|
114
|
+
'drop.hint': 'zip = 整合包,tgz = 单个插件;点这里也能选文件',
|
|
115
|
+
'browse.titleDir': '选择目录',
|
|
116
|
+
'browse.titleFile': '选择文件',
|
|
117
|
+
'browse.pick': '选这个目录',
|
|
118
|
+
'browse.pickDir': '选这个目录',
|
|
119
|
+
'browse.pickHere': '用当前这个目录',
|
|
120
|
+
'browse.jumpOk': '已跳转',
|
|
121
|
+
'browse.jumpEmpty': '先在上面填一个路径,或从下面点进去',
|
|
122
|
+
'browse.jumpSame': '已经在这个目录了;要选它就直接点右下角的按钮',
|
|
123
|
+
'browse.pathHint': '粘贴完整路径,回车或点到别处即确认(目录或 zip 都行)',
|
|
124
|
+
'browse.enter': '选它',
|
|
125
|
+
'browse.open': '进入',
|
|
126
|
+
'import.plugin.title': '本地导入插件',
|
|
127
|
+
'import.plugin.desc': '把一个已经在本机的插件目录接进当前 profile:复制到 local-plugins、写进 bundles、建好链接。',
|
|
128
|
+
'import.plugin.dir': '插件目录',
|
|
129
|
+
'import.plugin.dirHint': '选插件所在目录(含 package.json),例如 D:\\Documents\\my-plugin',
|
|
130
|
+
'import.plugin.run': '导入这个插件',
|
|
131
|
+
'import.plugin.done': '插件已导入',
|
|
132
|
+
'import.plugin.empty': '先选一个插件目录',
|
|
133
|
+
'import.plugin.note': '适合自己写的插件,或已经解压到本地的插件。整合包(zip)请用左边的「整合包」页签。',
|
|
134
|
+
'export.source.bundle': '整合包(全部)',
|
|
135
|
+
'export.source.plugin': '单个插件',
|
|
136
|
+
// 勾选数量自动决定导出类型,不需要用户选
|
|
137
|
+
'export.scope.none': '勾选插件:勾 1 个导出单个插件,勾多个导出整合包',
|
|
138
|
+
'export.scope.single': '将导出单个插件',
|
|
139
|
+
'export.scope.bundle': '将导出整合包',
|
|
140
|
+
'export.plugin.title': '导出单个插件',
|
|
141
|
+
'export.plugin.desc': '只把勾选的这一个插件连依赖打成一个 zip,方便单独分享。',
|
|
142
|
+
'export.plugin.pick': '选要导出的插件',
|
|
143
|
+
'export.plugin.run': '导出这个插件',
|
|
144
|
+
'jobs.title': '后台任务',
|
|
145
|
+
'jobs.kindExport': '导出',
|
|
146
|
+
'jobs.kindImport': '导入整合包',
|
|
147
|
+
'jobs.kindImportPlugin': '导入插件',
|
|
148
|
+
'jobs.kindRollback': '回滚',
|
|
149
|
+
'jobs.running': '进行中',
|
|
150
|
+
'jobs.done': '已完成',
|
|
151
|
+
'jobs.failed': '失败',
|
|
152
|
+
'jobs.clear': '清空已完成',
|
|
153
|
+
'conflict.title': '这些插件已经装过了',
|
|
154
|
+
'conflict.subtitle': '个插件与已安装的重名,请选择如何处理:',
|
|
155
|
+
'conflict.overwrite': '覆盖',
|
|
156
|
+
'conflict.skip': '跳过',
|
|
157
|
+
'conflict.allOverwrite': '全部覆盖',
|
|
158
|
+
'conflict.allSkip': '全部跳过',
|
|
159
|
+
'conflict.sameVersion': '同版本',
|
|
160
|
+
'conflict.confirm': '开始导入',
|
|
161
|
+
'conflict.cancel': '取消',
|
|
162
|
+
'backup.title': '备份与回滚',
|
|
163
|
+
'backup.desc': '每次导入都会在这里留一份快照。不满意就点「回滚」,配置和插件目录一起还原。',
|
|
164
|
+
'backup.none': '还没有导入备份',
|
|
165
|
+
'backup.rollback': '回滚',
|
|
166
|
+
'backup.rolling': '正在回滚…',
|
|
167
|
+
'backup.done': '回滚完成',
|
|
168
|
+
'backup.confirm': '确定要回滚到这份备份吗?本次导入新增的插件会被删掉。',
|
|
169
|
+
'backup.createdAt': '时间',
|
|
170
|
+
'backup.items': '项',
|
|
171
|
+
'recent.title': '最近导出',
|
|
172
|
+
'recent.none': '这个目录里还没有整合包',
|
|
173
|
+
'recent.import': '导入这个',
|
|
174
|
+
'help.title': '怎么用(不用记命令)',
|
|
175
|
+
'help.step1': '第一步:在这页勾选你想带走的插件,点「开始导出」,得到一个 zip 文件。',
|
|
176
|
+
'help.step2': '第二步:把 zip 拷到新电脑(或另一个 profile)。',
|
|
177
|
+
'help.step3': '第三步:在新机器上打开这页,切到「导入」,选那个 zip,点「开始导入」。',
|
|
178
|
+
'help.step4': '第四步:重启 dsh。插件、依赖、配置、开关全部照原样回来。',
|
|
179
|
+
'help.note1': '不用敲命令:这个页面做什么,跟 /pack 命令完全等价。',
|
|
180
|
+
'help.note2': '只想备份不想换机?把导出的 zip 留着就是备份,随时能导入回来。',
|
|
181
|
+
'help.note3': '导入不满意:切到「备份与回滚」,一键回到导入前的状态。',
|
|
182
|
+
'help.note4': '想改这个插件自己?右上角「热加载界面」按钮,改完不用重启 dsh。',
|
|
183
|
+
'help.advanced': '进阶:也可以用命令或直接对 agent 说',
|
|
184
|
+
'help.cmd': '/pack export /pack import <zip> /pack rollback',
|
|
185
|
+
'browse.title': '选择目录',
|
|
186
|
+
'browse.up': '上一级',
|
|
187
|
+
'browse.pick': '选这个目录',
|
|
188
|
+
'browse.cancel': '取消',
|
|
189
|
+
'common.cancel': '取消',
|
|
190
|
+
'common.close': '关闭',
|
|
191
|
+
'errors.crossOrigin': '跨站请求被拒绝',
|
|
192
|
+
'errors.offline': '连不上插件接口,确认 dsh 还在跑,然后刷新页面',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const en = {
|
|
196
|
+
'nav': 'Plugin Pack',
|
|
197
|
+
'summary': 'Open to export or import every plugin in one step',
|
|
198
|
+
'title': 'Plugin Pack',
|
|
199
|
+
'hotload': 'Hot-reload UI',
|
|
200
|
+
'hotload.running': 'Hot-reloading…',
|
|
201
|
+
'hotload.done': 'UI hot-reloaded, refresh the page to see it',
|
|
202
|
+
'hotload.failed': 'Hot-reload failed',
|
|
203
|
+
'hotload.why': 'After editing plugin config or client code, click here instead of restarting dsh.',
|
|
204
|
+
'subtitle': 'Bundle every third-party plugin with its deps and config into one zip; restore on another machine in one click.',
|
|
205
|
+
'tab.export': 'Export',
|
|
206
|
+
'tab.import': 'Import',
|
|
207
|
+
'tab.backup': 'Backup',
|
|
208
|
+
'tab.help': 'How to',
|
|
209
|
+
'tab.navImport': 'Import',
|
|
210
|
+
'tab.navExport': 'Export',
|
|
211
|
+
'refresh': 'Refresh',
|
|
212
|
+
'loading': 'Loading…',
|
|
213
|
+
'failed': 'Failed to load',
|
|
214
|
+
'retry': 'Retry',
|
|
215
|
+
'profile': 'Profile',
|
|
216
|
+
'profileDir': 'Directory',
|
|
217
|
+
'dshHome': 'DSH_HOME',
|
|
218
|
+
'plugins': 'Plugins',
|
|
219
|
+
'total': 'Total',
|
|
220
|
+
'selectAll': 'Select all',
|
|
221
|
+
'selectNone': 'Select none',
|
|
222
|
+
'selected': 'selected',
|
|
223
|
+
'files': 'Files',
|
|
224
|
+
'deps': 'Deps',
|
|
225
|
+
'patch': 'Patch rows',
|
|
226
|
+
'data': 'Data dirs',
|
|
227
|
+
'notPlugin': 'not a plugin',
|
|
228
|
+
'warning': 'Note',
|
|
229
|
+
'export.title': 'Export a bundle',
|
|
230
|
+
'export.desc': 'Tick the plugins to take, choose where to save, then press Export.',
|
|
231
|
+
'export.out': 'Save to',
|
|
232
|
+
'export.outHint': 'Leave empty for the default folder; a filename is added for directories',
|
|
233
|
+
'export.includeData': 'Include plugin data directories',
|
|
234
|
+
'export.includeDataHint': 'e.g. lark-plus login state, purge rules. May contain secrets',
|
|
235
|
+
'export.includeSecrets': 'Include .env / key files',
|
|
236
|
+
'export.includeSecretsHint': 'Excluded by default. Keep off when sharing',
|
|
237
|
+
'export.dry': 'Dry run (size only)',
|
|
238
|
+
'export.maxMb': 'Size limit',
|
|
239
|
+
'export.maxMbHint': '0 = unlimited. Plugins with native binaries (dsh-ops, dsh-pet) are large on their own.',
|
|
240
|
+
'export.maxMbFit': 'Raise to fit selection',
|
|
241
|
+
'export.maxMbNone': 'Unlimited',
|
|
242
|
+
'export.overLimit': 'Selection exceeds the limit; export will fail',
|
|
243
|
+
'export.run': 'Export',
|
|
244
|
+
'export.running': 'Packing…',
|
|
245
|
+
'export.done': 'Export finished',
|
|
246
|
+
'export.empty': 'Tick at least one plugin',
|
|
247
|
+
'export.size': 'Bundle size',
|
|
248
|
+
'export.raw': 'Uncompressed',
|
|
249
|
+
'export.entries': 'Entries',
|
|
250
|
+
'import.title': 'Import a bundle',
|
|
251
|
+
'import.desc': 'Pick a zip, inspect it first, then import. Current config is backed up automatically.',
|
|
252
|
+
'import.pick': 'Choose file',
|
|
253
|
+
'import.pathHint': 'Or paste the zip path here',
|
|
254
|
+
'import.inspect': 'Inspect',
|
|
255
|
+
'import.run': 'Import',
|
|
256
|
+
'import.running': 'Importing…',
|
|
257
|
+
'import.dry': 'Dry run (writes nothing)',
|
|
258
|
+
'import.mode': 'If target exists',
|
|
259
|
+
'import.modeMerge': 'Overwrite',
|
|
260
|
+
'import.modeSkip': 'Skip',
|
|
261
|
+
'import.restoreData': 'Restore data dirs',
|
|
262
|
+
'import.link': 'Link node_modules',
|
|
263
|
+
'import.done': 'Import finished',
|
|
264
|
+
'import.restart': 'Restart dsh to apply',
|
|
265
|
+
'import.empty': 'Choose a zip first',
|
|
266
|
+
'import.source.bundle': 'Bundle',
|
|
267
|
+
'import.source.plugin': 'Local plugin',
|
|
268
|
+
'import.notBundle': 'This zip is not a bundle',
|
|
269
|
+
'drop.title': 'Drop a zip / tgz or plugin folder here',
|
|
270
|
+
'drop.release': 'Release to import',
|
|
271
|
+
'drop.running': 'Importing…',
|
|
272
|
+
'drop.hint': 'zip = bundle, tgz = single plugin; click to pick files too',
|
|
273
|
+
'browse.titleDir': 'Choose a folder',
|
|
274
|
+
'browse.titleFile': 'Choose a file',
|
|
275
|
+
'browse.pick': 'Use this folder',
|
|
276
|
+
'browse.pickDir': 'Use this folder',
|
|
277
|
+
'browse.pickHere': 'Use current folder',
|
|
278
|
+
'browse.jumpOk': 'Jumped',
|
|
279
|
+
'browse.jumpEmpty': 'Type a path above, or click into a folder below',
|
|
280
|
+
'browse.jumpSame': 'Already in this folder; use the button at the bottom right to pick it',
|
|
281
|
+
'browse.pathHint': 'Paste a full path; Enter or clicking away confirms (folder or zip)',
|
|
282
|
+
'browse.enter': 'Pick',
|
|
283
|
+
'browse.open': 'Open',
|
|
284
|
+
'import.plugin.title': 'Import a local plugin',
|
|
285
|
+
'import.plugin.desc': 'Wire an existing plugin directory into this profile: copy to local-plugins, add to bundles, link it.',
|
|
286
|
+
'import.plugin.dir': 'Plugin directory',
|
|
287
|
+
'import.plugin.dirHint': 'Pick the directory containing package.json, e.g. D:\\Documents\\my-plugin',
|
|
288
|
+
'import.plugin.run': 'Import this plugin',
|
|
289
|
+
'import.plugin.done': 'Plugin imported',
|
|
290
|
+
'import.plugin.empty': 'Choose a plugin directory first',
|
|
291
|
+
'import.plugin.note': 'For plugins you wrote yourself or already unpacked locally. Use the Bundle tab for a zip.',
|
|
292
|
+
'export.source.bundle': 'Bundle (all)',
|
|
293
|
+
'export.source.plugin': 'Single plugin',
|
|
294
|
+
'export.scope.none': 'Tick plugins: 1 = single plugin, several = bundle',
|
|
295
|
+
'export.scope.single': 'Exporting a single plugin',
|
|
296
|
+
'export.scope.bundle': 'Exporting a bundle',
|
|
297
|
+
'export.plugin.title': 'Export one plugin',
|
|
298
|
+
'export.plugin.desc': 'Bundle just the ticked plugin with its deps into one zip, easy to share.',
|
|
299
|
+
'export.plugin.pick': 'Pick the plugin to export',
|
|
300
|
+
'export.plugin.run': 'Export this plugin',
|
|
301
|
+
'jobs.title': 'Background tasks',
|
|
302
|
+
'jobs.kindExport': 'Export',
|
|
303
|
+
'jobs.kindImport': 'Import bundle',
|
|
304
|
+
'jobs.kindImportPlugin': 'Import plugin',
|
|
305
|
+
'jobs.kindRollback': 'Rollback',
|
|
306
|
+
'jobs.running': 'Running',
|
|
307
|
+
'jobs.done': 'Done',
|
|
308
|
+
'jobs.failed': 'Failed',
|
|
309
|
+
'jobs.clear': 'Clear finished',
|
|
310
|
+
'conflict.title': 'These plugins are already installed',
|
|
311
|
+
'conflict.subtitle': 'plugins already exist. Choose what to do:',
|
|
312
|
+
'conflict.overwrite': 'Overwrite',
|
|
313
|
+
'conflict.skip': 'Skip',
|
|
314
|
+
'conflict.allOverwrite': 'Overwrite all',
|
|
315
|
+
'conflict.allSkip': 'Skip all',
|
|
316
|
+
'conflict.sameVersion': 'same version',
|
|
317
|
+
'conflict.confirm': 'Start import',
|
|
318
|
+
'conflict.cancel': 'Cancel',
|
|
319
|
+
'backup.title': 'Backups',
|
|
320
|
+
'backup.desc': 'Every import leaves a snapshot here. Roll back to restore config and plugin dirs.',
|
|
321
|
+
'backup.none': 'No import backups yet',
|
|
322
|
+
'backup.rollback': 'Roll back',
|
|
323
|
+
'backup.rolling': 'Rolling back…',
|
|
324
|
+
'backup.done': 'Rollback finished',
|
|
325
|
+
'backup.confirm': 'Roll back to this snapshot? Plugins added by that import will be removed.',
|
|
326
|
+
'backup.createdAt': 'Time',
|
|
327
|
+
'backup.items': 'items',
|
|
328
|
+
'recent.title': 'Recent exports',
|
|
329
|
+
'recent.none': 'No bundles in this folder yet',
|
|
330
|
+
'recent.import': 'Import this',
|
|
331
|
+
'help.title': 'How to use (no commands needed)',
|
|
332
|
+
'help.step1': 'Step 1: tick the plugins on this page and press Export — you get one zip.',
|
|
333
|
+
'help.step2': 'Step 2: copy the zip to the new machine (or another profile).',
|
|
334
|
+
'help.step3': 'Step 3: open this page there, switch to Import, pick the zip, press Import.',
|
|
335
|
+
'help.step4': 'Step 4: restart dsh. Plugins, deps, config and toggles all come back.',
|
|
336
|
+
'help.note1': 'No typing needed: this page does exactly what the /pack command does.',
|
|
337
|
+
'help.note2': 'Only want a backup? Keep the exported zip — you can import it any time.',
|
|
338
|
+
'help.note3': 'Not happy with an import? Open Backup and roll back in one click.',
|
|
339
|
+
'help.note4': 'Modifying this plugin itself? Use the "Hot-reload UI" button — no restart needed.',
|
|
340
|
+
'help.advanced': 'Advanced: commands or just ask the agent',
|
|
341
|
+
'help.cmd': '/pack export /pack import <zip> /pack rollback',
|
|
342
|
+
'browse.title': 'Choose a folder',
|
|
343
|
+
'browse.up': 'Up',
|
|
344
|
+
'browse.pick': 'Use this folder',
|
|
345
|
+
'browse.cancel': 'Cancel',
|
|
346
|
+
'common.cancel': 'Cancel',
|
|
347
|
+
'common.close': 'Close',
|
|
348
|
+
'errors.crossOrigin': 'Cross-site request blocked',
|
|
349
|
+
'errors.offline': 'Cannot reach the plugin API. Check dsh is running, then refresh',
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/* ---------------------------------- 样式 --------------------------------- */
|
|
353
|
+
|
|
354
|
+
const CSS = `
|
|
355
|
+
.pp-root{box-sizing:border-box;font-family:var(--dsw-font-family,inherit);font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary,#111)}
|
|
356
|
+
.pp-root *,.pp-root *::before,.pp-root *::after{box-sizing:border-box}
|
|
357
|
+
.pp-wrap{max-width:1080px;margin:0 auto;padding:4px 2px 32px}
|
|
358
|
+
.pp-h1{font-size:20px;line-height:28px;font-weight:600;margin:0 0 6px}
|
|
359
|
+
.pp-sub{color:var(--dsw-alias-label-secondary,#6b7280);margin:0 0 18px;font-size:13px;line-height:20px}
|
|
360
|
+
.pp-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px}
|
|
361
|
+
.pp-pill{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12px;line-height:18px;background:var(--dsw-alias-interactive-bg-hover,color-mix(in srgb,currentColor 8%,transparent));color:var(--dsw-alias-label-secondary,#6b7280)}
|
|
362
|
+
.pp-pill b{color:var(--dsw-alias-label-primary,#111);font-weight:600}
|
|
363
|
+
.pp-btn{appearance:none;cursor:pointer;font:inherit;font-size:13px;line-height:20px;padding:5px 13px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l3,#d5d5d5);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111);transition:background .12s}
|
|
364
|
+
.pp-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,color-mix(in srgb,currentColor 8%,transparent))}
|
|
365
|
+
.pp-btn:disabled{opacity:.5;cursor:not-allowed}
|
|
366
|
+
.pp-btn.primary{background:var(--dsw-alias-button-primary-fill,#111);border-color:transparent;color:var(--dsw-alias-label-primary-foreground,#fff);font-weight:500}
|
|
367
|
+
.pp-btn.primary:hover:not(:disabled){filter:brightness(1.12)}
|
|
368
|
+
.pp-btn.danger{border-color:var(--dsw-alias-state-error-primary,#dc2626);color:var(--dsw-alias-state-error-primary,#dc2626)}
|
|
369
|
+
.pp-btn.sm{font-size:12px;padding:3px 9px;border-radius:6px}
|
|
370
|
+
.pp-tabs{display:flex;gap:2px;border-bottom:.5px solid var(--dsw-alias-border-l1,#ececec);margin-bottom:16px}
|
|
371
|
+
.pp-tab{appearance:none;border:0;border-bottom:2px solid transparent;background:transparent;cursor:pointer;font:inherit;font-size:13px;line-height:20px;padding:7px 12px;margin-bottom:-1px;color:var(--dsw-alias-label-secondary,#6b7280);border-radius:0}
|
|
372
|
+
.pp-tab:hover{color:var(--dsw-alias-label-primary,#111)}
|
|
373
|
+
.pp-tab.on{color:var(--dsw-alias-label-primary,#111);font-weight:500;border-bottom-color:var(--dsw-alias-label-primary,#111)}
|
|
374
|
+
.pp-card{border:.5px solid var(--dsw-alias-border-l2,#e5e5e5);border-radius:12px;padding:14px 16px;margin-bottom:14px;background:var(--dsw-alias-bg-layer-1,#fff)}
|
|
375
|
+
.pp-card h3{font-size:15px;line-height:22px;font-weight:600;margin:0 0 4px}
|
|
376
|
+
.pp-card p.pp-desc{color:var(--dsw-alias-label-secondary,#6b7280);font-size:13px;line-height:20px;margin:0 0 12px}
|
|
377
|
+
.pp-list{border:.5px solid var(--dsw-alias-border-l2,#e5e5e5);border-radius:10px;overflow:hidden}
|
|
378
|
+
.pp-row{display:flex;align-items:flex-start;gap:10px;padding:9px 12px;border-bottom:.5px solid var(--dsw-alias-border-l1,#f0f0f0)}
|
|
379
|
+
.pp-row:last-child{border-bottom:0}
|
|
380
|
+
.pp-row:hover{background:var(--dsw-alias-interactive-bg-hover,color-mix(in srgb,currentColor 5%,transparent))}
|
|
381
|
+
.pp-row input[type=checkbox]{margin:3px 0 0;flex:none;width:15px;height:15px;cursor:pointer;accent-color:var(--dsw-alias-button-primary-fill,#111)}
|
|
382
|
+
.pp-row .pp-main{flex:1;min-width:0}
|
|
383
|
+
.pp-row .pp-name{font-weight:500;font-size:13px;line-height:20px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
384
|
+
.pp-row .pp-ver{font-size:11px;line-height:16px;padding:0 5px;border-radius:4px;background:var(--dsw-alias-interactive-bg-hover,color-mix(in srgb,currentColor 8%,transparent));color:var(--dsw-alias-label-secondary,#6b7280);font-weight:400}
|
|
385
|
+
.pp-row .pp-meta{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#6b7280);margin-top:1px}
|
|
386
|
+
.pp-row .pp-warn{font-size:12px;line-height:18px;color:var(--dsw-alias-state-warning-primary,#b45309);margin-top:2px}
|
|
387
|
+
.pp-row.pp-dim{opacity:.55}
|
|
388
|
+
.pp-side{flex:none;text-align:right;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#6b7280);white-space:nowrap}
|
|
389
|
+
.pp-field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px}
|
|
390
|
+
.pp-label{font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-label-primary,#111)}
|
|
391
|
+
.pp-hint{font-size:11px;line-height:17px;color:var(--dsw-alias-label-secondary,#6b7280)}
|
|
392
|
+
.pp-input{font:inherit;font-size:13px;line-height:20px;padding:6px 10px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l4,#d0d0d0);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111);width:100%;outline:none}
|
|
393
|
+
.pp-input:focus{border-color:var(--dsw-alias-label-primary,#111)}
|
|
394
|
+
.pp-input-row{display:flex;gap:8px;align-items:center}
|
|
395
|
+
.pp-check{display:flex;align-items:flex-start;gap:8px;padding:7px 0;cursor:pointer}
|
|
396
|
+
.pp-check input{margin:3px 0 0;flex:none;width:15px;height:15px;accent-color:var(--dsw-alias-button-primary-fill,#111);cursor:pointer}
|
|
397
|
+
.pp-check .pp-check-body{flex:1}
|
|
398
|
+
.pp-check .pp-check-title{font-size:13px;line-height:20px}
|
|
399
|
+
.pp-note{border-left:2px solid var(--dsw-alias-border-l4,#d0d0d0);padding:2px 0 2px 10px;margin:10px 0 0;font-size:12px;line-height:19px;color:var(--dsw-alias-label-secondary,#6b7280)}
|
|
400
|
+
.pp-out{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:18px;word-break:break-all;background:var(--dsw-alias-markdown-code-block,#f7f7f8);border-radius:8px;padding:9px 11px;margin:10px 0 0;color:var(--dsw-alias-label-primary,#111)}
|
|
401
|
+
.pp-alert{border-radius:8px;padding:9px 12px;font-size:12px;line-height:19px;margin:10px 0 0}
|
|
402
|
+
.pp-alert.err{background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#dc2626) 10%,transparent);color:var(--dsw-alias-state-error-primary,#b91c1c)}
|
|
403
|
+
.pp-alert.ok{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#16a34a) 12%,transparent);color:var(--dsw-alias-state-success-primary,#15803d)}
|
|
404
|
+
.pp-alert.info{background:var(--dsw-alias-interactive-bg-hover,color-mix(in srgb,currentColor 8%,transparent));color:var(--dsw-alias-label-secondary,#4b5563)}
|
|
405
|
+
.pp-table{width:100%;border-collapse:collapse;font-size:12px;line-height:18px}
|
|
406
|
+
.pp-table th{text-align:left;font-weight:500;color:var(--dsw-alias-label-secondary,#6b7280);padding:4px 8px 6px;border-bottom:.5px solid var(--dsw-alias-border-l2,#e5e5e5);white-space:nowrap}
|
|
407
|
+
.pp-table td{padding:5px 8px;border-bottom:.5px solid var(--dsw-alias-border-l1,#f2f2f2);vertical-align:top}
|
|
408
|
+
.pp-table tr:last-child td{border-bottom:0}
|
|
409
|
+
.pp-mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}
|
|
410
|
+
.pp-empty{padding:22px 12px;text-align:center;color:var(--dsw-alias-label-secondary,#6b7280);font-size:13px}
|
|
411
|
+
.pp-step{display:flex;gap:10px;margin-bottom:12px}
|
|
412
|
+
.pp-step .pp-num{flex:none;width:22px;height:22px;border-radius:50%;background:var(--dsw-alias-button-primary-fill,#111);color:var(--dsw-alias-label-primary-foreground,#fff);font-size:12px;line-height:22px;text-align:center;font-weight:600}
|
|
413
|
+
.pp-step .pp-step-body{flex:1;font-size:13px;line-height:21px}
|
|
414
|
+
.pp-not-bundle{border:1.5px solid var(--dsw-alias-state-warning-primary,#d97706);border-radius:10px;padding:12px 14px;background:color-mix(in srgb,var(--dsw-alias-state-warning-primary,#d97706) 8%,transparent)}
|
|
415
|
+
.pp-not-bundle-title{font-size:13px;line-height:20px;font-weight:600;color:var(--dsw-alias-state-warning-primary,#b45309);margin-bottom:4px}
|
|
416
|
+
.pp-not-bundle-detail{font-size:12px;line-height:19px;color:var(--dsw-alias-label-secondary,#6b7280);word-break:break-word}
|
|
417
|
+
.pp-spin{display:inline-block;width:12px;height:12px;border:1.5px solid currentColor;border-right-color:transparent;border-radius:50%;animation:pp-spin .7s linear infinite;vertical-align:-1px;margin-right:6px}
|
|
418
|
+
@keyframes pp-spin{to{transform:rotate(360deg)}}
|
|
419
|
+
.pp-mode-dot{flex:none;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-border-l4,#c8c8c8)}
|
|
420
|
+
.pp-mode-dot[data-kind="single"]{background:var(--dsw-alias-state-success-primary,#16a34a)}
|
|
421
|
+
.pp-mode-dot[data-kind="bundle"]{background:var(--dsw-alias-button-primary-fill,#111)}
|
|
422
|
+
`;
|
|
423
|
+
|
|
424
|
+
/* --------------------------------- 接口层 -------------------------------- */
|
|
425
|
+
|
|
426
|
+
let translate = (key) => key;
|
|
427
|
+
|
|
428
|
+
/** 带超时的 fetch。 */
|
|
429
|
+
async function api(pathname, init = {}) {
|
|
430
|
+
const url = (() => {
|
|
431
|
+
try {
|
|
432
|
+
return new URL(pathname, window.location.origin).toString();
|
|
433
|
+
} catch {
|
|
434
|
+
return pathname;
|
|
435
|
+
}
|
|
436
|
+
})();
|
|
437
|
+
const controller = new AbortController();
|
|
438
|
+
const slow = /\/(export|import|rollback)$/.test(pathname);
|
|
439
|
+
const timer = setTimeout(() => controller.abort(), slow ? 600000 : 30000);
|
|
440
|
+
try {
|
|
441
|
+
const response = await fetch(url, {
|
|
442
|
+
cache: 'no-store',
|
|
443
|
+
credentials: 'same-origin',
|
|
444
|
+
signal: controller.signal,
|
|
445
|
+
...init,
|
|
446
|
+
headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
|
|
447
|
+
});
|
|
448
|
+
const text = await response.text();
|
|
449
|
+
let data = null;
|
|
450
|
+
try {
|
|
451
|
+
data = text ? JSON.parse(text) : null;
|
|
452
|
+
} catch {
|
|
453
|
+
throw new Error(`接口返回的不是 JSON(${response.status}): ${text.slice(0, 120)}`);
|
|
454
|
+
}
|
|
455
|
+
if (!response.ok) {
|
|
456
|
+
const error = new Error((data && (data.error || data.message)) || `${response.status} ${response.statusText}`);
|
|
457
|
+
// 把服务端的错误码带出来 —— 界面据此区分"拖错文件"和"真出故障"
|
|
458
|
+
if (data?.code) error.code = String(data.code);
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
return data;
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (error?.name === 'AbortError') throw new Error('请求超时,包太大或磁盘太慢');
|
|
464
|
+
if (/failed to fetch|networkerror|load failed/i.test(String(error?.message ?? ''))) {
|
|
465
|
+
throw new Error(translate('errors.offline'));
|
|
466
|
+
}
|
|
467
|
+
throw error;
|
|
468
|
+
} finally {
|
|
469
|
+
clearTimeout(timer);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const getStatus = () => api('/plugin-pack/status');
|
|
474
|
+
const doExport = (body) => api('/plugin-pack/export', { method: 'POST', body: JSON.stringify(body) });
|
|
475
|
+
const doImport = (body) => api('/plugin-pack/import', { method: 'POST', body: JSON.stringify(body) });
|
|
476
|
+
// inspect 接口保留在服务端(工具/CLI 会用),界面这层不再需要它
|
|
477
|
+
const doRollback = (backup) => api('/plugin-pack/rollback', { method: 'POST', body: JSON.stringify({ backup }) });
|
|
478
|
+
const doBrowse = (dir, options = {}) =>
|
|
479
|
+
api('/plugin-pack/browse', { method: 'POST', body: JSON.stringify({ dir, create: Boolean(options.create) }) });
|
|
480
|
+
const doHotload = (pkg) => api('/plugin-pack/hotload', { method: 'POST', body: JSON.stringify({ package: pkg }) });
|
|
481
|
+
const getClientStatus = (pkg) => api(`/plugin-pack/client?package=${encodeURIComponent(pkg ?? 'dsh-plugin-pack')}`);
|
|
482
|
+
const doImportPlugin = (dir) => api('/plugin-pack/import-plugin', { method: 'POST', body: JSON.stringify({ dir }) });
|
|
483
|
+
/** 后台任务列表(进行中 + 已完成)。 */
|
|
484
|
+
const getJobs = () => api('/plugin-pack/jobs?limit=20');
|
|
485
|
+
const clearJobs = () => api('/plugin-pack/jobs/clear', { method: 'POST', body: '{}' });
|
|
486
|
+
|
|
487
|
+
/** 上传一个 zip,返回宿主落盘后的绝对路径。 */
|
|
488
|
+
async function uploadZip(file) {
|
|
489
|
+
const form = new FormData();
|
|
490
|
+
form.append('file', file, file.name);
|
|
491
|
+
const response = await fetch(new URL('/plugin-pack/upload-zip', window.location.origin).toString(), {
|
|
492
|
+
method: 'POST',
|
|
493
|
+
credentials: 'same-origin',
|
|
494
|
+
body: form,
|
|
495
|
+
});
|
|
496
|
+
const text = await response.text();
|
|
497
|
+
let data = null;
|
|
498
|
+
try {
|
|
499
|
+
data = text ? JSON.parse(text) : null;
|
|
500
|
+
} catch {
|
|
501
|
+
throw new Error(`上传返回的不是 JSON(${response.status})`);
|
|
502
|
+
}
|
|
503
|
+
if (!response.ok || !data?.ok) throw new Error(data?.error ?? `上传失败(${response.status})`);
|
|
504
|
+
return data;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* 上传一个拖进来的目录。
|
|
509
|
+
*
|
|
510
|
+
* 浏览器只给 DataTransferItem。用 webkit 的 entry API 递归读取目录结构,
|
|
511
|
+
* 每个文件的 name 用它的相对路径(宿主按这个还原目录树)。
|
|
512
|
+
* 不支持 entry API 时退回只读文件列表。
|
|
513
|
+
*/
|
|
514
|
+
async function uploadDirectory(dataTransfer) {
|
|
515
|
+
const entries = [];
|
|
516
|
+
const items = [...(dataTransfer.items ?? [])];
|
|
517
|
+
const rootName = items[0]?.webkitGetAsEntry?.()?.name ?? 'plugin';
|
|
518
|
+
|
|
519
|
+
const readEntry = async (entry, prefix) => {
|
|
520
|
+
if (entry.isFile) {
|
|
521
|
+
const file = await new Promise((resolve, reject) => entry.file(resolve, reject));
|
|
522
|
+
entries.push({ rel: prefix ? `${prefix}/${file.name}` : file.name, file });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (!entry.isDirectory) return;
|
|
526
|
+
const reader = entry.createReader();
|
|
527
|
+
const readBatch = () =>
|
|
528
|
+
new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
|
529
|
+
// readEntries 一次最多给 100 条,要循环读到空
|
|
530
|
+
for (;;) {
|
|
531
|
+
const batch = await readBatch();
|
|
532
|
+
if (!batch.length) break;
|
|
533
|
+
for (const child of batch) {
|
|
534
|
+
await readEntry(child, prefix ? `${prefix}/${entry.name}` : entry.name);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
for (const item of items) {
|
|
540
|
+
const entry = item.webkitGetAsEntry?.();
|
|
541
|
+
if (!entry) continue;
|
|
542
|
+
if (entry.isFile) {
|
|
543
|
+
const file = await new Promise((resolve, reject) => entry.file(resolve, reject));
|
|
544
|
+
entries.push({ rel: file.name, file });
|
|
545
|
+
} else if (entry.isDirectory) {
|
|
546
|
+
await readEntry(entry, '');
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!entries.length) throw new Error('没读到目录内容(浏览器可能不支持目录拖放)');
|
|
551
|
+
|
|
552
|
+
const form = new FormData();
|
|
553
|
+
for (const item of entries) {
|
|
554
|
+
form.append(`f:${item.rel}`, item.file, item.file.name);
|
|
555
|
+
}
|
|
556
|
+
const response = await fetch(new URL('/plugin-pack/upload-dir', window.location.origin).toString(), {
|
|
557
|
+
method: 'POST',
|
|
558
|
+
credentials: 'same-origin',
|
|
559
|
+
body: form,
|
|
560
|
+
});
|
|
561
|
+
const text = await response.text();
|
|
562
|
+
let data = null;
|
|
563
|
+
try {
|
|
564
|
+
data = text ? JSON.parse(text) : null;
|
|
565
|
+
} catch {
|
|
566
|
+
throw new Error(`上传返回的不是 JSON(${response.status})`);
|
|
567
|
+
}
|
|
568
|
+
if (!response.ok || !data?.ok) throw new Error(data?.error ?? `上传失败(${response.status})`);
|
|
569
|
+
return { ...data, rootName: data.rootName ?? rootName };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/* -------------------------------- 小工具件 ------------------------------- */
|
|
573
|
+
|
|
574
|
+
function Alert({ kind = 'info', children }) {
|
|
575
|
+
return h('div', { className: `pp-alert ${kind}` }, children);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function Field({ label, hint, children }) {
|
|
579
|
+
return h('div', { className: 'pp-field' }, h('label', { className: 'pp-label' }, label), children, hint ? h('span', { className: 'pp-hint' }, hint) : null);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function Check({ checked, onChange, title, hint }) {
|
|
583
|
+
return h(
|
|
584
|
+
'label',
|
|
585
|
+
{ className: 'pp-check' },
|
|
586
|
+
h('input', { type: 'checkbox', checked, onChange: (event) => onChange(event.target.checked) }),
|
|
587
|
+
h(
|
|
588
|
+
'span',
|
|
589
|
+
{ className: 'pp-check-body' },
|
|
590
|
+
h('span', { className: 'pp-check-title' }, title),
|
|
591
|
+
hint ? h('span', { className: 'pp-hint', style: { display: 'block' } }, hint) : null,
|
|
592
|
+
),
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function Spinner({ label }) {
|
|
597
|
+
return h('span', null, h('span', { className: 'pp-spin' }), label);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/* -------------------------------- 目录选择器 ------------------------------ */
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 选择器。
|
|
604
|
+
*
|
|
605
|
+
* 目标是「不费劲」:能粘路径就直接跳、有面包屑可以跳级、能点盘符换盘、
|
|
606
|
+
* 目录和 zip 一起列、上下键 + 回车就能走完。
|
|
607
|
+
*
|
|
608
|
+
* @param {object} props
|
|
609
|
+
* @param {boolean} props.open
|
|
610
|
+
* @param {string} [props.startDir]
|
|
611
|
+
* @param {'dir'|'file'} [props.mode] dir=选目录(底部按钮挑当前目录),file=选 zip(点文件即选)
|
|
612
|
+
* @param {(path: string) => void} props.onPick
|
|
613
|
+
* @param {() => void} props.onClose
|
|
614
|
+
*/
|
|
615
|
+
function DirPicker({ open, startDir, mode = 'file', onPick, onClose }) {
|
|
616
|
+
const [state, setState] = useState({ dir: '', parent: null, ancestors: [], roots: [], items: [], error: '', selectFile: null });
|
|
617
|
+
const [busy, setBusy] = useState(false);
|
|
618
|
+
const [typed, setTyped] = useState('');
|
|
619
|
+
const [cursor, setCursor] = useState(-1);
|
|
620
|
+
/** 跳转反馈:'ok' | { error } | null。没有它用户会以为回车没生效。 */
|
|
621
|
+
const [jumpState, setJumpState] = useState(null);
|
|
622
|
+
/** 输入框的"基线"值:失焦时只有真改过才自动确认,避免误触发跳转。 */
|
|
623
|
+
const typedBaseline = useRef('');
|
|
624
|
+
const listRef = useRef(null);
|
|
625
|
+
/** 这次是在选目录(导出目标 / 插件目录),还是选文件(zip)。 */
|
|
626
|
+
const pickingDir = mode === 'dir';
|
|
627
|
+
|
|
628
|
+
const load = useCallback(async (dir) => {
|
|
629
|
+
setBusy(true);
|
|
630
|
+
try {
|
|
631
|
+
// 选导出目标时允许顺手创建新目录;找 zip 时绝不创建
|
|
632
|
+
const data = await doBrowse(dir ?? '', { create: mode === 'dir' });
|
|
633
|
+
setState({
|
|
634
|
+
dir: data.dir,
|
|
635
|
+
parent: data.parent,
|
|
636
|
+
ancestors: data.ancestors ?? [],
|
|
637
|
+
roots: data.roots ?? [],
|
|
638
|
+
items: data.items ?? [],
|
|
639
|
+
error: '',
|
|
640
|
+
selectFile: data.selectFile ?? null,
|
|
641
|
+
});
|
|
642
|
+
setTyped(data.dir);
|
|
643
|
+
typedBaseline.current = data.dir;
|
|
644
|
+
setCursor(-1);
|
|
645
|
+
setJumpState(null);
|
|
646
|
+
} catch (error) {
|
|
647
|
+
setState((prev) => ({ ...prev, error: String(error?.message ?? error) }));
|
|
648
|
+
} finally {
|
|
649
|
+
setBusy(false);
|
|
650
|
+
}
|
|
651
|
+
}, []);
|
|
652
|
+
|
|
653
|
+
useEffect(() => {
|
|
654
|
+
if (!open) return;
|
|
655
|
+
load(startDir);
|
|
656
|
+
}, [open, startDir, load]);
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* 输入框回车:按输入的路径直接跳(没有按钮,回车即确认)。
|
|
660
|
+
*
|
|
661
|
+
* 为什么单独做一层而不是直接 load():
|
|
662
|
+
* - 空路径、与当前目录相同 —— 回车看起来"没反应",所以要明确回话
|
|
663
|
+
* - 用户多半是粘贴一个完整路径(含文件名),要能识别"这是文件"并选中它
|
|
664
|
+
*/
|
|
665
|
+
const doJump = useCallback(
|
|
666
|
+
async (raw) => {
|
|
667
|
+
const text = String(raw ?? '').trim();
|
|
668
|
+
if (!text) {
|
|
669
|
+
setJumpState({ error: translate('browse.jumpEmpty') });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
// 已经在这个目录:明确告诉用户,而不是静默无变化
|
|
673
|
+
const normalized = text.replace(/[\\/]+$/, '');
|
|
674
|
+
if (normalized === state.dir.replace(/[\\/]+$/, '') && !/\.zip$/i.test(text)) {
|
|
675
|
+
setJumpState({ error: translate('browse.jumpSame') });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
setBusy(true);
|
|
679
|
+
setJumpState(null);
|
|
680
|
+
try {
|
|
681
|
+
const data = await doBrowse(text, { create: mode === 'dir' });
|
|
682
|
+
setState({
|
|
683
|
+
dir: data.dir,
|
|
684
|
+
parent: data.parent,
|
|
685
|
+
ancestors: data.ancestors ?? [],
|
|
686
|
+
roots: data.roots ?? [],
|
|
687
|
+
items: data.items ?? [],
|
|
688
|
+
error: '',
|
|
689
|
+
selectFile: data.selectFile ?? null,
|
|
690
|
+
});
|
|
691
|
+
setTyped(data.dir);
|
|
692
|
+
setCursor(-1);
|
|
693
|
+
// 粘的是文件(如某个 zip):跳到它所在目录并选中它
|
|
694
|
+
if (data.selectFile) {
|
|
695
|
+
const index = (data.items ?? []).findIndex((item) => item.path === data.selectFile);
|
|
696
|
+
if (index >= 0) setCursor(index);
|
|
697
|
+
setJumpState('ok');
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
setJumpState('ok');
|
|
701
|
+
} catch (error) {
|
|
702
|
+
setJumpState({ error: String(error?.message ?? error) });
|
|
703
|
+
} finally {
|
|
704
|
+
setBusy(false);
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
[state.dir],
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
// 上下键移动 + 回车确认;只在选择器打开时挂
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
if (!open) return;
|
|
713
|
+
const onKey = (event) => {
|
|
714
|
+
if (event.key === 'Escape') {
|
|
715
|
+
onClose();
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const nav = state.items;
|
|
719
|
+
if (event.key === 'ArrowDown') {
|
|
720
|
+
if (!nav.length) return;
|
|
721
|
+
event.preventDefault();
|
|
722
|
+
setCursor((prev) => (prev + 1) % nav.length);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (event.key === 'ArrowUp') {
|
|
726
|
+
if (!nav.length) return;
|
|
727
|
+
event.preventDefault();
|
|
728
|
+
setCursor((prev) => (prev <= 0 ? nav.length - 1 : prev - 1));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (event.key === 'Enter') {
|
|
732
|
+
const item = cursor >= 0 ? nav[cursor] : null;
|
|
733
|
+
event.preventDefault();
|
|
734
|
+
if (!item) {
|
|
735
|
+
// 没选中任何项:直接跳输入框里的路径
|
|
736
|
+
load(typed);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
// 选目录时回车 = 选定它(不是钻进去);选文件时回车 = 进入/选定
|
|
740
|
+
if (item.dir) {
|
|
741
|
+
if (pickingDir) onPick(item.path);
|
|
742
|
+
else load(item.path);
|
|
743
|
+
} else {
|
|
744
|
+
onPick(item.path);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
window.addEventListener('keydown', onKey);
|
|
749
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
750
|
+
}, [open, state.items, cursor, typed, load, onPick, onClose, pickingDir]);
|
|
751
|
+
|
|
752
|
+
// 选中的项滚进视野
|
|
753
|
+
useEffect(() => {
|
|
754
|
+
if (cursor < 0 || !listRef.current) return;
|
|
755
|
+
const node = listRef.current.querySelector(`[data-idx="${cursor}"]`);
|
|
756
|
+
node?.scrollIntoView({ block: 'nearest' });
|
|
757
|
+
}, [cursor]);
|
|
758
|
+
|
|
759
|
+
if (!open) return null;
|
|
760
|
+
|
|
761
|
+
const crumbs = state.ancestors ?? [];
|
|
762
|
+
const roots = state.roots ?? [];
|
|
763
|
+
const currentItem = cursor >= 0 ? state.items[cursor] : null;
|
|
764
|
+
const canPickDir = pickingDir || Boolean(currentItem && currentItem.dir);
|
|
765
|
+
|
|
766
|
+
return h(
|
|
767
|
+
'div',
|
|
768
|
+
{
|
|
769
|
+
style: {
|
|
770
|
+
position: 'fixed',
|
|
771
|
+
inset: 0,
|
|
772
|
+
zIndex: 10060,
|
|
773
|
+
background: 'rgba(0,0,0,.34)',
|
|
774
|
+
display: 'flex',
|
|
775
|
+
alignItems: 'center',
|
|
776
|
+
justifyContent: 'center',
|
|
777
|
+
padding: 16,
|
|
778
|
+
},
|
|
779
|
+
onClick: onClose,
|
|
780
|
+
},
|
|
781
|
+
h(
|
|
782
|
+
'div',
|
|
783
|
+
{
|
|
784
|
+
className: 'pp-root',
|
|
785
|
+
style: {
|
|
786
|
+
width: 'min(720px, 100%)',
|
|
787
|
+
height: 'min(560px, 86vh)',
|
|
788
|
+
display: 'flex',
|
|
789
|
+
flexDirection: 'column',
|
|
790
|
+
background: 'var(--dsw-alias-bg-layer-1,#fff)',
|
|
791
|
+
borderRadius: 14,
|
|
792
|
+
border: '.5px solid var(--dsw-alias-border-l3,#d5d5d5)',
|
|
793
|
+
overflow: 'hidden',
|
|
794
|
+
},
|
|
795
|
+
onClick: (event) => event.stopPropagation(),
|
|
796
|
+
},
|
|
797
|
+
// 标题 + 盘符
|
|
798
|
+
h(
|
|
799
|
+
'div',
|
|
800
|
+
{ className: 'pp-bar', style: { padding: '10px 14px 0', marginBottom: 0 } },
|
|
801
|
+
h('b', { style: { fontSize: 13, fontWeight: 600, flex: 'none' } }, translate(mode === 'dir' ? 'browse.titleDir' : 'browse.titleFile')),
|
|
802
|
+
h(
|
|
803
|
+
'div',
|
|
804
|
+
{ style: { display: 'flex', gap: 4, marginLeft: 'auto', flexWrap: 'wrap' } },
|
|
805
|
+
roots.map((root) =>
|
|
806
|
+
h(
|
|
807
|
+
'button',
|
|
808
|
+
{
|
|
809
|
+
key: root.path,
|
|
810
|
+
className: `pp-btn sm${state.dir.toLowerCase().startsWith(root.path.toLowerCase()) ? ' primary' : ''}`,
|
|
811
|
+
style: { padding: '2px 8px', fontSize: 11 },
|
|
812
|
+
onClick: () => load(root.path),
|
|
813
|
+
title: root.path,
|
|
814
|
+
},
|
|
815
|
+
root.name,
|
|
816
|
+
),
|
|
817
|
+
),
|
|
818
|
+
),
|
|
819
|
+
),
|
|
820
|
+
// 路径输入框:回车即确认;点开别处(失焦)也自动确认 —— 不用记得按什么
|
|
821
|
+
h(
|
|
822
|
+
'div',
|
|
823
|
+
{ style: { padding: '8px 14px' } },
|
|
824
|
+
h('input', {
|
|
825
|
+
className: 'pp-input pp-mono',
|
|
826
|
+
value: typed,
|
|
827
|
+
placeholder: translate('browse.pathHint'),
|
|
828
|
+
onChange: (event) => {
|
|
829
|
+
setTyped(event.target.value);
|
|
830
|
+
setJumpState(null);
|
|
831
|
+
},
|
|
832
|
+
onKeyDown: (event) => {
|
|
833
|
+
if (event.key === 'Enter') {
|
|
834
|
+
event.preventDefault();
|
|
835
|
+
doJump(typed);
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
onBlur: () => {
|
|
839
|
+
// 只在「值确实被改过」时才自动确认:
|
|
840
|
+
// 否则点下面任何文件夹都会把光标移走、触发一次多余跳转。
|
|
841
|
+
const text = String(typed ?? '').trim();
|
|
842
|
+
if (!text) return;
|
|
843
|
+
if (text === typedBaseline.current) return;
|
|
844
|
+
doJump(text);
|
|
845
|
+
},
|
|
846
|
+
}),
|
|
847
|
+
// 结果就地反馈:成功/失败都要说话,否则用户以为没反应
|
|
848
|
+
jumpState === 'ok'
|
|
849
|
+
? h('div', { className: 'pp-hint', style: { marginTop: 4, color: 'var(--dsw-alias-state-success-primary,#15803d)' } }, translate('browse.jumpOk'))
|
|
850
|
+
: null,
|
|
851
|
+
jumpState && typeof jumpState === 'object'
|
|
852
|
+
? h('div', { className: 'pp-hint', style: { marginTop: 4, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, jumpState.error)
|
|
853
|
+
: null,
|
|
854
|
+
),
|
|
855
|
+
// 面包屑
|
|
856
|
+
h(
|
|
857
|
+
'div',
|
|
858
|
+
{
|
|
859
|
+
style: {
|
|
860
|
+
padding: '0 14px 8px',
|
|
861
|
+
display: 'flex',
|
|
862
|
+
gap: 2,
|
|
863
|
+
flexWrap: 'wrap',
|
|
864
|
+
alignItems: 'center',
|
|
865
|
+
fontSize: 12,
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
crumbs.map((crumb, index) =>
|
|
869
|
+
h(
|
|
870
|
+
'span',
|
|
871
|
+
{ key: crumb.path, style: { display: 'inline-flex', alignItems: 'center', gap: 2 } },
|
|
872
|
+
index > 0 ? h('span', { className: 'pp-hint' }, '›') : null,
|
|
873
|
+
h(
|
|
874
|
+
'button',
|
|
875
|
+
{
|
|
876
|
+
className: 'pp-btn sm',
|
|
877
|
+
style: { padding: '1px 6px', fontSize: 11, maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
878
|
+
onClick: () => load(crumb.path),
|
|
879
|
+
title: crumb.path,
|
|
880
|
+
},
|
|
881
|
+
crumb.name,
|
|
882
|
+
),
|
|
883
|
+
),
|
|
884
|
+
),
|
|
885
|
+
),
|
|
886
|
+
// 列表
|
|
887
|
+
h(
|
|
888
|
+
'div',
|
|
889
|
+
{ ref: listRef, style: { flex: 1, minHeight: 160, overflow: 'auto', borderTop: '.5px solid var(--dsw-alias-border-l1,#f0f0f0)' } },
|
|
890
|
+
state.error ? h(Alert, { kind: 'err' }, state.error) : null,
|
|
891
|
+
busy ? h('div', { className: 'pp-empty' }, h(Spinner, { label: translate('loading') })) : null,
|
|
892
|
+
!busy &&
|
|
893
|
+
state.items
|
|
894
|
+
// 选目录模式下不列文件:用户是在挑"导出到哪儿",
|
|
895
|
+
// 列出 .tgz/.zip 只会让人以为能选压缩包(实际会被当成目录名)。
|
|
896
|
+
.filter((item) => !pickingDir || item.dir)
|
|
897
|
+
.map((item, index) =>
|
|
898
|
+
h(
|
|
899
|
+
'div',
|
|
900
|
+
{
|
|
901
|
+
key: item.path,
|
|
902
|
+
'data-idx': index,
|
|
903
|
+
className: `pp-row${cursor === index ? ' pp-row-on' : ''}`,
|
|
904
|
+
style: {
|
|
905
|
+
cursor: 'pointer',
|
|
906
|
+
background: cursor === index ? 'var(--dsw-alias-interactive-bg-hover,color-mix(in srgb,currentColor 9%,transparent))' : undefined,
|
|
907
|
+
},
|
|
908
|
+
onClick: () => {
|
|
909
|
+
// 单击只选中;进入目录要双击(选目录模式下双击也不进,
|
|
910
|
+
// 否则想把当前层某个目录当目标时永远点不到)。
|
|
911
|
+
if (item.dir) {
|
|
912
|
+
setCursor(index);
|
|
913
|
+
if (!pickingDir) return;
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
onPick(item.path);
|
|
917
|
+
},
|
|
918
|
+
onDoubleClick: () => {
|
|
919
|
+
if (item.dir) {
|
|
920
|
+
setCursor(index);
|
|
921
|
+
load(item.path);
|
|
922
|
+
} else {
|
|
923
|
+
onPick(item.path);
|
|
924
|
+
}
|
|
925
|
+
},
|
|
926
|
+
onMouseEnter: () => setCursor(index),
|
|
927
|
+
},
|
|
928
|
+
h('span', { style: { flex: 'none', width: 16, opacity: 0.7 } }, item.dir ? '📁' : '📦'),
|
|
929
|
+
h('span', { className: 'pp-main pp-mono', style: { fontSize: 12, wordBreak: 'break-all' } }, item.name),
|
|
930
|
+
item.dir
|
|
931
|
+
? pickingDir
|
|
932
|
+
? h(
|
|
933
|
+
'button',
|
|
934
|
+
{
|
|
935
|
+
className: 'pp-btn sm',
|
|
936
|
+
onClick: (event) => {
|
|
937
|
+
event.stopPropagation();
|
|
938
|
+
onPick(item.path);
|
|
939
|
+
},
|
|
940
|
+
},
|
|
941
|
+
translate('browse.enter'),
|
|
942
|
+
)
|
|
943
|
+
: h(
|
|
944
|
+
'button',
|
|
945
|
+
{
|
|
946
|
+
className: 'pp-btn sm',
|
|
947
|
+
onClick: (event) => {
|
|
948
|
+
event.stopPropagation();
|
|
949
|
+
load(item.path);
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
translate('browse.open'),
|
|
953
|
+
)
|
|
954
|
+
: h('span', { className: 'pp-side' }, `${(item.size / 1048576).toFixed(1)} MiB`),
|
|
955
|
+
item.dir
|
|
956
|
+
? null
|
|
957
|
+
: pickingDir
|
|
958
|
+
? null
|
|
959
|
+
: h(
|
|
960
|
+
'button',
|
|
961
|
+
{
|
|
962
|
+
className: 'pp-btn sm',
|
|
963
|
+
onClick: (event) => {
|
|
964
|
+
event.stopPropagation();
|
|
965
|
+
onPick(item.path);
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
'选它',
|
|
969
|
+
),
|
|
970
|
+
),
|
|
971
|
+
),
|
|
972
|
+
!busy && !state.items.length && !state.error
|
|
973
|
+
? h(
|
|
974
|
+
'div',
|
|
975
|
+
{ className: 'pp-empty' },
|
|
976
|
+
pickingDir ? '空目录(这里没有子目录)' : '空目录(这里没有子目录,也没有 zip)',
|
|
977
|
+
)
|
|
978
|
+
: null,
|
|
979
|
+
),
|
|
980
|
+
// 底部
|
|
981
|
+
h(
|
|
982
|
+
'div',
|
|
983
|
+
{
|
|
984
|
+
style: {
|
|
985
|
+
padding: '10px 14px',
|
|
986
|
+
borderTop: '.5px solid var(--dsw-alias-border-l1,#eee)',
|
|
987
|
+
display: 'flex',
|
|
988
|
+
gap: 8,
|
|
989
|
+
alignItems: 'center',
|
|
990
|
+
},
|
|
991
|
+
},
|
|
992
|
+
h(
|
|
993
|
+
'span',
|
|
994
|
+
{ className: 'pp-hint', style: { flex: 1 } },
|
|
995
|
+
pickingDir ? '↑↓ 选择 · 回车选定该目录 · 双击进入 · Esc 关闭' : '↑↓ 选择 · 回车打开 / 选文件 · Esc 关闭',
|
|
996
|
+
),
|
|
997
|
+
h(
|
|
998
|
+
'button',
|
|
999
|
+
{
|
|
1000
|
+
className: 'pp-btn',
|
|
1001
|
+
disabled: !state.parent,
|
|
1002
|
+
onClick: () => state.parent && load(state.parent),
|
|
1003
|
+
},
|
|
1004
|
+
translate('browse.up'),
|
|
1005
|
+
),
|
|
1006
|
+
h('button', { className: 'pp-btn', onClick: onClose }, translate('browse.cancel')),
|
|
1007
|
+
h(
|
|
1008
|
+
'button',
|
|
1009
|
+
{
|
|
1010
|
+
className: 'pp-btn primary',
|
|
1011
|
+
onClick: () => {
|
|
1012
|
+
if (pickingDir) {
|
|
1013
|
+
onPick(state.dir);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const item = cursor >= 0 ? state.items[cursor] : null;
|
|
1017
|
+
if (item?.dir) onPick(item.path);
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
// 选目录时,主按钮永远是「用当前这个目录」——这才是导出最常用的动作
|
|
1021
|
+
pickingDir ? translate('browse.pickHere') : translate('browse.pickDir'),
|
|
1022
|
+
),
|
|
1023
|
+
),
|
|
1024
|
+
),
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* 拖放区:把 zip 或插件目录拖进窗口就能导入。
|
|
1030
|
+
*
|
|
1031
|
+
* 浏览器只给文件名与内容,不给绝对路径(安全限制),
|
|
1032
|
+
* 所以分两条路:
|
|
1033
|
+
* - 拖进来的是 zip:读它的字节,交给宿主临时落盘再按整合包导入
|
|
1034
|
+
* - 拖进来的是目录:用 webkit 的 entry API 拿到相对结构,交给宿主落盘成插件目录
|
|
1035
|
+
*
|
|
1036
|
+
* @param {object} props
|
|
1037
|
+
* @param {(file: File) => void} props.onFile
|
|
1038
|
+
* @param {boolean} [props.busy]
|
|
1039
|
+
* @param {string} [props.hint]
|
|
1040
|
+
*/
|
|
1041
|
+
function DropZone({ onFile, busy = false, hint, dropRef }) {
|
|
1042
|
+
const [over, setOver] = useState(false);
|
|
1043
|
+
const [hover, setHover] = useState(false);
|
|
1044
|
+
const inputRef = useRef(null);
|
|
1045
|
+
|
|
1046
|
+
const handleDrop = (event) => {
|
|
1047
|
+
event.preventDefault();
|
|
1048
|
+
event.stopPropagation();
|
|
1049
|
+
setOver(false);
|
|
1050
|
+
const files = [...(event.dataTransfer?.files ?? [])];
|
|
1051
|
+
if (!files.length) return;
|
|
1052
|
+
// 目录需要原始 dataTransfer 去读结构,先存下来
|
|
1053
|
+
if (dropRef) dropRef.current.dataTransfer = event.dataTransfer;
|
|
1054
|
+
// 只取第一个:一次拖一个,避免歧义
|
|
1055
|
+
onFile(files[0]);
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
return h(
|
|
1059
|
+
'div',
|
|
1060
|
+
{
|
|
1061
|
+
className: 'pp-drop',
|
|
1062
|
+
'data-over': over ? '1' : '0',
|
|
1063
|
+
onClick: () => !busy && inputRef.current?.click(),
|
|
1064
|
+
onDragEnter: (event) => {
|
|
1065
|
+
event.preventDefault();
|
|
1066
|
+
setOver(true);
|
|
1067
|
+
},
|
|
1068
|
+
onDragOver: (event) => {
|
|
1069
|
+
event.preventDefault();
|
|
1070
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
1071
|
+
if (!over) setOver(true);
|
|
1072
|
+
},
|
|
1073
|
+
onDragLeave: (event) => {
|
|
1074
|
+
event.preventDefault();
|
|
1075
|
+
setOver(false);
|
|
1076
|
+
},
|
|
1077
|
+
onDrop: handleDrop,
|
|
1078
|
+
onMouseEnter: () => setHover(true),
|
|
1079
|
+
onMouseLeave: () => setHover(false),
|
|
1080
|
+
style: {
|
|
1081
|
+
border: `1.5px dashed ${over ? 'var(--dsw-alias-button-primary-fill,#111)' : 'var(--dsw-alias-border-l4,#c8c8c8)'}`,
|
|
1082
|
+
borderColor: over ? 'var(--dsw-alias-button-primary-fill,#111)' : undefined,
|
|
1083
|
+
background: over
|
|
1084
|
+
? 'color-mix(in srgb,var(--dsw-alias-button-primary-fill,#111) 7%,transparent)'
|
|
1085
|
+
: 'var(--dsw-alias-bg-layer-2,#fafafa)',
|
|
1086
|
+
borderRadius: 12,
|
|
1087
|
+
padding: '22px 16px',
|
|
1088
|
+
textAlign: 'center',
|
|
1089
|
+
cursor: busy ? 'wait' : 'pointer',
|
|
1090
|
+
transition: 'border-color .12s, background .12s',
|
|
1091
|
+
marginBottom: 14,
|
|
1092
|
+
},
|
|
1093
|
+
},
|
|
1094
|
+
h('div', { style: { fontSize: 24, lineHeight: '30px', marginBottom: 4, opacity: over ? 1 : 0.75 } }, over ? '📥' : '📂'),
|
|
1095
|
+
h(
|
|
1096
|
+
'div',
|
|
1097
|
+
{ style: { fontSize: 13, fontWeight: 500 } },
|
|
1098
|
+
busy ? translate('drop.running') : over ? translate('drop.release') : translate('drop.title'),
|
|
1099
|
+
),
|
|
1100
|
+
h('div', { className: 'pp-hint', style: { marginTop: 4 } }, hint ?? translate('drop.hint')),
|
|
1101
|
+
h('input', {
|
|
1102
|
+
ref: inputRef,
|
|
1103
|
+
type: 'file',
|
|
1104
|
+
accept: '.zip,.tgz,.gz,application/zip,application/gzip',
|
|
1105
|
+
style: { display: 'none' },
|
|
1106
|
+
onChange: (event) => {
|
|
1107
|
+
const file = event.target.files?.[0];
|
|
1108
|
+
if (file) onFile(file);
|
|
1109
|
+
event.target.value = '';
|
|
1110
|
+
},
|
|
1111
|
+
}),
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* 重名冲突确认弹窗。
|
|
1117
|
+
*
|
|
1118
|
+
* 导入时如果包里有已经装过的插件,先问清楚再动盘:
|
|
1119
|
+
* - 每个插件单独选「覆盖 / 跳过」
|
|
1120
|
+
* - 也可以一键「全部覆盖」「全部跳过」
|
|
1121
|
+
* - 确认后把 { 包名: 'overwrite'|'skip' } 交回去
|
|
1122
|
+
*
|
|
1123
|
+
* @param {object} props
|
|
1124
|
+
* @param {object} props.pending { kind, file, conflicts }
|
|
1125
|
+
* @param {(decisions: object) => void} props.onConfirm
|
|
1126
|
+
* @param {() => void} props.onCancel
|
|
1127
|
+
* @param {(msg: string) => string} props.t
|
|
1128
|
+
*/
|
|
1129
|
+
function ConflictDialog({ pending, onConfirm, onCancel, t }) {
|
|
1130
|
+
const conflicts = pending?.conflicts ?? [];
|
|
1131
|
+
/** 初始全部选「覆盖」——多数人是想升级到新版本。 */
|
|
1132
|
+
const [decisions, setDecisions] = useState(() =>
|
|
1133
|
+
Object.fromEntries(conflicts.map((item) => [item.name, 'overwrite'])),
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1136
|
+
// 弹窗打开时挂 Esc
|
|
1137
|
+
useEffect(() => {
|
|
1138
|
+
const onKey = (event) => {
|
|
1139
|
+
if (event.key === 'Escape') onCancel();
|
|
1140
|
+
};
|
|
1141
|
+
window.addEventListener('keydown', onKey);
|
|
1142
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
1143
|
+
}, [onCancel]);
|
|
1144
|
+
|
|
1145
|
+
if (!pending) return null;
|
|
1146
|
+
|
|
1147
|
+
const setAll = (value) => setDecisions(Object.fromEntries(conflicts.map((item) => [item.name, value])));
|
|
1148
|
+
const overwriteCount = Object.values(decisions).filter((v) => v === 'overwrite').length;
|
|
1149
|
+
const skipCount = Object.values(decisions).filter((v) => v === 'skip').length;
|
|
1150
|
+
|
|
1151
|
+
return h(
|
|
1152
|
+
'div',
|
|
1153
|
+
{
|
|
1154
|
+
style: {
|
|
1155
|
+
position: 'fixed',
|
|
1156
|
+
inset: 0,
|
|
1157
|
+
zIndex: 10070,
|
|
1158
|
+
background: 'rgba(0,0,0,.4)',
|
|
1159
|
+
display: 'flex',
|
|
1160
|
+
alignItems: 'center',
|
|
1161
|
+
justifyContent: 'center',
|
|
1162
|
+
padding: 16,
|
|
1163
|
+
},
|
|
1164
|
+
onClick: onCancel,
|
|
1165
|
+
},
|
|
1166
|
+
h(
|
|
1167
|
+
'div',
|
|
1168
|
+
{
|
|
1169
|
+
className: 'pp-root',
|
|
1170
|
+
style: {
|
|
1171
|
+
width: 'min(640px, 100%)',
|
|
1172
|
+
maxHeight: '80vh',
|
|
1173
|
+
display: 'flex',
|
|
1174
|
+
flexDirection: 'column',
|
|
1175
|
+
background: 'var(--dsw-alias-bg-layer-1,#fff)',
|
|
1176
|
+
borderRadius: 14,
|
|
1177
|
+
border: '.5px solid var(--dsw-alias-border-l3,#d5d5d5)',
|
|
1178
|
+
overflow: 'hidden',
|
|
1179
|
+
},
|
|
1180
|
+
onClick: (event) => event.stopPropagation(),
|
|
1181
|
+
},
|
|
1182
|
+
// 头部
|
|
1183
|
+
h(
|
|
1184
|
+
'div',
|
|
1185
|
+
{ style: { padding: '12px 16px', borderBottom: '.5px solid var(--dsw-alias-border-l1,#eee)' } },
|
|
1186
|
+
h('b', { style: { fontSize: 14, fontWeight: 600, display: 'block', marginBottom: 4 } }, t('conflict.title')),
|
|
1187
|
+
h(
|
|
1188
|
+
'span',
|
|
1189
|
+
{ className: 'pp-hint' },
|
|
1190
|
+
`${conflicts.length} ${t('conflict.subtitle')}`,
|
|
1191
|
+
),
|
|
1192
|
+
),
|
|
1193
|
+
// 一键按钮
|
|
1194
|
+
h(
|
|
1195
|
+
'div',
|
|
1196
|
+
{ className: 'pp-bar', style: { padding: '10px 16px 0', marginBottom: 0 } },
|
|
1197
|
+
h('button', { className: 'pp-btn sm', onClick: () => setAll('overwrite') }, t('conflict.allOverwrite')),
|
|
1198
|
+
h('button', { className: 'pp-btn sm', onClick: () => setAll('skip') }, t('conflict.allSkip')),
|
|
1199
|
+
h(
|
|
1200
|
+
'span',
|
|
1201
|
+
{ className: 'pp-hint', style: { marginLeft: 'auto' } },
|
|
1202
|
+
`${t('conflict.overwrite')} ${overwriteCount} · ${t('conflict.skip')} ${skipCount}`,
|
|
1203
|
+
),
|
|
1204
|
+
),
|
|
1205
|
+
// 列表
|
|
1206
|
+
h(
|
|
1207
|
+
'div',
|
|
1208
|
+
{ style: { flex: 1, minHeight: 120, overflow: 'auto', padding: '8px 16px' } },
|
|
1209
|
+
conflicts.map((item) =>
|
|
1210
|
+
h(
|
|
1211
|
+
'div',
|
|
1212
|
+
{
|
|
1213
|
+
key: item.name,
|
|
1214
|
+
className: 'pp-row',
|
|
1215
|
+
style: { alignItems: 'center', gap: 12, padding: '8px 0' },
|
|
1216
|
+
},
|
|
1217
|
+
h(
|
|
1218
|
+
'div',
|
|
1219
|
+
{ className: 'pp-main' },
|
|
1220
|
+
h(
|
|
1221
|
+
'div',
|
|
1222
|
+
{ className: 'pp-name' },
|
|
1223
|
+
item.name,
|
|
1224
|
+
item.installedVersion
|
|
1225
|
+
? h('span', { className: 'pp-ver' }, `${item.installedVersion} → ${item.version}`)
|
|
1226
|
+
: h('span', { className: 'pp-ver' }, item.version),
|
|
1227
|
+
item.sameVersion ? h('span', { className: 'pp-ver' }, t('conflict.sameVersion')) : null,
|
|
1228
|
+
),
|
|
1229
|
+
h('div', { className: 'pp-meta pp-mono', style: { fontSize: 11 } }, item.dest),
|
|
1230
|
+
),
|
|
1231
|
+
// 每个插件单独选
|
|
1232
|
+
h(
|
|
1233
|
+
'div',
|
|
1234
|
+
{ style: { display: 'flex', gap: 4, flex: 'none' } },
|
|
1235
|
+
h(
|
|
1236
|
+
'button',
|
|
1237
|
+
{
|
|
1238
|
+
className: `pp-btn sm${decisions[item.name] === 'overwrite' ? ' primary' : ''}`,
|
|
1239
|
+
onClick: () => setDecisions((prev) => ({ ...prev, [item.name]: 'overwrite' })),
|
|
1240
|
+
},
|
|
1241
|
+
t('conflict.overwrite'),
|
|
1242
|
+
),
|
|
1243
|
+
h(
|
|
1244
|
+
'button',
|
|
1245
|
+
{
|
|
1246
|
+
className: `pp-btn sm${decisions[item.name] === 'skip' ? ' primary' : ''}`,
|
|
1247
|
+
onClick: () => setDecisions((prev) => ({ ...prev, [item.name]: 'skip' })),
|
|
1248
|
+
},
|
|
1249
|
+
t('conflict.skip'),
|
|
1250
|
+
),
|
|
1251
|
+
),
|
|
1252
|
+
),
|
|
1253
|
+
),
|
|
1254
|
+
),
|
|
1255
|
+
// 底部
|
|
1256
|
+
h(
|
|
1257
|
+
'div',
|
|
1258
|
+
{
|
|
1259
|
+
style: {
|
|
1260
|
+
padding: '10px 16px',
|
|
1261
|
+
borderTop: '.5px solid var(--dsw-alias-border-l1,#eee)',
|
|
1262
|
+
display: 'flex',
|
|
1263
|
+
gap: 8,
|
|
1264
|
+
justifyContent: 'flex-end',
|
|
1265
|
+
},
|
|
1266
|
+
},
|
|
1267
|
+
h('button', { className: 'pp-btn', onClick: onCancel }, t('conflict.cancel')),
|
|
1268
|
+
h(
|
|
1269
|
+
'button',
|
|
1270
|
+
{ className: 'pp-btn primary', onClick: () => onConfirm(decisions) },
|
|
1271
|
+
t('conflict.confirm'),
|
|
1272
|
+
),
|
|
1273
|
+
),
|
|
1274
|
+
),
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* 后台任务条。
|
|
1280
|
+
*
|
|
1281
|
+
* 解决的问题:导出/导入要跑几十秒,切走画面结果就丢了。
|
|
1282
|
+
* 现在任务状态存在宿主里,切回来能继续看到进度或结果。
|
|
1283
|
+
*
|
|
1284
|
+
* @param {object} props
|
|
1285
|
+
* @param {object[]} props.jobs
|
|
1286
|
+
* @param {() => void} props.onClear
|
|
1287
|
+
* @param {(key: string) => string} props.t
|
|
1288
|
+
*/
|
|
1289
|
+
function JobList({ jobs, onClear, t }) {
|
|
1290
|
+
if (!jobs.length) return null;
|
|
1291
|
+
|
|
1292
|
+
const kindLabel = (kind) =>
|
|
1293
|
+
({
|
|
1294
|
+
export: t('jobs.kindExport'),
|
|
1295
|
+
import: t('jobs.kindImport'),
|
|
1296
|
+
'import-plugin': t('jobs.kindImportPlugin'),
|
|
1297
|
+
rollback: t('jobs.kindRollback'),
|
|
1298
|
+
})[kind] ?? kind;
|
|
1299
|
+
|
|
1300
|
+
const statusColor = (status) =>
|
|
1301
|
+
status === 'running'
|
|
1302
|
+
? 'var(--dsw-alias-label-secondary,#6b7280)'
|
|
1303
|
+
: status === 'done'
|
|
1304
|
+
? 'var(--dsw-alias-state-success-primary,#16a34a)'
|
|
1305
|
+
: 'var(--dsw-alias-state-error-primary,#dc2626)';
|
|
1306
|
+
|
|
1307
|
+
const statusText = (status) =>
|
|
1308
|
+
status === 'running' ? t('jobs.running') : status === 'done' ? t('jobs.done') : t('jobs.failed');
|
|
1309
|
+
|
|
1310
|
+
const fmtTime = (ms) => {
|
|
1311
|
+
if (!ms) return '';
|
|
1312
|
+
const d = new Date(ms);
|
|
1313
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1314
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
const fmtSize = (bytes) => (bytes ? `${(bytes / 1048576).toFixed(1)} MiB` : '');
|
|
1318
|
+
|
|
1319
|
+
return h(
|
|
1320
|
+
'div',
|
|
1321
|
+
{ className: 'pp-card', style: { marginBottom: 14 } },
|
|
1322
|
+
h(
|
|
1323
|
+
'div',
|
|
1324
|
+
{ className: 'pp-bar', style: { marginBottom: 8 } },
|
|
1325
|
+
h('h3', { style: { margin: 0, fontSize: 15, fontWeight: 600 } }, t('jobs.title')),
|
|
1326
|
+
h('span', { style: { flex: 1 } }),
|
|
1327
|
+
h('button', { className: 'pp-btn sm', onClick: onClear }, t('jobs.clear')),
|
|
1328
|
+
),
|
|
1329
|
+
h(
|
|
1330
|
+
'div',
|
|
1331
|
+
{ className: 'pp-list' },
|
|
1332
|
+
jobs.map((job) =>
|
|
1333
|
+
h(
|
|
1334
|
+
'div',
|
|
1335
|
+
{ key: job.id, className: 'pp-row', style: { flexDirection: 'column', alignItems: 'stretch', gap: 4 } },
|
|
1336
|
+
h(
|
|
1337
|
+
'div',
|
|
1338
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
1339
|
+
job.status === 'running'
|
|
1340
|
+
? h('span', { className: 'pp-spin', style: { flex: 'none' } })
|
|
1341
|
+
: h('span', {
|
|
1342
|
+
style: {
|
|
1343
|
+
flex: 'none',
|
|
1344
|
+
width: 7,
|
|
1345
|
+
height: 7,
|
|
1346
|
+
borderRadius: '50%',
|
|
1347
|
+
background: statusColor(job.status),
|
|
1348
|
+
},
|
|
1349
|
+
}),
|
|
1350
|
+
h('span', { style: { fontWeight: 500, fontSize: 13 } }, kindLabel(job.kind)),
|
|
1351
|
+
h('span', { className: 'pp-hint' }, job.title),
|
|
1352
|
+
h('span', { style: { flex: 1 } }),
|
|
1353
|
+
h('span', { className: 'pp-hint', style: { color: statusColor(job.status) } }, statusText(job.status)),
|
|
1354
|
+
h('span', { className: 'pp-hint pp-mono', style: { fontSize: 11 } }, fmtTime(job.createdAt)),
|
|
1355
|
+
),
|
|
1356
|
+
// 进行中:进度条 + 最后一条进度
|
|
1357
|
+
job.status === 'running'
|
|
1358
|
+
? h(
|
|
1359
|
+
'div',
|
|
1360
|
+
null,
|
|
1361
|
+
h(
|
|
1362
|
+
'div',
|
|
1363
|
+
{
|
|
1364
|
+
style: {
|
|
1365
|
+
height: 3,
|
|
1366
|
+
borderRadius: 2,
|
|
1367
|
+
background: 'var(--dsw-alias-border-l1,#eee)',
|
|
1368
|
+
overflow: 'hidden',
|
|
1369
|
+
marginTop: 2,
|
|
1370
|
+
},
|
|
1371
|
+
},
|
|
1372
|
+
h('div', {
|
|
1373
|
+
style: {
|
|
1374
|
+
width: `${Math.max(4, job.percent)}%`,
|
|
1375
|
+
height: '100%',
|
|
1376
|
+
background: 'var(--dsw-alias-button-primary-fill,#111)',
|
|
1377
|
+
transition: 'width .3s',
|
|
1378
|
+
},
|
|
1379
|
+
}),
|
|
1380
|
+
),
|
|
1381
|
+
job.progress?.length
|
|
1382
|
+
? h('div', { className: 'pp-hint', style: { marginTop: 2 } }, job.progress[job.progress.length - 1].message)
|
|
1383
|
+
: null,
|
|
1384
|
+
)
|
|
1385
|
+
: null,
|
|
1386
|
+
// 完成:结果摘要
|
|
1387
|
+
job.status === 'done' && job.result
|
|
1388
|
+
? h(
|
|
1389
|
+
'div',
|
|
1390
|
+
{ className: 'pp-hint', style: { marginTop: 2 } },
|
|
1391
|
+
[
|
|
1392
|
+
job.result.file ? job.result.file : null,
|
|
1393
|
+
fmtSize(job.result.bytes),
|
|
1394
|
+
job.result.plugins?.length ? `${job.result.plugins.length} ${t('plugins')}` : null,
|
|
1395
|
+
job.durationMs ? `${(job.durationMs / 1000).toFixed(1)}s` : null,
|
|
1396
|
+
]
|
|
1397
|
+
.filter(Boolean)
|
|
1398
|
+
.join(' · '),
|
|
1399
|
+
)
|
|
1400
|
+
: null,
|
|
1401
|
+
job.status === 'failed'
|
|
1402
|
+
? h('div', { className: 'pp-hint', style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 2 } }, job.error ?? '')
|
|
1403
|
+
: null,
|
|
1404
|
+
),
|
|
1405
|
+
),
|
|
1406
|
+
),
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/* --------------------------------- 主面板 -------------------------------- */
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* 主面板。
|
|
1414
|
+
*
|
|
1415
|
+
* @param {object} props
|
|
1416
|
+
* @param {string} [props.onlyTab] 只渲染某一个子页(插件页的「导入」「导出」标签用这个)
|
|
1417
|
+
*/
|
|
1418
|
+
function Panel({ onlyTab = null } = {}) {
|
|
1419
|
+
const t = translate;
|
|
1420
|
+
const [tab, setTab] = useState(onlyTab ?? 'export');
|
|
1421
|
+
const [status, setStatus] = useState(null);
|
|
1422
|
+
const [error, setError] = useState('');
|
|
1423
|
+
const [busy, setBusy] = useState(false);
|
|
1424
|
+
const [selected, setSelected] = useState(null);
|
|
1425
|
+
const [out, setOut] = useState('');
|
|
1426
|
+
const [includeData, setIncludeData] = useState(false);
|
|
1427
|
+
const [includeSecrets, setIncludeSecrets] = useState(false);
|
|
1428
|
+
const [dryExport, setDryExport] = useState(false);
|
|
1429
|
+
const [exportResult, setExportResult] = useState(null);
|
|
1430
|
+
const [zipPath, setZipPath] = useState('');
|
|
1431
|
+
const [importResult, setImportResult] = useState(null);
|
|
1432
|
+
const [rollbackResult, setRollbackResult] = useState(null);
|
|
1433
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
1434
|
+
const [pickerDir, setPickerDir] = useState('');
|
|
1435
|
+
const [pickerMode, setPickerMode] = useState('file');
|
|
1436
|
+
const [importPluginResult, setImportPluginResult] = useState(null);
|
|
1437
|
+
/** 待确认的重名冲突:{ kind, file, conflicts, plugins }。非空时弹窗。 */
|
|
1438
|
+
const [pendingImport, setPendingImport] = useState(null);
|
|
1439
|
+
/** 后台任务列表:切走画面回来靠它恢复 */
|
|
1440
|
+
const [jobs, setJobs] = useState([]);
|
|
1441
|
+
/** 体积上限(MiB),0 = 不限制。默认跟着服务端配置走。 */
|
|
1442
|
+
const [maxMb, setMaxMb] = useState(0);
|
|
1443
|
+
const [hotBusy, setHotBusy] = useState(false);
|
|
1444
|
+
const [hotResult, setHotResult] = useState(null);
|
|
1445
|
+
/** 拖放时的原始 DataTransfer:目录要用它的 webkitGetAsEntry 读结构。 */
|
|
1446
|
+
const lastDrop = useRef({ dataTransfer: null });
|
|
1447
|
+
|
|
1448
|
+
const load = useCallback(async () => {
|
|
1449
|
+
setError('');
|
|
1450
|
+
setBusy(true);
|
|
1451
|
+
try {
|
|
1452
|
+
const data = await getStatus();
|
|
1453
|
+
setStatus(data);
|
|
1454
|
+
setSelected((prev) => {
|
|
1455
|
+
if (prev) return prev;
|
|
1456
|
+
const map = {};
|
|
1457
|
+
for (const plugin of data.plugins) map[plugin.name] = plugin.skipped !== 'not-plugin';
|
|
1458
|
+
return map;
|
|
1459
|
+
});
|
|
1460
|
+
setOut((prev) => prev || data.exportsDir || '');
|
|
1461
|
+
// 上限默认跟服务端配置;只在用户没手动改过时才覆盖
|
|
1462
|
+
setMaxMb((prev) => (prev === 0 ? Math.round((data.settings?.maxBytes ?? 0) / 1048576) : prev));
|
|
1463
|
+
} catch (err) {
|
|
1464
|
+
setError(String(err?.message ?? err));
|
|
1465
|
+
} finally {
|
|
1466
|
+
setBusy(false);
|
|
1467
|
+
}
|
|
1468
|
+
}, []);
|
|
1469
|
+
|
|
1470
|
+
useEffect(() => {
|
|
1471
|
+
load();
|
|
1472
|
+
}, [load]);
|
|
1473
|
+
|
|
1474
|
+
/* 后台任务轮询。
|
|
1475
|
+
* 挂载时先拉一次(切走画面回来能立刻看到上次的结果),
|
|
1476
|
+
* 之后有任务在跑就每 1.5 秒刷新,全跑完就停 —— 不空转。 */
|
|
1477
|
+
useEffect(() => {
|
|
1478
|
+
let alive = true;
|
|
1479
|
+
let timer = null;
|
|
1480
|
+
|
|
1481
|
+
const tick = async () => {
|
|
1482
|
+
if (!alive) return;
|
|
1483
|
+
try {
|
|
1484
|
+
const data = await getJobs();
|
|
1485
|
+
if (!alive) return;
|
|
1486
|
+
setJobs(data.jobs ?? []);
|
|
1487
|
+
const anyRunning = (data.running ?? []).length > 0;
|
|
1488
|
+
if (anyRunning) timer = setTimeout(tick, 1500);
|
|
1489
|
+
} catch {
|
|
1490
|
+
// 拉失败就慢点重试,不刷屏
|
|
1491
|
+
if (alive) timer = setTimeout(tick, 5000);
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
|
|
1495
|
+
tick();
|
|
1496
|
+
return () => {
|
|
1497
|
+
alive = false;
|
|
1498
|
+
if (timer) clearTimeout(timer);
|
|
1499
|
+
};
|
|
1500
|
+
}, []);
|
|
1501
|
+
|
|
1502
|
+
/** 任务结束(成功或失败)后手动催一次刷新。 */
|
|
1503
|
+
const refreshJobs = useCallback(async () => {
|
|
1504
|
+
try {
|
|
1505
|
+
const data = await getJobs();
|
|
1506
|
+
setJobs(data.jobs ?? []);
|
|
1507
|
+
} catch {
|
|
1508
|
+
/* 忽略 */
|
|
1509
|
+
}
|
|
1510
|
+
}, []);
|
|
1511
|
+
|
|
1512
|
+
const clearJobHistory = async () => {
|
|
1513
|
+
try {
|
|
1514
|
+
await clearJobs();
|
|
1515
|
+
await refreshJobs();
|
|
1516
|
+
} catch {
|
|
1517
|
+
/* 忽略 */
|
|
1518
|
+
}
|
|
1519
|
+
};
|
|
1520
|
+
|
|
1521
|
+
const plugins = status?.plugins ?? [];
|
|
1522
|
+
const exporters = plugins.filter((plugin) => plugin.skipped !== 'not-plugin');
|
|
1523
|
+
const chosen = exporters.filter((plugin) => selected?.[plugin.name]);
|
|
1524
|
+
const chosenBytes = chosen.reduce((sum, plugin) => sum + plugin.bytes, 0);
|
|
1525
|
+
|
|
1526
|
+
const toggle = (nameIn, value) => setSelected((prev) => ({ ...(prev ?? {}), [nameIn]: value }));
|
|
1527
|
+
const allOn = exporters.length > 0 && chosen.length === exporters.length;
|
|
1528
|
+
|
|
1529
|
+
const runHotload = async () => {
|
|
1530
|
+
setHotBusy(true);
|
|
1531
|
+
setHotResult(null);
|
|
1532
|
+
try {
|
|
1533
|
+
const data = await doHotload('dsh-plugin-pack');
|
|
1534
|
+
setHotResult(data);
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
setHotResult({ ok: false, error: String(err?.message ?? err) });
|
|
1537
|
+
} finally {
|
|
1538
|
+
setHotBusy(false);
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
/** 「本地导入插件」:把一个已存在的插件目录接进当前 profile。 */
|
|
1543
|
+
/** 拖放进来:zip/tgz 走整合包导入,目录走本地插件导入。 */
|
|
1544
|
+
const runDrop = async (file) => {
|
|
1545
|
+
setBusy(true);
|
|
1546
|
+
setImportResult(null);
|
|
1547
|
+
setImportPluginResult(null);
|
|
1548
|
+
try {
|
|
1549
|
+
const name = String(file?.name ?? '');
|
|
1550
|
+
// .zip = 整合包;.tgz / .tar.gz = 单个插件的 npm tarball。
|
|
1551
|
+
// 两者都走"上传 → 导入",具体分流由服务端按扩展名决定。
|
|
1552
|
+
const isArchive = /\.(zip|tgz|tar\.gz)$/i.test(name);
|
|
1553
|
+
if (isArchive) {
|
|
1554
|
+
const uploaded = await uploadZip(file);
|
|
1555
|
+
setZipPath(uploaded.file);
|
|
1556
|
+
|
|
1557
|
+
// 先 dryRun 探一次:有重名冲突就弹窗让用户决定
|
|
1558
|
+
const preview = await doImport({ file: uploaded.file, dryRun: true });
|
|
1559
|
+
const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
|
|
1560
|
+
if (conflicts.length) {
|
|
1561
|
+
setPendingImport({
|
|
1562
|
+
kind: 'bundle',
|
|
1563
|
+
file: uploaded.file,
|
|
1564
|
+
conflicts,
|
|
1565
|
+
plugins: preview.plugins ?? [],
|
|
1566
|
+
});
|
|
1567
|
+
setBusy(false);
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const result = await doImport({
|
|
1572
|
+
file: uploaded.file,
|
|
1573
|
+
dryRun: false,
|
|
1574
|
+
mode: 'merge',
|
|
1575
|
+
restoreData: true,
|
|
1576
|
+
linkNodeModules: true,
|
|
1577
|
+
});
|
|
1578
|
+
setImportResult(result);
|
|
1579
|
+
await load();
|
|
1580
|
+
await refreshJobs();
|
|
1581
|
+
} else {
|
|
1582
|
+
// 目录(拖目录时浏览器的 File.name 是目录名,没有扩展名)
|
|
1583
|
+
const uploaded = await uploadDirectory(lastDrop.dataTransfer ?? {});
|
|
1584
|
+
const preview = await doImportPlugin(uploaded.dir);
|
|
1585
|
+
if (preview?.ok === false) {
|
|
1586
|
+
setImportPluginResult({ error: preview.error ?? '导入失败' });
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
const result = preview;
|
|
1590
|
+
setImportPluginResult(result);
|
|
1591
|
+
await load();
|
|
1592
|
+
}
|
|
1593
|
+
} catch (err) {
|
|
1594
|
+
const message = String(err?.message ?? err);
|
|
1595
|
+
const code = err?.code ? String(err.code) : null;
|
|
1596
|
+
if (/\.(zip|tgz|tar\.gz)$/i.test(String(file?.name ?? ''))) setImportResult({ error: message, code });
|
|
1597
|
+
else setImportPluginResult({ error: message, code });
|
|
1598
|
+
} finally {
|
|
1599
|
+
setBusy(false);
|
|
1600
|
+
lastDrop.dataTransfer = null;
|
|
1601
|
+
}
|
|
1602
|
+
};
|
|
1603
|
+
|
|
1604
|
+
/** 用户在冲突弹窗里做了选择:带着 decisions 真正导入。 */
|
|
1605
|
+
const confirmImport = async (decisions) => {
|
|
1606
|
+
const pending = pendingImport;
|
|
1607
|
+
if (!pending) return;
|
|
1608
|
+
setPendingImport(null);
|
|
1609
|
+
setBusy(true);
|
|
1610
|
+
setImportResult(null);
|
|
1611
|
+
try {
|
|
1612
|
+
const result = await doImport({
|
|
1613
|
+
file: pending.file,
|
|
1614
|
+
dryRun: false,
|
|
1615
|
+
restoreData: true,
|
|
1616
|
+
linkNodeModules: true,
|
|
1617
|
+
decisions,
|
|
1618
|
+
});
|
|
1619
|
+
setImportResult(result);
|
|
1620
|
+
await load();
|
|
1621
|
+
await refreshJobs();
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
setImportResult({ error: String(err?.message ?? err) });
|
|
1624
|
+
} finally {
|
|
1625
|
+
setBusy(false);
|
|
1626
|
+
}
|
|
1627
|
+
};
|
|
1628
|
+
|
|
1629
|
+
const runExport = async () => {
|
|
1630
|
+
if (!chosen.length) {
|
|
1631
|
+
setExportResult({ error: translate('export.empty') });
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
setBusy(true);
|
|
1635
|
+
setExportResult(null);
|
|
1636
|
+
try {
|
|
1637
|
+
const data = await doExport({
|
|
1638
|
+
out: out.trim(),
|
|
1639
|
+
include: chosen.map((plugin) => plugin.name),
|
|
1640
|
+
includeData,
|
|
1641
|
+
includeSecrets,
|
|
1642
|
+
dryRun: dryExport,
|
|
1643
|
+
// 界面上的上限优先;0 表示不限制
|
|
1644
|
+
maxBytes: maxMb > 0 ? Math.round(Number(maxMb) * 1024 * 1024) : 0,
|
|
1645
|
+
});
|
|
1646
|
+
setExportResult(data);
|
|
1647
|
+
await load();
|
|
1648
|
+
await refreshJobs();
|
|
1649
|
+
} catch (err) {
|
|
1650
|
+
setExportResult({ error: String(err?.message ?? err) });
|
|
1651
|
+
} finally {
|
|
1652
|
+
setBusy(false);
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
const runRollback = async (backupName) => {
|
|
1657
|
+
if (!window.confirm(translate('backup.confirm'))) return;
|
|
1658
|
+
setBusy(true);
|
|
1659
|
+
setRollbackResult(null);
|
|
1660
|
+
try {
|
|
1661
|
+
const data = await doRollback(backupName);
|
|
1662
|
+
setRollbackResult(data);
|
|
1663
|
+
await load();
|
|
1664
|
+
} catch (err) {
|
|
1665
|
+
setRollbackResult({ error: String(err?.message ?? err) });
|
|
1666
|
+
} finally {
|
|
1667
|
+
setBusy(false);
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1670
|
+
|
|
1671
|
+
/* --- 渲染:头部 --- */
|
|
1672
|
+
const header = h(
|
|
1673
|
+
'div',
|
|
1674
|
+
null,
|
|
1675
|
+
h('h1', { className: 'pp-h1' }, t('title')),
|
|
1676
|
+
h('p', { className: 'pp-sub' }, t('subtitle')),
|
|
1677
|
+
h(
|
|
1678
|
+
'div',
|
|
1679
|
+
{ className: 'pp-bar' },
|
|
1680
|
+
h('span', { className: 'pp-pill' }, t('profile'), h('b', null, status?.profile?.name ?? '—')),
|
|
1681
|
+
h('span', { className: 'pp-pill' }, t('plugins'), h('b', null, exporters.length)),
|
|
1682
|
+
h('span', { className: 'pp-pill' }, t('total'), h('b', null, status?.totals?.bytesText ?? '—')),
|
|
1683
|
+
h('span', { style: { flex: 1 } }),
|
|
1684
|
+
h(
|
|
1685
|
+
'button',
|
|
1686
|
+
{ className: 'pp-btn sm', disabled: busy || hotBusy, onClick: runHotload, title: t('hotload.why') },
|
|
1687
|
+
hotBusy ? h(Spinner, { label: t('hotload.running') }) : t('hotload'),
|
|
1688
|
+
),
|
|
1689
|
+
h('button', { className: 'pp-btn sm', disabled: busy, onClick: load }, busy ? h(Spinner, { label: t('loading') }) : t('refresh')),
|
|
1690
|
+
),
|
|
1691
|
+
error ? h(Alert, { kind: 'err' }, `${t('failed')}: ${error}`) : null,
|
|
1692
|
+
hotResult
|
|
1693
|
+
? h(
|
|
1694
|
+
'div',
|
|
1695
|
+
{ style: { marginTop: 10 } },
|
|
1696
|
+
h(Alert, { kind: hotResult.ok ? 'ok' : 'err' },
|
|
1697
|
+
hotResult.ok ? t('hotload.done') : `${t('hotload.failed')}: ${hotResult.error ?? '未知原因'}`),
|
|
1698
|
+
hotResult.steps?.length
|
|
1699
|
+
? h('div', { className: 'pp-note' }, hotResult.steps.slice(0, 8).join(' · '))
|
|
1700
|
+
: null,
|
|
1701
|
+
hotResult.rev ? h('div', { className: 'pp-hint', style: { marginTop: 4 } }, `rev=${hotResult.rev}`) : null,
|
|
1702
|
+
)
|
|
1703
|
+
: null,
|
|
1704
|
+
);
|
|
1705
|
+
|
|
1706
|
+
/* --- 渲染:导出页 --- */
|
|
1707
|
+
const exportPage = h(
|
|
1708
|
+
'div',
|
|
1709
|
+
null,
|
|
1710
|
+
// 后台任务:导出要跑几十秒,切走画面回来还能看到进度/结果
|
|
1711
|
+
h(JobList, { jobs, onClear: clearJobHistory, t }),
|
|
1712
|
+
h(
|
|
1713
|
+
'div',
|
|
1714
|
+
{ className: 'pp-card' },
|
|
1715
|
+
h('h3', null, t('export.title')),
|
|
1716
|
+
h('p', { className: 'pp-desc' }, t('export.desc')),
|
|
1717
|
+
// 不再让用户选"单个插件 / 整合包":勾几个就自动是什么。
|
|
1718
|
+
// 勾 1 个 → 单个插件;勾多个 → 整合包。这里只把结果说出来。
|
|
1719
|
+
h(
|
|
1720
|
+
'div',
|
|
1721
|
+
{
|
|
1722
|
+
className: 'pp-hint',
|
|
1723
|
+
style: { marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6 },
|
|
1724
|
+
},
|
|
1725
|
+
h('span', { className: 'pp-mode-dot', 'data-kind': chosen.length === 1 ? 'single' : chosen.length > 1 ? 'bundle' : 'none' }),
|
|
1726
|
+
chosen.length === 0
|
|
1727
|
+
? t('export.scope.none')
|
|
1728
|
+
: chosen.length === 1
|
|
1729
|
+
? `${t('export.scope.single')}:${chosen[0].name}`
|
|
1730
|
+
: `${t('export.scope.bundle')}(${chosen.length} 个)`,
|
|
1731
|
+
),
|
|
1732
|
+
h(
|
|
1733
|
+
'div',
|
|
1734
|
+
{ className: 'pp-bar', style: { marginBottom: 8 } },
|
|
1735
|
+
h('button', { className: 'pp-btn sm', onClick: () => setSelected(Object.fromEntries(exporters.map((p) => [p.name, true]))) }, t('selectAll')),
|
|
1736
|
+
h('button', { className: 'pp-btn sm', onClick: () => setSelected(Object.fromEntries(exporters.map((p) => [p.name, false]))) }, t('selectNone')),
|
|
1737
|
+
h(
|
|
1738
|
+
'span',
|
|
1739
|
+
{
|
|
1740
|
+
className: 'pp-hint',
|
|
1741
|
+
style: {
|
|
1742
|
+
marginLeft: 'auto',
|
|
1743
|
+
color: maxMb > 0 && chosenBytes > maxMb * 1048576 ? 'var(--dsw-alias-state-error-primary,#dc2626)' : undefined,
|
|
1744
|
+
},
|
|
1745
|
+
},
|
|
1746
|
+
`${chosen.length} / ${exporters.length} ${t('selected')} · ${(chosenBytes / 1048576).toFixed(1)} MiB` +
|
|
1747
|
+
(maxMb > 0 && chosenBytes > maxMb * 1048576 ? ` · ${t('export.overLimit')}` : ''),
|
|
1748
|
+
),
|
|
1749
|
+
),
|
|
1750
|
+
h(
|
|
1751
|
+
'div',
|
|
1752
|
+
{ className: 'pp-list' },
|
|
1753
|
+
exporters.length === 0
|
|
1754
|
+
? h('div', { className: 'pp-empty' }, t('backup.none'))
|
|
1755
|
+
: exporters.map((plugin) =>
|
|
1756
|
+
h(
|
|
1757
|
+
'div',
|
|
1758
|
+
{ key: plugin.name, className: `pp-row${selected?.[plugin.name] ? '' : ' pp-dim'}` },
|
|
1759
|
+
h('input', {
|
|
1760
|
+
type: 'checkbox',
|
|
1761
|
+
checked: Boolean(selected?.[plugin.name]),
|
|
1762
|
+
onChange: (event) => toggle(plugin.name, event.target.checked),
|
|
1763
|
+
}),
|
|
1764
|
+
h(
|
|
1765
|
+
'div',
|
|
1766
|
+
{ className: 'pp-main' },
|
|
1767
|
+
h(
|
|
1768
|
+
'div',
|
|
1769
|
+
{ className: 'pp-name' },
|
|
1770
|
+
plugin.name,
|
|
1771
|
+
h('span', { className: 'pp-ver' }, plugin.version),
|
|
1772
|
+
plugin.inBundles ? null : h('span', { className: 'pp-ver' }, plugin.kind === 'path' ? 'path' : 'deps-only'),
|
|
1773
|
+
),
|
|
1774
|
+
h(
|
|
1775
|
+
'div',
|
|
1776
|
+
{ className: 'pp-meta' },
|
|
1777
|
+
`${plugin.files} ${t('files')} · ${t('deps')} ${plugin.deps} · ${t('patch')} ${plugin.patchRows}+${plugin.insertRows}`,
|
|
1778
|
+
plugin.dataCandidates?.length ? ` · ${t('data')}: ${plugin.dataCandidates.join(', ')}` : '',
|
|
1779
|
+
),
|
|
1780
|
+
plugin.warnings?.length ? h('div', { className: 'pp-warn' }, plugin.warnings.join(';')) : null,
|
|
1781
|
+
),
|
|
1782
|
+
h('div', { className: 'pp-side' }, plugin.bytesText),
|
|
1783
|
+
),
|
|
1784
|
+
),
|
|
1785
|
+
),
|
|
1786
|
+
),
|
|
1787
|
+
h(
|
|
1788
|
+
'div',
|
|
1789
|
+
{ className: 'pp-card' },
|
|
1790
|
+
h(
|
|
1791
|
+
Field,
|
|
1792
|
+
{ label: t('export.out'), hint: t('export.outHint') },
|
|
1793
|
+
h(
|
|
1794
|
+
'div',
|
|
1795
|
+
{ className: 'pp-input-row' },
|
|
1796
|
+
h('input', {
|
|
1797
|
+
className: 'pp-input',
|
|
1798
|
+
value: out,
|
|
1799
|
+
placeholder: status?.exportsDir ?? '',
|
|
1800
|
+
onChange: (event) => setOut(event.target.value),
|
|
1801
|
+
}),
|
|
1802
|
+
h(
|
|
1803
|
+
'button',
|
|
1804
|
+
{
|
|
1805
|
+
className: 'pp-btn',
|
|
1806
|
+
onClick: () => {
|
|
1807
|
+
setPickerDir(out || status?.exportsDir || '');
|
|
1808
|
+
setPickerMode('dir');
|
|
1809
|
+
setPickerOpen(true);
|
|
1810
|
+
},
|
|
1811
|
+
},
|
|
1812
|
+
t('import.pick'),
|
|
1813
|
+
),
|
|
1814
|
+
),
|
|
1815
|
+
),
|
|
1816
|
+
h(Check, {
|
|
1817
|
+
checked: includeData,
|
|
1818
|
+
onChange: setIncludeData,
|
|
1819
|
+
title: t('export.includeData'),
|
|
1820
|
+
hint: t('export.includeDataHint'),
|
|
1821
|
+
}),
|
|
1822
|
+
h(Check, {
|
|
1823
|
+
checked: includeSecrets,
|
|
1824
|
+
onChange: setIncludeSecrets,
|
|
1825
|
+
title: t('export.includeSecrets'),
|
|
1826
|
+
hint: t('export.includeSecretsHint'),
|
|
1827
|
+
}),
|
|
1828
|
+
h(Check, { checked: dryExport, onChange: setDryExport, title: t('export.dry') }),
|
|
1829
|
+
// 体积上限:你这套插件(dsh-ops 407 MiB + dsh-pet 71 MiB)本来就超过 512 MiB,
|
|
1830
|
+
// 所以放到界面上让用户自己调,而不是硬挡。
|
|
1831
|
+
h(
|
|
1832
|
+
Field,
|
|
1833
|
+
{ label: t('export.maxMb'), hint: t('export.maxMbHint') },
|
|
1834
|
+
h(
|
|
1835
|
+
'div',
|
|
1836
|
+
{ className: 'pp-input-row' },
|
|
1837
|
+
h('input', {
|
|
1838
|
+
className: 'pp-input',
|
|
1839
|
+
type: 'number',
|
|
1840
|
+
min: 0,
|
|
1841
|
+
step: 128,
|
|
1842
|
+
value: maxMb,
|
|
1843
|
+
style: { maxWidth: 160 },
|
|
1844
|
+
onChange: (event) => setMaxMb(Math.max(0, Number(event.target.value) || 0)),
|
|
1845
|
+
}),
|
|
1846
|
+
h('span', { className: 'pp-hint' }, 'MiB'),
|
|
1847
|
+
maxMb > 0 && chosenBytes > maxMb * 1048576
|
|
1848
|
+
? h(
|
|
1849
|
+
'button',
|
|
1850
|
+
{
|
|
1851
|
+
className: 'pp-btn sm',
|
|
1852
|
+
onClick: () => setMaxMb(Math.ceil((chosenBytes * 1.15) / 1048576)),
|
|
1853
|
+
},
|
|
1854
|
+
t('export.maxMbFit'),
|
|
1855
|
+
)
|
|
1856
|
+
: null,
|
|
1857
|
+
h(
|
|
1858
|
+
'button',
|
|
1859
|
+
{ className: 'pp-btn sm', onClick: () => setMaxMb(0) },
|
|
1860
|
+
t('export.maxMbNone'),
|
|
1861
|
+
),
|
|
1862
|
+
),
|
|
1863
|
+
),
|
|
1864
|
+
h(
|
|
1865
|
+
'div',
|
|
1866
|
+
{ className: 'pp-bar', style: { marginTop: 12, marginBottom: 0 } },
|
|
1867
|
+
h('button', { className: 'pp-btn primary', disabled: busy, onClick: runExport }, busy ? h(Spinner, { label: t('export.running') }) : t('export.run')),
|
|
1868
|
+
),
|
|
1869
|
+
exportResult
|
|
1870
|
+
? exportResult.error
|
|
1871
|
+
? h(Alert, { kind: 'err' }, exportResult.error)
|
|
1872
|
+
: h(
|
|
1873
|
+
'div',
|
|
1874
|
+
null,
|
|
1875
|
+
h(Alert, { kind: 'ok' }, exportResult.dryRun ? `${t('export.dry')} · ${exportResult.bytes ? (exportResult.bytes / 1048576).toFixed(1) : 0} MiB` : t('export.done')),
|
|
1876
|
+
h('div', { className: 'pp-out' }, exportResult.file),
|
|
1877
|
+
h(
|
|
1878
|
+
'div',
|
|
1879
|
+
{ className: 'pp-hint', style: { marginTop: 6 } },
|
|
1880
|
+
`${t('export.size')} ${(exportResult.bytes / 1048576).toFixed(1)} MiB · ${t('export.raw')} ${((exportResult.rawBytes ?? 0) / 1048576).toFixed(1)} MiB · ${t('export.entries')} ${exportResult.entries ?? 0}`,
|
|
1881
|
+
),
|
|
1882
|
+
exportResult.warnings?.length
|
|
1883
|
+
? h('div', { className: 'pp-note' }, exportResult.warnings.slice(0, 8).join(';'))
|
|
1884
|
+
: null,
|
|
1885
|
+
exportResult.dryRun
|
|
1886
|
+
? null
|
|
1887
|
+
: h(
|
|
1888
|
+
'div',
|
|
1889
|
+
{ className: 'pp-bar', style: { marginTop: 10, marginBottom: 0 } },
|
|
1890
|
+
h(
|
|
1891
|
+
'button',
|
|
1892
|
+
{
|
|
1893
|
+
className: 'pp-btn',
|
|
1894
|
+
onClick: () => {
|
|
1895
|
+
// 导入页只有拖拽框,切过去让用户把解压/包直接拖进来
|
|
1896
|
+
setImportResult(null);
|
|
1897
|
+
setImportPluginResult(null);
|
|
1898
|
+
setTab('import');
|
|
1899
|
+
},
|
|
1900
|
+
},
|
|
1901
|
+
`${t('import.title')} →`,
|
|
1902
|
+
),
|
|
1903
|
+
),
|
|
1904
|
+
)
|
|
1905
|
+
: null,
|
|
1906
|
+
),
|
|
1907
|
+
);
|
|
1908
|
+
|
|
1909
|
+
/* --- 渲染:导入页 ---
|
|
1910
|
+
* 只留一个拖拽框。zip 和插件目录都认,拖进来就开始导入,
|
|
1911
|
+
* 不再有「整合包 / 本地插件」页签和一堆输入框、勾选项。 */
|
|
1912
|
+
const importPage = h(
|
|
1913
|
+
'div',
|
|
1914
|
+
null,
|
|
1915
|
+
h(DropZone, { onFile: runDrop, busy, dropRef: lastDrop }),
|
|
1916
|
+
// 后台任务:切走画面回来能看到刚才那次导入的结果
|
|
1917
|
+
h(JobList, { jobs, onClear: clearJobHistory, t }),
|
|
1918
|
+
|
|
1919
|
+
// 结果反馈:导完要说清楚发生了什么
|
|
1920
|
+
importResult
|
|
1921
|
+
? importResult.error
|
|
1922
|
+
? h(
|
|
1923
|
+
'div',
|
|
1924
|
+
{ style: { marginTop: 10 } },
|
|
1925
|
+
// 「zip 不是整合包」这类是"拖错文件了",不是系统故障,
|
|
1926
|
+
// 用更醒目的块单独说清楚,别和普通错误混在一起
|
|
1927
|
+
['NOT_A_ZIP', 'NOT_A_BUNDLE', 'BAD_MANIFEST', 'BAD_FORMAT', 'BAD_PACK'].includes(String(importResult.code ?? '')) ||
|
|
1928
|
+
/不是整合包|不是有效的 zip|没有 manifest/i.test(String(importResult.error))
|
|
1929
|
+
? h(
|
|
1930
|
+
'div',
|
|
1931
|
+
{ className: 'pp-not-bundle' },
|
|
1932
|
+
h('div', { className: 'pp-not-bundle-title' }, t('import.notBundle')),
|
|
1933
|
+
h('div', { className: 'pp-not-bundle-detail' }, String(importResult.error)),
|
|
1934
|
+
)
|
|
1935
|
+
: h(Alert, { kind: 'err' }, importResult.error),
|
|
1936
|
+
)
|
|
1937
|
+
: h(
|
|
1938
|
+
'div',
|
|
1939
|
+
null,
|
|
1940
|
+
h(
|
|
1941
|
+
Alert,
|
|
1942
|
+
{ kind: 'ok' },
|
|
1943
|
+
importResult.dryRun
|
|
1944
|
+
? `${t('import.dry')} · ${importResult.plugins?.length ?? 0} ${t('plugins')}`
|
|
1945
|
+
: `${t('import.done')} · ${t('import.restart')}`,
|
|
1946
|
+
),
|
|
1947
|
+
importResult.plugins?.length
|
|
1948
|
+
? h(
|
|
1949
|
+
'table',
|
|
1950
|
+
{ className: 'pp-table', style: { marginTop: 8 } },
|
|
1951
|
+
h('thead', null, h('tr', null, h('th', null, t('plugins')), h('th', null, 'action'), h('th', null, t('files')))),
|
|
1952
|
+
h(
|
|
1953
|
+
'tbody',
|
|
1954
|
+
null,
|
|
1955
|
+
importResult.plugins.map((plugin, index) =>
|
|
1956
|
+
h(
|
|
1957
|
+
'tr',
|
|
1958
|
+
{ key: `${plugin.name}-${index}` },
|
|
1959
|
+
h('td', null, plugin.name),
|
|
1960
|
+
h('td', null, plugin.action ?? ''),
|
|
1961
|
+
h('td', null, String(plugin.files ?? '')),
|
|
1962
|
+
),
|
|
1963
|
+
),
|
|
1964
|
+
),
|
|
1965
|
+
)
|
|
1966
|
+
: null,
|
|
1967
|
+
importResult.backup ? h('div', { className: 'pp-out' }, importResult.backup) : null,
|
|
1968
|
+
!importResult.dryRun
|
|
1969
|
+
? h(
|
|
1970
|
+
'div',
|
|
1971
|
+
{ className: 'pp-bar', style: { marginTop: 10, marginBottom: 0 } },
|
|
1972
|
+
h('button', { className: 'pp-btn danger', onClick: () => setTab('backup') }, `${t('backup.rollback')} →`),
|
|
1973
|
+
)
|
|
1974
|
+
: null,
|
|
1975
|
+
)
|
|
1976
|
+
: null,
|
|
1977
|
+
|
|
1978
|
+
// 本地插件导入的结果(拖目录进来的那条路)
|
|
1979
|
+
importPluginResult
|
|
1980
|
+
? importPluginResult.error
|
|
1981
|
+
? h(Alert, { kind: 'err' }, importPluginResult.error)
|
|
1982
|
+
: h(
|
|
1983
|
+
'div',
|
|
1984
|
+
{ style: { marginTop: 10 } },
|
|
1985
|
+
h(Alert, { kind: 'ok' }, `${t('import.plugin.done')}${importPluginResult.restartRequired ? ` · ${t('import.restart')}` : ''}`),
|
|
1986
|
+
h('div', { className: 'pp-out' }, importPluginResult.name ?? ''),
|
|
1987
|
+
importPluginResult.deps?.length
|
|
1988
|
+
? h('div', { className: 'pp-hint', style: { marginTop: 6 } }, `${t('deps')}: ${importPluginResult.deps.length}`)
|
|
1989
|
+
: null,
|
|
1990
|
+
)
|
|
1991
|
+
: null,
|
|
1992
|
+
);
|
|
1993
|
+
|
|
1994
|
+
/* --- 渲染:备份页 --- */
|
|
1995
|
+
const backupPage = h(
|
|
1996
|
+
'div',
|
|
1997
|
+
null,
|
|
1998
|
+
h(
|
|
1999
|
+
'div',
|
|
2000
|
+
{ className: 'pp-card' },
|
|
2001
|
+
h('h3', null, t('backup.title')),
|
|
2002
|
+
h('p', { className: 'pp-desc' }, t('backup.desc')),
|
|
2003
|
+
status?.backups?.length
|
|
2004
|
+
? h(
|
|
2005
|
+
'table',
|
|
2006
|
+
{ className: 'pp-table' },
|
|
2007
|
+
h(
|
|
2008
|
+
'thead',
|
|
2009
|
+
null,
|
|
2010
|
+
h('tr', null, h('th', null, 'backup'), h('th', null, t('backup.createdAt')), h('th', null, t('backup.items')), h('th', null, '')),
|
|
2011
|
+
),
|
|
2012
|
+
h(
|
|
2013
|
+
'tbody',
|
|
2014
|
+
null,
|
|
2015
|
+
status.backups.map((item) =>
|
|
2016
|
+
h(
|
|
2017
|
+
'tr',
|
|
2018
|
+
{ key: item.name },
|
|
2019
|
+
h('td', { className: 'pp-mono' }, item.name),
|
|
2020
|
+
h('td', { className: 'pp-mono' }, String(item.createdAt ?? '').replace('T', ' ').slice(0, 19)),
|
|
2021
|
+
h('td', null, String(item.plugins)),
|
|
2022
|
+
h(
|
|
2023
|
+
'td',
|
|
2024
|
+
null,
|
|
2025
|
+
h('button', { className: 'pp-btn sm danger', disabled: busy, onClick: () => runRollback(item.name) }, busy ? t('backup.rolling') : t('backup.rollback')),
|
|
2026
|
+
),
|
|
2027
|
+
),
|
|
2028
|
+
),
|
|
2029
|
+
),
|
|
2030
|
+
)
|
|
2031
|
+
: h('div', { className: 'pp-empty' }, t('backup.none')),
|
|
2032
|
+
rollbackResult
|
|
2033
|
+
? rollbackResult.error
|
|
2034
|
+
? h(Alert, { kind: 'err' }, rollbackResult.error)
|
|
2035
|
+
: h(
|
|
2036
|
+
'div',
|
|
2037
|
+
null,
|
|
2038
|
+
h(Alert, { kind: 'ok' }, `${t('backup.done')} · ${t('import.restart')}`),
|
|
2039
|
+
rollbackResult.restored?.length
|
|
2040
|
+
? h('div', { className: 'pp-note' }, `恢复: ${rollbackResult.restored.join(', ')}`)
|
|
2041
|
+
: null,
|
|
2042
|
+
)
|
|
2043
|
+
: null,
|
|
2044
|
+
),
|
|
2045
|
+
status?.recentExports?.length
|
|
2046
|
+
? h(
|
|
2047
|
+
'div',
|
|
2048
|
+
{ className: 'pp-card' },
|
|
2049
|
+
h('h3', null, t('recent.title')),
|
|
2050
|
+
h(
|
|
2051
|
+
'div',
|
|
2052
|
+
{ className: 'pp-list' },
|
|
2053
|
+
status.recentExports.map((item) =>
|
|
2054
|
+
h(
|
|
2055
|
+
'div',
|
|
2056
|
+
{ key: item.path, className: 'pp-row' },
|
|
2057
|
+
h('div', { className: 'pp-main' }, h('div', { className: 'pp-name pp-mono', style: { fontSize: 12 } }, item.name)),
|
|
2058
|
+
h('div', { className: 'pp-side' }, `${(item.bytes / 1048576).toFixed(1)} MiB`),
|
|
2059
|
+
h(
|
|
2060
|
+
'button',
|
|
2061
|
+
{
|
|
2062
|
+
className: 'pp-btn sm',
|
|
2063
|
+
disabled: busy,
|
|
2064
|
+
onClick: async () => {
|
|
2065
|
+
// 导入页只剩拖拽框,所以这里直接导入,不再跳页等确认
|
|
2066
|
+
setBusy(true);
|
|
2067
|
+
setImportResult(null);
|
|
2068
|
+
try {
|
|
2069
|
+
const data = await doImport({
|
|
2070
|
+
file: item.path,
|
|
2071
|
+
dryRun: false,
|
|
2072
|
+
mode: 'merge',
|
|
2073
|
+
restoreData: true,
|
|
2074
|
+
linkNodeModules: true,
|
|
2075
|
+
});
|
|
2076
|
+
setImportResult(data);
|
|
2077
|
+
await load();
|
|
2078
|
+
setTab('import');
|
|
2079
|
+
} catch (err) {
|
|
2080
|
+
setImportResult({ error: String(err?.message ?? err) });
|
|
2081
|
+
setTab('import');
|
|
2082
|
+
} finally {
|
|
2083
|
+
setBusy(false);
|
|
2084
|
+
}
|
|
2085
|
+
},
|
|
2086
|
+
},
|
|
2087
|
+
t('recent.import'),
|
|
2088
|
+
),
|
|
2089
|
+
),
|
|
2090
|
+
),
|
|
2091
|
+
),
|
|
2092
|
+
)
|
|
2093
|
+
: null,
|
|
2094
|
+
);
|
|
2095
|
+
|
|
2096
|
+
/* --- 渲染:帮助页 --- */
|
|
2097
|
+
const step = (num, text) =>
|
|
2098
|
+
h('div', { className: 'pp-step' }, h('span', { className: 'pp-num' }, num), h('div', { className: 'pp-step-body' }, text));
|
|
2099
|
+
|
|
2100
|
+
const helpPage = h(
|
|
2101
|
+
'div',
|
|
2102
|
+
{ className: 'pp-card' },
|
|
2103
|
+
h('h3', null, t('help.title')),
|
|
2104
|
+
step('1', t('help.step1')),
|
|
2105
|
+
step('2', t('help.step2')),
|
|
2106
|
+
step('3', t('help.step3')),
|
|
2107
|
+
step('4', t('help.step4')),
|
|
2108
|
+
h('div', { className: 'pp-note' }, t('help.note1')),
|
|
2109
|
+
h('div', { className: 'pp-note' }, t('help.note2')),
|
|
2110
|
+
h('div', { className: 'pp-note' }, t('help.note3')),
|
|
2111
|
+
h('div', { className: 'pp-note' }, t('help.note4')),
|
|
2112
|
+
h('h3', { style: { marginTop: 18 } }, t('help.advanced')),
|
|
2113
|
+
h('div', { className: 'pp-out' }, t('help.cmd')),
|
|
2114
|
+
);
|
|
2115
|
+
|
|
2116
|
+
const tabs = [
|
|
2117
|
+
['export', t('tab.export')],
|
|
2118
|
+
['import', t('tab.import')],
|
|
2119
|
+
['backup', t('tab.backup')],
|
|
2120
|
+
['help', t('tab.help')],
|
|
2121
|
+
];
|
|
2122
|
+
|
|
2123
|
+
const body = !status && busy ? h('div', { className: 'pp-empty' }, h(Spinner, { label: t('loading') })) : h(
|
|
2124
|
+
'div',
|
|
2125
|
+
null,
|
|
2126
|
+
// onlyTab:插件页的「导入」「导出」标签只显示对应那一页,不显示子 tab 栏
|
|
2127
|
+
onlyTab
|
|
2128
|
+
? null
|
|
2129
|
+
: h(
|
|
2130
|
+
'div',
|
|
2131
|
+
{ className: 'pp-tabs' },
|
|
2132
|
+
tabs.map(([key, label]) =>
|
|
2133
|
+
h('button', { key, className: `pp-tab${tab === key ? ' on' : ''}`, onClick: () => setTab(key) }, label),
|
|
2134
|
+
),
|
|
2135
|
+
),
|
|
2136
|
+
tab === 'export' ? exportPage : tab === 'import' ? importPage : tab === 'backup' ? backupPage : helpPage,
|
|
2137
|
+
);
|
|
2138
|
+
|
|
2139
|
+
return h(
|
|
2140
|
+
'div',
|
|
2141
|
+
{ className: 'pp-root' },
|
|
2142
|
+
h(
|
|
2143
|
+
'div',
|
|
2144
|
+
{ className: 'pp-wrap' },
|
|
2145
|
+
header,
|
|
2146
|
+
body,
|
|
2147
|
+
h(
|
|
2148
|
+
'div',
|
|
2149
|
+
{ className: 'pp-hint', style: { marginTop: 16, paddingTop: 10, borderTop: '.5px solid var(--dsw-alias-border-l1,#eee)' } },
|
|
2150
|
+
`${t('profileDir')}: ${status?.profile?.dir ?? '—'}`,
|
|
2151
|
+
),
|
|
2152
|
+
),
|
|
2153
|
+
h(DirPicker, {
|
|
2154
|
+
open: pickerOpen,
|
|
2155
|
+
startDir: pickerDir,
|
|
2156
|
+
mode: pickerMode,
|
|
2157
|
+
onClose: () => setPickerOpen(false),
|
|
2158
|
+
onPick: (picked) => {
|
|
2159
|
+
setPickerOpen(false);
|
|
2160
|
+
if (tab === 'import') {
|
|
2161
|
+
// 导入页只剩拖拽框,选择器不会再往这里送文件;
|
|
2162
|
+
// 万一从旧路径进来,也走和拖放一样的"先探冲突"流程。
|
|
2163
|
+
if (pickerMode === 'dir') {
|
|
2164
|
+
doImportPlugin(picked)
|
|
2165
|
+
.then((data) => {
|
|
2166
|
+
setImportPluginResult(data.ok === false ? { error: data.error ?? '导入失败' } : data);
|
|
2167
|
+
return load();
|
|
2168
|
+
})
|
|
2169
|
+
.catch((err) => setImportPluginResult({ error: String(err?.message ?? err) }));
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
doImport({ file: picked, dryRun: true })
|
|
2173
|
+
.then((preview) => {
|
|
2174
|
+
const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
|
|
2175
|
+
if (conflicts.length) {
|
|
2176
|
+
setPendingImport({ kind: 'bundle', file: picked, conflicts });
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
return doImport({ file: picked, dryRun: false, mode: 'merge', restoreData: true, linkNodeModules: true }).then(
|
|
2180
|
+
(data) => {
|
|
2181
|
+
setImportResult(data);
|
|
2182
|
+
return load();
|
|
2183
|
+
},
|
|
2184
|
+
);
|
|
2185
|
+
})
|
|
2186
|
+
.catch((err) => setImportResult({ error: String(err?.message ?? err) }));
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
// 导出:只有选中的是「目录」才补末尾分隔符,让后端按"目录 + 自动命名"处理。
|
|
2190
|
+
//
|
|
2191
|
+
// 之前这里无条件补 `\`,导致选中 xxx.tgz 这种文件后变成 `xxx.tgz\`,
|
|
2192
|
+
// 后端按"末尾带分隔符 → 当目录"处理,又拼了一次默认文件名,
|
|
2193
|
+
// 结果在名为 xxx.tgz 的文件夹里塞了个新 zip —— 路径看着像文件其实是目录。
|
|
2194
|
+
// 所以带扩展名的就当文件,原样保留。
|
|
2195
|
+
const looksLikeFile = /\.[a-z0-9]{2,8}$/i.test(picked);
|
|
2196
|
+
setOut(looksLikeFile || picked.endsWith('\\') || picked.endsWith('/') ? picked : `${picked}\\`);
|
|
2197
|
+
},
|
|
2198
|
+
}),
|
|
2199
|
+
// 重名冲突弹窗:叠在最上层
|
|
2200
|
+
h(ConflictDialog, {
|
|
2201
|
+
pending: pendingImport,
|
|
2202
|
+
t,
|
|
2203
|
+
onConfirm: confirmImport,
|
|
2204
|
+
onCancel: () => setPendingImport(null),
|
|
2205
|
+
}),
|
|
2206
|
+
);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/* --------------------- 插件页顶部的两个标签页 --------------------- */
|
|
2210
|
+
|
|
2211
|
+
/**
|
|
2212
|
+
* 「导入」标签页:本地导入插件 / 整合包。
|
|
2213
|
+
*
|
|
2214
|
+
* 复用主面板的导入子页;宿主传进来的 install/openPackage 目前用不上
|
|
2215
|
+
* (我们的导入走自己的整合包格式,而不是 npm 包安装),保留在 props 里不打散布局。
|
|
2216
|
+
*/
|
|
2217
|
+
function ImportTab() {
|
|
2218
|
+
return h(Panel, { onlyTab: 'import' });
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
/** 「导出」标签页:导出插件包 / 整合包。 */
|
|
2222
|
+
function ExportTab() {
|
|
2223
|
+
return h(Panel, { onlyTab: 'export' });
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/* ---------------------------------- 挂载 --------------------------------- */
|
|
2227
|
+
|
|
2228
|
+
let panelCtx = null;
|
|
2229
|
+
|
|
2230
|
+
/** 把 locale 字典绑到 translate,供全局 t() 使用。 */
|
|
2231
|
+
function bindTranslate(ctx) {
|
|
2232
|
+
if (!ctx) return;
|
|
2233
|
+
try {
|
|
2234
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-plugin-pack: dictionaries');
|
|
2235
|
+
} catch {
|
|
2236
|
+
/* 没有 locale 服务时退回 key 原文 */
|
|
2237
|
+
}
|
|
2238
|
+
try {
|
|
2239
|
+
translate = ctx.locale.bind(NS);
|
|
2240
|
+
} catch {
|
|
2241
|
+
/* 老版本没有 bind */
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
function apply(ctx) {
|
|
2246
|
+
panelCtx = ctx;
|
|
2247
|
+
bindTranslate(ctx);
|
|
2248
|
+
|
|
2249
|
+
/* 主入口:插件页「已安装」列表里点开这一行。
|
|
2250
|
+
* 宿主 ui-plugin-manager 只用两种 view 渲染这个插槽(读它源码确认):
|
|
2251
|
+
* { view: 'summary' } —— 列表里那一行的简介,只有一行字的空间
|
|
2252
|
+
* { view: 'page', form } —— 点进去的详情页,这才是完整面板
|
|
2253
|
+
* 注意:没有 'full' 之类的第三值,判错就会渲染成空白。 */
|
|
2254
|
+
ctx.slots.inject('plugins.row.config', () =>
|
|
2255
|
+
ctx.slots.register(
|
|
2256
|
+
{ name: 'plugins.row.config', key: 'dsh-plugin-pack#dsh-plugin-pack', locale: NS },
|
|
2257
|
+
(props) => {
|
|
2258
|
+
if (props?.t) translate = props.t;
|
|
2259
|
+
if (props?.view === 'summary') return h('span', null, translate('summary'));
|
|
2260
|
+
return h(Panel, null);
|
|
2261
|
+
},
|
|
2262
|
+
),
|
|
2263
|
+
);
|
|
2264
|
+
|
|
2265
|
+
/* 入口二、三:插件页顶部「已安装 / 发现」旁边加两个标签页 —— 「导入」「导出」。
|
|
2266
|
+
* 宿主 ui-plugin-manager 的 plugins.tab 插槽约定(读源码确认):
|
|
2267
|
+
* entries("plugins.tab") 只读 options.id 与 options.label
|
|
2268
|
+
* renderSlot('plugins.tab', { install, openPackage }, { only: tab }) 只在选中时渲染
|
|
2269
|
+
* 所以 id 必须稳定,label 可以是函数(resolveSlotLabel 支持)。 */
|
|
2270
|
+
ctx.slots.inject('plugins.tab', () => {
|
|
2271
|
+
const disposers = [];
|
|
2272
|
+
disposers.push(
|
|
2273
|
+
ctx.slots.register(
|
|
2274
|
+
{ name: 'plugins.tab', id: 'plugin-pack-import', order: 10, label: () => translate('tab.navImport'), locale: NS },
|
|
2275
|
+
(props) => {
|
|
2276
|
+
if (props?.t) translate = props.t;
|
|
2277
|
+
return h(ImportTab, null);
|
|
2278
|
+
},
|
|
2279
|
+
),
|
|
2280
|
+
);
|
|
2281
|
+
disposers.push(
|
|
2282
|
+
ctx.slots.register(
|
|
2283
|
+
{ name: 'plugins.tab', id: 'plugin-pack-export', order: 20, label: () => translate('tab.navExport'), locale: NS },
|
|
2284
|
+
(props) => {
|
|
2285
|
+
if (props?.t) translate = props.t;
|
|
2286
|
+
return h(ExportTab, null);
|
|
2287
|
+
},
|
|
2288
|
+
),
|
|
2289
|
+
);
|
|
2290
|
+
return () => {
|
|
2291
|
+
for (const dispose of disposers) {
|
|
2292
|
+
try {
|
|
2293
|
+
dispose();
|
|
2294
|
+
} catch {
|
|
2295
|
+
/* 忽略 */
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
};
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
/* 入口四:设置 → 插件整合包(整页,功能齐全) */
|
|
2302
|
+
ctx.slots.inject('settings.section', () =>
|
|
2303
|
+
ctx.slots.register(
|
|
2304
|
+
{
|
|
2305
|
+
name: 'settings.section',
|
|
2306
|
+
id: 'dsh-plugin-pack',
|
|
2307
|
+
order: 460,
|
|
2308
|
+
label: () => translate('nav'),
|
|
2309
|
+
locale: NS,
|
|
2310
|
+
inject: () => ({ t: translate }),
|
|
2311
|
+
},
|
|
2312
|
+
(props) => {
|
|
2313
|
+
if (props?.t) translate = props.t;
|
|
2314
|
+
return h(Panel, null);
|
|
2315
|
+
},
|
|
2316
|
+
),
|
|
2317
|
+
);
|
|
2318
|
+
|
|
2319
|
+
// 样式表
|
|
2320
|
+
try {
|
|
2321
|
+
ctx.effect(() => {
|
|
2322
|
+
const style = document.createElement('style');
|
|
2323
|
+
style.dataset.dshPluginPack = 'true';
|
|
2324
|
+
style.textContent = CSS;
|
|
2325
|
+
document.head.append(style);
|
|
2326
|
+
return () => style.remove();
|
|
2327
|
+
}, 'dsh-plugin-pack: styles');
|
|
2328
|
+
} catch {
|
|
2329
|
+
/* 非浏览器环境(SSR/测试) */
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
exports.name = name;
|
|
2334
|
+
exports.inject = inject;
|
|
2335
|
+
exports.apply = apply;
|
|
2336
|
+
return module.exports;
|
|
2337
|
+
} });
|