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 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 ? translate('drop.running') : over ? translate('drop.release') : translate('drop.title'),
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
- /** 待确认的重名冲突:{ kind, file, conflicts, plugins }。非空时弹窗。 */
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: pending.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
- setPendingImport({ kind: 'bundle', file: picked, conflicts });
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: () => setPendingImport(null),
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 job = {
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 { runJob, listJobs, getJob, runningJobs, clearFinished } from './jobs.js';
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: body.decisions && typeof body.decisions === 'object' ? body.decisions : {},
609
+ decisions,
599
610
  onProgress: report,
600
611
  });
601
612
  if (dryRun) {
602
- sendJson(response, 200, doImportBundle(() => {}));
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',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-pack",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "DeepSeek Harness 插件整合包:一键把当前 profile 的第三方插件连依赖和配置导出成 zip(内部每个插件是标准 npm tgz),换机/换 profile 拖进去就能还原。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",