dsh-plugin-pack 1.0.3 → 1.0.5
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/client.js +321 -7
- package/lib/jobs.js +103 -5
- package/lib/server.js +79 -3
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -108,6 +108,9 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
108
108
|
'import.source.bundle': '整合包',
|
|
109
109
|
'import.source.plugin': '本地插件',
|
|
110
110
|
'import.notBundle': 'zip 不是整合包',
|
|
111
|
+
'import.reading': '正在读取包内容…',
|
|
112
|
+
'import.uploading': '正在上传…',
|
|
113
|
+
'import.checking': '正在检查冲突…',
|
|
111
114
|
'drop.title': '把 zip / tgz 或插件文件夹拖到这里',
|
|
112
115
|
'drop.release': '松手就导入',
|
|
113
116
|
'drop.running': '正在导入…',
|
|
@@ -266,6 +269,9 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
266
269
|
'import.source.bundle': 'Bundle',
|
|
267
270
|
'import.source.plugin': 'Local plugin',
|
|
268
271
|
'import.notBundle': 'This zip is not a bundle',
|
|
272
|
+
'import.reading': 'Reading package contents…',
|
|
273
|
+
'import.uploading': 'Uploading…',
|
|
274
|
+
'import.checking': 'Checking for conflicts…',
|
|
269
275
|
'drop.title': 'Drop a zip / tgz or plugin folder here',
|
|
270
276
|
'drop.release': 'Release to import',
|
|
271
277
|
'drop.running': 'Importing…',
|
|
@@ -483,6 +489,139 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
483
489
|
/** 后台任务列表(进行中 + 已完成)。 */
|
|
484
490
|
const getJobs = () => api('/plugin-pack/jobs?limit=20');
|
|
485
491
|
const clearJobs = () => api('/plugin-pack/jobs/clear', { method: 'POST', body: '{}' });
|
|
492
|
+
/** 待决策任务(导入遇到重名时登记的):切回来捡弹窗用。 */
|
|
493
|
+
const getWaiting = () => api('/plugin-pack/waiting');
|
|
494
|
+
const cancelWaiting = (id, reason) =>
|
|
495
|
+
api('/plugin-pack/waiting/cancel', { method: 'POST', body: JSON.stringify({ id, reason }) });
|
|
496
|
+
|
|
497
|
+
/* ------------------------- 浏览器端读 zip 头部 ------------------------- */
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* 只读 zip 的中央目录,取出条目名(不解压内容)。
|
|
501
|
+
*
|
|
502
|
+
* 为什么在浏览器里做:拖入一个几百 MB 的包,原来的流程是
|
|
503
|
+
* 「整个文件 POST 上去 → 服务端 dryRun → 才弹冲突窗」,
|
|
504
|
+
* 用户要等上传完才看到"哪些要覆盖"。
|
|
505
|
+
*
|
|
506
|
+
* zip 的中央目录在文件尾部(最后 22 字节是 EOCD,里面记着目录偏移),
|
|
507
|
+
* 所以只读尾部 ~1 MB 就能得到全部条目名,不用传整个文件。
|
|
508
|
+
* 这样拖进来几百毫秒就能弹出冲突列表,点确认之后才真正上传。
|
|
509
|
+
*
|
|
510
|
+
* @param {File} file
|
|
511
|
+
* @returns {Promise<{ names: string[], manifest: any | null } | null>}
|
|
512
|
+
* 不是有效 zip 时返回 null(交给服务端给出正式错误)
|
|
513
|
+
*/
|
|
514
|
+
async function peekZip(file) {
|
|
515
|
+
try {
|
|
516
|
+
const size = file.size;
|
|
517
|
+
if (size < 22) return null;
|
|
518
|
+
|
|
519
|
+
// 1) 读尾部,找 EOCD
|
|
520
|
+
const tailLen = Math.min(size, 2 * 1024 * 1024);
|
|
521
|
+
const tail = new Uint8Array(await file.slice(size - tailLen).arrayBuffer());
|
|
522
|
+
let eocd = -1;
|
|
523
|
+
for (let i = tail.length - 22; i >= 0; i -= 1) {
|
|
524
|
+
if (tail[i] === 0x50 && tail[i + 1] === 0x4b && tail[i + 2] === 0x05 && tail[i + 3] === 0x06) {
|
|
525
|
+
eocd = i;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (eocd < 0) return null;
|
|
530
|
+
|
|
531
|
+
const total = tail[eocd + 10] | (tail[eocd + 11] << 8);
|
|
532
|
+
const cdSize = tail[eocd + 12] | (tail[eocd + 13] << 8) | (tail[eocd + 14] << 16) | (tail[eocd + 15] << 24);
|
|
533
|
+
const cdOffset = tail[eocd + 16] | (tail[eocd + 17] << 8) | (tail[eocd + 18] << 16) | (tail[eocd + 19] << 24);
|
|
534
|
+
|
|
535
|
+
// 2) 按需读中央目录那一段
|
|
536
|
+
let cd = tail;
|
|
537
|
+
if (cdOffset < size - tailLen) {
|
|
538
|
+
cd = new Uint8Array(await file.slice(cdOffset, cdOffset + cdSize).arrayBuffer());
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const names = [];
|
|
542
|
+
let p = 0;
|
|
543
|
+
const base = cdOffset < size - tailLen ? 0 : cdOffset - (size - tailLen);
|
|
544
|
+
p = base;
|
|
545
|
+
while (p + 46 <= cd.length && names.length < total) {
|
|
546
|
+
if (!(cd[p] === 0x50 && cd[p + 1] === 0x4b && cd[p + 2] === 0x01 && cd[p + 3] === 0x02)) break;
|
|
547
|
+
const nameLen = cd[p + 28] | (cd[p + 29] << 8);
|
|
548
|
+
const extraLen = cd[p + 30] | (cd[p + 31] << 8);
|
|
549
|
+
const commentLen = cd[p + 32] | (cd[p + 33] << 8);
|
|
550
|
+
const raw = cd.subarray(p + 46, p + 46 + nameLen);
|
|
551
|
+
let name = '';
|
|
552
|
+
for (let k = 0; k < raw.length; k += 1) name += String.fromCharCode(raw[k]);
|
|
553
|
+
try {
|
|
554
|
+
name = decodeURIComponent(escape(name));
|
|
555
|
+
} catch {
|
|
556
|
+
/* 保持原样 */
|
|
557
|
+
}
|
|
558
|
+
names.push(name.replace(/\\/g, '/'));
|
|
559
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
560
|
+
}
|
|
561
|
+
if (!names.length) return null;
|
|
562
|
+
|
|
563
|
+
// 3) 有 manifest.json 才解压它(其余一律不解压)
|
|
564
|
+
let manifest = null;
|
|
565
|
+
const manifestEntry = names.find((n) => n === 'manifest.json');
|
|
566
|
+
if (manifestEntry) {
|
|
567
|
+
manifest = await readZipEntryAsJson(file, cd, base, manifestEntry, cdOffset < size - tailLen);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return { names, manifest };
|
|
571
|
+
} catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** 从中央目录找到某条目,解压它并 JSON.parse。失败返回 null。 */
|
|
577
|
+
async function readZipEntryAsJson(file, cd, base, targetName, cdIsSeparate) {
|
|
578
|
+
try {
|
|
579
|
+
let p = base;
|
|
580
|
+
let found = null;
|
|
581
|
+
while (p + 46 <= cd.length) {
|
|
582
|
+
if (!(cd[p] === 0x50 && cd[p + 1] === 0x4b && cd[p + 2] === 0x01 && cd[p + 3] === 0x02)) break;
|
|
583
|
+
const method = cd[p + 10] | (cd[p + 11] << 8);
|
|
584
|
+
const compSize = cd[p + 20] | (cd[p + 21] << 8) | (cd[p + 22] << 16) | (cd[p + 23] << 24);
|
|
585
|
+
const nameLen = cd[p + 28] | (cd[p + 29] << 8);
|
|
586
|
+
const extraLen = cd[p + 30] | (cd[p + 31] << 8);
|
|
587
|
+
const commentLen = cd[p + 32] | (cd[p + 33] << 8);
|
|
588
|
+
const localOffset = cd[p + 42] | (cd[p + 43] << 8) | (cd[p + 44] << 16) | (cd[p + 45] << 24);
|
|
589
|
+
const raw = cd.subarray(p + 46, p + 46 + nameLen);
|
|
590
|
+
let name = '';
|
|
591
|
+
for (let k = 0; k < raw.length; k += 1) name += String.fromCharCode(raw[k]);
|
|
592
|
+
try {
|
|
593
|
+
name = decodeURIComponent(escape(name));
|
|
594
|
+
} catch {
|
|
595
|
+
/* 保持原样 */
|
|
596
|
+
}
|
|
597
|
+
if (name.replace(/\\/g, '/') === targetName) {
|
|
598
|
+
found = { method, compSize, localOffset };
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
602
|
+
}
|
|
603
|
+
if (!found) return null;
|
|
604
|
+
|
|
605
|
+
// 读本地头拿到文件名/extra 的实际长度
|
|
606
|
+
const local = new Uint8Array(await file.slice(found.localOffset, found.localOffset + 30).arrayBuffer());
|
|
607
|
+
const lNameLen = local[26] | (local[27] << 8);
|
|
608
|
+
const lExtraLen = local[28] | (local[29] << 8);
|
|
609
|
+
const dataStart = found.localOffset + 30 + lNameLen + lExtraLen;
|
|
610
|
+
const raw = new Uint8Array(await file.slice(dataStart, dataStart + found.compSize).arrayBuffer());
|
|
611
|
+
|
|
612
|
+
let bytes = raw;
|
|
613
|
+
if (found.method === 8) {
|
|
614
|
+
// deflate 解压用浏览器内置的 DecompressionStream
|
|
615
|
+
const stream = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
|
616
|
+
const buf = await new Response(stream).arrayBuffer();
|
|
617
|
+
bytes = new Uint8Array(buf);
|
|
618
|
+
}
|
|
619
|
+
const text = new TextDecoder('utf-8').decode(bytes);
|
|
620
|
+
return JSON.parse(text);
|
|
621
|
+
} catch {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
486
625
|
|
|
487
626
|
/** 上传一个 zip,返回宿主落盘后的绝对路径。 */
|
|
488
627
|
async function uploadZip(file) {
|
|
@@ -1038,7 +1177,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1038
1177
|
* @param {boolean} [props.busy]
|
|
1039
1178
|
* @param {string} [props.hint]
|
|
1040
1179
|
*/
|
|
1041
|
-
function DropZone({ onFile, busy = false, hint, dropRef }) {
|
|
1180
|
+
function DropZone({ onFile, busy = false, hint, dropRef, phase }) {
|
|
1042
1181
|
const [over, setOver] = useState(false);
|
|
1043
1182
|
const [hover, setHover] = useState(false);
|
|
1044
1183
|
const inputRef = useRef(null);
|
|
@@ -1095,7 +1234,11 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1095
1234
|
h(
|
|
1096
1235
|
'div',
|
|
1097
1236
|
{ style: { fontSize: 13, fontWeight: 500 } },
|
|
1098
|
-
busy
|
|
1237
|
+
busy
|
|
1238
|
+
? phase || translate('drop.running')
|
|
1239
|
+
: over
|
|
1240
|
+
? translate('drop.release')
|
|
1241
|
+
: translate('drop.title'),
|
|
1099
1242
|
),
|
|
1100
1243
|
h('div', { className: 'pp-hint', style: { marginTop: 4 } }, hint ?? translate('drop.hint')),
|
|
1101
1244
|
h('input', {
|
|
@@ -1434,8 +1577,17 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1434
1577
|
const [pickerDir, setPickerDir] = useState('');
|
|
1435
1578
|
const [pickerMode, setPickerMode] = useState('file');
|
|
1436
1579
|
const [importPluginResult, setImportPluginResult] = useState(null);
|
|
1437
|
-
/**
|
|
1580
|
+
/**
|
|
1581
|
+
* 待确认的重名冲突:{ jobId, kind, file, conflicts, plugins }。非空时弹窗。
|
|
1582
|
+
*
|
|
1583
|
+
* 这个状态来自服务端的"待决策"任务,不是纯前端内存 ——
|
|
1584
|
+
* 切走画面再回来能捡回弹窗,否则会留下文件已上传但没人处理的悬空状态。
|
|
1585
|
+
*/
|
|
1438
1586
|
const [pendingImport, setPendingImport] = useState(null);
|
|
1587
|
+
/** 当前弹窗对应的服务端任务 id(取消时要通知服务端)。 */
|
|
1588
|
+
const pendingJobId = useRef(null);
|
|
1589
|
+
/** 导入进度阶段文案(读取中 / 上传中 / 检查冲突…),空串表示没在跑。 */
|
|
1590
|
+
const [phase, setPhase] = useState('');
|
|
1439
1591
|
/** 后台任务列表:切走画面回来靠它恢复 */
|
|
1440
1592
|
const [jobs, setJobs] = useState([]);
|
|
1441
1593
|
/** 体积上限(MiB),0 = 不限制。默认跟着服务端配置走。 */
|
|
@@ -1509,6 +1661,64 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1509
1661
|
}
|
|
1510
1662
|
}, []);
|
|
1511
1663
|
|
|
1664
|
+
/* 挂载时捡回"待决策"的导入。
|
|
1665
|
+
*
|
|
1666
|
+
* 场景:用户在冲突弹窗里没做选择就切走了画面。组件卸载后前端 state 丢了,
|
|
1667
|
+
* 但服务端还记着这个待决策任务 —— 回来时把它捡回来重开弹窗,
|
|
1668
|
+
* 而不是让那次导入悬在半空(文件传上去了,没人处理)。
|
|
1669
|
+
*/
|
|
1670
|
+
useEffect(() => {
|
|
1671
|
+
let alive = true;
|
|
1672
|
+
(async () => {
|
|
1673
|
+
try {
|
|
1674
|
+
const data = await getWaiting();
|
|
1675
|
+
if (!alive) return;
|
|
1676
|
+
const list = Array.isArray(data?.waiting) ? data.waiting : [];
|
|
1677
|
+
const first = list[0];
|
|
1678
|
+
if (!first) return;
|
|
1679
|
+
pendingJobId.current = first.id;
|
|
1680
|
+
setPendingImport({
|
|
1681
|
+
kind: 'bundle',
|
|
1682
|
+
jobId: first.id,
|
|
1683
|
+
file: first.payload?.file ?? null,
|
|
1684
|
+
conflicts: first.payload?.conflicts ?? [],
|
|
1685
|
+
plugins: first.payload?.plugins ?? [],
|
|
1686
|
+
resumed: true,
|
|
1687
|
+
});
|
|
1688
|
+
} catch {
|
|
1689
|
+
/* 服务端还没这个接口就静默跳过 */
|
|
1690
|
+
}
|
|
1691
|
+
})();
|
|
1692
|
+
return () => {
|
|
1693
|
+
alive = false;
|
|
1694
|
+
};
|
|
1695
|
+
}, []);
|
|
1696
|
+
|
|
1697
|
+
/* 组件卸载 = 用户切走画面/关页面。
|
|
1698
|
+
*
|
|
1699
|
+
* 此时如果弹窗还开着、用户没做选择,就通知服务端取消这次导入,
|
|
1700
|
+
* 避免留下"文件已上传但不处理"的悬空任务。
|
|
1701
|
+
*/
|
|
1702
|
+
useEffect(
|
|
1703
|
+
() => () => {
|
|
1704
|
+
const id = pendingJobId.current;
|
|
1705
|
+
if (!id) return;
|
|
1706
|
+
pendingJobId.current = null;
|
|
1707
|
+
// 卸载时不能等结果,用 keepalive 尽量送达
|
|
1708
|
+
try {
|
|
1709
|
+
fetch(new URL('/plugin-pack/waiting/cancel', window.location.origin).toString(), {
|
|
1710
|
+
method: 'POST',
|
|
1711
|
+
headers: { 'content-type': 'application/json' },
|
|
1712
|
+
body: JSON.stringify({ id, reason: '用户切走了画面,未做选择' }),
|
|
1713
|
+
keepalive: true,
|
|
1714
|
+
}).catch(() => {});
|
|
1715
|
+
} catch {
|
|
1716
|
+
/* 忽略 */
|
|
1717
|
+
}
|
|
1718
|
+
},
|
|
1719
|
+
[],
|
|
1720
|
+
);
|
|
1721
|
+
|
|
1512
1722
|
const clearJobHistory = async () => {
|
|
1513
1723
|
try {
|
|
1514
1724
|
await clearJobs();
|
|
@@ -1551,20 +1761,80 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1551
1761
|
// 两者都走"上传 → 导入",具体分流由服务端按扩展名决定。
|
|
1552
1762
|
const isArchive = /\.(zip|tgz|tar\.gz)$/i.test(name);
|
|
1553
1763
|
if (isArchive) {
|
|
1764
|
+
/* 第一步:先在浏览器里只读 zip 尾部,几百毫秒就知道包里有哪些插件、
|
|
1765
|
+
* 哪些跟已装的重名 —— 不用等整个文件传完。
|
|
1766
|
+
* 用户把几百 MB 的包拖进来时,这个差别是"秒弹"和"等半分钟"的区别。 */
|
|
1767
|
+
const isZip = /\.zip$/i.test(name);
|
|
1768
|
+
let quick = null;
|
|
1769
|
+
if (isZip) {
|
|
1770
|
+
setPhase(t('import.reading'));
|
|
1771
|
+
quick = await peekZip(file);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
if (quick && Array.isArray(quick.names) && quick.names.length) {
|
|
1775
|
+
const installed = new Map((status?.plugins ?? []).map((plugin) => [plugin.name, plugin]));
|
|
1776
|
+
const packPlugins = (quick.manifest?.plugins ?? []).filter(Boolean);
|
|
1777
|
+
const conflicts = packPlugins
|
|
1778
|
+
.filter((plugin) => installed.has(plugin.name))
|
|
1779
|
+
.map((plugin) => ({
|
|
1780
|
+
name: plugin.name,
|
|
1781
|
+
version: plugin.version ?? null,
|
|
1782
|
+
installedVersion: installed.get(plugin.name)?.version ?? null,
|
|
1783
|
+
dest: installed.get(plugin.name)?.dir ?? null,
|
|
1784
|
+
bytes: plugin.bytes ?? null,
|
|
1785
|
+
sameVersion: (installed.get(plugin.name)?.version ?? null) === (plugin.version ?? null),
|
|
1786
|
+
}));
|
|
1787
|
+
|
|
1788
|
+
// 不是整合包就直接报,省得白传一遍
|
|
1789
|
+
const looksLikeBundle = Boolean(quick.manifest) && String(quick.manifest?.format ?? '').startsWith('dsh-plugin-pack@');
|
|
1790
|
+
if (!looksLikeBundle) {
|
|
1791
|
+
setImportResult({
|
|
1792
|
+
error: `zip 不是整合包:这个 zip 里没有 dsh-plugin-pack 的 manifest.json(共 ${quick.names.length} 个文件)。它可能是普通的压缩包,或者是别的工具打的包。`,
|
|
1793
|
+
code: 'NOT_A_BUNDLE',
|
|
1794
|
+
});
|
|
1795
|
+
setBusy(false);
|
|
1796
|
+
setPhase('');
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
if (conflicts.length) {
|
|
1801
|
+
/* 先把冲突列表给出来,用户选完才上传 + 真导入。
|
|
1802
|
+
* 这时还没上传,所以待决策任务在服务端也不存在 —— 全靠确认时再登记。 */
|
|
1803
|
+
setPendingImport({
|
|
1804
|
+
kind: 'bundle',
|
|
1805
|
+
pendingFile: file, // 还没上传,先拿着 File 对象
|
|
1806
|
+
file: null,
|
|
1807
|
+
conflicts,
|
|
1808
|
+
plugins: packPlugins,
|
|
1809
|
+
quick: true,
|
|
1810
|
+
});
|
|
1811
|
+
setBusy(false);
|
|
1812
|
+
setPhase('');
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// 第二步:确认无冲突(或拿不到快速清单)才上传
|
|
1818
|
+
setPhase(t('import.uploading'));
|
|
1554
1819
|
const uploaded = await uploadZip(file);
|
|
1555
1820
|
setZipPath(uploaded.file);
|
|
1556
1821
|
|
|
1557
1822
|
// 先 dryRun 探一次:有重名冲突就弹窗让用户决定
|
|
1823
|
+
setPhase(t('import.checking'));
|
|
1558
1824
|
const preview = await doImport({ file: uploaded.file, dryRun: true });
|
|
1559
1825
|
const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
|
|
1560
1826
|
if (conflicts.length) {
|
|
1827
|
+
// 服务端在 dryRun 时已登记了"待决策"任务,带 jobId 回来
|
|
1828
|
+
pendingJobId.current = preview?.jobId ?? null;
|
|
1561
1829
|
setPendingImport({
|
|
1562
1830
|
kind: 'bundle',
|
|
1831
|
+
jobId: preview?.jobId ?? null,
|
|
1563
1832
|
file: uploaded.file,
|
|
1564
1833
|
conflicts,
|
|
1565
1834
|
plugins: preview.plugins ?? [],
|
|
1566
1835
|
});
|
|
1567
1836
|
setBusy(false);
|
|
1837
|
+
setPhase('');
|
|
1568
1838
|
return;
|
|
1569
1839
|
}
|
|
1570
1840
|
|
|
@@ -1596,6 +1866,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1596
1866
|
if (/\.(zip|tgz|tar\.gz)$/i.test(String(file?.name ?? ''))) setImportResult({ error: message, code });
|
|
1597
1867
|
else setImportPluginResult({ error: message, code });
|
|
1598
1868
|
} finally {
|
|
1869
|
+
setPhase('');
|
|
1599
1870
|
setBusy(false);
|
|
1600
1871
|
lastDrop.dataTransfer = null;
|
|
1601
1872
|
}
|
|
@@ -1605,27 +1876,63 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1605
1876
|
const confirmImport = async (decisions) => {
|
|
1606
1877
|
const pending = pendingImport;
|
|
1607
1878
|
if (!pending) return;
|
|
1879
|
+
const jobId = pending.jobId ?? pendingJobId.current ?? null;
|
|
1880
|
+
// 明确了选择,就别让卸载钩子把它当"未决策"取消掉
|
|
1881
|
+
pendingJobId.current = null;
|
|
1608
1882
|
setPendingImport(null);
|
|
1609
1883
|
setBusy(true);
|
|
1610
1884
|
setImportResult(null);
|
|
1611
1885
|
try {
|
|
1886
|
+
/* quick 模式:拖进来时只读了 zip 尾部就弹了冲突窗,文件还没上传。
|
|
1887
|
+
* 现在用户确认了,才真正把文件传上去。 */
|
|
1888
|
+
let file = pending.file;
|
|
1889
|
+
if (!file && pending.pendingFile) {
|
|
1890
|
+
setPhase(t('import.uploading'));
|
|
1891
|
+
const uploaded = await uploadZip(pending.pendingFile);
|
|
1892
|
+
setZipPath(uploaded.file);
|
|
1893
|
+
file = uploaded.file;
|
|
1894
|
+
setPhase(t('import.checking'));
|
|
1895
|
+
// 服务端 dryRun 一次,把待决策任务登记上(这样切走也能取消)
|
|
1896
|
+
const preview = await doImport({ file, dryRun: true });
|
|
1897
|
+
pendingJobId.current = preview?.jobId ?? null;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
setPhase('');
|
|
1612
1901
|
const result = await doImport({
|
|
1613
|
-
file
|
|
1902
|
+
file,
|
|
1614
1903
|
dryRun: false,
|
|
1615
1904
|
restoreData: true,
|
|
1616
1905
|
linkNodeModules: true,
|
|
1617
1906
|
decisions,
|
|
1907
|
+
// 复用同一个任务记录,界面上是同一条从"待决策"变成"完成"
|
|
1908
|
+
...(jobId || pendingJobId.current ? { jobId: jobId ?? pendingJobId.current } : {}),
|
|
1618
1909
|
});
|
|
1910
|
+
pendingJobId.current = null;
|
|
1619
1911
|
setImportResult(result);
|
|
1620
1912
|
await load();
|
|
1621
1913
|
await refreshJobs();
|
|
1622
1914
|
} catch (err) {
|
|
1623
1915
|
setImportResult({ error: String(err?.message ?? err) });
|
|
1624
1916
|
} finally {
|
|
1917
|
+
setPhase('');
|
|
1625
1918
|
setBusy(false);
|
|
1626
1919
|
}
|
|
1627
1920
|
};
|
|
1628
1921
|
|
|
1922
|
+
/** 用户点了取消 / 按了 Esc / 点了遮罩:视为放弃这次导入。 */
|
|
1923
|
+
const cancelImport = async () => {
|
|
1924
|
+
const id = pendingJobId.current ?? pendingImport?.jobId ?? null;
|
|
1925
|
+
pendingJobId.current = null;
|
|
1926
|
+
setPendingImport(null);
|
|
1927
|
+
if (!id) return;
|
|
1928
|
+
try {
|
|
1929
|
+
await cancelWaiting(id, '用户取消了导入');
|
|
1930
|
+
await refreshJobs();
|
|
1931
|
+
} catch {
|
|
1932
|
+
/* 取消失败不阻塞界面 */
|
|
1933
|
+
}
|
|
1934
|
+
};
|
|
1935
|
+
|
|
1629
1936
|
const runExport = async () => {
|
|
1630
1937
|
if (!chosen.length) {
|
|
1631
1938
|
setExportResult({ error: translate('export.empty') });
|
|
@@ -1912,7 +2219,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
1912
2219
|
const importPage = h(
|
|
1913
2220
|
'div',
|
|
1914
2221
|
null,
|
|
1915
|
-
h(DropZone, { onFile: runDrop, busy, dropRef: lastDrop }),
|
|
2222
|
+
h(DropZone, { onFile: runDrop, busy, dropRef: lastDrop, phase }),
|
|
1916
2223
|
// 后台任务:切走画面回来能看到刚才那次导入的结果
|
|
1917
2224
|
h(JobList, { jobs, onClear: clearJobHistory, t }),
|
|
1918
2225
|
|
|
@@ -2173,7 +2480,14 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
2173
2480
|
.then((preview) => {
|
|
2174
2481
|
const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
|
|
2175
2482
|
if (conflicts.length) {
|
|
2176
|
-
|
|
2483
|
+
pendingJobId.current = preview?.jobId ?? null;
|
|
2484
|
+
setPendingImport({
|
|
2485
|
+
kind: 'bundle',
|
|
2486
|
+
jobId: preview?.jobId ?? null,
|
|
2487
|
+
file: picked,
|
|
2488
|
+
conflicts,
|
|
2489
|
+
plugins: preview?.plugins ?? [],
|
|
2490
|
+
});
|
|
2177
2491
|
return;
|
|
2178
2492
|
}
|
|
2179
2493
|
return doImport({ file: picked, dryRun: false, mode: 'merge', restoreData: true, linkNodeModules: true }).then(
|
|
@@ -2201,7 +2515,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
|
|
|
2201
2515
|
pending: pendingImport,
|
|
2202
2516
|
t,
|
|
2203
2517
|
onConfirm: confirmImport,
|
|
2204
|
-
onCancel:
|
|
2518
|
+
onCancel: cancelImport,
|
|
2205
2519
|
}),
|
|
2206
2520
|
);
|
|
2207
2521
|
}
|
package/lib/jobs.js
CHANGED
|
@@ -39,13 +39,13 @@ function newId() {
|
|
|
39
39
|
function prune() {
|
|
40
40
|
const now = Date.now();
|
|
41
41
|
for (const [id, job] of jobs) {
|
|
42
|
-
const finished = job.status === 'done' || job.status === 'failed';
|
|
42
|
+
const finished = job.status === 'done' || job.status === 'failed' || job.status === 'cancelled';
|
|
43
43
|
if (finished && now - job.finishedAt > KEEP_DONE_MS) jobs.delete(id);
|
|
44
44
|
}
|
|
45
45
|
// 超过上限时,先删最老的已完成任务
|
|
46
46
|
if (jobs.size > MAX_JOBS) {
|
|
47
47
|
const finished = [...jobs.values()]
|
|
48
|
-
.filter((job) => job.status === 'done' || job.status === 'failed')
|
|
48
|
+
.filter((job) => job.status === 'done' || job.status === 'failed' || job.status === 'cancelled')
|
|
49
49
|
.sort((a, b) => a.seq - b.seq);
|
|
50
50
|
for (const job of finished) {
|
|
51
51
|
if (jobs.size <= MAX_JOBS) break;
|
|
@@ -54,6 +54,94 @@ function prune() {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* 登记一个「等用户决策」的任务。
|
|
59
|
+
*
|
|
60
|
+
* 解决的问题:导入遇到重名时要弹窗问「覆盖还是跳过」,但弹窗状态原本只在
|
|
61
|
+
* 前端内存里 —— 用户切走画面再回来,组件卸载,弹窗就没了,导入悬在半空:
|
|
62
|
+
* 文件传上去了但没人处理。
|
|
63
|
+
*
|
|
64
|
+
* 所以要有一个服务端的"待决策"位,让界面切回来能捡回来。用户不决策直接
|
|
65
|
+
* 切走/关页面,就走 cancelWaiting 视为取消。
|
|
66
|
+
*
|
|
67
|
+
* @param {object} options
|
|
68
|
+
* @param {'import'} options.kind
|
|
69
|
+
* @param {string} options.title
|
|
70
|
+
* @param {object} [options.meta]
|
|
71
|
+
* @param {object} options.payload 给界面用的数据(冲突列表等)
|
|
72
|
+
* @returns {string} 任务 id
|
|
73
|
+
*/
|
|
74
|
+
export function openWaiting({ kind, title, meta = {}, payload = {} }) {
|
|
75
|
+
prune();
|
|
76
|
+
const id = newId();
|
|
77
|
+
const job = {
|
|
78
|
+
id,
|
|
79
|
+
kind,
|
|
80
|
+
title,
|
|
81
|
+
meta,
|
|
82
|
+
seq: ++sequence,
|
|
83
|
+
status: 'waiting',
|
|
84
|
+
createdAt: Date.now(),
|
|
85
|
+
finishedAt: null,
|
|
86
|
+
progress: [{ at: Date.now(), message: '等待选择覆盖或跳过' }],
|
|
87
|
+
percent: 0,
|
|
88
|
+
result: null,
|
|
89
|
+
error: null,
|
|
90
|
+
payload,
|
|
91
|
+
};
|
|
92
|
+
jobs.set(id, job);
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 取一个待决策任务的完整数据(含 payload)。 */
|
|
97
|
+
export function getWaiting(id) {
|
|
98
|
+
const job = jobs.get(String(id ?? ''));
|
|
99
|
+
if (!job || job.status !== 'waiting') return null;
|
|
100
|
+
return { ...publicJob(job), payload: job.payload ?? {} };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 列出所有待决策任务(界面切回来要捡的那种)。 */
|
|
104
|
+
export function listWaiting() {
|
|
105
|
+
prune();
|
|
106
|
+
return [...jobs.values()]
|
|
107
|
+
.filter((job) => job.status === 'waiting')
|
|
108
|
+
.sort((a, b) => b.seq - a.seq)
|
|
109
|
+
.map((job) => ({ ...publicJob(job), payload: job.payload ?? {} }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 取消一个待决策任务(用户切走、关页面、点取消都走这里)。
|
|
114
|
+
*
|
|
115
|
+
* @param {string} id
|
|
116
|
+
* @param {string} [reason]
|
|
117
|
+
* @returns {boolean} 是否真的取消了(不存在或已决策返回 false)
|
|
118
|
+
*/
|
|
119
|
+
export function cancelWaiting(id, reason = '已取消') {
|
|
120
|
+
const job = jobs.get(String(id ?? ''));
|
|
121
|
+
if (!job || job.status !== 'waiting') return false;
|
|
122
|
+
job.status = 'cancelled';
|
|
123
|
+
job.finishedAt = Date.now();
|
|
124
|
+
job.durationMs = job.finishedAt - job.createdAt;
|
|
125
|
+
job.error = reason;
|
|
126
|
+
job.payload = null;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 从等待转为执行中(用户做了决策)。
|
|
132
|
+
*
|
|
133
|
+
* @param {string} id
|
|
134
|
+
* @returns {boolean}
|
|
135
|
+
*/
|
|
136
|
+
export function resumeWaiting(id) {
|
|
137
|
+
const job = jobs.get(String(id ?? ''));
|
|
138
|
+
if (!job || job.status !== 'waiting') return false;
|
|
139
|
+
job.status = 'running';
|
|
140
|
+
job.payload = null;
|
|
141
|
+
job.progress.push({ at: Date.now(), message: '开始导入' });
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
57
145
|
/**
|
|
58
146
|
* 跑一个后台任务(支持同步或异步 task)。
|
|
59
147
|
*
|
|
@@ -64,13 +152,15 @@ function prune() {
|
|
|
64
152
|
* @param {'export'|'import'|'rollback'|'import-plugin'} options.kind
|
|
65
153
|
* @param {string} options.title 人读的标题(如 "导出 5 个插件")
|
|
66
154
|
* @param {object} [options.meta] 任意附加信息(参数摘要等)
|
|
155
|
+
* @param {string} [options.jobId] 复用既有任务记录(决策后继续用同一个 id)
|
|
67
156
|
* @param {(report: (message: string, percent?: number) => void) => any | Promise<any>} options.task
|
|
68
157
|
* @returns {Promise<{ id: string, result: any, job: object }>}
|
|
69
158
|
*/
|
|
70
|
-
export async function runJob({ kind, title, meta = {}, task }) {
|
|
159
|
+
export async function runJob({ kind, title, meta = {}, task, jobId }) {
|
|
71
160
|
prune();
|
|
72
|
-
const id = newId();
|
|
73
|
-
const
|
|
161
|
+
const id = jobId ?? newId();
|
|
162
|
+
const existing = jobId ? jobs.get(String(jobId)) : null;
|
|
163
|
+
const job = existing ?? {
|
|
74
164
|
id,
|
|
75
165
|
kind,
|
|
76
166
|
title,
|
|
@@ -84,6 +174,14 @@ export async function runJob({ kind, title, meta = {}, task }) {
|
|
|
84
174
|
result: null,
|
|
85
175
|
error: null,
|
|
86
176
|
};
|
|
177
|
+
if (existing) {
|
|
178
|
+
existing.status = 'running';
|
|
179
|
+
existing.payload = null;
|
|
180
|
+
existing.error = null;
|
|
181
|
+
}
|
|
182
|
+
job.status = 'running';
|
|
183
|
+
job.payload = null;
|
|
184
|
+
job.error = null;
|
|
87
185
|
jobs.set(id, job);
|
|
88
186
|
|
|
89
187
|
const report = (message, percent) => {
|
package/lib/server.js
CHANGED
|
@@ -29,7 +29,17 @@ import { reloadClientPackage, clientStatus, localVerdict } from '../hotload.js';
|
|
|
29
29
|
import { importLocalPlugin } from './import-plugin.js';
|
|
30
30
|
import { importTarball, isTarball } from './import-tgz.js';
|
|
31
31
|
import { parseMultipart, boundaryOf, saveIncomingZip, saveIncomingDir, pruneInbox } from './inbox.js';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
runJob,
|
|
34
|
+
listJobs,
|
|
35
|
+
getJob,
|
|
36
|
+
runningJobs,
|
|
37
|
+
clearFinished,
|
|
38
|
+
openWaiting,
|
|
39
|
+
getWaiting,
|
|
40
|
+
listWaiting,
|
|
41
|
+
cancelWaiting,
|
|
42
|
+
} from './jobs.js';
|
|
33
43
|
import { dshHome as resolveDshHome, profileDir as resolveProfileDir, profileName as resolveProfileName, humanBytes } from './home.js';
|
|
34
44
|
import { readJson, writeJson, isDir, isFile } from './util.js';
|
|
35
45
|
|
|
@@ -585,6 +595,7 @@ export function mountRoutes(ctx, config = {}) {
|
|
|
585
595
|
return;
|
|
586
596
|
}
|
|
587
597
|
const dryRun = Boolean(body.dryRun);
|
|
598
|
+
const decisions = body.decisions && typeof body.decisions === 'object' ? body.decisions : {};
|
|
588
599
|
const doImportBundle = (report) =>
|
|
589
600
|
importBundle({
|
|
590
601
|
file: body.file,
|
|
@@ -595,17 +606,40 @@ export function mountRoutes(ctx, config = {}) {
|
|
|
595
606
|
linkNodeModules: body.linkNodeModules !== false,
|
|
596
607
|
only: Array.isArray(body.only) && body.only.length ? body.only : null,
|
|
597
608
|
// 逐插件决策:界面弹窗后传进来,优先级高于全局 mode
|
|
598
|
-
decisions
|
|
609
|
+
decisions,
|
|
599
610
|
onProgress: report,
|
|
600
611
|
});
|
|
601
612
|
if (dryRun) {
|
|
602
|
-
|
|
613
|
+
const preview = doImportBundle(() => {});
|
|
614
|
+
const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
|
|
615
|
+
/* 有冲突且用户还没决策 → 登记成"待决策"任务。
|
|
616
|
+
* 这样用户切走画面再回来,界面能从 /plugin-pack/waiting 捡回这个弹窗;
|
|
617
|
+
* 一直不决策就走 cancel,不会留下"文件传了但没人处理"的悬空状态。 */
|
|
618
|
+
if (conflicts.length && !Object.keys(decisions).length) {
|
|
619
|
+
const jobId = openWaiting({
|
|
620
|
+
kind: 'import',
|
|
621
|
+
title: `导入整合包(${conflicts.length} 个重名待决定)`,
|
|
622
|
+
meta: { file: body.file },
|
|
623
|
+
payload: {
|
|
624
|
+
file: body.file,
|
|
625
|
+
conflicts,
|
|
626
|
+
plugins: preview.plugins ?? [],
|
|
627
|
+
restoreData: body.restoreData !== false,
|
|
628
|
+
linkNodeModules: body.linkNodeModules !== false,
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
sendJson(response, 200, { ...preview, jobId });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
sendJson(response, 200, preview);
|
|
603
635
|
return;
|
|
604
636
|
}
|
|
605
637
|
const { id, result } = await runJob({
|
|
606
638
|
kind: 'import',
|
|
607
639
|
title: `导入整合包`,
|
|
608
640
|
meta: { file: body.file },
|
|
641
|
+
// 决策后继续用同一个任务 id,界面上就是同一条记录从"待决策"变成"完成"
|
|
642
|
+
jobId: body.jobId ? String(body.jobId) : undefined,
|
|
609
643
|
task: (report) => doImportBundle(report),
|
|
610
644
|
});
|
|
611
645
|
sendJson(response, 200, { ...result, jobId: id });
|
|
@@ -614,6 +648,48 @@ export function mountRoutes(ctx, config = {}) {
|
|
|
614
648
|
'plugin-pack: import',
|
|
615
649
|
);
|
|
616
650
|
|
|
651
|
+
/* 待决策任务:导入遇到重名时登记,界面切走再回来靠它捡回弹窗。
|
|
652
|
+
* GET /plugin-pack/waiting 列出待决策任务(含冲突列表)
|
|
653
|
+
* POST /plugin-pack/waiting/cancel 取消(用户切走/关页面/点取消)
|
|
654
|
+
*/
|
|
655
|
+
register(
|
|
656
|
+
{
|
|
657
|
+
kind: 'exact',
|
|
658
|
+
path: `${PREFIX}/waiting`,
|
|
659
|
+
handler: async (request, response) => {
|
|
660
|
+
if (request.method !== 'GET') {
|
|
661
|
+
sendJson(response, 405, { ok: false, error: '只支持 GET' });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
665
|
+
const id = url.searchParams.get('id');
|
|
666
|
+
if (id) {
|
|
667
|
+
sendJson(response, 200, { ok: true, waiting: getWaiting(id) });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
sendJson(response, 200, { ok: true, waiting: listWaiting() });
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
'plugin-pack: waiting',
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
register(
|
|
677
|
+
{
|
|
678
|
+
kind: 'exact',
|
|
679
|
+
path: `${PREFIX}/waiting/cancel`,
|
|
680
|
+
handler: async (request, response) => {
|
|
681
|
+
if (request.method !== 'POST') {
|
|
682
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const body = await readJsonBody(request);
|
|
686
|
+
const cancelled = cancelWaiting(body.id, body.reason || '用户取消了导入');
|
|
687
|
+
sendJson(response, 200, { ok: true, cancelled });
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
'plugin-pack: waiting-cancel',
|
|
691
|
+
);
|
|
692
|
+
|
|
617
693
|
register(
|
|
618
694
|
{
|
|
619
695
|
kind: 'exact',
|