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 +19 -0
- package/package.json +1 -1
- package/src/modules/repos/service.js +29 -2
- package/src/modules/repos/view.jsx +176 -183
- package/src/web/frontend/style.css +158 -0
- package/src/web/public/assets/{CliHints-C-BFn59G.js → CliHints-CrVZoRln.js} +1 -1
- package/src/web/public/assets/index-D3Gh8xAs.css +1 -0
- package/src/web/public/assets/{index-CAoAkDuU.js → index-gEMHU8gc.js} +2 -2
- package/src/web/public/assets/{view-DJbamokf.js → view-CQT74IeU.js} +1 -1
- package/src/web/public/assets/{view-CGbnAQ7o.js → view-DZZz-uRj.js} +1 -1
- package/src/web/public/assets/{view-D0i4WoOA.js → view-KyrAiPse.js} +1 -1
- package/src/web/public/assets/view-PJtYOdms.js +10 -0
- package/src/web/public/assets/{view-CuAyOXB0.js → view-QRE2Bldq.js} +1 -1
- package/src/web/public/index.html +2 -2
- package/src/web/public/assets/index-Bi6ABn3T.css +0 -1
- package/src/web/public/assets/view-B_8D1S1p.js +0 -10
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
|
@@ -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
|
|