nx-kv 0.11.1 → 0.11.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.11.2] - 2026-10-23 仓库页改成中心清单(扁平,不分组)+ 看板只当标签
8
+
9
+ ### Changed
10
+
11
+ - **仓库页的主视图改成一张扁平的中心清单**。以前是「按看板分组的卡片墙 + 拖拽」:
12
+ 同一个仓库会在多个分组里重复出现、看不到全貌,而**拖拽读起来就是"移动"**。
13
+ 现在是「一张中心清单(行 = 一个仓库)+ 看板只是筛选器与行内标签」:
14
+ - 清单里**所有仓库各占一行**,按名称排序,不分组;
15
+ - 上方一排筛选器:`全部 15` + 各看板(带计数),点一个就只看那批,**且去重**
16
+ (一个仓库在多个看板里也只出现一次——它在中心清单里本来就只有一条);
17
+ - 每行的「所属看板」是一枚枚**标签**,标签上的 × 只摘掉那一份;行尾的「+ 看板」
18
+ 下拉是**加入**(复制一份归属)。加与减都不动仓库本体。
19
+ - 看板管理(新建/重命名/删除/导入/导出)收进次要的「看板管理」折叠区。
20
+ 删一个看板,仓库**全都还在清单里**,只是少了一份归属。
21
+ - `repo list --json` 现在返回**扁平的中心清单**(按名排序),每条多一个 `boards`
22
+ 字段(它所在的看板)——新增字段,向后兼容。
23
+ - 修:仓库页在 nx-kv 里**页头与表单是裸的**(`.page-head` / `.form-grid` 这几个类名
24
+ 随 nx-rh 迁过来时 CSS 没跟上,标题、说明与按钮各占一行)。按 nx-kv 的变量补齐。
25
+
7
26
  ## [0.11.1] - 2026-10-23 单个项目目录也能从远程导入 + 面板导入 ref/refs 键修复
8
27
 
9
28
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -100,8 +100,31 @@ function normalizeRepoInput({ path, name, tags, notes, desc }) {
100
100
  };
101
101
  }
102
102
 
103
+ /**
104
+ * **中心清单**:一份扁平的仓库列表(按名称排序),每条带它所属的看板。
105
+ *
106
+ * 为什么是「中心清单 + 看板只是标签」:
107
+ * - 仓库登记是**唯一真相**(name/path/tags/desc/notes),它一直在这张清单里;
108
+ * - 看板只存 `repoIds`(引用),所以**加入看板是复制**,不是移动——
109
+ * 同一个仓库可以同时在多个看板里(fr 同时在「fr常用」与「远程打字」);
110
+ * - 取消某份「副本」只摘那个看板的引用,仓库本体纹丝不动。
111
+ *
112
+ * 之前页面是「按看板分组的卡片墙 + 拖拽」,那套交互读起来就是「移动」,
113
+ * 而且同一个仓库会在多个分组里重复出现、看不到全貌。现在主视图是这张扁平的
114
+ * 中心清单,看板退成筛选器与标签。
115
+ */
103
116
  export async function listRepos() {
104
- return (await loadStore()).repos;
117
+ const s = await loadStore();
118
+ const boardsOf = new Map();
119
+ for (const b of s.boards) {
120
+ for (const rid of b.repoIds || []) {
121
+ if (!boardsOf.has(rid)) boardsOf.set(rid, []);
122
+ boardsOf.get(rid).push({ id: b.id, name: b.name, desc: b.desc || '' });
123
+ }
124
+ }
125
+ return s.repos
126
+ .map((r) => ({ ...r, boards: boardsOf.get(r.id) || [] }))
127
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)));
105
128
  }
106
129
 
107
130
  export async function addRepo(input) {
@@ -156,7 +179,11 @@ export async function getRepo(idOrPath) {
156
179
  r = s.repos.find((x) => x.path.toLowerCase() === p.toLowerCase());
157
180
  }
158
181
  if (!r) throw notFound('repo 不存在: ' + idOrPath);
159
- return r;
182
+ // 带上它所属的看板(中心清单的同一份数据,单条查询也别缺这一维)
183
+ const boards = s.boards
184
+ .filter((b) => (b.repoIds || []).includes(r.id))
185
+ .map((b) => ({ id: b.id, name: b.name, desc: b.desc || '' }));
186
+ return { ...r, boards };
160
187
  }
161
188
 
162
189
  const SKIP_DIRS = new Set([
@@ -1,13 +1,19 @@
1
- // 仓库上下文页:登记 / 扫描 / 编辑 / 打开 / 删除 + 看板(主题容器)编排。
1
+ // 仓库上下文页:**一张扁平的中心清单** + 看板(只当筛选器与标签用)。
2
2
  //
3
3
  // **来历**:原本长在 nx-rh,2026-10-23 整体迁到 nx-kv(远程清单 topic=goal 的 #72)——
4
4
  // 仓库登记是本机事实,它的价值是给「本地任务管理」当上下文,所以它跟着任务走。
5
5
  // 每个按钮 = 一条 HTTP 路由 = 一条 CLI 命令(三者同源于模块的 action 声明)。
6
6
  //
7
- // 版面(plan/02):看板纵向堆叠,仓库以「手机 app 式」方格卡片呈现——
8
- // 顶部主题容器(看板头 + 卡片网格),未分组固定最后;卡片可拖拽入板 /
9
- // 插到某卡前 / 拖回未分组。录入与扫描收在弹窗,主页面只留编排。
10
- import { useCallback, useEffect, useState } from 'react';
7
+ // 版面:**中心清单 + 看板只是标签**。
8
+ // - 仓库登记是唯一真相,一直在这张清单里;
9
+ // - 看板只存引用 → **加入看板是复制**,不是移动:同一个仓库可以同时在多个
10
+ // 看板里(fr 同时在「fr常用」与「远程打字」);
11
+ // - 取消某份副本只摘那个看板的引用,仓库本体纹丝不动。
12
+ //
13
+ // 之前这一页是「按看板分组的卡片墙 + 拖拽」:同一套数据,但读起来就是「移动」,
14
+ // 而且同一个仓库会在多个分组里重复出现、看不到全貌。现在主视图是扁平的中心清单,
15
+ // 看板退成上方的筛选器与行内的标签。
16
+ import { useCallback, useEffect, useMemo, useState } from 'react';
11
17
  import { api } from '../../web/frontend/api/client.js';
12
18
  import { useStore } from '../../web/frontend/store.jsx';
13
19
  import { useGuard, useDialog, useToast, Modal } from '../../web/frontend/components/ui.jsx';
@@ -15,108 +21,58 @@ import { CliHints } from '../../web/frontend/components/CliHints.jsx';
15
21
 
16
22
  const EMPTY_FORM = { path: '', name: '', desc: '', tags: '', notes: '' };
17
23
 
18
- // 手机 app 式方格卡片:首字圆角块 + 名称 + 两行描述;hover 浮出操作。
19
- // 拖拽语义:拖到看板空白 = 追加末尾;拖到某张卡上 = 插到它前面。
20
- function RepoCard({ r, boardId, dragId, dropHint, setDropHint, onDropCard, acts }) {
21
- const dragging = dragId === r.id;
22
- const drop = dropHint === 'card:' + r.id;
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));
23
28
  return (
24
- <div
25
- className={'bd-card' + (drop ? ' drop' : '') + (dragging ? ' dragging' : '')}
26
- draggable
27
- title={r.path}
28
- onDragStart={(e) => {
29
- e.dataTransfer.effectAllowed = 'move';
30
- e.dataTransfer.setData('text/plain', r.id);
31
- setDropHint('');
32
- acts.dragStart(r.id);
33
- }}
34
- onDragEnd={() => acts.dragEnd()}
35
- onDragOver={(e) => {
36
- if (!dragId || dragId === r.id) return;
37
- e.preventDefault();
38
- e.stopPropagation();
39
- setDropHint('card:' + r.id);
40
- }}
41
- onDrop={(e) => {
42
- e.stopPropagation();
43
- onDropCard(r.id);
44
- }}
45
- >
46
- <span className="glyph">{(r.name[0] || '·').toUpperCase()}</span>
47
- <span className="nm">{r.name}</span>
48
- <span className="ds">{r.desc || '\u00a0'}</span>
49
- <span className="acts" onClick={(e) => e.stopPropagation()}>
50
- {boardId ? (
51
- <button
52
- className="btn small ghost"
53
- title="从本看板移出(登记与其他看板不受影响)"
54
- onClick={() => acts.out(r, boardId)}
29
+ <tr>
30
+ <td className="rp-name">
31
+ <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
+ }}
55
62
  >
56
- 移出
57
- </button>
63
+ <option value="">+ 看板</option>
64
+ {notIn.map((b) => (
65
+ <option key={b.id} value={b.id}>{b.name}</option>
66
+ ))}
67
+ </select>
58
68
  ) : null}
69
+ </td>
70
+ <td className="rp-acts">
59
71
  <button className="btn small ghost" title="在文件管理器中打开" onClick={() => acts.open(r)}>打开</button>
60
72
  <button className="btn small ghost" onClick={() => acts.edit(r)}>编辑</button>
61
73
  <button className="btn small ghost" onClick={() => acts.del(r)}>删除</button>
62
- </span>
63
- </div>
64
- );
65
- }
66
-
67
- // 主题容器:看板头(名称 / 描述 / 计数 / 操作)+ 卡片网格;空板提示拖入
68
- function BoardSection({ id, name, desc, cards, renamable, dragId, dropHint, setDropHint, onDropSection, acts, onRename, onRemove }) {
69
- const key = id || 'unassigned';
70
- const drop = dropHint === key;
71
- return (
72
- <div
73
- className={'bd-board card' + (drop ? ' drop' : '')}
74
- onDragOver={(e) => {
75
- if (!dragId) return;
76
- e.preventDefault();
77
- setDropHint(key);
78
- }}
79
- onDragLeave={(e) => {
80
- if (e.currentTarget.contains(e.relatedTarget)) return;
81
- setDropHint((k) => (k === key ? '' : k));
82
- }}
83
- onDrop={(e) => {
84
- e.preventDefault();
85
- onDropSection(id);
86
- }}
87
- data-testid={'bd-board-' + key}
88
- >
89
- <div className="colhead bd-head">
90
- <h3>{name}</h3>
91
- {desc ? <span className="muted">{desc}</span> : null}
92
- <span className="muted bd-count">{cards.length} 个仓库</span>
93
- {renamable ? (
94
- <span className="acts">
95
- <button className="btn small ghost" onClick={() => onRename()}>重命名</button>
96
- <button className="btn small ghost" onClick={onRemove}>删除看板</button>
97
- </span>
98
- ) : null}
99
- </div>
100
- <div className="bd-grid">
101
- {cards.map((r) => (
102
- <RepoCard
103
- key={r.id}
104
- r={r}
105
- boardId={id}
106
- dragId={dragId}
107
- dropHint={dropHint}
108
- setDropHint={setDropHint}
109
- onDropCard={(beforeId) => acts.drop(id, beforeId)}
110
- acts={acts}
111
- />
112
- ))}
113
- {!cards.length ? (
114
- <div className="bd-empty muted">
115
- {id ? '空看板——把仓库卡片拖进来(聚合可跨看板,仓库可同时在多个主题)' : '(所有仓库都已聚合进主题)'}
116
- </div>
117
- ) : null}
118
- </div>
119
- </div>
74
+ </td>
75
+ </tr>
120
76
  );
121
77
  }
122
78
 
@@ -132,16 +88,16 @@ export default function ReposView() {
132
88
  const [scan, setScan] = useState({ root: '', depth: '3' });
133
89
  const [scanResult, setScanResult] = useState(null);
134
90
 
135
- // 看板编排
136
- const [boards, setBoards] = useState(null); // { boards, unassigned }
137
- const [dragId, setDragId] = useState('');
138
- const [dropHint, setDropHint] = useState('');
91
+ // 看板(只作筛选与标签)
92
+ const [boards, setBoards] = useState(null); // { boards: [...], unassigned: [...] }
93
+ const [filter, setFilter] = useState(''); // '' = 全部(中心清单)
139
94
  const [boardForm, setBoardForm] = useState(null); // null | { id?, name, desc }
140
95
  const [impOpen, setImpOpen] = useState(false);
141
96
  const [impText, setImpText] = useState('');
142
97
  const [expText, setExpText] = useState(null);
98
+ const [boardsOpen, setBoardsOpen] = useState(false);
143
99
 
144
- // 仓库清单自己取(nx-kv 的 bootstrap 里没有 repos 字段——它不是全局配置,是本机登记)
100
+ // 仓库中心清单(扁平;每条带 boards)
145
101
  const [repos, setRepos] = useState([]);
146
102
  const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
147
103
 
@@ -155,6 +111,16 @@ export default function ReposView() {
155
111
  }, []);
156
112
  useEffect(() => { loadRepos(); loadBoards(); }, [loadRepos, loadBoards]);
157
113
 
114
+ // 看板清单(用来做「+ 看板」下拉与筛选器)
115
+ const boardList = useMemo(() => (boards && boards.boards) || [], [boards]);
116
+
117
+ // 筛选后的中心清单:选某个看板时**只显示该看板的仓库**,
118
+ // 且去重(一个仓库在多个看板里也只出现一次 —— 它在中心清单里只有一条)。
119
+ const shown = useMemo(() => {
120
+ if (!filter) return repos;
121
+ return repos.filter((r) => (r.boards || []).some((b) => b.id === filter));
122
+ }, [repos, filter]);
123
+
158
124
  const openAdd = () => {
159
125
  if (editing) { setForm(EMPTY_FORM); setEditing(null); }
160
126
  setFormOpen(true);
@@ -211,7 +177,7 @@ export default function ReposView() {
211
177
  setFormOpen(true);
212
178
  } else if (act === 'del') {
213
179
  const ok = await dialog({
214
- message: `删除仓库登记「${r.name}」?(仅移除登记,磁盘文件不受影响;看板里同步摘除)`,
180
+ message: `删除仓库登记「${r.name}」?(仅移除登记,磁盘文件不受影响;各看板里的归属同步摘除)`,
215
181
  danger: true,
216
182
  });
217
183
  if (!ok) return;
@@ -220,7 +186,18 @@ export default function ReposView() {
220
186
  }
221
187
  });
222
188
 
223
- // ---- 看板编排 ----
189
+ // ---- 看板归属:**复制**,不是移动 ----
190
+ // 加一个看板 = 多一份归属,仓库本体不动、别的看板也不动;
191
+ // 移出某个看板 = 只摘那一份,仓库本体不动。
192
+ const assign = (r, boardId, remove) => guard(async () => {
193
+ await api('/api/boards/assign', {
194
+ method: 'POST',
195
+ body: remove ? { id: r.id, board: boardId, remove: true } : { id: r.id, board: boardId },
196
+ });
197
+ await Promise.all([loadRepos(), loadBoards()]);
198
+ });
199
+
200
+ // ---- 看板管理 ----
224
201
  const saveBoard = () => guard(async () => {
225
202
  const body = { name: boardForm.name.trim(), desc: boardForm.desc.trim() };
226
203
  if (!body.name) return;
@@ -232,26 +209,12 @@ export default function ReposView() {
232
209
 
233
210
  const removeBoard = (b) => guard(async () => {
234
211
  const ok = await dialog({
235
- message: `删看板「${b.name}」?里面的 ${b.repos.length} 个仓库回到未分组(登记不动)。`,
212
+ message: `删看板「${b.name}」?它里面的 ${b.repos.length} 个仓库**仍留在中心清单里**(只是少了一份归属)。`,
236
213
  danger: true,
237
214
  });
238
215
  if (!ok) return;
239
216
  await api(`/api/boards/${encodeURIComponent(b.id)}`, { method: 'DELETE', body: {} });
240
- await loadBoards();
241
- });
242
-
243
- // 卡片拖放(聚合语义):拖进看板 = 聚合进来(不动它在其他看板的成员关系);
244
- // 拖到未分组区 = 从所有看板移出;beforeId = 插到该卡片之前。登记本身永不改动。
245
- const dropCard = (boardId, beforeId) => guard(async () => {
246
- const id = dragId;
247
- setDragId('');
248
- setDropHint('');
249
- if (!id || id === beforeId) return;
250
- const body = boardId
251
- ? { id, board: boardId, before: beforeId || undefined }
252
- : { id, remove: true };
253
- await api('/api/boards/assign', { method: 'POST', body });
254
- await loadBoards();
217
+ await Promise.all([loadRepos(), loadBoards()]);
255
218
  });
256
219
 
257
220
  const doImport = () => guard(async () => {
@@ -261,7 +224,7 @@ export default function ReposView() {
261
224
  setImpText('');
262
225
  toast(`已导入:新建 ${r.created} 个看板、合并 ${r.merged} 个、匹配仓库 ${r.matched} 个`
263
226
  + (r.unmatched.length ? `;未匹配 ${r.unmatched.length} 条(见 CLI board import 输出)` : ''));
264
- await loadBoards();
227
+ await Promise.all([loadRepos(), loadBoards()]);
265
228
  });
266
229
 
267
230
  const doExport = () => guard(async () => {
@@ -284,18 +247,12 @@ export default function ReposView() {
284
247
  );
285
248
  }
286
249
 
287
- // 卡片与拖放的公共行为包(RepoCard / BoardSection 共用)
288
- const cardActs = {
250
+ const rowActs = {
289
251
  open: (r) => rowAct('open', r),
290
252
  edit: (r) => rowAct('edit', r),
291
253
  del: (r) => rowAct('del', r),
292
- out: (r, boardId) => guard(async () => {
293
- await api('/api/boards/assign', { method: 'POST', body: { id: r.id, board: boardId, remove: true } });
294
- await loadBoards();
295
- }),
296
- dragStart: (id) => setDragId(id),
297
- dragEnd: () => { setDragId(''); setDropHint(''); },
298
- drop: (boardId, beforeId) => dropCard(boardId, beforeId),
254
+ add: (r, boardId) => assign(r, boardId, false),
255
+ out: (r, boardId) => assign(r, boardId, true),
299
256
  };
300
257
 
301
258
  return (
@@ -304,15 +261,13 @@ export default function ReposView() {
304
261
  <div className="title-block">
305
262
  <h2>仓库</h2>
306
263
  <div className="page-desc">
307
- 登记本机的 git 仓库;看板 = 主题聚合视图:一个仓库可同时聚合进多个主题,
308
- 拖卡片即可归组(登记与磁盘不受影响)。
264
+ 本机 git 仓库的<strong>一张中心清单</strong>。看板只是标签:加入看板是
265
+ <strong>复制</strong>一份归属(同一个仓库可同时在多个看板里),
266
+ 仓库本体永远在清单里,不会被"移动"走。
309
267
  </div>
310
268
  </div>
311
269
  <div className="acts">
312
270
  <button className="btn ghost" onClick={() => setScanOpen(true)}>扫描</button>
313
- <button className="btn ghost" onClick={() => setBoardForm({ id: null, name: '', desc: '' })}>新建看板</button>
314
- <button className="btn ghost" title="粘贴/选择看板 JSON,一个看板 = 一个主题" onClick={() => setImpOpen(true)}>导入看板</button>
315
- <button className="btn ghost" onClick={doExport}>导出</button>
316
271
  <button className="btn" onClick={openAdd}>{editing ? '继续编辑' : '添加仓库'}</button>
317
272
  </div>
318
273
  </div>
@@ -334,38 +289,88 @@ export default function ReposView() {
334
289
  </div>
335
290
  ) : null}
336
291
 
337
- {/* 看板工作台:主题容器纵向堆叠,未分组固定最后 */}
338
- <div className="bd-workbench" data-testid="bd-workbench">
339
- {(boards.boards || []).map((b) => (
340
- <BoardSection
292
+ {/* 看板筛选器:看板只是筛选维度,中心清单永远是那一张 */}
293
+ <div className="rp-filters" data-testid="rp-filters">
294
+ <button
295
+ className={'rp-chip' + (filter ? '' : ' on')}
296
+ onClick={() => setFilter('')}
297
+ title="显示全部仓库(中心清单)"
298
+ >
299
+ 全部 <b>{repos.length}</b>
300
+ </button>
301
+ {boardList.map((b) => (
302
+ <button
341
303
  key={b.id}
342
- id={b.id}
343
- name={b.name}
344
- desc={b.desc}
345
- cards={b.repos}
346
- renamable
347
- dragId={dragId}
348
- dropHint={dropHint}
349
- setDropHint={setDropHint}
350
- onDropSection={(bid) => dropCard(bid, null)}
351
- acts={cardActs}
352
- onRename={() => setBoardForm({ id: b.id, name: b.name, desc: b.desc })}
353
- onRemove={() => removeBoard(b)}
354
- />
304
+ className={'rp-chip' + (filter === b.id ? ' on' : '')}
305
+ onClick={() => setFilter(filter === b.id ? '' : b.id)}
306
+ title={`只看「${b.name}」里的仓库(${b.repos.length} 个)`}
307
+ >
308
+ {b.name} <b>{b.repos.length}</b>
309
+ </button>
355
310
  ))}
356
- <BoardSection
357
- id=""
358
- name="未分组"
359
- desc="不在任何看板里的仓库"
360
- cards={boards.unassigned || []}
361
- renamable={false}
362
- dragId={dragId}
363
- dropHint={dropHint}
364
- setDropHint={setDropHint}
365
- onDropSection={(bid) => dropCard(bid, null)}
366
- acts={cardActs}
367
- />
311
+ <button className="rp-chip rp-chip-manage" onClick={() => setBoardsOpen(!boardsOpen)}>
312
+ 看板管理{boardsOpen ? ' ▾' : ' ▸'}
313
+ </button>
368
314
  </div>
315
+
316
+ {/* 看板管理(次要):建 / 改 / 删 / 导入 / 导出 */}
317
+ {boardsOpen ? (
318
+ <div className="card rp-boards-manage">
319
+ <div className="colhead">
320
+ <h3>看板</h3>
321
+ <span className="muted">看板是仓库的<strong>标签</strong>:删一个看板,仓库全都在清单里,只是少了一份归属。</span>
322
+ <span className="acts" style={{ marginLeft: 'auto' }}>
323
+ <button className="btn small ghost" onClick={() => setBoardForm({ id: null, name: '', desc: '' })}>新建看板</button>
324
+ <button className="btn small ghost" title="粘贴/选择看板 JSON,一个看板 = 一个主题" onClick={() => setImpOpen(true)}>导入看板</button>
325
+ <button className="btn small ghost" onClick={doExport}>导出</button>
326
+ </span>
327
+ </div>
328
+ <div className="rp-board-list">
329
+ {boardList.map((b) => (
330
+ <div className="rp-board-item" key={b.id}>
331
+ <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>
334
+ <span className="acts">
335
+ <button className="btn small ghost" onClick={() => setBoardForm({ id: b.id, name: b.name, desc: b.desc || '' })}>重命名</button>
336
+ <button className="btn small ghost" onClick={() => removeBoard(b)}>删除看板</button>
337
+ </span>
338
+ </div>
339
+ ))}
340
+ {!boardList.length ? <div className="muted">还没有看板 —— 建一个当标签用。</div> : null}
341
+ </div>
342
+ </div>
343
+ ) : null}
344
+
345
+ {/* 中心清单:扁平的仓库表,一行一个仓库,不分组 */}
346
+ <div className="rp-table-wrap">
347
+ <table className="rp-table">
348
+ <thead>
349
+ <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>
355
+ </tr>
356
+ </thead>
357
+ <tbody>
358
+ {shown.map((r) => (
359
+ <RepoRow key={r.id} r={r} boards={boardList} acts={rowActs} />
360
+ ))}
361
+ {!shown.length ? (
362
+ <tr>
363
+ <td colSpan="5" className="muted rp-empty">
364
+ {repos.length
365
+ ? '这个看板下还没有仓库 —— 在「全部」里挑一个加进来(加入 = 复制一份归属)。'
366
+ : '还没有登记仓库 —— 点「扫描」自动发现,或「添加仓库」手工登记。'}
367
+ </td>
368
+ </tr>
369
+ ) : null}
370
+ </tbody>
371
+ </table>
372
+ </div>
373
+
369
374
  <CliHints module="repos" />
370
375
 
371
376
  {/* 添加 / 编辑仓库弹窗 */}
@@ -486,22 +491,10 @@ export default function ReposView() {
486
491
  </Modal>
487
492
  ) : null}
488
493
 
489
- {/* 导出结果弹窗 */}
490
- {expText !== null ? (
491
- <Modal title="看板导出(JSON,可回灌给导入)" onClose={() => setExpText(null)}>
494
+ {/* 导出结果 */}
495
+ {expText ? (
496
+ <Modal title="看板 JSON(可回灌给导入)" onClose={() => setExpText(null)}>
492
497
  <textarea className="bd-exp" rows={14} readOnly value={expText} spellCheck="false" />
493
- <div className="form-acts">
494
- <button className="btn ghost" onClick={() => setExpText(null)}>关闭</button>
495
- <button
496
- className="btn"
497
- onClick={() => guard(async () => {
498
- try { await navigator.clipboard.writeText(expText); toast('已复制到剪贴板'); }
499
- catch { toast('复制失败,请手动选择文本'); }
500
- })}
501
- >
502
- 复制 JSON
503
- </button>
504
- </div>
505
498
  </Modal>
506
499
  ) : null}
507
500