dsh-plugin-pack 1.0.3 → 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) {
@@ -1434,8 +1438,15 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1434
1438
  const [pickerDir, setPickerDir] = useState('');
1435
1439
  const [pickerMode, setPickerMode] = useState('file');
1436
1440
  const [importPluginResult, setImportPluginResult] = useState(null);
1437
- /** 待确认的重名冲突:{ kind, file, conflicts, plugins }。非空时弹窗。 */
1441
+ /**
1442
+ * 待确认的重名冲突:{ jobId, kind, file, conflicts, plugins }。非空时弹窗。
1443
+ *
1444
+ * 这个状态来自服务端的"待决策"任务,不是纯前端内存 ——
1445
+ * 切走画面再回来能捡回弹窗,否则会留下文件已上传但没人处理的悬空状态。
1446
+ */
1438
1447
  const [pendingImport, setPendingImport] = useState(null);
1448
+ /** 当前弹窗对应的服务端任务 id(取消时要通知服务端)。 */
1449
+ const pendingJobId = useRef(null);
1439
1450
  /** 后台任务列表:切走画面回来靠它恢复 */
1440
1451
  const [jobs, setJobs] = useState([]);
1441
1452
  /** 体积上限(MiB),0 = 不限制。默认跟着服务端配置走。 */
@@ -1509,6 +1520,64 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1509
1520
  }
1510
1521
  }, []);
1511
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
+
1512
1581
  const clearJobHistory = async () => {
1513
1582
  try {
1514
1583
  await clearJobs();
@@ -1558,8 +1627,11 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1558
1627
  const preview = await doImport({ file: uploaded.file, dryRun: true });
1559
1628
  const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
1560
1629
  if (conflicts.length) {
1630
+ // 服务端在 dryRun 时已登记了"待决策"任务,带 jobId 回来
1631
+ pendingJobId.current = preview?.jobId ?? null;
1561
1632
  setPendingImport({
1562
1633
  kind: 'bundle',
1634
+ jobId: preview?.jobId ?? null,
1563
1635
  file: uploaded.file,
1564
1636
  conflicts,
1565
1637
  plugins: preview.plugins ?? [],
@@ -1605,6 +1677,9 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1605
1677
  const confirmImport = async (decisions) => {
1606
1678
  const pending = pendingImport;
1607
1679
  if (!pending) return;
1680
+ const jobId = pending.jobId ?? pendingJobId.current;
1681
+ // 明确了选择,就别让卸载钩子把它当"未决策"取消掉
1682
+ pendingJobId.current = null;
1608
1683
  setPendingImport(null);
1609
1684
  setBusy(true);
1610
1685
  setImportResult(null);
@@ -1615,6 +1690,8 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1615
1690
  restoreData: true,
1616
1691
  linkNodeModules: true,
1617
1692
  decisions,
1693
+ // 复用同一个任务记录,界面上是同一条从"待决策"变成"完成"
1694
+ ...(jobId ? { jobId } : {}),
1618
1695
  });
1619
1696
  setImportResult(result);
1620
1697
  await load();
@@ -1626,6 +1703,20 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
1626
1703
  }
1627
1704
  };
1628
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
+
1629
1720
  const runExport = async () => {
1630
1721
  if (!chosen.length) {
1631
1722
  setExportResult({ error: translate('export.empty') });
@@ -2173,7 +2264,14 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
2173
2264
  .then((preview) => {
2174
2265
  const conflicts = Array.isArray(preview?.conflicts) ? preview.conflicts : [];
2175
2266
  if (conflicts.length) {
2176
- 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
+ });
2177
2275
  return;
2178
2276
  }
2179
2277
  return doImport({ file: picked, dryRun: false, mode: 'merge', restoreData: true, linkNodeModules: true }).then(
@@ -2201,7 +2299,7 @@ window.__ModuleLoader__.load({ id: 'dsh-plugin-pack', factory: (require) => {
2201
2299
  pending: pendingImport,
2202
2300
  t,
2203
2301
  onConfirm: confirmImport,
2204
- onCancel: () => setPendingImport(null),
2302
+ onCancel: cancelImport,
2205
2303
  }),
2206
2304
  );
2207
2305
  }
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.4",
4
4
  "description": "DeepSeek Harness 插件整合包:一键把当前 profile 的第三方插件连依赖和配置导出成 zip(内部每个插件是标准 npm tgz),换机/换 profile 拖进去就能还原。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",