nx-kv 0.11.0 → 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,40 @@
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
+
26
+ ## [0.11.1] - 2026-10-23 单个项目目录也能从远程导入 + 面板导入 ref/refs 键修复
27
+
28
+ ### Added
29
+
30
+ - **每个项目目录单独也能从远程导入**。目录看板的每一行操作列现在都有「导入」按钮
31
+ (全局池行同样有),点开就是同一个子弹窗:按主题筛选 + 单条勾选,导入落点是
32
+ **这一行的目录队列**——不用先点开它的队列再导。
33
+
34
+ ### Fixed
35
+
36
+ - **【面板上点「导入」什么也没发生】**。action 只读 `ctx.ref`(CLI 的 `--ref` flag 名),
37
+ 而面板 body 发的是 `refs`;HTTP body 里未声明的键会原样透传,于是 `ctx.ref` 是
38
+ undefined → 后端报「要导入的远程任务不能为空」,面板**静默导了 0 条**。
39
+ 现在 `ref` 与 `refs` 两个键都收(合并逻辑抽成纯函数 `mergeImportRefs()`,可单测)。
40
+
7
41
  ## [0.11.0] - 2026-10-23 远程任务导入 + 仓库上下文迁入 + 面板视觉收敛
8
42
 
9
43
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -147,7 +147,10 @@ export default {
147
147
  },
148
148
  run: (ctx) =>
149
149
  service.importRemoteTasks({
150
- refs: ctx.ref,
150
+ // ref(CLI 的 --ref,可给多次)与 refs(面板发的列表)**都传过去**:
151
+ // HTTP body 里未声明的键会原样透传,两边各叫各的更顺手。
152
+ ref: ctx.ref,
153
+ refs: ctx.refs,
151
154
  cwd: ctx.cwd,
152
155
  global: ctx.global,
153
156
  topic: ctx.topic,
@@ -1143,14 +1143,31 @@ async function todoService() {
1143
1143
  return todoMod;
1144
1144
  }
1145
1145
 
1146
+ /**
1147
+ * 合并两个来源的"要导入的远程任务内容":**ref**(CLI 的 `--ref`,可给多次)与
1148
+ * **refs**(面板发的列表)。
1149
+ *
1150
+ * 为什么两个键:CLI 的 flag 叫 `--ref`(沿用 todo 模块"按内容定位"的约定),
1151
+ * 面板一次要导多条,发的是 `refs`;HTTP body 里未声明的键会原样透传,所以两个
1152
+ * 都可能到 action 的 ctx 里。**只认一个就会整批丢掉**——曾经只读 `ctx.ref`,
1153
+ * 面板那次导入静默地导了 0 条。
1154
+ *
1155
+ * 单独抽成纯函数是为了能单测(真跑导入要连远程后端,单测里连不上)。
1156
+ */
1157
+ export function mergeImportRefs(refs, ref) {
1158
+ const a = Array.isArray(refs) ? refs : [];
1159
+ const b = Array.isArray(ref) ? ref : [];
1160
+ return [...a, ...b].map((r) => String(r ?? '').trim()).filter(Boolean);
1161
+ }
1162
+
1146
1163
  /**
1147
1164
  * 从远程清单导入任务到本地队列。
1148
1165
  *
1149
1166
  * @param {object} opts
1150
1167
  * @param {string} [opts.cwd] 目标本地目录(不给则用当前目录)
1151
1168
  * @param {boolean} [opts.global] true = 导入到全局共享池
1152
- * @param {Array} opts.refs 要导入的远程任务**内容整串**(按内容定位,不按 id ——
1153
- * 远程 id 会被复用,详见 todo 模块的「定位一律按内容」)
1169
+ * @param {Array} [opts.refs] 要导入的远程任务**内容整串**(面板发这个)
1170
+ * @param {Array} [opts.ref] 同上,CLI 的 `--ref` 走这个键
1154
1171
  * @param {string} [opts.topic] 远程主题(消歧用)
1155
1172
  * @param {number} [opts.pick] 同内容多条时的编号(消歧用)
1156
1173
  * @param {number|string} [opts.groupId] 远程工作空间(缺省 = 当前)
@@ -1158,12 +1175,13 @@ async function todoService() {
1158
1175
  export async function importRemoteTasks({
1159
1176
  cwd: rawCwd,
1160
1177
  global: toGlobal = false,
1161
- refs = [],
1178
+ refs,
1179
+ ref,
1162
1180
  topic,
1163
1181
  pick,
1164
1182
  groupId,
1165
1183
  } = {}) {
1166
- const list = Array.isArray(refs) ? refs.map((r) => String(r ?? '').trim()).filter(Boolean) : [];
1184
+ const list = mergeImportRefs(refs, ref);
1167
1185
  if (!list.length) throw badInput('要导入的远程任务不能为空(按内容指定 --ref)');
1168
1186
 
1169
1187
  const todo = await todoService();
@@ -1035,7 +1035,25 @@ export default function SessionTaskView() {
1035
1035
  }}
1036
1036
  title="把远程(KV 后端)上还没人领的任务勾几条搬进全局任务池"
1037
1037
  >
1038
- 从远程导入
1038
+ 导入
1039
+ </button>
1040
+ )}
1041
+ {/* 每个项目目录**单独**也能从远程导入(#73:「全局任务栏里面,队列栏
1042
+ 里面也可以从中提取」)——不用先点开它的队列,直接在看板这一行
1043
+ 把远程任务搬进这个目录的队列。 */}
1044
+ {!row.isGlobal && (
1045
+ <button
1046
+ className="btn small ghost"
1047
+ onClick={(e) => {
1048
+ e.stopPropagation();
1049
+ setModal({
1050
+ title: `从远程清单导入到「${row.name}」的队列`,
1051
+ importRemote: { global: false, cwd: row.cwd, name: row.name },
1052
+ });
1053
+ }}
1054
+ title={`把远程(KV 后端)上还没人领的任务勾几条搬进「${row.name}」的队列`}
1055
+ >
1056
+ 导入
1039
1057
  </button>
1040
1058
  )}
1041
1059
  {goal ? (
@@ -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