nx-sk 0.23.1 → 0.27.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 (30) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/assets/nx-sk/SKILL.md +6 -1
  3. package/assets/nx-sk/references/10-sections-entries.md +46 -4
  4. package/assets/nx-sk/references/15-special-fields.md +27 -14
  5. package/assets/sk-source/SKILL.md +2 -2
  6. package/assets/sk-source/references/20-intake.md +1 -1
  7. package/package.json +2 -1
  8. package/src/core/render.js +6 -0
  9. package/src/core/store.js +4 -0
  10. package/src/modules/entries/view.jsx +60 -22
  11. package/src/modules/sections/index.js +3 -1
  12. package/src/modules/sections/service.js +30 -1
  13. package/src/shared/blockLayout.js +367 -0
  14. package/src/web/frontend/components/blockBoard.jsx +307 -0
  15. package/src/web/frontend/components/fieldEditor.jsx +1 -1
  16. package/src/web/frontend/components/sourcePanel.jsx +17 -17
  17. package/src/web/frontend/components/tableField.jsx +317 -159
  18. package/src/web/frontend/styles.css +386 -80
  19. package/src/web/public/assets/{files-Cr-oMOeX.js → files--kTIDQLp.js} +1 -1
  20. package/src/web/public/assets/index-DDEmDb0K.css +1 -0
  21. package/src/web/public/assets/{index-DPjpWSaw.js → index-Dk-VfQfE.js} +9 -9
  22. package/src/web/public/assets/{view-CpkUF3Xr.js → view-BaXP_gJq.js} +1 -1
  23. package/src/web/public/assets/{view-C8iiWh9-.js → view-Bcy062Uw.js} +1 -1
  24. package/src/web/public/assets/{view-7t9TLUNM.js → view-Bji535Kc.js} +1 -1
  25. package/src/web/public/assets/view-CgLZr6WS.css +1 -0
  26. package/src/web/public/assets/{view-DYFqX9mq.js → view-ChbT0W1t.js} +1 -1
  27. package/src/web/public/assets/view-D_mRuEro.js +46 -0
  28. package/src/web/public/index.html +2 -2
  29. package/src/web/public/assets/index-BiDzPPaq.css +0 -1
  30. package/src/web/public/assets/view-qaSrOBcK.js +0 -42
package/CHANGELOG.md CHANGED
@@ -2,6 +2,129 @@
2
2
 
3
3
  本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
4
4
 
5
+ ## 0.27.0 (2026-11-08)
6
+
7
+ **表格字段重做:列头 ⋮ 属性菜单、行列都有抓手、去掉了那一整块「列设置」表单。**
8
+
9
+ 原来的表格能用,但长得像后台配置页:表格下面铺着一整块「列设置」,一列一行、七八个输入框
10
+ (key / 名称 / 类型 / 汇总 / 单位 / 默认值 / 密文 / ←→×),而行与列的操作散在两端。
11
+ 这一版按 Notion 数据库的手感重排:**表头就是属性入口,操作都收在 hover 里,静止时这页只有数据**。
12
+
13
+ ### Changed
14
+
15
+ - **列头 ⋮ 打开属性菜单**:列名 / 类型(含「计算列(JS)」)/ key / 单位 / 默认值 / 表尾统计 / 密文 /
16
+ 下拉选项 / 公式,以及左移右移、左右插入、复制属性、删除属性。弹层按锚点定位,空间不够自动翻到上方。
17
+ - **表头末尾 `+`**:加列的入口长在列该长的位置;原来底栏那个「+ 添加列」只在空表时出现。
18
+ - **行抓手挪到左侧沟槽**:静止显示行号,hover 变成 `⋮`(在上/下方插入、复制、删除)+ `⠿`(拖动排序)。
19
+ 原来行操作挤在行尾 78px 的操作列里,现在表格右侧干净了。
20
+ - **表尾「新建一行」**:加行不用回工具栏;回车在最后一格仍然能续行。
21
+ - **质感**:格子不再是「描边输入框」——无边框、无底色,只有 1px 网格线;hover 整行浮起,
22
+ focus 才在格子里显一圈极淡边界。表头小字灰色、计算列浅底、统计行加粗、数字等宽右对齐。
23
+ - 删掉了整块「列设置」面板(`.tf-cols` / `.tf-colrow`),列定义现在只在列头弹层里改。
24
+
25
+ ### Fixed
26
+
27
+ - **勾选列被撑成一整格黑块**:复选框套了给文本框用的 `.tf-input`(width/height 100%),
28
+ 现在单独用 `.tf-check`(15px)。
29
+ - **弹层跑到屏幕左上角**:行/列的 `⋮` 原本用 `display:none` 藏在 hover 里,键盘或脚本点开时
30
+ `getBoundingClientRect()` 全是 0。改成「常驻布局 + opacity 切换」,行号绝对定位叠在同一格上。
31
+
32
+ ## 0.26.0 (2026-11-08)
33
+
34
+ **原始资料(`source`)改成真正的侧边栏;块头 / 面板标题整行都能拖。**
35
+
36
+ 它本来就有 master-detail 的骨架(左清单 / 右正文),但外面套的是「全屏遮罩 + 模态 dialog」:
37
+ 打开时整页被压暗、还被块的 `overflow: hidden` 裁掉一角——整理素材时看不见原文,
38
+ 而「整理」恰恰要求两边对着看。
39
+
40
+ ### Changed
41
+
42
+ - `SourceDrawer` 用 `createPortal` 挂到 `document.body`,定位改为**窗口右侧通栏**
43
+ (`position: fixed`,宽 `min(720px, 46vw)`,窄屏 94vw),带一条柔和投影与轻微的滑入动画。
44
+ - **去掉遮罩与 `aria-modal`**:改成 `role="complementary"`,左边条目仍然可读、可滚;
45
+ 关闭走「关闭」按钮 / `Esc`(焦点仍然归还折叠条)。
46
+ - 层级排好:侧边栏 45 < 弹窗 50,toast 提到 70(不会被侧边栏盖住)。
47
+ - **块头、面板标题行整条都是抓手**(不再只靠那个 10px 的 `⠿`):hover 给浅底、`⠿` 平时淡显
48
+ hover 变实、光标 `cursor: grab`——三件事一起说「这行能拖」。块体(输入区)仍然不能拖,
49
+ 否则选词会变成搬块;面板行内按钮也不会触发拖动。
50
+
51
+ ### Fixed
52
+
53
+ - **拖块不再「卡手」:改成插入式排序**(用户反馈「只能显示在可以显示的位置」)。
54
+ 网格里全宽块只有 `x=0` 一个落点、半宽块只有两个,用引擎的自由拖拽时块只能在「合法格」
55
+ 之间跳,还会被纵向压实实时吸回去——拖到哪儿都不对。现在:
56
+ - 拖动只表达**顺序**:拖到某块的上/下半,出现一条 2px 插入线(与表格搬行同一套语义),
57
+ 松手按新顺序重新铺格(`repackBlocks`,**保留每块原来的宽度**)。
58
+ - 宽度仍是独立自由度:拖块的左右边缘改宽,所以「单列变多列」照旧成立。
59
+ - 拖动过程中不再有实时重排(引擎的 drag 已关掉),落点所见即所得。
60
+ - **「拖拽有点难触发」的真因**:上一版把 `Block` 组件直接当成网格的子元素,而
61
+ `react-grid-layout` 靠 `cloneElement` + `ref` 给子元素注入定位与拖拽手柄——函数组件接不住
62
+ ref,按下时抛 `<DraggableCore> not mounted on DragStart`,拖动因此在 mousedown 就断了。
63
+ 现在子元素回到原生 `div`(`.blk-cell`),块放在里面。
64
+
65
+ ## 0.25.0 (2026-11-08)
66
+
67
+ **正文改成「按内容长」的文档:md 有多长就多高,界面去掉边框,读的内容回到第一眼。**
68
+
69
+ 上一版把正文块化了,但块高是固定的:md 正文被塞进一个 220px 的框里滚动——那还是「面板」的思路,
70
+ 不是「读文档」的思路。这一版把高度的决定权还给内容,并顺手把视觉收干净。
71
+
72
+ ### Changed
73
+
74
+ - **块高由内容决定**:行高压到 8px 一档,用 `ResizeObserver` 量块里的内容(md 正文、附件列表、
75
+ 清单/表格、自动增高的 textarea),显示高度 = max(存储的最小高, 内容高度)。内容删掉就缩回最小高。
76
+ - 存的 `h` 因此变成**最小高**:版式 JSON 描述「这个栏目怎么排」,不是「这一条内容有多高」——
77
+ 后者每条记录都不一样,不该落盘(视图期算出来的高度会在回写时剥掉)。
78
+ - **只保留左右缩放手柄**:高度既然跟内容走,再叠一个手动高度只会互相打架(拖宽 → 内容重排 → 高度自己跟)。
79
+ - **md 正文不再限高滚动**:`.md-preview` 去掉 `max-height: 340px / 560px`,阅读态连灰底也去掉,
80
+ 正文直接落在页面上;工具行(字数 / 图片数 / 编辑)退到 hover 才出现。
81
+ - **无边框**:新增 `--line`(rgba 8.5%)代替原来的实色分隔线;块与面板去掉边框与底色,
82
+ 靠留白与字号分层;把手、字段 key、面板操作平时不出现,hover / 聚焦才浮出。
83
+ - **现代控件**:输入框改成「无边框 + 浅底,focus 才显边界」;按钮/标签去掉浮雕投影,改为平色块 + hover 加深;
84
+ 顶部 tab 扁平化(选中只加深底色);浮层统一一层柔和投影。
85
+ - **阅读优先的排版**:条目名抬到标题级(16px / 600,仍是就地可改的输入);
86
+ 「+ 直接加字段」收成折叠项,不再常驻一行;块内正文字号 13px(工具条仍是 12px)。
87
+
88
+ ### Notes
89
+
90
+ - 版式 JSON 的 `h` 语义变了(最小高 / 8px 一档),但**结构与读写口径不变**:
91
+ `section get --json` / `section update --layout` 照旧;老版式读进来会被夹到最小高,
92
+ 视觉上相当于「内容多长就多长」,不需要迁移脚本。
93
+ - 全局样式改动波及所有页面(条目 / 栏目 / 附件 / 导出 / 设置),逐页截图确认过没有退化。
94
+
95
+ ## 0.24.0 (2026-11-08)
96
+
97
+ **条目正文块逻辑化:一个分组 = 一块面板,一个字段 = 面板里的一个块——能拖、能并排、窄屏自动折列。**
98
+
99
+ 过去正文是「分组小标题 + 自动填充的字段矩阵」:顺序由字段字典决定,想把「岗位」挪到「公司名称」
100
+ 右边、想让两块并排、想让长文独占一整行,都只能去改字段字典。现在**版式是独立的一份 JSON**
101
+ (`section.layout`),拖动即改、自动落盘,与「字段值」是两条保存链——拖一下版式不会把没填完的值一起提交。
102
+
103
+ ### Added
104
+
105
+ - **块面板**:每组一块面板,面板里每个字段一个块(块头 = 标签 + 密文/填写策略标签 + 字段 key)。
106
+ 底层用 `react-grid-layout`(MIT)做网格引擎:拖块头 `⠿` 搬块、拖右下角改宽高、单列变多列、
107
+ 窄屏按断点自动折成一列。碰撞 / 回弹 / 折行都交给引擎,**不自己实现面板引擎**——
108
+ 这一层最容易出错,也最没有复用价值。
109
+ - **版式单独存**(`section.layout`):
110
+ `{v, mode, panels[], groups{分组:{collapsed, layouts{断点:[{i,x,y,w,h}]}}}}`。面板顺序、折叠态、
111
+ 每个块的位置/宽度/高度都在里面,`i` 就是字段 key;坐标是列单位(lg 12 / md 10 / sm 6 / xs 4 / xxs 2)。
112
+ - **结构变了版式自愈**(幂等,读写两条路都过一遍):删字段 → 对应块消失且不留空位;加字段 →
113
+ 接到该组最底部(不插队打散已排好的版式);删分组整块移除、新分组接末尾。
114
+ 没存过版式 = 按默认编排铺一遍(窄字段半列、宽字段整行、窄屏单列)。
115
+ - **CLI 对等**:`section get --json` 带出**已按当前字段对齐**的 `layout`;
116
+ `section update --layout @layout.json` 整体写版式、`--clear-layout` 回到默认编排。
117
+ 端到端冒烟把「面板走的那条 HTTP PATCH」与 CLI 两头都钉住了。
118
+ - **落位吸附**:半宽的块从「缝上」(x=3 这种)吸附到 0 / 6 这类档位,两块并排才齐;
119
+ 用户特意拉的自由宽度(除不尽列数,如 5 列)保持原样,不被「自动摆正」。
120
+ - **表单视图**:一键切回单列流式(长文 `md` / 表格字段多时更好读),版式保留、切回来即恢复。
121
+
122
+ ### Notes
123
+
124
+ - 新增依赖 `react-grid-layout@^2.3.0`(含 `react-draggable` / `react-resizable`);构建产物 +约 105KB gzip
125
+ 在条目视图的懒加载分片里,首屏不受影响。
126
+ - 版式**不影响**条目值、完整度与 CLI 读写口径;老 store 没有 `layout` 也能直接用(读一次就按默认编排渲染)。
127
+
5
128
  ## 0.23.1 (2026-11-05)
6
129
 
7
130
  ### Added
@@ -193,6 +193,11 @@ nx-sk file open f_xxxx # 系统默认程序打开原件
193
193
  - 附件中心:**「清理未引用(N)」**一键删掉所有没人引用的附件(判据与「未引用」筛选片同一套);
194
194
  - 条目表单:**保存按钮固定在右上角**,有改动时顶栏出现「N 处改动未保存 · 保存」,
195
195
  长栏目滚到一半也看得见(底部也还有一个)。
196
+ - **正文是块面板**:一个分组一块面板、一个字段一块,拖块头整行换顺序、拖左右边缘改宽(可并排成多列);
197
+ 高度**跟内容走**(md 正文/附件/清单有多长就多高,不在块内滚);版式(`section.layout`)自动保存,
198
+ 与字段值两条保存链。想用命令改版式:
199
+ `nx-sk section get <栏目> --json`(读,已按当前字段对齐)/ `section update <栏目> --layout @layout.json`
200
+ / `section update <栏目> --clear-layout`(回默认编排)。形状与自愈规则见 [[10-sections-entries]] 第六节。
196
201
 
197
202
  求职条目里的「简历附件 / 头像图片 / 成绩单 / 证书扫描件」是 `file` 字段,
198
203
  它的值是**附件 id 的列表**——一个字段可以挂**多个**附件(简历多版本、头像多张)
@@ -278,7 +283,7 @@ nx-sk source update <条目> --id <itemId> --mapped 字段key # 标注「这
278
283
  nx-sk source ensure # 给老栏目补上这个字段(模板是后加的,老栏目没有)
279
284
  ```
280
285
 
281
- 面板上:条目表单底部一条**折叠条**「原始资料 · N 条」,点开是右侧抽屉(左清单、中正文),
286
+ 面板上:条目表单底部一条**折叠条**「原始资料 · N 条」,点开是右侧**侧边栏**(左清单、中正文;不遮不挡,左边条目还能读),
282
287
  文件可拖进去(也给了「选择文件」按钮与粘贴框——拖拽不能是唯一入口,WCAG 2.2 AA)。
283
288
  三条纪律:**原文不改写**、**不确定就留空**、**敏感内容不进袋**(袋是明文的;
284
289
  密码走场景栏目的密文字段,不进袋)。
@@ -7,7 +7,7 @@
7
7
  ```
8
8
  store.json(~/nx-sk/store.json 或 $NX_SK_STORE)
9
9
  ├─ settings 全局配置(可读 JSON/Markdown 导出见 60-transfer)
10
- ├─ sections[] 栏目:id / title / order / template / titleField / groups[] / fields[]
10
+ ├─ sections[] 栏目:id / title / order / template / titleField / groups[] / fields[] / layout
11
11
  └─ entries[] 条目:id(e_xxx) / section / title / tags[] / values{字段key: 值} / 时间戳
12
12
  ```
13
13
 
@@ -171,7 +171,8 @@ nx-sk section update job --fields @fields.json
171
171
  > 还能有**计算列**(JS 表达式)与**表尾统计**。最根本的差别是**结构归数据**:列定义、行、公式、
172
172
  > 统计配置全部存在**条目值里**(`{v, columns, rows, totals?, summary?}`),字段定义里的 `columns`
173
173
  > 只是「新建时的初始列」。所以「这一条想多一列」不必改 schema,也不影响同栏目其它条目。
174
- > 面板上**行与列都能拖**:拖表头搬列、拖行尾的 ⠿ 搬行(松手前有插入线)。
174
+ > 面板上**列头有 ⋮ 属性菜单**(改名 / 换类型 / 单位 / 汇总 / 默认值 / 密文 / 选项 / 公式 + 左右插入 / 复制 / 删除),
175
+ > 表头末尾有 `+` 加列;**行与列都能拖**(拖表头搬列、拖行左缘的 ⠿ 搬行,松手前有插入线)。
175
176
  >
176
177
  > ```bash
177
178
  > nx-sk section update mysec --add-field '{"key":"tasks","label":"任务表","type":"table"}'
@@ -187,7 +188,7 @@ nx-sk section update job --fields @fields.json
187
188
  > 杂乱资料(邮件、截图、PDF 摘录、手抄的表)先**原样**丢进袋里,之后由 agent 或人
188
189
  > 按字段字典整理进上面的字段——「先乱入、后整理」因此成立:没有这个袋子,用户只有
189
190
  > 把乱数据硬塞进结构化字段(把结构搞脏)或干脆不存(信息丢了)两个坏选择。
190
- > - 面板上:条目表单底部一条**折叠条**「原始资料 · N 条」,点开是右侧抽屉——
191
+ > - 面板上:条目表单底部一条**折叠条**「原始资料 · N 条」,点开是右侧**侧边栏**(不遮不挡,左边条目还能读)——
191
192
  > 左清单、中间正文,文件可拖进去(也给了「选择文件」按钮与粘贴框:拖拽不能是唯一入口)。
192
193
  > - CLI:`nx-sk source list / get / add / update / remove / ensure`(细节见 `sk-source` skill)。
193
194
  > - 三条纪律:**原文不改写**(整理结果写进字段或新的一条)、**不确定就留空**、
@@ -272,11 +273,52 @@ nx-sk entry get 张三
272
273
 
273
274
  `nx-sk serve` → 每个栏目一个 tab,共用同一个条目视图:
274
275
 
275
- - 左侧条目列表(带完整度),右侧按分组渲染的字段表单;
276
+ - 左侧条目列表(带完整度),右侧**按分组渲染的块面板**:一个分组 = 一块面板,一个字段 = 面板里的一个块;
277
+ - **块布局(默认)**:拖**块头整行**(不用对准 ⠿)**换顺序**——拖到某块的上/下半就插到它前面/后面;
278
+ 拖块的**左右边缘**改宽度(单列可变多列);拖**面板标题行**换面板顺序。
279
+ 块可以并排,窄屏自动折成一列(响应式由网格引擎按断点算,不是手写媒体查询)。
280
+ **高度跟内容走**:`md` 正文、附件列表、清单表格有多长就多高,不在块内滚(阅读优先);删掉内容再缩回最小高。
281
+ **版式改动自动保存**,与字段值的「保存」互不影响——拖一下版式不会把没填完的值一起提交;
282
+ - **表单视图**:同一份数据按字段顺序单列铺开(长文 `md`、`table` 字段多时更好读);切回块布局版式原样恢复;
276
283
  - **「栏目」页的「编辑」弹窗能改整个结构**:分组可新增 / 改名(含换 id)/ 上下挪 / 删(空的才让删),
277
284
  字段可改标签、类型、分组、提示、必填策略、组内上下挪、删除 —— 一次改动一次落盘;
278
285
  - 密文字段默认显示原文;「打码显示(投屏/截图时用)」勾选等价于 `--mask`(选择持久化在 localStorage);
279
286
  - 保存是 **PATCH**:只有被你改动过的字段才会发出去(面板会显示「N 处改动」);
280
287
  - 每个页面底部会派生显示该模块的 CLI 等价命令(可点击复制)。
281
288
 
289
+ ### 版式(`section.layout`):**显示关系单独存**
290
+
291
+ 「有哪些字段」(字段字典)与「这些块怎么摆」(版式)是两件事,所以版式是栏目上**另一份 JSON**:
292
+
293
+ ```json
294
+ {
295
+ "v": 1,
296
+ "mode": "grid", // grid = 面板块 · flow = 单列表单
297
+ "panels": ["basic", "contact"], // 面板(分组)顺序
298
+ "groups": {
299
+ "basic": {
300
+ "collapsed": false,
301
+ "layouts": { // 按断点各一份,坐标是**列单位**
302
+ "lg": [{ "i": "name", "x": 0, "y": 0, "w": 6, "h": 2 }]
303
+ }
304
+ }
305
+ }
306
+ }
307
+ ```
308
+
309
+ - `i` = 字段 key;`x / y / w / h` 是列单位(`lg` 12 列、`md` 10、`sm` 6、`xs` 4、`xxs` 2)。
310
+ `h` 是**最小高**(一档 8px + 10px 间距):实际显示高度 = max(最小高, 内容量到的高度),
311
+ 所以同一份版式套在不同内容上都能读。底层是 `react-grid-layout` 的网格形状,面板只管喂 JSON;
312
+ 碰撞、回弹、折列都由引擎算。
313
+ - **结构变了版式自愈**(幂等,读写两条路都过一遍):删字段 → 对应块消失且不留空位;
314
+ 加字段 → 接到该组最底部(不插队打散已排好的版式);删分组 → 整块移除;新分组 → 面板接在末尾。
315
+ - 没存过版式 = 按默认编排铺一遍(窄字段半列、宽字段整行,窄屏单列),**不是**没有版式。
316
+ - 版式不参与完整度、不影响条目值;`flow` 模式下版式仍保留。
317
+
318
+ ```bash
319
+ nx-sk section get job --json # 含 layout(已按当前字段对齐)
320
+ nx-sk section update job --layout @layout.json # 整体写版式(面板拖完回写的就是这条)
321
+ nx-sk section update job --clear-layout # 回到默认编排(不是删成 null)
322
+ ```
323
+
282
324
  面板上没有 CLI 做不到的事——两边共享同一套 action 声明。
@@ -13,11 +13,11 @@
13
13
 
14
14
  | type | 值形状 | 结构(列)归谁 | 面板怎么编 | 读它 |
15
15
  | --- | --- | --- | --- | --- |
16
- | `table` | `{v, columns, rows, totals?, summary?}` | **数据自己**(字段定义只给初始列) | 表格控件 + 「列设置」;**拖表头搬列、拖行尾 ⠿ 搬行** | `entry table` / `entry get` |
16
+ | `table` | `{v, columns, rows, totals?, summary?}` | **数据自己**(字段定义只给初始列) | 表格控件:**列头 ⋮ 属性菜单**、表头 + 加列;拖表头搬列、拖行左缘 ⠿ 搬行 | `entry table` / `entry get` |
17
17
  | `entrylist` | `[{列key: 值}, …]` | 字段字典的 `fields` | 密集迷你表(直接增删行,**拖 ⠿ 调行序**) | `entry get` |
18
18
  | `md` | 一段 markdown 字符串 | — | 图文编辑器(Ctrl+V 贴图) | `entry get`(图片还原成路径) |
19
19
  | `file` | `[附件id, …]` | 字段字典的 `kind` / `maxItems` | 附件选择器(上传 / 从库里选) | `entry get`(附件名 + 物理路径) |
20
- | `source` | `[{id, kind, name, text\|fileId\|url, addedAt, …}]` | 内置(每个栏目自带一个) | 底部折叠条 → 右侧抽屉 | `source get` |
20
+ | `source` | `[{id, kind, name, text\|fileId\|url, addedAt, …}]` | 内置(每个栏目自带一个) | 底部折叠条 → 右侧侧边栏 | `source get` |
21
21
  | `secret` | 一个字符串(落盘是密文) | — | 输入框 | `key get` / `entry get` |
22
22
 
23
23
  共同点:**写入即校验**(不合法就整条拒绝,不会存半条)、**空值有明确语义**、
@@ -120,20 +120,33 @@
120
120
  默认值只在**新增行**时用(面板「添加行」按列的 `default` 预填),不会回头改已有行;
121
121
  字段级的默认是:初始列 + 表尾自动统计(数字 sum / 勾选 countTrue)+ 列类型默认 text。
122
122
 
123
- ### 面板上怎么调顺序(拖)
123
+ ### 面板上怎么改结构、怎么调顺序
124
124
 
125
- 排序是表格最常见的编辑动作之一,所以**行与列都能拖**,不用去点箭头:
125
+ **列管理在列头上**:鼠标移到表头 → 右侧出现 `⋮` → 打开这一列的属性菜单:
126
126
 
127
- - **搬列**:直接拖**表头**(表头里没有输入框,不会跟选词打架);「列设置」里每列前面的 ⠿
128
- 也能拖,效果一样。
129
- - **搬行**:拖行尾的 ⠿。整行 `draggable` 不这么做——那会跟单元格里选文本冲突
130
- (想选一段字结果把行拖走了),所以把手是唯一的拖拽入口。
131
- - 松手前会有一条**插入线**落在上/下半(行)或左/右半(列)的位置:线在哪就插到哪。
132
- 只高亮「这一行」是做不到这件事的。
133
- - ↑↓ / ←→ 保留(鼠标悬停在行上才浮出来),键盘与「就挪一格」的场景仍然走得通;
134
- 行内操作键统一是 16px 的小图标键——密集表里的按钮不该抢内容的注意力。
127
+ | 菜单里能做什么 | 说明 |
128
+ | --- | --- |
129
+ | 列名 / 类型 | 就地改;改成「计算列(JS)」后填公式 |
130
+ | key / 单位 / 默认值 | key 是公式里引用的名字(改 key 会连值一起搬) |
131
+ | 统计 | 表尾这一格怎么算(求和 / 平均 / 计数 / 不统计…) |
132
+ | 密文 | 这一列的值以 AES-256-GCM 密文落盘 |
133
+ | 选项 | `select` 类型的候选项,逗号分隔 |
134
+ | 左移 / 右移 | 挪一格 |
135
+ | 向左 / 向右插入列 | 在它旁边插一列空列 |
136
+ | 复制属性 | 连**值**一起复制出一列(`xxxCopy`) |
137
+ | 删除属性 | 连值一起删(只删定义会留下「没有列的数据」) |
138
+
139
+ 表头**末尾有一个 `+`**:加列就在列该长的位置,不用回头去工具栏找。
140
+
141
+ 排序:行与列都能拖,拖到哪儿有一条**插入线**告诉你会插在哪:
142
+
143
+ - **搬列**:直接拖**表头**(表头里没有输入框,不会跟选词打架)。
144
+ - **搬行**:拖行**左侧沟槽**的 ⠿(hover 时行号会变成 `⋮`(行菜单:上下插入 / 复制 / 删除)+ `⠿`)。
145
+ 整行 `draggable` 不这么做——那会跟单元格里选文本冲突(想选一段字结果把行拖走了)。
146
+ - 表尾有一整行「**新建一行**」:加行不用回工具栏;回车在最后一格也能直接续一行。
147
+ - 行内操作统一是小图标,hover 才出现——密集表里的按钮不该抢内容的注意力。
135
148
 
136
- `entrylist` 的行同理(拖 ⠿),删除键也从「一颗正常大小的按钮」换成了小图标键。
149
+ `entrylist` 的行同理(拖 ⠿),删除键也是小图标键。
137
150
 
138
151
  ### 常见错 → 改法
139
152
 
@@ -173,7 +186,7 @@
173
186
  - 每个非 KV 栏目自带一个:杂乱资料先**原样**入袋(不改写),之后按字段字典整理。
174
187
  - **`text` 的底层是 markdown**——聊天记录图文混排,图和文不能分开存:
175
188
  正文里直接写 `![说明](f_xxx)`(附件库的图)或 `![说明](https://…)`(外链图);
176
- 面板抽屉里图片 Ctrl+V / 拖进编辑器就自动上传并插入引用。
189
+ 面板侧边栏里图片 Ctrl+V / 拖进编辑器就自动上传并插入引用。
177
190
  普通文字本来就是合法 markdown,老数据不用迁移。
178
191
  - 不计完整度(`fill: optional`);袋里是明文,**敏感内容不要进袋**。
179
192
 
@@ -94,7 +94,7 @@ nx-sk source add <条目> --url "https://qcloud.sicau.edu.cn/login"
94
94
  ```
95
95
 
96
96
  - `--file` **默认复制不搬走**原件(这是「加一条资料」,不是 `file add` 的搬运语义)。
97
- - 一次要收很多份:面板里在「原始资料」抽屉中**多选文件**拖进去,或用 `--file` 重复调用。
97
+ - 一次要收很多份:面板里在「原始资料」侧边栏中**多选文件**拖进去,或用 `--file` 重复调用。
98
98
  - 文件分发细则(PDF 怎么拆、截图怎么批量进、表格怎么变成清单行)见 [[20-intake]]。
99
99
 
100
100
  ### 第 2 步:读袋子
@@ -160,7 +160,7 @@ nx-sk section dump <栏目> --json # 完整度与未填清单(袋子
160
160
  ## 面板里怎么用
161
161
 
162
162
  条目表单底部有一条**折叠条**「原始资料 · N 条」(暂存区不该在日常填表时占地方)。
163
- 点开是右侧抽屉:左边清单、中间正文。
163
+ 点开是右侧侧边栏:左边清单、中间正文(不遮挡条目)。
164
164
 
165
165
  - 拖文件进左栏 → 自动上传并入袋
166
166
  - 「+ 选择文件」按钮 → 同样的事(**拖拽不是唯一入口**:WCAG 2.2 AA 要求键盘/单指针也能完成)
@@ -43,7 +43,7 @@ nx-sk source add <条目> --file "D:\资料\用户指南.pdf" --name "平台用
43
43
 
44
44
  ## 批量进一批文件
45
45
 
46
- 面板最快:条目表单底部「原始资料」折叠条 → 抽屉 → 「+ 选择文件」(可多选)
46
+ 面板最快:条目表单底部「原始资料」折叠条 → 右侧侧边栏 → 「+ 选择文件」(可多选)
47
47
  或直接把一批文件拖进左栏。
48
48
 
49
49
  CLI 逐个加(每条一次调用):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.23.1",
3
+ "version": "0.27.0",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,6 +45,7 @@
45
45
  "dependencies": {
46
46
  "react": "^18.3.1",
47
47
  "react-dom": "^18.3.1",
48
+ "react-grid-layout": "^2.3.0",
48
49
  "react-markdown": "^10.1.0",
49
50
  "remark-breaks": "^4.0.0",
50
51
  "remark-gfm": "^4.0.1",
@@ -8,6 +8,9 @@ import { isCipherBlob, maskValue } from './crypto.js';
8
8
  import { FILL_LABELS, fillPolicy, isExcludedField, isSensitiveField } from './fields.js';
9
9
  // 表格的「字段定义 + 数据」怎么合成一份自描述文档,只有一份实现(见 shared/table.js)。
10
10
  import { columnToDef, normalizeSpec } from '../shared/table.js';
11
+ // 版式(块布局)的对齐规则也只有一份:读出去之前先按当前字段自愈一遍,
12
+ // 面板与 CLI 才看到同一份「现在实际怎么摆」。
13
+ import { groupsForLayout, reconcileLayout } from '../shared/blockLayout.js';
11
14
 
12
15
  export function isBlank(v) {
13
16
  if (v === null || v === undefined) return true;
@@ -207,6 +210,9 @@ export function dumpSection(section, entries, { mask = false, decrypt } = {}) {
207
210
  fields: section.fields.length,
208
211
  },
209
212
  groups: section.groups,
213
+ // 正文版式:分组面板的顺序、每块的位置/宽度/高度(按断点)。字段增删后在这里
214
+ // 对齐一次,所以读到的永远能直接渲染——不会出现「引用了已删字段的空块」。
215
+ layout: reconcileLayout(section.layout, groupsForLayout(section)),
210
216
  fields: section.fields.map((x) => ({
211
217
  key: x.key,
212
218
  label: x.label,
package/src/core/store.js CHANGED
@@ -6,6 +6,7 @@ import { displayPath, migrationDir, storeFile } from './paths.js';
6
6
  import { ensureDir, pathExists, writeAtomic } from './fsx.js';
7
7
  import { ensureSourceBuiltin, instantiateTemplate, seedEntries, seedSections, syncSectionGroups } from './fields.js';
8
8
  import { normalizeFileRecord } from './attachments.js';
9
+ import { sanitizeLayout } from '../shared/blockLayout.js';
9
10
  import { nowIso, stamp } from './ids.js';
10
11
 
11
12
  /** v2:密钥栏目从 8 字段模板改成单字段 KV(名称 → 值)。
@@ -50,6 +51,9 @@ function normalizeSection(raw) {
50
51
  titleLabel: String(raw.titleLabel || '名称'),
51
52
  groups: Array.isArray(raw.groups) ? raw.groups.map((g) => ({ id: String(g.id), title: String(g.title || g.id) })) : [],
52
53
  fields: Array.isArray(raw.fields) ? raw.fields.map((x) => ({ ...x, key: String(x.key), label: String(x.label || x.key) })) : [],
54
+ // 版式(块布局)是**显示关系**,与字段字典分开存:结构归栏目,版式归这一份 JSON。
55
+ // 读的时候一律过 sanitizeLayout——手搓的脏 JSON 不许进 store,也不许让面板白屏。
56
+ layout: sanitizeLayout(raw.layout),
53
57
  createdAt: raw.createdAt || null,
54
58
  updatedAt: raw.updatedAt || null,
55
59
  };
@@ -3,7 +3,8 @@ import { api } from '../../web/frontend/api/client.js';
3
3
  import { checkZipFile, fmtBytes, isNeedPassphraseError, postZip } from '../../web/frontend/api/zip.js';
4
4
  import { useStore } from '../../web/frontend/store.jsx';
5
5
  import { CliHints, Empty, Modal, Tag } from '../../web/frontend/components/ui.jsx';
6
- import { FieldGroup } from '../../web/frontend/components/fieldEditor.jsx';
6
+ import BlockBoard from '../../web/frontend/components/blockBoard.jsx';
7
+ import { reconcileLayout, sameLayout } from '../../shared/blockLayout.js';
7
8
  import KvTable from './kvTable.jsx';
8
9
 
9
10
  // 脏值比较用:对象/数组(tags、entrylist 的行)必须走 JSON 序列化——
@@ -140,6 +141,31 @@ export default function SectionView({ sectionId }) {
140
141
  return data.groups.map((g) => ({ ...g, fields: data.fields.filter((f) => f.group === g.id) })).filter((g) => g.fields.length);
141
142
  }, [data]);
142
143
 
144
+ // —— 正文版式(块布局)——
145
+ // 版式是**显示关系**,存在 section.layout 里(与字段字典分开),所以有两条保存链:
146
+ // 拖块 / 改宽高 / 换面板顺序只写版式(防抖自动存),填字段仍然走「N 处改动 → 保存」。
147
+ // 这样拖一下版式不会把没填完的值一起提交,反过来改字段也不会打乱版式。
148
+ const [board, setBoard] = useState(null);
149
+ const boardTouched = useRef(false);
150
+ const boardTimer = useRef(null);
151
+ useEffect(() => { boardTouched.current = false; setBoard(null); }, [sectionId]);
152
+ // 服务端读回来的版式已经按当前字段对齐过;只有当用户还没动过版式时才跟着刷新
153
+ // (否则「保存字段」触发的重读会把刚拖好的版式弹回去)
154
+ useEffect(() => { if (!boardTouched.current && data?.layout) setBoard(data.layout); }, [data]);
155
+ useEffect(() => () => { if (boardTimer.current) clearTimeout(boardTimer.current); }, []);
156
+ const boardView = useMemo(() => reconcileLayout(board || data?.layout, groups), [board, data, groups]);
157
+ const applyBoard = (next) => {
158
+ if (sameLayout(next, boardView)) return;
159
+ boardTouched.current = true;
160
+ setBoard(next);
161
+ if (boardTimer.current) clearTimeout(boardTimer.current);
162
+ boardTimer.current = setTimeout(guard(async () => {
163
+ boardTimer.current = null;
164
+ await api(`/api/sections/${encodeURIComponent(sectionId)}`, { method: 'PATCH', body: { layout: next } });
165
+ toast('版式已保存');
166
+ }), 700);
167
+ };
168
+
143
169
  const patchField = (key, value) => setDraft((d) => (d ? { ...d, values: { ...d.values, [key]: value } } : d));
144
170
 
145
171
  const dirty = useMemo(() => {
@@ -479,31 +505,43 @@ export default function SectionView({ sectionId }) {
479
505
  ) : null}
480
506
  </div>
481
507
 
482
- <div className="field wide pad" style={{ borderBottom: '1px solid var(--soft-2)' }}>
483
- <div className="flabel">
484
- <span>{data.section.titleLabel || '名称'}</span>
485
- <span className="bad">改动后必须保存</span>
486
- </div>
487
- <div className="fctrl">
488
- <input value={draft.title} onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))} />
489
- </div>
490
- </div>
491
-
492
- <div className="rowgap" style={{ padding: '6px 14px', borderBottom: '1px solid var(--soft-2)' }}>
508
+ {/* 条目名是「这一页的标题」,不是第一个字段:给它一点标题的语气,
509
+ 但仍然是可编辑的输入,不让用户以为要另开弹窗改名 */}
510
+ <div className="entry-title pad">
511
+ <div className="entry-title-label">{data.section.titleLabel || '名称'}</div>
493
512
  <input
494
- className="grow"
495
- style={{ minWidth: 220 }}
496
- placeholder="直接加字段:字段=值 (回车写入;没见过的键会自动登记)"
497
- value={quick}
498
- onChange={(e) => setQuick(e.target.value)}
499
- onKeyDown={(e) => { if (e.key === 'Enter') quickAdd(); }}
513
+ className="entry-title-input"
514
+ value={draft.title}
515
+ onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
516
+ aria-label={data.section.titleLabel || '名称'}
500
517
  />
501
- <button className="btn small" onClick={quickAdd} disabled={!quick.includes('=')}>写入</button>
502
518
  </div>
503
519
 
504
- {groups.map((g) => (
505
- <FieldGroup key={g.id} title={g.title} fields={g.fields} draft={draft.values} onPatch={patchField} sectionTitle={data.section.title} entryTitle={current?.title || draft.title} />
506
- ))}
520
+ {/* 「直接加字段」是给熟手的旁路,平时收起来——正文优先,别让一行表单常驻在顶部 */}
521
+ <details className="quick-add">
522
+ <summary>+ 直接加字段</summary>
523
+ <div className="rowgap">
524
+ <input
525
+ className="grow"
526
+ style={{ minWidth: 220 }}
527
+ placeholder="字段=值 (回车写入;没见过的键会自动登记)"
528
+ value={quick}
529
+ onChange={(e) => setQuick(e.target.value)}
530
+ onKeyDown={(e) => { if (e.key === 'Enter') quickAdd(); }}
531
+ />
532
+ <button className="btn small" onClick={quickAdd} disabled={!quick.includes('=')}>写入</button>
533
+ </div>
534
+ </details>
535
+
536
+ <BlockBoard
537
+ groups={groups}
538
+ board={boardView}
539
+ onBoard={applyBoard}
540
+ draft={draft.values}
541
+ onPatch={patchField}
542
+ sectionTitle={data.section.title}
543
+ entryTitle={current?.title || draft.title}
544
+ />
507
545
 
508
546
  <div className="rowgap pad">
509
547
  <button className="btn" onClick={save} disabled={!dirty.count}>保存{dirty.count ? `(${dirty.count} 处改动)` : ''}</button>
@@ -77,7 +77,7 @@ export default {
77
77
  id: 'section.update',
78
78
  cli: ['section', 'update'],
79
79
  http: ['PATCH', '/api/sections/:ref'],
80
- summary: '改栏目(PATCH 语义:只改传入项)。**整个结构都能改**:分组用 add-group / rename-group / move-group / remove-group,字段用 add-field / set-field / move-field / remove-field / fields / fill;--kv 标成 KV 表',
80
+ summary: '改栏目(PATCH 语义:只改传入项)。**整个结构都能改**:分组用 add-group / rename-group / move-group / remove-group,字段用 add-field / set-field / move-field / remove-field / fields / fill;--kv 标成 KV 表;--layout 整体写版式(块布局),--clear-layout 回到默认编排',
81
81
  args: ['ref'],
82
82
  flags: {
83
83
  title: { type: 'string' },
@@ -97,6 +97,8 @@ export default {
97
97
  'move-group': { type: 'json', hint: '{"id":"contact","dir":"up|down"},可重复' },
98
98
  'remove-group': { type: 'array', hint: '分组 id,可重复。**非空不给删**,内置「原始资料」不可删' },
99
99
  fill: { type: 'kv', hint: '字段=normal|optional|avoid,可重复。optional 不必填、avoid 填了可能减分,两者都不计入完整度;改回照常填传 normal' },
100
+ layout: { type: 'json', hint: '整体写版式(块布局):{"mode":"grid|flow","panels":["分组id"],"groups":{"分组id":{"collapsed":false,"layouts":{"lg":[{"i":"字段key","x":0,"y":0,"w":6,"h":2}]}}}}。字段增删后旧版式会自动对齐(删掉的块移除、新字段接组尾)' },
101
+ 'clear-layout': { type: 'boolean', hint: '版式回到默认编排(按字段顺序铺一遍)' },
100
102
  'dry-run': { type: 'boolean' },
101
103
  },
102
104
  run: (ctx) => service.updateSection(ctx.ref, ctx),
@@ -5,6 +5,7 @@ import { assertFieldKey, assertSafeId } from '../../core/paths.js';
5
5
  import { badInput, blocked, conflict, notFound } from '../../core/errors.js';
6
6
  import { FILL_POLICIES, FIELD_TYPES, SOURCE_GROUP, ensureSourceBuiltin, findSection, getTemplate, groupFields, instantiateTemplate, mergeTemplate, sectionNames, syncSectionGroups, templateSummaries } from '../../core/fields.js';
7
7
  import { computeTable, sanitizeColumns, sanitizeTotals } from '../../shared/table.js';
8
+ import { groupsForLayout, reconcileLayout, sanitizeLayout, sameLayout } from '../../shared/blockLayout.js';
8
9
  import { completeness, dumpSection, formatValue } from '../../core/render.js';
9
10
  import { sensitiveViewer } from '../../core/vault.js';
10
11
  import { nowIso } from '../../core/ids.js';
@@ -184,6 +185,8 @@ export async function getSection(ref) {
184
185
  updatedAt: s.updatedAt,
185
186
  groupList: s.groups,
186
187
  fieldList: s.fields,
188
+ // 版式(块布局):agent 要照着改/复制版式时读这个字段,形状见 shared/blockLayout.js
189
+ layout: reconcileLayout(s.layout, groupsForLayout(s)),
187
190
  };
188
191
  }
189
192
 
@@ -240,6 +243,19 @@ export async function updateSection(ref, patch = {}) {
240
243
  // 标记/取消「本栏目是一张 KV 表」。取消要显式写 `--kv=false`(boolean flag 的 `=` 形式)
241
244
  if (patch.kv !== undefined) changes.kv = patch.kv === true;
242
245
  if (patch.titleField !== undefined) changes.titleField = patch.titleField ? assertFieldKey(patch.titleField) : null;
246
+ // —— 版式(块布局)——
247
+ // 「字段怎么摆」与「有哪些字段」是两件事:前者是一份独立的 JSON,可以整体读写,
248
+ // 也可以只让面板在拖拽后回写。--clear-layout 不是「删掉这份配置」而是「回到默认编排」——
249
+ // 删成 null 与没有配置在渲染上等价,不如直接落一份默认版式,读出来立刻可用。
250
+ const clearLayout = patch['clear-layout'] === true;
251
+ if (clearLayout) changes.layout = null;
252
+ else if (patch.layout !== undefined) {
253
+ const l = sanitizeLayout(patch.layout);
254
+ if (!l) {
255
+ throw badInput('--layout 需要版式 JSON 对象:{"mode":"grid|flow","panels":["分组id"],"groups":{"分组id":{"collapsed":false,"layouts":{"lg":[{"i":"字段key","x":0,"y":0,"w":6,"h":2}]}}}}');
256
+ }
257
+ changes.layout = l;
258
+ }
243
259
  if (patch.groups !== undefined) changes.groups = Array.isArray(patch.groups) ? patch.groups.map(validateGroupDef) : [];
244
260
  if (patch.fields !== undefined) {
245
261
  if (!Array.isArray(patch.fields)) throw badInput('--fields 需要是字段定义数组的 JSON');
@@ -461,8 +477,21 @@ export async function updateSection(ref, patch = {}) {
461
477
  if (synced.length !== base.length) changes.groups = synced;
462
478
  }
463
479
 
480
+ // 版式对齐:结构动过(增删字段 / 分组)或显式给了版式时,把版式按**最终结构**收拾一遍——
481
+ // 删掉的字段不再留空块,新字段接到组尾,删掉的分组整块移除。幂等:没动结构也没给版式时不落键,
482
+ // 免得「改个标题」也顺手写一份版式进 store。
483
+ const layoutGiven = changes.layout !== undefined;
484
+ const layoutBase = layoutGiven ? changes.layout : target.layout;
485
+ if (layoutGiven || (layoutBase && (changes.fields || changes.groups))) {
486
+ const next = reconcileLayout(layoutBase, groupsForLayout({
487
+ groups: changes.groups || target.groups,
488
+ fields: changes.fields || target.fields,
489
+ }));
490
+ if (layoutGiven || !sameLayout(next, layoutBase)) changes.layout = next;
491
+ }
492
+
464
493
  if (!Object.keys(changes).length) {
465
- throw badInput('没有要修改的内容(可改 title / description / order / fields / groups / add-field / set-field / move-field / remove-field / add-group / rename-group / move-group / remove-group / fill)');
494
+ throw badInput('没有要修改的内容(可改 title / description / order / fields / groups / add-field / set-field / move-field / remove-field / add-group / rename-group / move-group / remove-group / fill / layout / clear-layout)');
466
495
  }
467
496
 
468
497
  if (dryRun) return { status: 'skipped', dryRun: true, wouldChange: changes, added, removed, fillChanged, ops };