dsh-plugin-pack 1.0.2 → 1.0.4

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
@@ -483,6 +483,10 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
483
483
  /** 后台任务列表(进行中 + 已完成)。 */
484
484
  const getJobs = () => api('/plugin-pack/jobs?limit=20');
485
485
  const clearJobs = () => api('/plugin-pack/jobs/clear', { method: 'POST', body: '{}' });
486
+ /** 待决策任务(导入遇到重名时登记的):切回来捡弹窗用。 */
487
+ const getWaiting = () => api('/plugin-pack/waiting');
488
+ const cancelWaiting = (id, reason) =>
489
+ api('/plugin-pack/waiting/cancel', { method: 'POST', body: JSON.stringify({ id, reason }) });
486
490
 
487
491
  /** 上传一个 zip,返回宿主落盘后的绝对路径。 */
488
492
  async function uploadZip(file) {
@@ -890,7 +894,11 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
890
894
  state.error ? h(Alert, { kind: 'err' }, state.error) : null,
891
895
  busy ? h('div', { className: 'pp-empty' }, h(Spinner, { label: translate('loading') })) : null,
892
896
  !busy &&
893
- state.items.map((item, index) =>
897
+ state.items
898
+ // 选目录模式下不列文件:用户是在挑"导出到哪儿",
899
+ // 列出 .tgz/.zip 只会让人以为能选压缩包(实际会被当成目录名)。
900
+ .filter((item) => !pickingDir || item.dir)
901
+ .map((item, index) =>
894
902
  h(
895
903
  'div',
896
904
  {
@@ -950,20 +958,28 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
950
958
  : h('span', { className: 'pp-side' }, `${(item.size / 1048576).toFixed(1)} MiB`),
951
959
  item.dir
952
960
  ? null
953
- : h(
954
- 'button',
955
- {
956
- className: 'pp-btn sm',
957
- onClick: (event) => {
958
- event.stopPropagation();
959
- onPick(item.path);
961
+ : pickingDir
962
+ ? null
963
+ : h(
964
+ 'button',
965
+ {
966
+ className: 'pp-btn sm',
967
+ onClick: (event) => {
968
+ event.stopPropagation();
969
+ onPick(item.path);
970
+ },
960
971
  },
961
- },
962
- '选它',
963
- ),
972
+ '选它',
973
+ ),
964
974
  ),
965
975
  ),
966
- !busy && !state.items.length && !state.error ? h('div', { className: 'pp-empty' }, '空目录(这里没有子目录,也没有 zip)') : null,
976
+ !busy && !state.items.length && !state.error
977
+ ? h(
978
+ 'div',
979
+ { className: 'pp-empty' },
980
+ pickingDir ? '空目录(这里没有子目录)' : '空目录(这里没有子目录,也没有 zip)',
981
+ )
982
+ : null,
967
983
  ),
968
984
  // 底部
969
985
  h(
@@ -1422,8 +1438,15 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1422
1438
  const [pickerDir, setPickerDir] = useState('');
1423
1439
  const [pickerMode, setPickerMode] = useState('file');
1424
1440
  const [importPluginResult, setImportPluginResult] = useState(null);
1425
- /** 待确认的重名冲突:{ kind, file, conflicts, plugins }。非空时弹窗。 */
1441
+ /**
1442
+ * 待确认的重名冲突:{ jobId, kind, file, conflicts, plugins }。非空时弹窗。
1443
+ *
1444
+ * 这个状态来自服务端的"待决策"任务,不是纯前端内存 ——
1445
+ * 切走画面再回来能捡回弹窗,否则会留下文件已上传但没人处理的悬空状态。
1446
+ */
1426
1447
  const [pendingImport, setPendingImport] = useState(null);
1448
+ /** 当前弹窗对应的服务端任务 id(取消时要通知服务端)。 */
1449
+ const pendingJobId = useRef(null);
1427
1450
  /** 后台任务列表:切走画面回来靠它恢复 */
1428
1451
  const [jobs, setJobs] = useState([]);
1429
1452
  /** 体积上限(MiB),0 = 不限制。默认跟着服务端配置走。 */
@@ -1497,6 +1520,64 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1497
1520
  }
1498
1521
  }, []);
1499
1522
 
1523
+ /* 挂载时捡回"待决策"的导入。
1524
+ *
1525
+ * 场景:用户在冲突弹窗里没做选择就切走了画面。组件卸载后前端 state 丢了,
1526
+ * 但服务端还记着这个待决策任务 —— 回来时把它捡回来重开弹窗,
1527
+ * 而不是让那次导入悬在半空(文件传上去了,没人处理)。
1528
+ */
1529
+ useEffect(() => {
1530
+ let alive = true;
1531
+ (async () => {
1532
+ try {
1533
+ const data = await getWaiting();
1534
+ if (!alive) return;
1535
+ const list = Array.isArray(data?.waiting) ? data.waiting : [];
1536
+ const first = list[0];
1537
+ if (!first) return;
1538
+ pendingJobId.current = first.id;
1539
+ setPendingImport({
1540
+ kind: 'bundle',
1541
+ jobId: first.id,
1542
+ file: first.payload?.file ?? null,
1543
+ conflicts: first.payload?.conflicts ?? [],
1544
+ plugins: first.payload?.plugins ?? [],
1545
+ resumed: true,
1546
+ });
1547
+ } catch {
1548
+ /* 服务端还没这个接口就静默跳过 */
1549
+ }
1550
+ })();
1551
+ return () => {
1552
+ alive = false;
1553
+ };
1554
+ }, []);
1555
+
1556
+ /* 组件卸载 = 用户切走画面/关页面。
1557
+ *
1558
+ * 此时如果弹窗还开着、用户没做选择,就通知服务端取消这次导入,
1559
+ * 避免留下"文件已上传但不处理"的悬空任务。
1560
+ */
1561
+ useEffect(
1562
+ () => () => {
1563
+ const id = pendingJobId.current;
1564
+ if (!id) return;
1565
+ pendingJobId.current = null;
1566
+ // 卸载时不能等结果,用 keepalive 尽量送达
1567
+ try {
1568
+ fetch(new URL('/plugin-pack/waiting/cancel', window.location.origin).toString(), {
1569
+ method: 'POST',
1570
+ headers: { 'content-type': 'application/json' },
1571
+ body: JSON.stringify({ id, reason: '用户切走了画面,未做选择' }),
1572
+ keepalive: true,
1573
+ }).catch(() => {});
1574
+ } catch {
1575
+ /* 忽略 */
1576
+ }
1577
+ },
1578
+ [],
1579
+ );
1580
+
1500
1581
  const clearJobHistory = async () => {
1501
1582
  try {
1502
1583
  await clearJobs();
@@ -1546,8 +1627,11 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1546
1627
  const preview = await doImport({ file: uploaded.file, dryRun: true });
1547
1628
  const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
1548
1629
  if (conflicts.length) {
1630
+ // 服务端在 dryRun 时已登记了"待决策"任务,带 jobId 回来
1631
+ pendingJobId.current = preview?.jobId ?? null;
1549
1632
  setPendingImport({
1550
1633
  kind: 'bundle',
1634
+ jobId: preview?.jobId ?? null,
1551
1635
  file: uploaded.file,
1552
1636
  conflicts,
1553
1637
  plugins: preview.plugins ?? [],
@@ -1593,6 +1677,9 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1593
1677
  const confirmImport = async (decisions) => {
1594
1678
  const pending = pendingImport;
1595
1679
  if (!pending) return;
1680
+ const jobId = pending.jobId ?? pendingJobId.current;
1681
+ // 明确了选择,就别让卸载钩子把它当"未决策"取消掉
1682
+ pendingJobId.current = null;
1596
1683
  setPendingImport(null);
1597
1684
  setBusy(true);
1598
1685
  setImportResult(null);
@@ -1603,6 +1690,8 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1603
1690
  restoreData: true,
1604
1691
  linkNodeModules: true,
1605
1692
  decisions,
1693
+ // 复用同一个任务记录,界面上是同一条从"待决策"变成"完成"
1694
+ ...(jobId ? { jobId } : {}),
1606
1695
  });
1607
1696
  setImportResult(result);
1608
1697
  await load();
@@ -1614,6 +1703,20 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1614
1703
  }
1615
1704
  };
1616
1705
 
1706
+ /** 用户点了取消 / 按了 Esc / 点了遮罩:视为放弃这次导入。 */
1707
+ const cancelImport = async () => {
1708
+ const id = pendingJobId.current ?? pendingImport?.jobId ?? null;
1709
+ pendingJobId.current = null;
1710
+ setPendingImport(null);
1711
+ if (!id) return;
1712
+ try {
1713
+ await cancelWaiting(id, '用户取消了导入');
1714
+ await refreshJobs();
1715
+ } catch {
1716
+ /* 取消失败不阻塞界面 */
1717
+ }
1718
+ };
1719
+
1617
1720
  const runExport = async () => {
1618
1721
  if (!chosen.length) {
1619
1722
  setExportResult({ error: translate('export.empty') });
@@ -2161,7 +2264,14 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
2161
2264
  .then((preview) => {
2162
2265
  const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
2163
2266
  if (conflicts.length) {
2164
- setPendingImport({ kind: 'bundle', file: picked, conflicts });
2267
+ pendingJobId.current = preview?.jobId ?? null;
2268
+ setPendingImport({
2269
+ kind: 'bundle',
2270
+ jobId: preview?.jobId ?? null,
2271
+ file: picked,
2272
+ conflicts,
2273
+ plugins: preview?.plugins ?? [],
2274
+ });
2165
2275
  return;
2166
2276
  }
2167
2277
  return doImport({ file: picked, dryRun: false, mode: 'merge', restoreData: true, linkNodeModules: true }).then(
@@ -2174,9 +2284,14 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
2174
2284
  .catch((err) => setImportResult({ error: String(err?.message ?? err) }));
2175
2285
  return;
2176
2286
  }
2177
- // 导出:选的是目录 → 补末尾分隔符,让后端按"目录 + 自动命名"处理,
2178
- // 而不是把目录名当成 zip 文件名再加 .zip
2179
- setOut(picked.endsWith('\\') || picked.endsWith('/') ? picked : `${picked}\\`);
2287
+ // 导出:只有选中的是「目录」才补末尾分隔符,让后端按"目录 + 自动命名"处理。
2288
+ //
2289
+ // 之前这里无条件补 `\`,导致选中 xxx.tgz 这种文件后变成 `xxx.tgz\`,
2290
+ // 后端按"末尾带分隔符 → 当目录"处理,又拼了一次默认文件名,
2291
+ // 结果在名为 xxx.tgz 的文件夹里塞了个新 zip —— 路径看着像文件其实是目录。
2292
+ // 所以带扩展名的就当文件,原样保留。
2293
+ const looksLikeFile = /\.[a-z0-9]{2,8}$/i.test(picked);
2294
+ setOut(looksLikeFile || picked.endsWith('\\') || picked.endsWith('/') ? picked : `${picked}\\`);
2180
2295
  },
2181
2296
  }),
2182
2297
  // 重名冲突弹窗:叠在最上层
@@ -2184,7 +2299,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
2184
2299
  pending: pendingImport,
2185
2300
  t,
2186
2301
  onConfirm: confirmImport,
2187
- onCancel: () => setPendingImport(null),
2302
+ onCancel: cancelImport,
2188
2303
  }),
2189
2304
  );
2190
2305
  }
package/lib/export.js CHANGED
@@ -373,8 +373,16 @@ export function resolveOutFile(raw, home, profileLabel, stampText, options = {})
373
373
  stat = null;
374
374
  }
375
375
  if (stat?.isDirectory()) return path.join(target, fallbackName);
376
- // 末尾带分隔符:用户明确指了"这个目录",即使它还不存在
377
- if (/[\\/]$/.test(text)) return path.join(target, fallbackName);
376
+ // 末尾带分隔符:用户明确指了"这个目录",即使它还不存在。
377
+ // 但 `xxx.tgz\` 这种是误操作(把文件当目录)——
378
+ // 去掉分隔符后如果像个带扩展名的文件名,就按文件处理,别再拼一层。
379
+ if (/[\\/]$/.test(text)) {
380
+ const trimmed = text.replace(/[\\/]+$/, '');
381
+ if (/\.[a-z0-9]{2,8}$/i.test(path.basename(trimmed))) {
382
+ return path.resolve(trimmed);
383
+ }
384
+ return path.join(target, fallbackName);
385
+ }
378
386
  if (stat?.isFile()) return target;
379
387
  // 已经带了本类型该有的扩展名就原样;否则补上
380
388
  if (target.toLowerCase().endsWith(ext)) return target;
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.2",
3
+ "version": "1.0.4",
4
4
  "description": "DeepSeek Harness 插件整合包:一键把当前 profile 的第三方插件连依赖和配置导出成 zip(内部每个插件是标准 npm tgz),换机/换 profile 拖进去就能还原。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",