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.
Files changed (33) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +13 -2
  3. package/assets/nx-sk/SKILL.md +8 -0
  4. package/assets/nx-sk/references/10-sections-entries.md +2 -0
  5. package/assets/nx-sk/references/30-export-backup.md +1 -1
  6. package/package.json +1 -1
  7. package/src/core/attachments.js +15 -6
  8. package/src/core/store.js +3 -0
  9. package/src/modules/backup/view.jsx +6 -11
  10. package/src/modules/export/view.jsx +10 -4
  11. package/src/modules/settings/service.js +57 -4
  12. package/src/modules/settings/view.jsx +78 -0
  13. package/src/web/frontend/App.jsx +108 -31
  14. package/src/web/frontend/components/fieldEditor.jsx +7 -4
  15. package/src/web/frontend/components/ui.jsx +4 -1
  16. package/src/web/frontend/registry.js +4 -2
  17. package/src/web/frontend/styles.css +31 -0
  18. package/src/web/public/assets/{files-DMIKmyJN.js → files-I6hzWb3h.js} +1 -1
  19. package/src/web/public/assets/index-Cwk9VSKZ.css +1 -0
  20. package/src/web/public/assets/index-Dxf_b602.js +41 -0
  21. package/src/web/public/assets/{view-DCR3mXQP.js → view-B5VFWIaH.js} +1 -1
  22. package/src/web/public/assets/view-CKwFgc0C.js +1 -0
  23. package/src/web/public/assets/view-CL1Ep4Vj.js +7 -0
  24. package/src/web/public/assets/view-D_Z9LvYS.js +7 -0
  25. package/src/web/public/assets/{view-DTFmAEtL.js → view-Hu9QKqmx.js} +1 -1
  26. package/src/web/public/assets/view-cl8ITPVa.js +5 -0
  27. package/src/web/public/index.html +2 -2
  28. package/src/web/public/assets/index-DpX9p_OX.js +0 -41
  29. package/src/web/public/assets/index-lfnBM8pt.css +0 -1
  30. package/src/web/public/assets/view-Bh_6V4-F.js +0 -5
  31. package/src/web/public/assets/view-CDaAGMaO.js +0 -1
  32. package/src/web/public/assets/view-qNmLIhoy.js +0 -7
  33. 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 面板的「加密备份」页同样支持:导出在浏览器下载 `.nxskbak`(可勾选连附件一起打包),
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 + 冒烟(104 项断言) + 单测(132 项)
281
+ pnpm test # = lint + build + 冒烟(109 项断言) + 单测(140 项)
271
282
  ```
272
283
 
273
284
  | 目录 | 职责 |
@@ -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「加密备份」页提供同样能力(浏览器下载 / 选择文件导入;超大包建议走 CLI)。
104
+ - 口令也可用环境变量 `NX_SK_BACKUP_PASSWORD` 提供;Web 面板「导出与备份」页提供同样能力(浏览器下载 / 选择文件导入;超大包建议走 CLI)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 s = String(x ?? '').trim();
144
- if (!s || out.includes(s)) continue;
145
- out.push(s);
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
- const fileName = safeFileName(name || rec.name);
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 { CliHints, Empty, Modal } from '../../web/frontend/components/ui.jsx';
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 default function BackupView() {
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">加密导出</div>
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">解密导入</div>
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>导出</strong>
65
- <span className="muted">把栏目数据导成可带走、可备份的文件</span>
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">导出选项</div>
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="export" />
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
- function coerceSetting(key, value) {
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 ? '是' : '否') : (v === '' ? '(空)' : String(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">
@@ -1,42 +1,57 @@
1
- import { Fragment, Suspense, useEffect, useMemo } from 'react';
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
- return m ? decodeURIComponent(m[1]) : '';
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 是**数据驱动**的(来自 bootstrap.sections),静态 tab 来自前端注册表。
15
- // 两者的 id 可能撞车时静态 tab 不参与第一优先级的排序——栏目 order 是 10/20,静态是 500+。
16
- const tabs = useMemo(() => {
17
- const dyn = (boot?.sections || []).map((s) => ({
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
- const stat = VIEWS.filter((v) => v.tab !== false).map((v) => ({
21
- id: v.id, title: v.title, order: v.order, kind: 'view', component: v.component,
22
- }));
23
- const seen = new Set();
24
- return [...dyn, ...stat]
25
- .sort((a, b) => (a.order ?? 50) - (b.order ?? 50))
26
- .filter((t) => (seen.has(t.id) ? false : (seen.add(t.id), true)));
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 之前**不要**拿 tabs[0] 兜底:那时动态栏目还没到,tabs 里只有静态视图,
44
+ // 没 boot 之前**不要**拿第一个 tab 兜底:那时动态栏目还没到,nav 里只有静态视图,
30
45
  // 兜底会选中第一个静态视图并由下面的 effect 写进 ui —— 于是「默认视图 = 求职」被顶掉、
31
- // 头部高亮也错位成「栏目」,连 `#/job` 这类深链都会被首屏覆盖。
46
+ // 头部高亮也错位,连 `#/job` 这类深链都会被首屏覆盖。
32
47
  const booted = !!boot;
33
- const current = tabs.find((t) => t.id === ui.view) || (booted ? tabs[0] : null) || null;
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 !== ui.view) patchUi({ view: fromHash });
39
- else if (!window.location.hash) window.location.hash = `#/${ui.view}`;
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 !== ui.view) patchUi({ view: current.id });
51
- }, [booted, current, ui.view, patchUi]);
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
- 否则十几个同级 pill 连成一排,看不出「哪些是自己的数据、哪些是系统功能」。 */}
96
+ {/* 导航栏:内容(栏目)与工具(附件/导出/设置/…)之间加一道细线分组。
97
+ 同域栏目可以在**设置页**收拢成一个分组入口(settings.navGroups)——
98
+ 悬浮或点击展开上下列表,栏目一多也不会连成一排分不出主次。 */}
83
99
  <nav className="tabs">
84
- {tabs.map((t, i) => (
85
- <Fragment key={t.id}>
86
- {i > 0 && tabs[i - 1].kind !== t.kind ? <span className="tabs-sep" aria-hidden /> : null}
87
- <button className={`tab ${t.id === current?.id ? 'active' : ''}`} onClick={() => go(t.id)}>
88
- {t.title}
89
- {typeof t.count === 'number' ? <span className="count">{t.count}</span> : null}
90
- </button>
91
- </Fragment>
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
- {!tabs.length ? <span className="muted">正在装载…</span> : null}
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
+ }