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 +34 -0
- package/package.json +1 -1
- package/src/modules/localtask/index.js +4 -1
- package/src/modules/localtask/service.js +22 -4
- package/src/modules/localtask/view.jsx +19 -1
- package/src/modules/repos/service.js +29 -2
- package/src/modules/repos/view.jsx +176 -183
- package/src/web/frontend/style.css +164 -4
- package/src/web/public/assets/{CliHints-CW3CNN-a.js → CliHints-CrVZoRln.js} +1 -1
- package/src/web/public/assets/index-D3Gh8xAs.css +1 -0
- package/src/web/public/assets/{index-bExI8RZr.js → index-gEMHU8gc.js} +2 -2
- package/src/web/public/assets/{view-Dw9EcIvL.js → view-CQT74IeU.js} +1 -1
- package/src/web/public/assets/view-DZZz-uRj.js +2 -0
- package/src/web/public/assets/{view-CvSI8bnC.js → view-KyrAiPse.js} +1 -1
- package/src/web/public/assets/view-PJtYOdms.js +10 -0
- package/src/web/public/assets/{view-JCaMEOQu.js → view-QRE2Bldq.js} +1 -1
- package/src/web/public/index.html +2 -2
- package/src/web/public/assets/index-CavBxD_P.css +0 -1
- package/src/web/public/assets/view-C_l0o2rw.js +0 -2
- package/src/web/public/assets/view-Cud3f17v.js +0 -10
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
|
@@ -147,7 +147,10 @@ export default {
|
|
|
147
147
|
},
|
|
148
148
|
run: (ctx) =>
|
|
149
149
|
service.importRemoteTasks({
|
|
150
|
-
|
|
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
|
|
1153
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
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
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
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
|
-
<
|
|
25
|
-
className=
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
title="
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
63
|
-
</
|
|
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 [
|
|
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
|
-
//
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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="
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
<
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
491
|
-
<Modal title="
|
|
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
|
|