nx-sk 0.8.1 → 0.9.0
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 +17 -0
- package/README.md +13 -2
- package/assets/nx-sk/SKILL.md +8 -0
- package/assets/nx-sk/references/10-sections-entries.md +2 -0
- package/assets/nx-sk/references/30-export-backup.md +1 -1
- package/package.json +1 -1
- package/src/core/attachments.js +15 -6
- package/src/core/store.js +3 -0
- package/src/modules/backup/view.jsx +6 -11
- package/src/modules/export/view.jsx +10 -4
- package/src/modules/settings/service.js +57 -4
- package/src/modules/settings/view.jsx +78 -0
- package/src/web/frontend/App.jsx +108 -31
- package/src/web/frontend/components/fieldEditor.jsx +7 -4
- package/src/web/frontend/components/ui.jsx +4 -1
- package/src/web/frontend/registry.js +4 -2
- package/src/web/frontend/styles.css +31 -0
- package/src/web/public/assets/{files-DMIKmyJN.js → files-I6hzWb3h.js} +1 -1
- package/src/web/public/assets/index-Cwk9VSKZ.css +1 -0
- package/src/web/public/assets/index-Dxf_b602.js +41 -0
- package/src/web/public/assets/{view-DCR3mXQP.js → view-B5VFWIaH.js} +1 -1
- package/src/web/public/assets/view-CKwFgc0C.js +1 -0
- package/src/web/public/assets/view-CL1Ep4Vj.js +7 -0
- package/src/web/public/assets/view-D_Z9LvYS.js +7 -0
- package/src/web/public/assets/{view-DTFmAEtL.js → view-Hu9QKqmx.js} +1 -1
- package/src/web/public/assets/view-cl8ITPVa.js +5 -0
- package/src/web/public/index.html +2 -2
- package/src/web/public/assets/index-DpX9p_OX.js +0 -41
- package/src/web/public/assets/index-lfnBM8pt.css +0 -1
- package/src/web/public/assets/view-Bh_6V4-F.js +0 -5
- package/src/web/public/assets/view-CDaAGMaO.js +0 -1
- package/src/web/public/assets/view-qNmLIhoy.js +0 -7
- package/src/web/public/assets/view-uH2FjbJC.js +0 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
|
|
4
4
|
|
|
5
|
+
## [0.9.0] - 2026-10-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **头部导航分组:设置里自己配,栏目收拢 + 悬浮展开**。栏目越建越多时,头部一排 pill 一眼分不出主次——现在可以在**设置页**(或 CLI)把同域栏目归成一组(如 项目经历 / 实习工作经历 / 校内职务实践 / 学校信息 / 申请相关 →「求职」),头部只显示**一个分组入口**(带成员条目数汇总),**悬浮展开上下列表**选具体栏目(点击行切换、当前栏目加粗;外点 / Esc 关闭;触屏点 pill 也能开)。
|
|
10
|
+
- 配置存在 `settings.navGroups`(`{title, sections[]}` 数组,**数组顺序 = 头部显示顺序**);缺省空数组 = 全部栏目平铺(老 store 读一次自动补默认,无需迁移)。
|
|
11
|
+
- 校验严格且明确:形状不对 / 栏目不存在 / 一个栏目进两个组 / 缺组名 / 空组 —— 都报 `INVALID_INPUT` 并说清是哪一组哪一项(栏目写**标题**也认,统一归一到 id)。
|
|
12
|
+
- 分组只是**显示配置**:不动栏目与条目数据;组内栏目被删掉时头部优雅略过(配置保留)。CLI:`nx-sk setting set --set 'navGroups=[…]'`,清空即恢复平铺。
|
|
13
|
+
- 未分组的栏目与系统工具(附件 / 导出与备份 / 设置)照旧平铺,顺序不变。
|
|
14
|
+
|
|
15
|
+
## [0.8.2] - 2026-10-15
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **`file update --name` 改名不带扩展名时,物理文件丢扩展名**:磁盘上只剩 `身份证人像面-赵刘学`(无 `.jpg`),而台账 `ext` 字段还记着 `.jpg`——内联预览、系统默认程序打开、`backup --with-files` 还原出来的文件全部哑掉。现在 `relocateFile` 会在新名字没有扩展名时**自动接回原扩展名**;名字自带扩展名则原样保留(不重复追加)。
|
|
20
|
+
- **历史连写的附件值解析不出来**:0.8.0 之前 `--set 头像图片=f_a,f_b` 会把两个 id **连成一个字符串**存进 values,`fileIdsOf` 此前不拆串内分隔符 → 面板显示「未解析」。现在 `fileIdsOf` / 前端 `toIds` 对字符串统一按 `,,、;;` 拆开——单值、数组、连写串三种历史形态都能归一(附件名里带逗号的极端情况请改用 id 关联)。
|
|
21
|
+
|
|
5
22
|
## [0.8.1] - 2026-10-15
|
|
6
23
|
|
|
7
24
|
### Added
|
package/README.md
CHANGED
|
@@ -198,6 +198,17 @@ nx-sk file get $FID # 反查「被引用:求职 /
|
|
|
198
198
|
> 若你的求职栏目是很早建的,跑一次 `nx-sk section sync job`(或面板「栏目 → 编辑 → 同步模板字段」)
|
|
199
199
|
> 就能把模板新增的「附件资料」组与 4 个 `file` 字段补进来,且不改动你已有的字段与数据。
|
|
200
200
|
|
|
201
|
+
**头部导航分组**(栏目多了以后收拢头部,一眼分得出主次):在设置里把同域栏目归成一组,
|
|
202
|
+
头部只显示组名,**悬浮展开上下列表**选具体栏目;不归组的栏目保持平铺。
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
# 面板:设置 → 头部导航分组(勾选栏目 / 改名 / 排序 / 增删组),保存后头部立即生效
|
|
206
|
+
nx-sk setting set --set 'navGroups=[{"title":"求职","sections":["projects","internships","campus"]}]'
|
|
207
|
+
nx-sk setting set --set 'navGroups=[]' # 清空 = 全部栏目恢复平铺
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
分组只是**显示配置**(存在 `settings.navGroups`):不动栏目与条目的数据,删掉栏目时头部自动略过。
|
|
211
|
+
|
|
201
212
|
整体导出(凭据默认**含明文**——本机自己用;要分享给别人时加 `--no-secrets` 打码):
|
|
202
213
|
|
|
203
214
|
```bash
|
|
@@ -221,7 +232,7 @@ nx-sk backup import --file ~/nx-sk/backup/xxx.nxskbak --password '你的备份
|
|
|
221
232
|
导入完成给出完整报告。`--replace` 才整份替换(灾难恢复 / 跨机器迁移用)。
|
|
222
233
|
两种方式都先自动留安全快照。
|
|
223
234
|
|
|
224
|
-
Web
|
|
235
|
+
Web 面板的「导出与备份」页同样支持:导出在浏览器下载 `.nxskbak`(可勾选连附件一起打包),
|
|
225
236
|
导入选择文件并输入口令、报告以弹窗呈现;口令错误或文件损坏会明确报错、不改动当前数据。
|
|
226
237
|
|
|
227
238
|
## 数据与安全
|
|
@@ -267,7 +278,7 @@ nx-sk skill get nx-sk 10-sections-entries # 裸名 → references/10-sections-
|
|
|
267
278
|
## 开发
|
|
268
279
|
|
|
269
280
|
```bash
|
|
270
|
-
pnpm test # = lint + build + 冒烟(
|
|
281
|
+
pnpm test # = lint + build + 冒烟(109 项断言) + 单测(140 项)
|
|
271
282
|
```
|
|
272
283
|
|
|
273
284
|
| 目录 | 职责 |
|
package/assets/nx-sk/SKILL.md
CHANGED
|
@@ -99,6 +99,14 @@ nx-sk file get f_bbb # 反查「被引用:求职
|
|
|
99
99
|
所以模板**后来**新增的字段不会自动出现在已建好的栏目里。用 `nx-sk section sync <栏目>`(可先 `--dry-run`)
|
|
100
100
|
把模板新增的分组与字段补进来(只增不改不删);面板侧是「栏目 → 编辑 → 同步模板字段」。
|
|
101
101
|
|
|
102
|
+
栏目建得多了,可以在**设置里归组**收拢头部(同域栏目一个入口,悬浮展开上下列表;
|
|
103
|
+
纯**显示配置**,不动栏目与条目数据):
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
nx-sk setting set --set 'navGroups=[{"title":"求职","sections":["projects","internships"]}]'
|
|
107
|
+
nx-sk setting set --set 'navGroups=[]' # 清空 = 全部栏目恢复平铺
|
|
108
|
+
```
|
|
109
|
+
|
|
102
110
|
## ref 路由表(按需加载)
|
|
103
111
|
|
|
104
112
|
| ref | 何时读取 |
|
|
@@ -17,6 +17,8 @@ store.json(~/nx-sk/store.json 或 $NX_SK_STORE)
|
|
|
17
17
|
- 一个**栏目** = 一组同构条目 + 一份字段字典(**固定的结构**)。
|
|
18
18
|
- 一个**条目** = 一行数据,值放在 `values` 里,键是字段 `key`;写台账里没有的键会**自动登记**
|
|
19
19
|
(见第五节)——**登记过的键面板才渲染得出来**,没登记的值在界面上看不见。
|
|
20
|
+
- **头部分组是显示配置**:`settings.navGroups`(`[{title, sections:[栏目id…]}]`,设置页可配)只决定
|
|
21
|
+
头部怎么摆——同域栏目可收拢成一个分组入口(悬浮展开上下列表);**不动**栏目与条目的任何数据。
|
|
20
22
|
- 定位方式都放宽:栏目与条目**都接受 id 或名字**。条目名重名时报 `CONFLICT` 让你改用 id。
|
|
21
23
|
|
|
22
24
|
## 二、栏目命令(集合资源,五操作齐备)
|
|
@@ -101,4 +101,4 @@ nx-sk backup import --file <备份>.nxskbak --password '口令' --replace # 整
|
|
|
101
101
|
- 两种方式都会**先自动留安全快照**(`…-before-import.json` / `…-before-restore.json`),可回滚。
|
|
102
102
|
- 敏感字段在包内是明文(靠口令保护),导入时自动用**本机 vaultKey** 重新加密落盘——报告里的值一律打码,密钥明文不进报告。
|
|
103
103
|
- 带 `--with-files` 的包导入时自动还原附件原件(sha256 校验;磁盘已有且内容不同则以磁盘为准);v2 旧包可导入,但不含原件,报告会如实列出缺哪些文件。
|
|
104
|
-
- 口令也可用环境变量 `NX_SK_BACKUP_PASSWORD` 提供;Web
|
|
104
|
+
- 口令也可用环境变量 `NX_SK_BACKUP_PASSWORD` 提供;Web 面板「导出与备份」页提供同样能力(浏览器下载 / 选择文件导入;超大包建议走 CLI)。
|
package/package.json
CHANGED
package/src/core/attachments.js
CHANGED
|
@@ -130,8 +130,9 @@ export function normalizeFileRecord(raw) {
|
|
|
130
130
|
/**
|
|
131
131
|
* 一个 `file` 字段的值 → 附件 id 数组(**全仓库唯一的归一化入口**)。
|
|
132
132
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
133
|
+
* 字段可以挂**多个**附件:新写入统一存数组(哪怕只有一个)。要吃下三种历史形态:
|
|
134
|
+
* 单值字符串、id 数组、以及旧版把多个 id **连写成一个字符串**(`"f_a,f_b"`——
|
|
135
|
+
* 0.8.0 之前 `--set` 不拆逗号),所以字符串统一按分隔符拆开。
|
|
135
136
|
* 去空、去重、保序:同一个附件在一个字段里出现两次没有意义。
|
|
136
137
|
*/
|
|
137
138
|
export function fileIdsOf(value) {
|
|
@@ -140,9 +141,11 @@ export function fileIdsOf(value) {
|
|
|
140
141
|
: (Array.isArray(value) ? value : [value]);
|
|
141
142
|
const out = [];
|
|
142
143
|
for (const x of raw) {
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
for (const piece of String(x ?? '').split(/[,,、;;]+/)) {
|
|
145
|
+
const id = piece.trim();
|
|
146
|
+
if (!id || out.includes(id)) continue;
|
|
147
|
+
out.push(id);
|
|
148
|
+
}
|
|
146
149
|
}
|
|
147
150
|
return out;
|
|
148
151
|
}
|
|
@@ -326,7 +329,13 @@ export async function importContent(store, opts = {}) {
|
|
|
326
329
|
*/
|
|
327
330
|
export async function relocateFile(store, rec, { category, name }) {
|
|
328
331
|
const cat = category || rec.category;
|
|
329
|
-
|
|
332
|
+
// 改名时**不带扩展名**就把原扩展名接回去——否则物理文件丢掉 .jpg/.pdf,
|
|
333
|
+
// 一切按扩展名认类型的地方(内联预览 / Content-Type / 系统默认程序打开 /
|
|
334
|
+
// backup --with-files 还原后的文件)都会哑掉,而台账里的 ext 字段还骗人说没丢。
|
|
335
|
+
let wanted = name || rec.name;
|
|
336
|
+
const originalExt = rec.ext || extname(rec.name).toLowerCase();
|
|
337
|
+
if (name && originalExt && !extname(wanted)) wanted += originalExt;
|
|
338
|
+
const fileName = safeFileName(wanted);
|
|
330
339
|
const same = cat === rec.category && fileName === rec.name;
|
|
331
340
|
const rel = same ? rec.rel : uniqueRel(store, cat, fileName, rec.id);
|
|
332
341
|
if (!same) {
|
package/src/core/store.js
CHANGED
|
@@ -21,6 +21,9 @@ export function defaultSettings() {
|
|
|
21
21
|
exportFormat: 'both', // json | md | both
|
|
22
22
|
includeSecretsInExport: true, // 默认含明文:本机单人工具,自己看不拦自己
|
|
23
23
|
defaultSection: 'job',
|
|
24
|
+
// 头部导航分组:[{ title, sections: [栏目 id…] }],数组顺序 = 显示顺序。
|
|
25
|
+
// 缺省空数组 = 全部栏目平铺(保持老行为);栏目建得多了再在设置页收拢。
|
|
26
|
+
navGroups: [],
|
|
24
27
|
};
|
|
25
28
|
}
|
|
26
29
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRef, useState } from 'react';
|
|
2
2
|
import { api } from '../../web/frontend/api/client.js';
|
|
3
3
|
import { useStore } from '../../web/frontend/store.jsx';
|
|
4
|
-
import {
|
|
4
|
+
import { Empty, Modal } from '../../web/frontend/components/ui.jsx';
|
|
5
5
|
|
|
6
6
|
// Web 上传走 JSON 文本体,接口上限 32MB——留出余量在前端先拦,
|
|
7
7
|
// 超限建议走 CLI(命令行没有请求体上限)。
|
|
@@ -93,7 +93,9 @@ function ReplaceReport({ r }) {
|
|
|
93
93
|
);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
export
|
|
96
|
+
// 备份半页:由「导出与备份」合并页(export 模块的 view.jsx)组合渲染,
|
|
97
|
+
// 所以不带页面头与 CLI 提示(CliHints 在合并页统一出一条)。tab: false,不单独出现在导航。
|
|
98
|
+
export default function BackupPanel() {
|
|
97
99
|
const { toast, guard, dialog, reload } = useStore();
|
|
98
100
|
const [pw, setPw] = useState('');
|
|
99
101
|
const [pw2, setPw2] = useState('');
|
|
@@ -177,13 +179,8 @@ export default function BackupView() {
|
|
|
177
179
|
|
|
178
180
|
return (
|
|
179
181
|
<>
|
|
180
|
-
<div className="toolbar rowgap pad">
|
|
181
|
-
<strong>加密备份</strong>
|
|
182
|
-
<span className="muted">整份数据用口令对称加密(AES-256-GCM);导出设口令、恢复输口令,口令不落盘</span>
|
|
183
|
-
</div>
|
|
184
|
-
|
|
185
182
|
<div className="card">
|
|
186
|
-
<div className="colhead"
|
|
183
|
+
<div className="colhead">加密备份(口令 .nxskbak)</div>
|
|
187
184
|
<div className="pad stack">
|
|
188
185
|
<div className="grid">
|
|
189
186
|
<div className="field">
|
|
@@ -211,7 +208,7 @@ export default function BackupView() {
|
|
|
211
208
|
</div>
|
|
212
209
|
|
|
213
210
|
<div className="card">
|
|
214
|
-
<div className="colhead"
|
|
211
|
+
<div className="colhead">解密导入(.nxskbak)</div>
|
|
215
212
|
<div className="pad stack">
|
|
216
213
|
<div className="grid">
|
|
217
214
|
<div className="field">
|
|
@@ -241,8 +238,6 @@ export default function BackupView() {
|
|
|
241
238
|
</div>
|
|
242
239
|
</div>
|
|
243
240
|
|
|
244
|
-
<CliHints module="backup" />
|
|
245
|
-
|
|
246
241
|
{result ? (
|
|
247
242
|
<Modal
|
|
248
243
|
title={result.mode === 'replace' ? '导入完成(整份替换)' : '导入完成(合并)'}
|
|
@@ -2,7 +2,11 @@ import { useCallback, useEffect, useState } from 'react';
|
|
|
2
2
|
import { api } from '../../web/frontend/api/client.js';
|
|
3
3
|
import { useStore } from '../../web/frontend/store.jsx';
|
|
4
4
|
import { CliHints, Copyable, Empty, Modal, Tag } from '../../web/frontend/components/ui.jsx';
|
|
5
|
+
import BackupPanel from '../backup/view.jsx';
|
|
5
6
|
|
|
7
|
+
// 「导出与备份」合并页:明文导出(带走可读文件)与口令加密备份(整份可还原)
|
|
8
|
+
// 对用户来说是同一件事——把数据倒出去、再倒回来,所以并成一个 tab。
|
|
9
|
+
// 后端仍是 export / backup 两个模块、两组 CLI 命令;页脚的 CliHints 把两组合成一条提示。
|
|
6
10
|
export default function ExportView() {
|
|
7
11
|
const { boot, toast, guard } = useStore();
|
|
8
12
|
const [sections, setSections] = useState([]);
|
|
@@ -61,12 +65,12 @@ export default function ExportView() {
|
|
|
61
65
|
return (
|
|
62
66
|
<>
|
|
63
67
|
<div className="toolbar rowgap pad">
|
|
64
|
-
<strong
|
|
65
|
-
<span className="muted"
|
|
68
|
+
<strong>导出与备份</strong>
|
|
69
|
+
<span className="muted">明文导出走可读文件(JSON / Markdown),加密备份走口令保护的 .nxskbak(整份可还原);都只在本机落盘</span>
|
|
66
70
|
</div>
|
|
67
71
|
|
|
68
72
|
<div className="card">
|
|
69
|
-
<div className="colhead"
|
|
73
|
+
<div className="colhead">导出为 JSON / Markdown</div>
|
|
70
74
|
<div className="pad">
|
|
71
75
|
<div className="grid">
|
|
72
76
|
<div className="field">
|
|
@@ -111,6 +115,8 @@ export default function ExportView() {
|
|
|
111
115
|
</div>
|
|
112
116
|
</div>
|
|
113
117
|
|
|
118
|
+
<BackupPanel />
|
|
119
|
+
|
|
114
120
|
<div className="card">
|
|
115
121
|
<div className="colhead">历次导出<span className="right muted">{history?.count ?? 0}</span></div>
|
|
116
122
|
<div className="colbody">
|
|
@@ -147,7 +153,7 @@ export default function ExportView() {
|
|
|
147
153
|
</div>
|
|
148
154
|
</div>
|
|
149
155
|
|
|
150
|
-
<CliHints module=
|
|
156
|
+
<CliHints module={['export', 'backup']} />
|
|
151
157
|
|
|
152
158
|
{modal ? <Modal title={modal.title} onClose={() => setModal(null)}>{modal.body}</Modal> : null}
|
|
153
159
|
</>
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// settings 是**基础模块**:允许被其它模块单向只读依赖(见 eslint 互依禁列——它故意不在里面)。
|
|
2
2
|
// 设置的唯一读写入口在这里,别绕过去直接改 store.settings。
|
|
3
3
|
import { loadStore, mutateStore, snapshotStore } from '../../core/store.js';
|
|
4
|
+
import { findSection } from '../../core/fields.js';
|
|
4
5
|
import { badInput, notFound } from '../../core/errors.js';
|
|
5
6
|
|
|
6
|
-
export const SETTING_KEYS = ['exportDir', 'exportFormat', 'includeSecretsInExport', 'defaultSection'];
|
|
7
|
+
export const SETTING_KEYS = ['exportDir', 'exportFormat', 'includeSecretsInExport', 'defaultSection', 'navGroups'];
|
|
7
8
|
|
|
8
9
|
const SCHEMA = {
|
|
9
10
|
exportDir: { type: 'string', hint: '导出目录,留空 = ~/nx-sk/export' },
|
|
10
11
|
exportFormat: { type: 'string', enum: ['json', 'md', 'both'], hint: '整体导出默认格式' },
|
|
11
12
|
includeSecretsInExport: { type: 'boolean', hint: '整体导出是否默认带密钥明文(默认否)' },
|
|
12
13
|
defaultSection: { type: 'string', hint: '默认栏目 id' },
|
|
14
|
+
navGroups: { type: 'json', hint: '头部导航分组:[{title, sections:[栏目id…]}],数组顺序 = 显示顺序' },
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
export function settingsSchema() {
|
|
@@ -21,9 +23,54 @@ export async function getSettings() {
|
|
|
21
23
|
return { settings: store.settings, schema: settingsSchema() };
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
/**
|
|
27
|
+
* 头部导航分组(`settings.navGroups`)的形状校验 + 归一。
|
|
28
|
+
*
|
|
29
|
+
* 分组是**显示配置**,写坏了头部就少一块,所以这里不静默丢任何东西:
|
|
30
|
+
* 栏目不存在 / 一个栏目进了两个组 / 空组 —— 一律报错并说清是哪一组哪一项。
|
|
31
|
+
* 归一:栏目可以写 id 也可以写标题(统一存成真实 id);组内去重保序。
|
|
32
|
+
*/
|
|
33
|
+
function coerceNavGroups(value, store) {
|
|
34
|
+
let arr = value;
|
|
35
|
+
if (typeof value === 'string') {
|
|
36
|
+
try {
|
|
37
|
+
arr = JSON.parse(value);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
throw badInput(`navGroups 需要 JSON 数组(${e.message}):[{"title":"求职","sections":["projects",…]}, …]`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(arr)) {
|
|
43
|
+
throw badInput('navGroups 需要是数组:[{"title":"求职","sections":["projects",…]}, …]');
|
|
44
|
+
}
|
|
45
|
+
const owner = new Map();
|
|
46
|
+
return arr.map((g, i) => {
|
|
47
|
+
if (!g || typeof g !== 'object') throw badInput(`第 ${i + 1} 个分组不是对象`);
|
|
48
|
+
const title = String(g.title ?? '').trim();
|
|
49
|
+
if (!title) throw badInput(`第 ${i + 1} 个分组缺少 title(组名)`);
|
|
50
|
+
const sections = [];
|
|
51
|
+
const inGroup = new Set();
|
|
52
|
+
for (const raw of (Array.isArray(g.sections) ? g.sections : [])) {
|
|
53
|
+
const ref = String(raw ?? '').trim();
|
|
54
|
+
if (!ref) continue;
|
|
55
|
+
const sec = findSection(store, ref);
|
|
56
|
+
if (!sec) throw badInput(`分组「${title}」里的栏目不存在: ${ref}`);
|
|
57
|
+
if (inGroup.has(sec.id)) continue; // 同组里 id / 标题混着写同一个栏目 → 去重
|
|
58
|
+
if (owner.has(sec.id)) {
|
|
59
|
+
throw badInput(`栏目 ${sec.id} 同时在分组「${owner.get(sec.id)}」与「${title}」里——一个栏目只能属于一个分组`);
|
|
60
|
+
}
|
|
61
|
+
inGroup.add(sec.id);
|
|
62
|
+
owner.set(sec.id, title);
|
|
63
|
+
sections.push(sec.id);
|
|
64
|
+
}
|
|
65
|
+
if (!sections.length) throw badInput(`分组「${title}」没有任何栏目——要么勾一个,要么删掉这个分组`);
|
|
66
|
+
return { title, sections };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function coerceSetting(key, value, store) {
|
|
25
71
|
const s = SCHEMA[key];
|
|
26
72
|
if (!s) throw notFound(`未知设置项: ${key}(可用: ${SETTING_KEYS.join(', ')})`);
|
|
73
|
+
if (s.type === 'json') return coerceNavGroups(value, store);
|
|
27
74
|
if (s.type === 'boolean') {
|
|
28
75
|
if (typeof value === 'boolean') return value;
|
|
29
76
|
const v = String(value).trim().toLowerCase();
|
|
@@ -45,8 +92,10 @@ export async function updateSettings({ set, key, value, 'dry-run': dryRun } = {}
|
|
|
45
92
|
const keys = Object.keys(patch);
|
|
46
93
|
if (!keys.length) throw badInput('至少要给一项:setting set --key <名> --value <值>,或 --set k=v(可重复)');
|
|
47
94
|
|
|
95
|
+
// 校验要拿 store 做参照(navGroups 里的栏目 id 必须真实存在)
|
|
96
|
+
const store = await loadStore();
|
|
48
97
|
const resolved = {};
|
|
49
|
-
for (const k of keys) resolved[k] = coerceSetting(k, patch[k]);
|
|
98
|
+
for (const k of keys) resolved[k] = coerceSetting(k, patch[k], store);
|
|
50
99
|
|
|
51
100
|
if (dryRun) return { status: 'skipped', dryRun: true, wouldSet: resolved };
|
|
52
101
|
|
|
@@ -63,7 +112,11 @@ export function renderSettings(data) {
|
|
|
63
112
|
const lines = ['当前设置(改:nx-sk setting set --key <名> --value <值>)'];
|
|
64
113
|
for (const s of data.schema) {
|
|
65
114
|
const v = data.settings[s.key];
|
|
66
|
-
const shown = s.type === 'boolean' ? (v ? '是' : '否')
|
|
115
|
+
const shown = s.type === 'boolean' ? (v ? '是' : '否')
|
|
116
|
+
: (v === '' || v === undefined ? '(空)'
|
|
117
|
+
: (v && typeof v === 'object'
|
|
118
|
+
? (() => { const j = JSON.stringify(v); return j.length > 56 ? `${j.slice(0, 56)}…` : j; })()
|
|
119
|
+
: String(v)));
|
|
67
120
|
lines.push(` ${s.key.padEnd(22)} ${shown.padEnd(10)} ${s.hint}`);
|
|
68
121
|
}
|
|
69
122
|
return lines.join('\n');
|
|
@@ -7,6 +7,7 @@ export default function SettingsView() {
|
|
|
7
7
|
const { boot, reload, toast, guard } = useStore();
|
|
8
8
|
const [data, setData] = useState(null);
|
|
9
9
|
const [form, setForm] = useState(null);
|
|
10
|
+
const [groups, setGroups] = useState([]);
|
|
10
11
|
// 面板地址不写死:直接取用户此刻访问的这个 origin,换端口/换 host 都自动跟上
|
|
11
12
|
const panelUrl = typeof window === 'undefined' ? '' : window.location.origin;
|
|
12
13
|
const panelPort = typeof window === 'undefined' ? '' : (window.location.port || '80');
|
|
@@ -16,6 +17,7 @@ export default function SettingsView() {
|
|
|
16
17
|
const d = await api('/api/settings');
|
|
17
18
|
setData(d);
|
|
18
19
|
setForm({ ...d.settings });
|
|
20
|
+
setGroups((d.settings.navGroups || []).map((g) => ({ title: g.title, sections: [...g.sections] })));
|
|
19
21
|
} catch (e) {
|
|
20
22
|
toast(e.message, 'bad');
|
|
21
23
|
}
|
|
@@ -36,6 +38,35 @@ export default function SettingsView() {
|
|
|
36
38
|
await reload();
|
|
37
39
|
});
|
|
38
40
|
|
|
41
|
+
// 头部导航分组:组顺序 = 头部显示顺序;一个栏目只能属于一个组(别组已勾的置灰)
|
|
42
|
+
const patchGroup = (gi, patch) => setGroups((gs) => gs.map((g, i) => (i === gi ? { ...g, ...patch } : g)));
|
|
43
|
+
|
|
44
|
+
const toggleMember = (gi, sid) => setGroups((gs) => gs.map((g, i) => {
|
|
45
|
+
if (i !== gi) return g;
|
|
46
|
+
const has = g.sections.includes(sid);
|
|
47
|
+
return { ...g, sections: has ? g.sections.filter((x) => x !== sid) : [...g.sections, sid] };
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const moveGroup = (gi, delta) => setGroups((gs) => {
|
|
51
|
+
const to = gi + delta;
|
|
52
|
+
if (to < 0 || to >= gs.length) return gs;
|
|
53
|
+
const next = [...gs];
|
|
54
|
+
[next[gi], next[to]] = [next[to], next[gi]];
|
|
55
|
+
return next;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const saveGroups = guard(async () => {
|
|
59
|
+
const naked = groups.find((g) => !String(g.title).trim());
|
|
60
|
+
if (naked) { toast('每个分组都要有组名', 'bad'); return; }
|
|
61
|
+
const empty = groups.find((g) => !g.sections.length);
|
|
62
|
+
if (empty) { toast(`分组「${empty.title}」还没有勾选栏目——要么勾一个,要么删掉这个分组`, 'bad'); return; }
|
|
63
|
+
const r = await api('/api/settings', { method: 'PATCH', body: { navGroups: groups } });
|
|
64
|
+
toast(groups.length ? `已保存 ${groups.length} 个导航分组` : '已取消分组,栏目恢复平铺');
|
|
65
|
+
setData({ ...data, settings: r.set });
|
|
66
|
+
setGroups((r.set.navGroups || []).map((g) => ({ title: g.title, sections: [...g.sections] })));
|
|
67
|
+
await reload();
|
|
68
|
+
});
|
|
69
|
+
|
|
39
70
|
if (!data || !form) return <div className="pad muted">加载中…</div>;
|
|
40
71
|
|
|
41
72
|
return (
|
|
@@ -92,6 +123,53 @@ export default function SettingsView() {
|
|
|
92
123
|
</div>
|
|
93
124
|
</div>
|
|
94
125
|
|
|
126
|
+
<div className="card">
|
|
127
|
+
<div className="colhead">
|
|
128
|
+
头部导航分组
|
|
129
|
+
<span className="right muted">{groups.length ? `${groups.length} 个分组` : '未分组(全部平铺)'}</span>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="pad stack">
|
|
132
|
+
<div className="hint">
|
|
133
|
+
把同域栏目收拢成一个头部入口:头部只显示组名,**悬浮展开上下列表**选具体栏目。
|
|
134
|
+
不勾进任何分组的栏目保持平铺;组顺序 = 头部显示顺序;一个栏目只能属于一个分组。
|
|
135
|
+
</div>
|
|
136
|
+
{groups.map((g, gi) => (
|
|
137
|
+
<div className="stack" key={gi} style={{ borderTop: gi ? 'var(--border)' : 0, paddingTop: gi ? 8 : 0 }}>
|
|
138
|
+
<div className="rowgap">
|
|
139
|
+
<input
|
|
140
|
+
value={g.title}
|
|
141
|
+
placeholder="组名,如「求职」"
|
|
142
|
+
onChange={(e) => patchGroup(gi, { title: e.target.value })}
|
|
143
|
+
style={{ minWidth: 160 }}
|
|
144
|
+
/>
|
|
145
|
+
<button className="btn small ghost" disabled={gi === 0} onClick={() => moveGroup(gi, -1)}>上移</button>
|
|
146
|
+
<button className="btn small ghost" disabled={gi === groups.length - 1} onClick={() => moveGroup(gi, 1)}>下移</button>
|
|
147
|
+
<button className="btn small ghost" onClick={() => setGroups((gs) => gs.filter((_, i) => i !== gi))}>删除组</button>
|
|
148
|
+
<span className="muted">已选 {g.sections.length} 个栏目</span>
|
|
149
|
+
</div>
|
|
150
|
+
<div className="rowgap">
|
|
151
|
+
{(boot?.sections || []).map((s) => {
|
|
152
|
+
const checked = g.sections.includes(s.id);
|
|
153
|
+
const elsewhere = groups.some((o, oi) => oi !== gi && o.sections.includes(s.id));
|
|
154
|
+
return (
|
|
155
|
+
<label className="inline" key={s.id} title={elsewhere ? '已归入其它分组' : ''}>
|
|
156
|
+
<input type="checkbox" checked={checked} disabled={elsewhere} onChange={() => toggleMember(gi, s.id)} />
|
|
157
|
+
<span className={elsewhere ? 'muted' : ''}>{s.title}<span className="count"> {s.entries}</span></span>
|
|
158
|
+
</label>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
))}
|
|
164
|
+
{!groups.length ? <div className="muted">还没有分组——所有栏目都平铺在头部。</div> : null}
|
|
165
|
+
<div className="rowgap">
|
|
166
|
+
<button className="btn" onClick={saveGroups}>保存分组</button>
|
|
167
|
+
<button className="btn small ghost" onClick={() => setGroups((gs) => [...gs, { title: '', sections: [] }])}>新增分组</button>
|
|
168
|
+
{groups.length ? <button className="btn small ghost" onClick={() => setGroups([])}>清空(恢复平铺)</button> : null}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
95
173
|
<div className="card">
|
|
96
174
|
<div className="colhead">默认行为</div>
|
|
97
175
|
<div className="pad">
|
package/src/web/frontend/App.jsx
CHANGED
|
@@ -1,42 +1,57 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { SECTION_VIEW, VIEWS } from './registry.js';
|
|
3
3
|
import { Copyable, DialogHost, ErrorBoundary, Toasts } from './components/ui.jsx';
|
|
4
4
|
import { useStore } from './store.jsx';
|
|
5
5
|
|
|
6
6
|
function viewFromHash() {
|
|
7
7
|
const m = /^#\/?([^?/]*)/.exec(window.location.hash || '');
|
|
8
|
-
|
|
8
|
+
const id = m ? decodeURIComponent(m[1]) : '';
|
|
9
|
+
return LEGACY_VIEW[id] || id;
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
// 加密备份并入「导出与备份」合并页后,旧书签(#/backup)与 localStorage 里
|
|
13
|
+
// 残留的 ui.view 都要落到合并页——否则会被「找不到 tab」的兜底甩到第一个栏目。
|
|
14
|
+
const LEGACY_VIEW = { backup: 'export' };
|
|
15
|
+
|
|
11
16
|
export default function App() {
|
|
12
17
|
const { boot, bootError, ui, patchUi, reload } = useStore();
|
|
18
|
+
const view = LEGACY_VIEW[ui.view] || ui.view;
|
|
13
19
|
|
|
14
|
-
// 栏目 tab
|
|
15
|
-
//
|
|
16
|
-
const
|
|
17
|
-
const
|
|
20
|
+
// 栏目 tab 是**数据驱动**的(bootstrap.sections);分组来自 settings.navGroups(设置页可配);
|
|
21
|
+
// 静态 tab 来自前端注册表。渲染顺序:未分组栏目(按 order)→ 分组(按配置顺序)→ 工具(按 order)。
|
|
22
|
+
const nav = useMemo(() => {
|
|
23
|
+
const sections = (boot?.sections || []).map((s) => ({
|
|
18
24
|
id: s.id, title: s.title, order: s.order, kind: 'section', count: s.entries,
|
|
19
25
|
}));
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
// 分组是**显示配置**:成员栏目被删掉时优雅降级(过滤掉、配置保留),成员全没了就不显示这个组
|
|
27
|
+
const groups = (boot?.settings?.navGroups || [])
|
|
28
|
+
.map((g) => ({
|
|
29
|
+
title: g.title,
|
|
30
|
+
sections: (g.sections || [])
|
|
31
|
+
.map((sid) => sections.find((s) => s.id === sid))
|
|
32
|
+
.filter(Boolean),
|
|
33
|
+
}))
|
|
34
|
+
.filter((g) => g.sections.length);
|
|
35
|
+
const grouped = new Set(groups.flatMap((g) => g.sections.map((s) => s.id)));
|
|
36
|
+
const flat = sections.filter((s) => !grouped.has(s.id)).sort((a, b) => (a.order ?? 50) - (b.order ?? 50));
|
|
37
|
+
const stat = VIEWS.filter((v) => v.tab !== false)
|
|
38
|
+
.map((v) => ({ id: v.id, title: v.title, order: v.order, kind: 'view', component: v.component }))
|
|
39
|
+
.filter((v) => !sections.some((s) => s.id === v.id))
|
|
40
|
+
.sort((a, b) => (a.order ?? 50) - (b.order ?? 50));
|
|
41
|
+
return { flat, groups, tools: stat, all: [...flat, ...groups.flatMap((g) => g.sections), ...stat] };
|
|
27
42
|
}, [boot]);
|
|
28
43
|
|
|
29
|
-
// 没 boot
|
|
44
|
+
// 没 boot 之前**不要**拿第一个 tab 兜底:那时动态栏目还没到,nav 里只有静态视图,
|
|
30
45
|
// 兜底会选中第一个静态视图并由下面的 effect 写进 ui —— 于是「默认视图 = 求职」被顶掉、
|
|
31
|
-
//
|
|
46
|
+
// 头部高亮也错位,连 `#/job` 这类深链都会被首屏覆盖。
|
|
32
47
|
const booted = !!boot;
|
|
33
|
-
const current =
|
|
48
|
+
const current = nav.all.find((t) => t.id === view) || (booted ? nav.all[0] : null) || null;
|
|
34
49
|
|
|
35
50
|
// hash 与 localStorage 双写对齐:只在挂载时对齐一次,之后交给 hashchange
|
|
36
51
|
useEffect(() => {
|
|
37
52
|
const fromHash = viewFromHash();
|
|
38
|
-
if (fromHash && fromHash !==
|
|
39
|
-
else if (!window.location.hash) window.location.hash = `#/${
|
|
53
|
+
if (fromHash && fromHash !== view) patchUi({ view: fromHash });
|
|
54
|
+
else if (!window.location.hash) window.location.hash = `#/${view}`;
|
|
40
55
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
56
|
}, []);
|
|
42
57
|
|
|
@@ -47,8 +62,8 @@ export default function App() {
|
|
|
47
62
|
}, [patchUi]);
|
|
48
63
|
|
|
49
64
|
useEffect(() => {
|
|
50
|
-
if (booted && current && current.id !==
|
|
51
|
-
}, [booted, current,
|
|
65
|
+
if (booted && current && current.id !== view) patchUi({ view: current.id });
|
|
66
|
+
}, [booted, current, view, patchUi]);
|
|
52
67
|
|
|
53
68
|
const go = (id) => {
|
|
54
69
|
patchUi({ view: id });
|
|
@@ -78,19 +93,26 @@ export default function App() {
|
|
|
78
93
|
</span>
|
|
79
94
|
</header>
|
|
80
95
|
|
|
81
|
-
{/*
|
|
82
|
-
|
|
96
|
+
{/* 导航栏:内容(栏目)与工具(附件/导出/设置/…)之间加一道细线分组。
|
|
97
|
+
同域栏目可以在**设置页**收拢成一个分组入口(settings.navGroups)——
|
|
98
|
+
悬浮或点击展开上下列表,栏目一多也不会连成一排分不出主次。 */}
|
|
83
99
|
<nav className="tabs">
|
|
84
|
-
{
|
|
85
|
-
<
|
|
86
|
-
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
100
|
+
{nav.flat.map((t) => (
|
|
101
|
+
<button key={t.id} className={`tab ${t.id === current?.id ? 'active' : ''}`} onClick={() => go(t.id)}>
|
|
102
|
+
{t.title}
|
|
103
|
+
{typeof t.count === 'number' ? <span className="count">{t.count}</span> : null}
|
|
104
|
+
</button>
|
|
105
|
+
))}
|
|
106
|
+
{nav.groups.map((g) => (
|
|
107
|
+
<GroupTab key={g.title} group={g} currentId={view} onGo={go} />
|
|
92
108
|
))}
|
|
93
|
-
{
|
|
109
|
+
{nav.tools.length ? <span className="tabs-sep" aria-hidden /> : null}
|
|
110
|
+
{nav.tools.map((t) => (
|
|
111
|
+
<button key={t.id} className={`tab ${t.id === current?.id ? 'active' : ''}`} onClick={() => go(t.id)}>
|
|
112
|
+
{t.title}
|
|
113
|
+
</button>
|
|
114
|
+
))}
|
|
115
|
+
{!nav.all.length ? <span className="muted">正在装载…</span> : null}
|
|
94
116
|
</nav>
|
|
95
117
|
|
|
96
118
|
<main className="body">
|
|
@@ -124,3 +146,58 @@ function SectionPane({ sectionId }) {
|
|
|
124
146
|
const View = SECTION_VIEW;
|
|
125
147
|
return <View sectionId={sectionId} />;
|
|
126
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 头部的一个**分组入口**:一个 pill 收拢多个栏目(同域栏目不再挤成一排)。
|
|
152
|
+
* 悬浮展开、点击也能开(触屏没有 hover);点行切换栏目。
|
|
153
|
+
* 外点关闭用 `pointerdown`——`click` 与触发按钮自己的 onClick 同阶段冒泡,
|
|
154
|
+
* 会「点一下关了又弹开」;受控组件而非纯 CSS hover,是为了能用键盘 Esc 关。
|
|
155
|
+
*/
|
|
156
|
+
function GroupTab({ group, currentId, onGo }) {
|
|
157
|
+
const [open, setOpen] = useState(false);
|
|
158
|
+
const ref = useRef(null);
|
|
159
|
+
const active = group.sections.some((s) => s.id === currentId);
|
|
160
|
+
const total = group.sections.reduce((n, s) => n + (s.count || 0), 0);
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!open) return undefined;
|
|
164
|
+
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
|
165
|
+
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
|
166
|
+
document.addEventListener('pointerdown', onDown);
|
|
167
|
+
document.addEventListener('keydown', onKey);
|
|
168
|
+
return () => {
|
|
169
|
+
document.removeEventListener('pointerdown', onDown);
|
|
170
|
+
document.removeEventListener('keydown', onKey);
|
|
171
|
+
};
|
|
172
|
+
}, [open]);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<span
|
|
176
|
+
className="tab-group"
|
|
177
|
+
ref={ref}
|
|
178
|
+
onMouseEnter={() => setOpen(true)}
|
|
179
|
+
onMouseLeave={() => setOpen(false)}
|
|
180
|
+
>
|
|
181
|
+
<button className={`tab ${active ? 'active' : ''}`} aria-expanded={open} onClick={() => setOpen(true)}>
|
|
182
|
+
{group.title}
|
|
183
|
+
<span className="count">{total}</span>
|
|
184
|
+
<span className="caret" aria-hidden />
|
|
185
|
+
</button>
|
|
186
|
+
{open ? (
|
|
187
|
+
<div className="tab-menu" role="menu">
|
|
188
|
+
{group.sections.map((s) => (
|
|
189
|
+
<button
|
|
190
|
+
key={s.id}
|
|
191
|
+
role="menuitem"
|
|
192
|
+
className={`tab-menu-row ${s.id === currentId ? 'current' : ''}`}
|
|
193
|
+
onClick={() => { setOpen(false); onGo(s.id); }}
|
|
194
|
+
>
|
|
195
|
+
<span className="grow nowrap">{s.title}</span>
|
|
196
|
+
{typeof s.count === 'number' ? <span className="count">{s.count}</span> : null}
|
|
197
|
+
</button>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
) : null}
|
|
201
|
+
</span>
|
|
202
|
+
);
|
|
203
|
+
}
|