nx-kv 0.11.2 → 0.11.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +1 -1
  3. package/assets/groups.json +4 -0
  4. package/assets/kv-repo/SKILL.md +83 -0
  5. package/assets/nx-kv/SKILL.md +5 -0
  6. package/assets/nx-kv/references/repo-registry.md +7 -5
  7. package/assets/nx-kv/references/todo-commands.md +1 -1
  8. package/package.json +1 -1
  9. package/src/core/config.js +1 -1
  10. package/src/modules/localtask/view.jsx +6 -18
  11. package/src/modules/repos/boards.js +60 -9
  12. package/src/modules/repos/index.js +22 -5
  13. package/src/modules/repos/service.js +8 -14
  14. package/src/modules/repos/view.jsx +178 -85
  15. package/src/modules/{group/view.jsx → todo/group-popup.jsx} +171 -159
  16. package/src/modules/todo/group.js +194 -0
  17. package/src/modules/todo/index.js +7 -1
  18. package/src/modules/todo/view.jsx +14 -1
  19. package/src/runtime/registry.js +3 -2
  20. package/src/web/frontend/registry.js +2 -1
  21. package/src/web/frontend/style.css +111 -44
  22. package/src/web/public/assets/{CliHints-CrVZoRln.js → CliHints-DltGgA21.js} +1 -1
  23. package/src/web/public/assets/{index-gEMHU8gc.js → index-Cg8J6GZg.js} +2 -2
  24. package/src/web/public/assets/index-DozxuMsm.css +1 -0
  25. package/src/web/public/assets/view-CCiJUhF6.js +1 -0
  26. package/src/web/public/assets/view-CV6w4TG6.js +3 -0
  27. package/src/web/public/assets/view-bE7JPI0N.js +13 -0
  28. package/src/web/public/assets/{view-QRE2Bldq.js → view-k4YFJYVz.js} +1 -1
  29. package/src/web/public/index.html +2 -2
  30. package/src/modules/group/index.js +0 -199
  31. package/src/web/public/assets/index-D3Gh8xAs.css +0 -1
  32. package/src/web/public/assets/view-CQT74IeU.js +0 -3
  33. package/src/web/public/assets/view-DZZz-uRj.js +0 -2
  34. package/src/web/public/assets/view-KyrAiPse.js +0 -1
  35. package/src/web/public/assets/view-PJtYOdms.js +0 -10
@@ -16,61 +16,52 @@
16
16
  import { useCallback, useEffect, useMemo, useState } from 'react';
17
17
  import { api } from '../../web/frontend/api/client.js';
18
18
  import { useStore } from '../../web/frontend/store.jsx';
19
- import { useGuard, useDialog, useToast, Modal } from '../../web/frontend/components/ui.jsx';
19
+ import { useGuard, useDialog, useToast, Modal, Copyable } from '../../web/frontend/components/ui.jsx';
20
20
  import { CliHints } from '../../web/frontend/components/CliHints.jsx';
21
21
 
22
- const EMPTY_FORM = { path: '', name: '', desc: '', tags: '', notes: '' };
22
+ const EMPTY_FORM = { path: '', name: '', desc: '', notes: '' };
23
23
 
24
- // 仓库行:名称+描述 / 路径 / 标签 / 所属看板(可加可摘)/ 操作
25
- function RepoRow({ r, boards, acts }) {
26
- const mine = r.boards || [];
27
- const notIn = boards.filter((b) => !mine.some((x) => x.id === b.id));
24
+ // 路径照搬目录看板的「可扫标签」画法:祖先目录变暗、末段略重,点击复制全文。
25
+ // 纯灰一长串(旧画法)里,人眼要逐字符对比才能找到目录名。
26
+ function RepoPath({ path }) {
27
+ const s = String(path || '');
28
+ const cut = s.replace(/\\/g, '/').lastIndexOf('/');
29
+ if (cut < 0) return <Copyable text={s}>{s}</Copyable>;
30
+ return (
31
+ <Copyable text={s} title="点击复制完整路径">
32
+ <span className="anc">{s.slice(0, cut)}</span>
33
+ {s.slice(cut)}
34
+ </Copyable>
35
+ );
36
+ }
37
+
38
+ // 仓库行:名称 + 描述 / 路径(点击复制)/ 操作。
39
+ // 中心清单里**不再显示所属看板**——行内的 chip + × + 下拉三件套又挤又乱,
40
+ // 归属的增删统一走「成员管理」弹窗(筛选到看板 → 导入仓库 / 摘除)。
41
+ // 看板视图下描述行显示**组内描述**(该仓库在这个组里的角色),没写则回退仓库描述。
42
+ function RepoRow({ r, filterBoard, acts }) {
43
+ const note = filterBoard
44
+ ? ((r.boards || []).find((b) => b.id === filterBoard.id)?.note || '')
45
+ : null;
46
+ const desc = note !== null ? (note || r.desc || '') : (r.desc || '');
47
+ const descTitle = note !== null
48
+ ? (note ? `组内描述:${note}` : `(组内描述未写,暂显示仓库描述)${r.desc || ''}`)
49
+ : (r.desc || '');
28
50
  return (
29
51
  <tr>
30
52
  <td className="rp-name">
31
53
  <strong>{r.name}</strong>
32
- {r.desc ? <div className="muted rp-desc" title={r.desc}>{r.desc}</div> : null}
33
- </td>
34
- <td className="mono rp-path" title={r.path}>{r.path}</td>
35
- <td className="rp-tags">
36
- {(r.tags || []).map((t) => (
37
- <span className="tag" key={t}>{t}</span>
38
- ))}
39
- </td>
40
- <td className="rp-boards">
41
- {mine.map((b) => (
42
- <span className="tag badge rp-board" key={b.id} title={`「${b.name}」里的一份副本 —— 点 × 只摘这份,仓库仍在清单里`}>
43
- {b.name}
44
- <button
45
- className="rp-x"
46
- aria-label={`从 ${b.name} 移出`}
47
- title="只从这一个看板移出(仓库仍在中心清单里)"
48
- onClick={() => acts.out(r, b.id)}
49
- >
50
- ×
51
- </button>
52
- </span>
53
- ))}
54
- {notIn.length ? (
55
- <select
56
- className="rp-add"
57
- value=""
58
- title="加入看板 = 复制一份归属,仓库本体不动"
59
- onChange={(e) => {
60
- if (e.target.value) acts.add(r, e.target.value);
61
- }}
62
- >
63
- <option value="">+ 看板</option>
64
- {notIn.map((b) => (
65
- <option key={b.id} value={b.id}>{b.name}</option>
66
- ))}
67
- </select>
68
- ) : null}
54
+ {desc ? <div className="muted rp-desc" title={descTitle}>{desc}</div> : null}
69
55
  </td>
56
+ <td className="mono rp-path"><RepoPath path={r.path} /></td>
70
57
  <td className="rp-acts">
71
58
  <button className="btn small ghost" title="在文件管理器中打开" onClick={() => acts.open(r)}>打开</button>
72
59
  <button className="btn small ghost" onClick={() => acts.edit(r)}>编辑</button>
73
- <button className="btn small ghost" onClick={() => acts.del(r)}>删除</button>
60
+ {filterBoard ? (
61
+ <button className="btn small ghost" title={`写「${r.name}」在看板「${filterBoard.name}」里的组内描述`}
62
+ onClick={() => acts.note(r)}>描述</button>
63
+ ) : null}
64
+ <button className="btn small ghost rp-del" onClick={() => acts.del(r)}>删除</button>
74
65
  </td>
75
66
  </tr>
76
67
  );
@@ -87,6 +78,11 @@ export default function ReposView() {
87
78
  const [scanOpen, setScanOpen] = useState(false);
88
79
  const [scan, setScan] = useState({ root: '', depth: '3' });
89
80
  const [scanResult, setScanResult] = useState(null);
81
+ // 成员管理弹窗:打开时 = 正在往这个看板里点选导入 / 摘除仓库
82
+ const [impBoardId, setImpBoardId] = useState(null);
83
+ // 组内描述编辑:{ id, repo, boardId, boardName, note } | null
84
+ const [noteFor, setNoteFor] = useState(null);
85
+ const [noteText, setNoteText] = useState('');
90
86
 
91
87
  // 看板(只作筛选与标签)
92
88
  const [boards, setBoards] = useState(null); // { boards: [...], unassigned: [...] }
@@ -111,6 +107,17 @@ export default function ReposView() {
111
107
  }, []);
112
108
  useEffect(() => { loadRepos(); loadBoards(); }, [loadRepos, loadBoards]);
113
109
 
110
+ // 组内描述保存:note 为空 = 清除
111
+ const saveNote = () => guard(async () => {
112
+ await api('/api/boards/note', {
113
+ method: 'POST',
114
+ body: { id: noteFor.id, board: noteFor.boardId, note: noteText },
115
+ });
116
+ setNoteFor(null);
117
+ await Promise.all([loadRepos(), loadBoards()]);
118
+ toast('已保存组内描述');
119
+ });
120
+
114
121
  // 看板清单(用来做「+ 看板」下拉与筛选器)
115
122
  const boardList = useMemo(() => (boards && boards.boards) || [], [boards]);
116
123
 
@@ -121,8 +128,13 @@ export default function ReposView() {
121
128
  return repos.filter((r) => (r.boards || []).some((b) => b.id === filter));
122
129
  }, [repos, filter]);
123
130
 
131
+ // 当前筛选到的看板(用来显示领域描述上下文条)与成员管理弹窗的目标看板
132
+ const filterBoard = useMemo(() => boardList.find((b) => b.id === filter) || null, [boardList, filter]);
133
+ const impBoard = useMemo(() => boardList.find((b) => b.id === impBoardId) || null, [boardList, impBoardId]);
134
+
124
135
  const openAdd = () => {
125
- if (editing) { setForm(EMPTY_FORM); setEditing(null); }
136
+ setForm(EMPTY_FORM);
137
+ setEditing(null);
126
138
  setFormOpen(true);
127
139
  };
128
140
 
@@ -132,7 +144,6 @@ export default function ReposView() {
132
144
  path: form.path.trim(),
133
145
  name: form.name.trim() || undefined,
134
146
  desc: form.desc.trim() || undefined,
135
- tags: form.tags.trim() || undefined,
136
147
  notes: form.notes,
137
148
  };
138
149
  if (editing) await api(`/api/repos/${encodeURIComponent(editing)}`, { method: 'PATCH', body });
@@ -189,12 +200,14 @@ export default function ReposView() {
189
200
  // ---- 看板归属:**复制**,不是移动 ----
190
201
  // 加一个看板 = 多一份归属,仓库本体不动、别的看板也不动;
191
202
  // 移出某个看板 = 只摘那一份,仓库本体不动。
192
- const assign = (r, boardId, remove) => guard(async () => {
203
+ // 行内的加/摘入口已收进「成员管理」弹窗(toggleMember),表格式逐行操作太乱。
204
+ const toggleMember = (r, add) => guard(async () => {
193
205
  await api('/api/boards/assign', {
194
206
  method: 'POST',
195
- body: remove ? { id: r.id, board: boardId, remove: true } : { id: r.id, board: boardId },
207
+ body: add ? { id: r.id, board: impBoardId } : { id: r.id, board: impBoardId, remove: true },
196
208
  });
197
- await Promise.all([loadRepos(), loadBoards()]);
209
+ setRepos((await api('/api/repos')) || []);
210
+ await loadBoards();
198
211
  });
199
212
 
200
213
  // ---- 看板管理 ----
@@ -251,8 +264,17 @@ export default function ReposView() {
251
264
  open: (r) => rowAct('open', r),
252
265
  edit: (r) => rowAct('edit', r),
253
266
  del: (r) => rowAct('del', r),
254
- add: (r, boardId) => assign(r, boardId, false),
255
- out: (r, boardId) => assign(r, boardId, true),
267
+ note: (r) => {
268
+ const mine = (r.boards || []).find((b) => b.id === filter);
269
+ setNoteFor({
270
+ id: r.id,
271
+ repo: r.name,
272
+ boardId: filter,
273
+ boardName: filterBoard?.name || '',
274
+ note: mine?.note || '',
275
+ });
276
+ setNoteText(mine?.note || '');
277
+ },
256
278
  };
257
279
 
258
280
  return (
@@ -261,23 +283,18 @@ export default function ReposView() {
261
283
  <div className="title-block">
262
284
  <h2>仓库</h2>
263
285
  <div className="page-desc">
264
- 本机 git 仓库的<strong>一张中心清单</strong>。看板只是标签:加入看板是
265
- <strong>复制</strong>一份归属(同一个仓库可同时在多个看板里),
266
- 仓库本体永远在清单里,不会被"移动"走。
286
+ 本机 git 仓库的<strong>中心清单</strong>。看板 = <strong>领域分组</strong>
287
+ (同一个仓库可同时归入多个):看板的领域描述写这组仓库的侧重与配合关系,
288
+ 成员仓库还能写<strong>组内描述</strong>——它在组里的角色、关键路径与搜索关键词
289
+ (筛选到看板后点行内「描述」)。加入是复制一份归属,仓库本体永远在清单里。
267
290
  </div>
268
291
  </div>
269
292
  <div className="acts">
270
293
  <button className="btn ghost" onClick={() => setScanOpen(true)}>扫描</button>
271
- <button className="btn" onClick={openAdd}>{editing ? '继续编辑' : '添加仓库'}</button>
294
+ <button className="btn" onClick={openAdd}>添加仓库</button>
272
295
  </div>
273
296
  </div>
274
297
 
275
- {editing ? (
276
- <div className="muted" style={{ margin: '0 0 10px' }}>
277
- 正在编辑「{repos.find((r) => r.id === editing)?.name || editing}」——点右上角「继续编辑」改完保存。
278
- </div>
279
- ) : null}
280
-
281
298
  {scanResult ? (
282
299
  <div className="card">
283
300
  <div className="colhead"><h3>扫描结果</h3>
@@ -313,12 +330,30 @@ export default function ReposView() {
313
330
  </button>
314
331
  </div>
315
332
 
333
+ {/* 看板上下文条:筛选到某个看板时,把它的领域描述亮出来 —— desc 写的是
334
+ 这个领域的侧重、谁配合谁(谁是中心/终端)、关键路径与搜索关键词。 */}
335
+ {filterBoard ? (
336
+ <div className="rp-context" data-testid="rp-context">
337
+ <span className="rp-context-name">{filterBoard.name}</span>
338
+ {filterBoard.desc ? (
339
+ <span className="rp-context-desc" title={filterBoard.desc}>{filterBoard.desc}</span>
340
+ ) : (
341
+ <span className="muted rp-context-desc">
342
+ 还没有领域描述 —— 点「看板管理」补一句:侧重什么、谁配合谁、关键路径与搜索关键词在哪。
343
+ </span>
344
+ )}
345
+ <span className="acts">
346
+ <button className="btn small" onClick={() => setImpBoardId(filterBoard.id)}>导入仓库</button>
347
+ </span>
348
+ </div>
349
+ ) : null}
350
+
316
351
  {/* 看板管理(次要):建 / 改 / 删 / 导入 / 导出 */}
317
352
  {boardsOpen ? (
318
353
  <div className="card rp-boards-manage">
319
354
  <div className="colhead">
320
355
  <h3>看板</h3>
321
- <span className="muted">看板是仓库的<strong>标签</strong>:删一个看板,仓库全都在清单里,只是少了一份归属。</span>
356
+ <span className="muted">看板 = 领域分组,描述写给人和 agent 读:侧重、配合关系、关键路径与关键词。删一个看板,仓库全都在清单里,只是少了一份归属。</span>
322
357
  <span className="acts" style={{ marginLeft: 'auto' }}>
323
358
  <button className="btn small ghost" onClick={() => setBoardForm({ id: null, name: '', desc: '' })}>新建看板</button>
324
359
  <button className="btn small ghost" title="粘贴/选择看板 JSON,一个看板 = 一个主题" onClick={() => setImpOpen(true)}>导入看板</button>
@@ -329,15 +364,21 @@ export default function ReposView() {
329
364
  {boardList.map((b) => (
330
365
  <div className="rp-board-item" key={b.id}>
331
366
  <span className="rp-board-name">{b.name}</span>
332
- {b.desc ? <span className="muted rp-board-desc">{b.desc}</span> : null}
333
- <span className="muted">{b.repos.length} 个仓库</span>
367
+ {b.desc ? <span className="muted rp-board-desc" title={b.desc}>{b.desc}</span> : null}
368
+ <span className="rp-board-count">{b.repos.length} 个仓库</span>
334
369
  <span className="acts">
335
- <button className="btn small ghost" onClick={() => setBoardForm({ id: b.id, name: b.name, desc: b.desc || '' })}>重命名</button>
370
+ <button className="btn small ghost" title="点选加入/摘除这个看板的成员仓库" onClick={() => setImpBoardId(b.id)}>成员管理</button>
371
+ <button className="btn small ghost" onClick={() => setBoardForm({ id: b.id, name: b.name, desc: b.desc || '' })}>编辑</button>
336
372
  <button className="btn small ghost" onClick={() => removeBoard(b)}>删除看板</button>
337
373
  </span>
338
374
  </div>
339
375
  ))}
340
376
  {!boardList.length ? <div className="muted">还没有看板 —— 建一个当标签用。</div> : null}
377
+ {boardList.length && boards.unassigned?.length ? (
378
+ <div className="rp-boards-unassigned">
379
+ 未分组 <b>{boards.unassigned.length}</b> 个 —— 不属于任何看板,只在「全部」里可见。
380
+ </div>
381
+ ) : null}
341
382
  </div>
342
383
  </div>
343
384
  ) : null}
@@ -347,22 +388,20 @@ export default function ReposView() {
347
388
  <table className="rp-table">
348
389
  <thead>
349
390
  <tr>
350
- <th style={{ width: '26%' }}>仓库</th>
351
- <th style={{ width: '30%' }}>路径</th>
352
- <th style={{ width: '12%' }}>标签</th>
353
- <th style={{ width: '22%' }}>所属看板</th>
354
- <th style={{ width: '10%' }}>操作</th>
391
+ <th scope="col" style={{ width: '32%' }}>仓库</th>
392
+ <th scope="col" style={{ width: '42%' }}>路径</th>
393
+ <th scope="col" style={{ width: '26%' }}>操作</th>
355
394
  </tr>
356
395
  </thead>
357
396
  <tbody>
358
397
  {shown.map((r) => (
359
- <RepoRow key={r.id} r={r} boards={boardList} acts={rowActs} />
398
+ <RepoRow key={r.id} r={r} filterBoard={filterBoard} acts={rowActs} />
360
399
  ))}
361
400
  {!shown.length ? (
362
401
  <tr>
363
- <td colSpan="5" className="muted rp-empty">
402
+ <td colSpan="3" className="muted rp-empty">
364
403
  {repos.length
365
- ? '这个看板下还没有仓库 —— 在「全部」里挑一个加进来(加入 = 复制一份归属)。'
404
+ ? '这个看板还没有成员 —— 点上方「导入仓库」,从中心清单里点选加入。'
366
405
  : '还没有登记仓库 —— 点「扫描」自动发现,或「添加仓库」手工登记。'}
367
406
  </td>
368
407
  </tr>
@@ -389,10 +428,6 @@ export default function ReposView() {
389
428
  <label htmlFor="repo-desc">一句话描述</label>
390
429
  <input id="repo-desc" spellCheck="false" placeholder="这个仓库是干什么的" value={form.desc} onChange={set('desc')} />
391
430
  </div>
392
- <div className="form-field">
393
- <label htmlFor="repo-tags">标签</label>
394
- <input id="repo-tags" placeholder="逗号分隔,如 web,tool" value={form.tags} onChange={set('tags')} />
395
- </div>
396
431
  <div className="form-field">
397
432
  <label htmlFor="repo-notes">备注</label>
398
433
  <textarea id="repo-notes" placeholder="补充信息(可选)" value={form.notes} onChange={set('notes')} />
@@ -418,7 +453,7 @@ export default function ReposView() {
418
453
  </div>
419
454
  <div className="form-field">
420
455
  <label htmlFor="scan-depth">深度</label>
421
- <input id="scan-depth" value={scan.depth}
456
+ <input id="scan-depth" type="number" min="1" max="10" value={scan.depth}
422
457
  onChange={(e) => setScan({ ...scan, depth: e.target.value })} />
423
458
  </div>
424
459
  <div className="form-acts">
@@ -430,12 +465,12 @@ export default function ReposView() {
430
465
  </Modal>
431
466
  ) : null}
432
467
 
433
- {/* 新建 / 重命名看板弹窗 */}
468
+ {/* 新建 / 编辑看板弹窗 */}
434
469
  {boardForm ? (
435
- <Modal title={boardForm.id ? '重命名看板' : '新建看板'} onClose={() => setBoardForm(null)}>
470
+ <Modal title={boardForm.id ? '编辑看板' : '新建看板'} onClose={() => setBoardForm(null)}>
436
471
  <div className="form-grid">
437
472
  <div className="form-field">
438
- <label htmlFor="bd-name">主题名称 *</label>
473
+ <label htmlFor="bd-name">看板名称 *</label>
439
474
  <input
440
475
  id="bd-name"
441
476
  placeholder="如 前端 / AI 工具"
@@ -444,10 +479,12 @@ export default function ReposView() {
444
479
  />
445
480
  </div>
446
481
  <div className="form-field">
447
- <label htmlFor="bd-desc">描述</label>
448
- <input
482
+ <label htmlFor="bd-desc">领域描述</label>
483
+ <textarea
449
484
  id="bd-desc"
450
- placeholder="这个主题是干什么的(可选)"
485
+ rows={4}
486
+ spellCheck="false"
487
+ placeholder={'这组仓库在这个环境里的侧重、配合关系、关键路径与搜索关键词。\n如:go-ah 是 OIDC 认证中心(issuer 192.0.2.47:8082),组内其余仓库都是它的接入终端。\n如:fr 组内仓库共享 API 契约(lib/api/),搜 fr_ 前缀。'}
451
488
  value={boardForm.desc}
452
489
  onChange={(e) => setBoardForm((f) => ({ ...f, desc: e.target.value }))}
453
490
  />
@@ -467,7 +504,10 @@ export default function ReposView() {
467
504
  <Modal title="导入看板 JSON" onClose={() => { setImpOpen(false); setImpText(''); }}>
468
505
  <div className="form-grid">
469
506
  <div className="form-field">
470
- <label htmlFor="bd-imp">JSON 内容({'{boards:[{name, repos:[{path|name}]}]}'}</label>
507
+ <label htmlFor="bd-imp">看板 JSON</label>
508
+ <div className="form-hint">
509
+ 结构 {'{ boards:[{ name, repos:[{ path|name }] }] }'} —— 按 path 或 name 匹配已登记仓库,匹配不上的会如实报告
510
+ </div>
471
511
  <input
472
512
  id="bd-imp-file"
473
513
  type="file"
@@ -498,6 +538,59 @@ export default function ReposView() {
498
538
  </Modal>
499
539
  ) : null}
500
540
 
541
+ {/* 组内描述编辑:同一个仓库在不同看板里的角色各不相同,描述挂在成员关系上。
542
+ agent 读看板时会把它带上(见 kv-repo skill),所以值得写关键路径与搜索词。 */}
543
+ {noteFor ? (
544
+ <Modal title={`组内描述 · ${noteFor.repo} @「${noteFor.boardName}」`} onClose={() => setNoteFor(null)}>
545
+ <div className="form-grid">
546
+ <div className="form-field">
547
+ <label htmlFor="rp-note">「{noteFor.repo}」在看板「{noteFor.boardName}」里的角色</label>
548
+ <div className="form-hint">
549
+ 写它在这个组里的特殊点与配合关系(谁是中心 / 终端、API 关联在哪),
550
+ 可多行、可含关键路径与搜索关键词。留空保存 = 清除。
551
+ </div>
552
+ <textarea
553
+ id="rp-note"
554
+ rows={5}
555
+ spellCheck="false"
556
+ placeholder={'如:OIDC 认证中心(issuer 192.0.2.47:8082),组内其余仓库都是它的接入终端;\nAPI 契约见 internal/middleware/oidc.go,搜 oidc_ 前缀。'}
557
+ value={noteText}
558
+ onChange={(e) => setNoteText(e.target.value)}
559
+ />
560
+ </div>
561
+ <div className="form-acts">
562
+ <button className="btn ghost" onClick={() => setNoteFor(null)}>取消</button>
563
+ <button className="btn" onClick={saveNote}>保存</button>
564
+ </div>
565
+ </div>
566
+ </Modal>
567
+ ) : null}
568
+
569
+ {/* 成员管理弹窗:一个看板的全部候选仓库,点选加入 / 摘除 —— 批量导入
570
+ 不必回「全部」逐行找下拉。加/摘即存,弹窗保持打开,一次导一批。 */}
571
+ {impBoard ? (
572
+ <Modal title={`「${impBoard.name}」· 成员仓库`} onClose={() => setImpBoardId(null)}>
573
+ {impBoard.desc ? <div className="rp-imp-desc">{impBoard.desc}</div> : null}
574
+ <div className="rp-imp-list">
575
+ {repos.map((r) => {
576
+ const inBoard = (r.boards || []).some((b) => b.id === impBoard.id);
577
+ return (
578
+ <div className="rp-imp-item" key={r.id}>
579
+ <span className="rp-imp-name">{r.name}</span>
580
+ <span className="rp-imp-path" title={r.path}>{r.path}</span>
581
+ {inBoard ? (
582
+ <button className="btn small ghost" onClick={() => toggleMember(r, false)}>摘除</button>
583
+ ) : (
584
+ <button className="btn small" onClick={() => toggleMember(r, true)}>加入</button>
585
+ )}
586
+ </div>
587
+ );
588
+ })}
589
+ {!repos.length ? <div className="muted rp-empty">还没有登记仓库 —— 先「扫描」或「添加仓库」。</div> : null}
590
+ </div>
591
+ </Modal>
592
+ ) : null}
593
+
501
594
  {dialogNode}
502
595
  </>
503
596
  );