nx-sk 0.16.0 → 0.18.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 (37) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/README.md +14 -1
  3. package/assets/nx-sk/SKILL.md +11 -1
  4. package/assets/nx-sk/references/40-files.md +36 -5
  5. package/assets/sk-needdo/SKILL.md +28 -3
  6. package/assets/sk-needdo/references/20-schema.md +5 -1
  7. package/assets/sk-needdo/references/30-fill.md +2 -2
  8. package/assets/sk-needdo/references/50-example.md +15 -9
  9. package/assets/sk-source/SKILL.md +1 -1
  10. package/package.json +1 -1
  11. package/src/core/fields.js +22 -7
  12. package/src/core/store.js +6 -1
  13. package/src/modules/entries/view.jsx +22 -1
  14. package/src/modules/files/index.js +9 -0
  15. package/src/modules/files/service.js +47 -0
  16. package/src/modules/files/view.jsx +32 -0
  17. package/src/modules/sections/service.js +9 -3
  18. package/src/modules/settings/navManager.jsx +102 -67
  19. package/src/modules/settings/view.jsx +3 -1
  20. package/src/web/frontend/App.jsx +69 -22
  21. package/src/web/frontend/components/fieldEditor.jsx +73 -6
  22. package/src/web/frontend/components/navModel.js +64 -7
  23. package/src/web/frontend/styles.css +60 -3
  24. package/src/web/public/assets/{files-Cc9BanTT.js → files--aIy09Sp.js} +1 -1
  25. package/src/web/public/assets/index-BWIrglCf.js +41 -0
  26. package/src/web/public/assets/index-CEIiV0jA.css +1 -0
  27. package/src/web/public/assets/{view-DtLbyce-.js → view-0jlrnuBr.js} +1 -1
  28. package/src/web/public/assets/{view-C_8pbtZX.js → view-1SYBZ3dg.js} +1 -1
  29. package/src/web/public/assets/view-Bt7O8gS_.js +2 -0
  30. package/src/web/public/assets/view-Bt8MZAtu.js +1 -0
  31. package/src/web/public/assets/view-CjX_ywS7.js +36 -0
  32. package/src/web/public/index.html +2 -2
  33. package/src/web/public/assets/index-B1tuW-Bw.js +0 -41
  34. package/src/web/public/assets/index-BotRwCy_.css +0 -1
  35. package/src/web/public/assets/view-C1Ux-1yp.js +0 -1
  36. package/src/web/public/assets/view-CLufVxNB.js +0 -35
  37. package/src/web/public/assets/view-U7H8BYBr.js +0 -1
package/CHANGELOG.md CHANGED
@@ -2,7 +2,83 @@
2
2
 
3
3
  本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
4
4
 
5
- ## [Unreleased]
5
+ ## 0.18.0 (2026-10-28)
6
+
7
+ **拖拽不再闪,悬浮不再乱弹;「原始资料」袋对每个栏目都成立。**
8
+
9
+ 这一版没有新栏目,都是「原来能用、但用起来别扭」的地方:设置页拖分组时列表自己
10
+ 在动,于是闪;头部悬浮分组入口时一秒钟弹开又收起好几个;自定义栏目丢了资料袋。
11
+
12
+ ### Changed
13
+
14
+ - **头部「分组入口」的悬浮展开加了意图延迟,并且同一时刻只开一个**:原先 `mouseenter`
15
+ 立刻弹、`mouseleave` 140ms 就收,鼠标横穿导航栏去点右边的 tab 时,路过的分组会被
16
+ 顺带弹开又收起(一眼看过去就是乱闪);各分组的展开态又各自持有,快速划过能同时
17
+ 叠出两个菜单。现在:停住 130ms 才展开(路过不算)、离开 180ms 后收起(划进菜单就撤销)、
18
+ 展开态交给 App 托管成「只开一个」、点击改为切换(触屏没有 hover,只能走这条路——
19
+ 按 `(hover: hover)` 分流,避免 tap 时合成的 mouseenter 先把菜单开掉、click 又把它关掉)。
20
+ 另加:展开时三角翻上去(只动 transform,不改布局)。
21
+ - **「原始资料」袋(`source` 字段)成为每个非 KV 栏目自带的结构**:此前只有模板实例
22
+ 自带,自定义栏目一旦传了 `--fields`/`--groups`(sk-needdo 的常规路径),袋就被
23
+ 整体覆盖掉了。现在内置规则收口为唯一函数 `ensureSourceBuiltin`,在 `instantiateTemplate`、
24
+ `section add`、`normalizeSection` 三处生效——存量栏目缺袋时读一次即自愈,
25
+ KV 表仍不加(形状固定)。sk-needdo 文档同步改为「杂乱输入先入袋、再整理」,
26
+ 并去掉字段设计里重复的「来源」text 字段(与 `source` 键冲突)。
27
+
28
+ ### Fixed
29
+
30
+ - **设置页「栏目管理」拖拽闪屏、边缘落点乱跳**:两处根因都在「拖的时候列表自己在动」。
31
+ - 闪屏:原先用**实时预览**(拖动中就把被拖的行挪到落点上)。行一挪,指针底下的元素
32
+ 就换了、下方容器整体位移一行(28px),`dragover` 于是算出另一个落点,再挪再算——
33
+ 自己推自己。现在拖动期间**列表静止**(被拖的那行半透明留在原地),落点用一条
34
+ **不占布局的指示线**(2px inset 压在行边上)表达:几何恒定 → 落点只随指针单调变化,
35
+ 松手一次性提交,结果就是指示线指的位置。拖影也从 10px 的把手换成整行。
36
+ - 边缘乱跳:容器空白处(缝隙、内边距、说明文字)原先一律判成「追加到末尾」,
37
+ 指针一进缝隙整列表就跳到队尾;现在整块容器都按**行中线**算插入位
38
+ (`navModel.slotAt`),边缘与缝隙都归到最近的一侧。
39
+ - 平铺区索引错位:`containerOf` 的平铺分支拿 `poolOrder`(含已归组 / 隐藏栏目的全量顺序)
40
+ 当行号,与屏幕上第几行差好几格,落点因此偏——改为按**可见行**(`viewRows`)取索引。
41
+ - 分组换位从「交换」改成「挪」:0 拖到 2 时中间那组上移一格,不再把 B 留在原地。
42
+ - 顺带:拖回原位不再提示「有未保存的改动」(导航按 `sameStructure` 判脏,
43
+ `order` 里留下的序列脚印不参与比较)。
44
+
45
+ ### 测试
46
+
47
+ - `nav-model.test.mjs` 新增 5 组用例:可见行口径(`viewRows` / 平铺区落点不偏格)、
48
+ 行中线取位(`slotAt`,缝隙不跳)、结构等价(`sameStructure`,拖回原位不算脏)、
49
+ 分组「挪」而非「交换」(`moveGroupTo`)。
50
+
51
+ ## 0.17.0 (2026-10-25)
52
+
53
+ **附件不再靠下拉框挑,孤儿附件能一键清,改动不用翻到页底才保存。**
54
+
55
+ 三处都是「长栏目 / 附件多了以后,原来的做法开始难受」逼出来的:
56
+
57
+ ### Added
58
+
59
+ - **附件库选择面板**(字段里的 `file` 字段):点「从附件库选…」打开的是
60
+ **可搜索的面板**,不是原生下拉框——库里几十个附件时,下拉框根本没法挑。
61
+ - **关键字搜索**:按名称 / 备注 / 标签过滤(Esc 关闭);
62
+ - **未引用优先**:没有任何条目引用的原件排在前面(刚导入还没挂上去的就在最上面),
63
+ 已被引用的排在后面并标注「已被 N 处引用」——仍然可选(同一原件可被多条条目共享);
64
+ - 点一行即**追加**到本字段,可连点多个;已挂上的会从列表里消失。
65
+ - **`nx-sk file cleanup`**:一键清理**未被任何条目引用**的附件(孤儿附件),
66
+ 物理原件一并删除、不可撤销。判据与附件中心的「未引用」筛选片**完全同一套**
67
+ (`fileReferences` 为空)——两套判据会出现「筛选片里看不到、清理却删了」这种吓人的不一致。
68
+ 有 `--dry-run`(先列清单);没有可清的返回 `skipped`(不是失败)。
69
+ 面板上:附件中心工具条「清理未引用(N)」,点了弹确认并报清数量。
70
+ - **条目表单的保存按钮固定在右上角**:有改动时表单顶栏出现「N 处改动未保存 · 保存」,
71
+ 且顶栏**吸顶**(`position: sticky`)——长栏目滚到一半也看得见保存,不必翻到表单最底。
72
+ 底部的保存按钮保留;关标签页前会拦一下(`beforeunload`),改到一半不留神关掉不丢改动。
73
+
74
+ ### Changed
75
+
76
+ - 字段里的 `file` 附件选择从原生 `<select>` 换成选择面板(原来只能在一堆选项里肉眼找)。
77
+
78
+ ### 测试
79
+
80
+ - 新增用例:`file cleanup` 只删未被引用的附件,被引用的原样留下(物理文件也在),
81
+ 试运行不动台账,反复清理不伤被引用的附件。
6
82
 
7
83
  ## 0.16.0 (2026-10-23)
8
84
 
package/README.md CHANGED
@@ -150,13 +150,14 @@ CLI 与 API 同源,skill 驱动 agent**。落到具体功能上:
150
150
  | `nx-sk routes [--module M] [--http "POST /api/entries"]` | 命令 ↔ 路由双向对照 |
151
151
  | `nx-sk section list \| get \| add \| update \| remove` | 栏目 CRUD |
152
152
  | `nx-sk section dump <id> [--mask]` | **一个栏目的全部信息**:字段台账 + 全部条目 + 每个条目的未填清单 |
153
- | `nx-sk section templates` | 内置模板(job / secret / projects / internships / campus / deploy) |
153
+ | `nx-sk section templates` | 内置模板(job / secret / projects / internships / campus / deploy / platform / thesis) |
154
154
  | `nx-sk section sync <id> [--dry-run]` | 按模板**补齐**本栏目缺的分组与字段(只增不改不删)——模板演进后让老栏目追上 |
155
155
  | `nx-sk entry list \| get \| add \| update \| remove` | 条目 CRUD(PATCH 语义) |
156
156
  | `nx-sk entry fields [--section job]` | 字段字典 —— 填之前先看这个,别猜字段名 |
157
157
  | `nx-sk file list \| get \| add \| update \| remove` | 附件 CRUD(原件在 `~/nx-sk/files/`,同内容去重) |
158
158
  | `nx-sk file add --path <文件> [--copy] [--category C]` | 导入附件:**默认移动原件**,`--copy` 仅复制 |
159
159
  | `nx-sk file list [--section <栏目\|none>] [--q 关键字]` | 列附件:按「哪些栏目在引用」筛选(`none` = 未引用);返回栏目汇总与存储目录 |
160
+ | `nx-sk file cleanup [--dry-run]` | **一键清理未被任何条目引用的附件**(孤儿附件,物理原件一并删);`--dry-run` 先看清单 |
160
161
  | `nx-sk file restore <id> [--to DIR] [--copy]` | **可逆**:移回原始位置并撤销台账;`--copy` 只导出副本 |
161
162
  | `nx-sk file content <id>` | 浏览器链接(内联预览 / `?download=1` 下载);CLI 打印物理路径 |
162
163
  | `nx-sk file open <id>` | 用系统默认程序打开原件 |
@@ -238,6 +239,8 @@ node assets/nx-sk/scripts/render-config.js /tmp/e.json ./docker-compose.local.ym
238
239
  nx-sk file add --path "D:\简历\张三-简历.pdf" # 默认移动原件进库
239
240
  nx-sk file list # 台账:按栏目汇总 + 被引用情况
240
241
  nx-sk file list --section none # 只看还没有条目引用的
242
+ nx-sk file cleanup --dry-run # 一键清理未引用附件:先看会删哪些
243
+ nx-sk file cleanup # 真删(物理原件一起删,不可撤销)
241
244
  nx-sk file restore f_xxxx # 可逆:移回原始位置、撤销台账
242
245
  nx-sk file restore f_xxxx --to "D:\资料" --copy # 或导出副本到指定目录
243
246
  ```
@@ -245,6 +248,16 @@ nx-sk file restore f_xxxx --to "D:\资料" --copy # 或导出副本到指定目
245
248
  **附件怎么被条目引用**:条目的 `file` 字段存的是**附件 id 的列表**——一个字段可以挂**多个**
246
249
  附件(简历多版本、头像多张)。id 全局唯一,所以每条条目各有各的附件、互不串台;
247
250
  同一份原件也能被多条条目引用(sha256 去重后是同一条)。
251
+
252
+ 面板上的三处配合:
253
+
254
+ - **字段里挑附件**:点「从附件库选…」打开的是**可搜索的选择面板**(不是下拉框)——
255
+ 按名称 / 备注 / 标签过滤,**未引用的排在前面**(刚导入还没挂上去的原件就在最上面),
256
+ 点一行即追加,可连点多个。
257
+ - **附件中心一键清理**:「清理未引用(N)」删掉所有没有任何条目引用的附件
258
+ (判据与「未引用」筛选片同一套),物理原件一并删除,操作前弹确认。
259
+ - **条目表单的保存按钮固定在右上角**:有改动时顶栏出现「N 处改动未保存 · 保存」,
260
+ 长栏目滚到一半也看得见,不必翻到表单最底;关标签页前还会拦一下。
248
261
  **上传 / 选择发生在栏目条目的字段里**(求职 → 张三 → 「简历附件」直接上传或从库里选),
249
262
  不在附件中心做指派:
250
263
 
@@ -174,11 +174,21 @@ nx-sk key remove OPENAI_KEY # 删除,不可撤销 → 先 nx-sk
174
174
  nx-sk file add --path "D:\简历\张三-简历.pdf" # 默认把原件移动进库(--copy 仅复制)
175
175
  nx-sk file list # 台账:按栏目汇总 + 被引用情况
176
176
  nx-sk file list --section none # 只看还没有条目引用的
177
+ nx-sk file cleanup --dry-run # 一键清理未引用附件:先看会删哪些
178
+ nx-sk file cleanup # 真删(物理原件一起删,不可撤销)
177
179
  nx-sk file restore f_xxxx # 可逆:移回原始位置并撤销台账
178
180
  nx-sk file restore f_xxxx --to DIR --copy # 或导出副本、库中保留
179
181
  nx-sk file open f_xxxx # 系统默认程序打开原件
180
182
  ```
181
183
 
184
+ **面板上对应三处**(用户在点,不必 agent 教,但知道行为更省事):
185
+
186
+ - 字段里挑附件:点「从附件库选…」是**可搜索的选择面板**,**未引用的排在前面**,
187
+ 点一行即追加到本字段;
188
+ - 附件中心:**「清理未引用(N)」**一键删掉所有没人引用的附件(判据与「未引用」筛选片同一套);
189
+ - 条目表单:**保存按钮固定在右上角**,有改动时顶栏出现「N 处改动未保存 · 保存」,
190
+ 长栏目滚到一半也看得见(底部也还有一个)。
191
+
182
192
  求职条目里的「简历附件 / 头像图片 / 成绩单 / 证书扫描件」是 `file` 字段,
183
193
  它的值是**附件 id 的列表**——一个字段可以挂**多个**附件(简历多版本、头像多张)
184
194
  (id 全局唯一 → 各条目互不串台;同内容去重后是同一条,可被多条条目共享):
@@ -286,7 +296,7 @@ nx-sk setting set --set 'navGroups=[]' # 清空 = 全部栏目恢复平铺
286
296
 
287
297
  | ref | 何时读取 |
288
298
  | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
289
- | `00-design` | 想搞懂**心智模型**时——栏目=结构、条目=数据、内置字段是什么、动态栏目 + 动态条目怎么用、四个聚合点在哪儿、密文边界,以及**东西该往哪儿放** |
299
+ | `00-design` | 想搞懂**心智模型**时——栏目=结构、条目=数据、内置字段是什么、动态栏目 + 动态条目怎么用、四个聚合点在哪儿、密文边界,以及**东西该往哪儿放** |
290
300
  | `10-sections-entries` | **动手填/改信息时必读**——栏目与条目的完整命令、AI 补全个人信息的推荐流程、字段字典怎么扩、读命令与写命令的参数陷阱 |
291
301
  | `20-secrets` | 密文机制、密钥从哪来(环境变量 vs 本机密钥文件)、KV 表的语义、什么时候会解不开 |
292
302
  | `30-credentials` | **做凭据自动化时读**——凭据归属哪个栏目、多组账号怎么建清单、跨栏目台账、把凭据渲染成 compose / k8s / .env 的完整流程与安全边界 |
@@ -30,13 +30,30 @@
30
30
  | 打开 | `nx-sk file open <id>`(本机默认程序) |
31
31
  | 还原 | `nx-sk file restore <id> [--to DIR] [--copy]` |
32
32
  | 删除 | `nx-sk file remove <id>` |
33
+ | **清理未引用** | `nx-sk file cleanup [--dry-run]` |
33
34
 
34
35
  - `file list --section <栏目>` 只看「被某栏目引用」的附件;`--section none` 只看**还没有任何条目引用**的。
35
36
  - `file content` 是浏览器链接:内联预览,加 `--download` 强制下载;CLI 侧打印物理路径与链接。
36
37
  - 所有写命令都支持 `--dry-run`;`file remove` 连物理原件一起删**不可撤销**——
37
38
  只想「拿出原件、库里留着」应该用 `file restore --copy`。
38
39
 
39
- ## 三、导入:默认移动,`--copy` 才复制
40
+ ## 三、一键清理未引用的附件(孤儿附件)
41
+
42
+ 附件库是「导入即进库」:批量导入 SOP 截图、临时传一份再删,都会留下**没人引用的原件**——
43
+ 既占空间,又在附件中心混在真资料里。逐个删是折磨。
44
+
45
+ ```bash
46
+ nx-sk file cleanup --dry-run # 先看会删哪些(名字 / 体积 / 物理路径)
47
+ nx-sk file cleanup # 真删:物理原件一并删除,不可撤销
48
+ ```
49
+
50
+ - 判据与「未引用」筛选片**完全同一套**:没有任何条目的 `file` 字段引用它、正文里也没有引用它的图。
51
+ 两套判据会出现「筛选片里看不到、清理却删了」这种吓人的不一致。
52
+ - 被引用的附件**一律不动**;同一个原件可以被多条条目共享,所以「已被引用」不是删除理由。
53
+ - 不可撤销:要留底就先 `nx-sk transfer export`。
54
+ - 面板上:附件中心工具条有「清理未引用(N)」,点了弹确认,报清数量。
55
+
56
+ ## 四、导入:默认移动,`--copy` 才复制
40
57
 
41
58
  ```bash
42
59
  nx-sk file add --path "D:\简历\张三-简历.pdf" # 默认把原件**移动**进库(原件不再留在原地)
@@ -49,7 +66,7 @@ nx-sk file add --path C:\扫描\成绩单.pdf --tags 成绩单 --note "教务处
49
66
  - 同 sha256 的文件直接复用已有记录(返回 `duplicated`),不会重复占空间。
50
67
  - `file add --dry-run` 只计算落点与校验,不移动、不写台账。
51
68
 
52
- ## 四、链接:`file` 字段值 = 附件 id 的**列表**(可挂多个)
69
+ ## 五、链接:`file` 字段值 = 附件 id 的**列表**(可挂多个)
53
70
 
54
71
  条目**不复制文件**,只存附件 id。把 id 写进栏目里 `file` 类型的字段,链接就建立了;
55
72
  **一个字段可以挂多个附件**(简历多个版本、头像多张、证书多份),值是 id 的数组:
@@ -73,7 +90,7 @@ nx-sk file get f_bbb
73
90
  - 老数据里存的**单个 id 字符串**原样兼容:读、反查、搜索、面板编辑都按「单元素列表」处理,
74
91
  不需要迁移;下一次在面板里编辑该字段保存后,会统一成数组。
75
92
 
76
- ## 五、还原(可逆)
93
+ ## 六、还原(可逆)
77
94
 
78
95
  ```bash
79
96
  nx-sk file restore f_xxxx # 移回**原始位置**并撤销台账(默认)
@@ -89,7 +106,7 @@ nx-sk file restore f_xxxx --to "D:\资料" # Web 上传件没有原始
89
106
  - Web 上传进来的附件没有「原始位置」,还原时必须给 `--to`。
90
107
  - 还原/删除都不留本地备份;删之前先 `transfer export` 导一份——见 `60-transfer`。
91
108
 
92
- ## 六、Web 面板
109
+ ## 七、Web 面板
93
110
 
94
111
  `nx-sk serve` → 顶部「附件」页:
95
112
 
@@ -99,10 +116,24 @@ nx-sk file restore f_xxxx --to "D:\资料" # Web 上传件没有原始
99
116
  - **两种引用都算已引用**:`file` 字段里选了本附件,或 `md` 字段正文里写了
100
117
  `![说明](f_xxx)`(外链图片 `https://…` 不算,它本来就不在附件库里)。
101
118
  - 编辑弹窗可改名称 / 分类 / 标签 / 备注(改分类或文件名时物理原件随之移动);
119
+ - **「清理未引用(N)」**:一键删掉所有没有任何条目引用的附件(判据与「未引用」筛选片同一套),
120
+ 物理原件一并删除,操作前弹确认;
102
121
  - 条目表单里的 `file` 字段是**列表**:文件可直接**拖进字段框**(可多选)、点「上传文件」多选、
103
122
  从库里逐个追加、逐行移除;保存即建立链接。
104
123
 
105
- ## 七、看不到 `file` 上传字段 / 条目里解析不到附件
124
+ ### 字段里的附件选择器(可搜索 + 未引用优先)
125
+
126
+ 点「从附件库选…」打开的是**选择面板**,不是下拉框:
127
+
128
+ - **可搜索**:按名称 / 备注 / 标签过滤(库里几十个附件时,下拉框根本没法挑);
129
+ - **未引用优先**:没有任何条目引用的原件排在前面(刚导入还没挂上去的就在最上面),
130
+ 已被引用的排在后面并标注「已被 N 处引用」——仍然可选(同一原件可被多条条目共享);
131
+ - 点一行即**追加**到本字段,可以连点多个;已挂上的会从列表里消失(它已经在这个字段里了)。
132
+
133
+ > 附件 id 是**全局唯一**的:同一个原件被两个条目挂上,两边指的是同一条记录。
134
+ > 「已被 N 处引用」说的就是这件事,不是「不能选」。
135
+
136
+ ## 八、看不到 `file` 上传字段 / 条目里解析不到附件
106
137
 
107
138
  1. **栏目里没有这类字段** —— 栏目字段台账是**持久化数据**:栏目实例化时深拷贝模板字段,
108
139
  此后独立演化,模板**后来**新增的字段不会自动回溯到已建栏目。用
@@ -17,9 +17,23 @@ description: 把用户随手丢来的混乱数据(一段话、聊天记录、
17
17
  为了三行数据新建一个栏目,三个月后你自己都不知道该往哪写。
18
18
  2. **结构先定,值后填。** 字段台账是数据不是代码,写下去容易、改起来难(面板按台账渲染)。
19
19
  所以先让用户看到「复用/新建哪个栏目 + 哪些字段 + 预计几条」,拍板后再写盘。
20
- 3. **原文不丢。** 文件进附件库、长文进 `md` 字段、链接原样留在链接字段。
20
+ 3. **原文不丢。** 杂乱的原件先丢进栏目自带的「原始资料」袋(`source` 字段),
21
+ 文件进附件库、长文进 `md` 字段、链接原样留在链接字段。
21
22
  摘要可以重写,原文不能只剩摘要 —— 收件箱的第一承诺是「收进来」,不是「总结掉」。
22
23
 
24
+ ## ⚠️ 每个栏目都自带「原始资料」袋(source 字段,先乱入后整理)
25
+
26
+ **每个非 KV 栏目都自带一个 `source` 字段(label「原始资料」,单独一组)**——它是结构的一部分,
27
+ 不是某个模板里的可选字段,不用也不应该在字段设计时手动加。用途只有一个:**装还没整理的杂乱输入**。
28
+
29
+ - 一段粘贴文字、一个拖进来的文件、一个网址,都可以先原样落袋(`source add`),
30
+ 之后再由你或人按字段字典整理进上面的结构化字段;整理完原件仍留袋里(只增不删,可标「已整理到某字段」)。
31
+ - 它是暂存区,`fill: optional`,**不计入完整度**;袋里不放密码 / token,敏感内容另去「密钥」栏目。
32
+ - 设计字段时**别再建一个「来源/原文/备注」字段去装乱数据**——那是重复造袋;真缺的是结构化字段。
33
+
34
+ 入口(每一步都会再点一次名):第 0 步的乱输入先入袋,第 2 步不重复设计 source,
35
+ 第 4 步建完结构确认袋已自带,第 5 步落值时袋里的原件可作为整理来源。
36
+
23
37
  ## ⚠️ md 字段的可交互特性(务必告诉用户)
24
38
 
25
39
  当一个字段的 type = **`md`** 时(备忘、会议纪要、SOP、待办清单、带图的资料……都常用它),
@@ -52,6 +66,10 @@ description: 把用户随手丢来的混乱数据(一段话、聊天记录、
52
66
 
53
67
  拆条的判据、混排里抽链接的规则、以及"哪些其实不该入库",见 [[10-inbox]]。
54
68
 
69
+ > 还没决定怎么拆 / 怎么归类时,**先把原始内容原样丢进候选栏目的「原始资料」袋**
70
+ > (`nx-sk source add <条目> --text/--file/--url`,没有条目可先建一条占位)——
71
+ > 先收进来,整理可以慢慢做,别让乱数据在对话里漂着。
72
+
55
73
  ### 第 1 步:侦察现有栏目
56
74
 
57
75
  ```bash
@@ -75,7 +93,9 @@ nx-sk section dump <候选栏目> --json # 字段台账 + 条目 + 每条未
75
93
  - 每个字段有 **key**(英文小写下划线、稳定)、**label**(中文)、**type**、**group**
76
94
  - 一条目一个**主标题字段**(`titleField`),否则条目列表全是"(无名)"
77
95
  - 密码 / token / 身份证号 → `sensitive: true`(密文落盘),或直接放 `secret` 这个 KV 栏目
78
- - 明显不是每次都有的信息(备注、附件、来源)→ `fill: optional`,别拉低完整度
96
+ - 明显不是每次都有的信息(备注、附件)→ `fill: optional`,别拉低完整度
97
+ - **不要设计「原始资料 / 来源 / 原文」字段**:`source` 袋每个栏目已自带(见上),
98
+ 字段设计只设计「整理之后」的结构化字段
79
99
 
80
100
  ### 第 3 步:让用户拍板(一步,别省)
81
101
 
@@ -105,6 +125,10 @@ nx-sk section update memo --add-field '{"key":"due","label":"截止","type":"dat
105
125
  `--fields` 是**整份替换**,`--add-field` 是**追加**。只想改某个字段的标签/hint 时,只能拿
106
126
  `section dump` 的字段数组改完整体 `--fields`(小心:漏掉字段就是删字段)。
107
127
 
128
+ > 建完 `section dump` 一下:字段数组末尾应自带 `source`(原始资料)、分组里自带「原始资料」组。
129
+ > 自定义栏目(没走 `--template`)也一样——`--fields` 不会把袋冲掉。存量老栏目缺袋时,
130
+ > 跑一次 `nx-sk source ensure`(或读一次 store 即自愈)。
131
+
108
132
  ### 第 5 步:填值
109
133
 
110
134
  一条一个 `entry add`,值走文件(命令行转义多行中文是自找麻烦):
@@ -116,7 +140,8 @@ nx-sk entry add --section memo --title "10 月值班表" --data @item-1.json --d
116
140
  - `--data` 可以是扁平 `{字段: 值}`,也可以是 `{values: {...}}`
117
141
  - 图片/文件先进 `file add` 拿 id,再在 `md` 字段里写 `![说明](f_xxx)`
118
142
  - 同名条目会报 `CONFLICT`:重跑前先 `entry list` 看有没有,有就 `entry update`
119
- - 细节(附件、md 字段、幂等、来源留痕)见 [[30-fill]]
143
+ - 整理自袋里的原件时,整理完给该条资料标 `--mapped <字段key>`(已整理到哪),下次不重复
144
+ - 细节(附件、md 字段、幂等、原始资料袋)见 [[30-fill]]
120
145
 
121
146
  ### 第 6 步:自检
122
147
 
@@ -3,6 +3,10 @@
3
3
  字段台账(section.fields)是**数据不是代码**:它随栏目存在 store 里,面板、CLI、agent 看到的是同一份。
4
4
  所以字段一旦写下去,就是"这个栏目长什么样"的长期承诺。
5
5
 
6
+ > **例外(不用设计、也删不掉)**:每个非 KV 栏目都自带 `source` 字段(label「原始资料」,
7
+ > 独立一组,`fill: optional`)——杂乱输入先原样入袋、再整理,它是结构的一部分。
8
+ > 设计字段时不要重复加「来源/原文/备注」袋;老栏目缺袋跑 `nx-sk source ensure`。
9
+
6
10
  ## 先定 titleField
7
11
 
8
12
  `section add` 一定要带 `--titleField <字段key>`(和 `--titleLabel <中文名>`):
@@ -102,7 +106,7 @@ nx-sk section add memo --title "备忘" --titleField name --titleLabel 名称 \
102
106
  类型 select 必填 待办 / 通知 / 资料
103
107
  截止 date 可选 过期就不显眼
104
108
  正文 md 可选 截图、步骤、清单
105
- 来源 text 可选 贴来的 / 链接 / 文件路径(去重用)
109
+ 原始资料 source 自带 乱输入先入袋(不计完整度,不用设计)
106
110
  ```
107
111
 
108
112
  用户看得懂这张表,看不懂 JSON。
@@ -33,8 +33,8 @@ nx-sk entry list --section memo --q "值班" --json # 先查
33
33
  nx-sk entry update <条目id> --data @item-1.json # 有就改
34
34
  ```
35
35
 
36
- **幂等靠两件事**:标题稳定 + 一个 `source` 字段(记"贴来的/链接/文件路径/日期")。
37
- 重跑时先按 source 查,命中就 update,没命中才 add。
36
+ **幂等靠两件事**:标题稳定 + 自带的「原始资料」袋(原文入袋,袋里每条带 `addedAt`、
37
+ 可标 `mapped`)。重跑时先按袋里的内容查(`source get`),命中就 update,没命中才 add。
38
38
 
39
39
  ## 附件:先入库,再引用
40
40
 
@@ -37,7 +37,7 @@ nx-sk section list --json
37
37
  类型 select 必填 待办 / 资料
38
38
  截止 date 可选 过期就不显眼
39
39
  正文 md 可选 细节、链接、截图
40
- 来源 text 可选 贴来的 / 链接(去重用)
40
+ (原始资料 source 每个栏目自带,不用设计;杂乱原文先丢袋里)
41
41
  ```
42
42
 
43
43
  `groups.json`:
@@ -46,19 +46,20 @@ nx-sk section list --json
46
46
  [{ "id": "main", "title": "基本信息" }, { "id": "detail", "title": "细节" }]
47
47
  ```
48
48
 
49
- `fields.json`:
49
+ `fields.json`(只写「整理之后」的字段;`source` 袋建栏目时自动附在末尾,不用写):
50
50
 
51
51
  ```json
52
52
  [
53
53
  { "key": "name", "label": "名称", "type": "text", "group": "main" },
54
54
  { "key": "kind", "label": "类型", "type": "select", "group": "main", "options": ["待办", "资料"] },
55
55
  { "key": "due", "label": "截止", "type": "date", "group": "main", "fill": "optional" },
56
- { "key": "body", "label": "正文", "type": "md", "group": "detail", "fill": "optional" },
57
- { "key": "source", "label": "来源", "type": "text", "group": "detail", "fill": "optional",
58
- "hint": "贴来的 / 链接 / 文件路径,用于去重" }
56
+ { "key": "body", "label": "正文", "type": "md", "group": "detail", "fill": "optional" }
59
57
  ]
60
58
  ```
61
59
 
60
+ > 去重 / 来源留痕不再单建 text 字段:原始内容原样进自带的「原始资料」袋,
61
+ > 袋里每条自带 `addedAt` 与可标 `mapped`(已整理到哪),重跑时按袋查即可。
62
+
62
63
  ## 第 3 步:拍板
63
64
 
64
65
  > 新建栏目「备忘」(memo),5 个字段:名称/类型/截止/正文/来源。
@@ -91,8 +92,7 @@ nx-sk section update memo --titleField name
91
92
  "name": "季度复盘会",
92
93
  "kind": "待办",
93
94
  "due": "2026-10-19",
94
- "body": "明天下午三点,3 楼会议室。\n\n- [ ] 带 Q4 预算表",
95
- "source": "贴来的 2026-10-18"
95
+ "body": "明天下午三点,3 楼会议室。\n\n- [ ] 带 Q4 预算表"
96
96
  }
97
97
  ```
98
98
 
@@ -102,11 +102,17 @@ nx-sk section update memo --titleField name
102
102
  {
103
103
  "name": "Q4 计划",
104
104
  "kind": "资料",
105
- "body": "https://example.com/2026-q4-plan",
106
- "source": "链接 2026-10-18"
105
+ "body": "https://example.com/2026-q4-plan"
107
106
  }
108
107
  ```
109
108
 
109
+ 原始的四段输入在整理前先入袋(以第 1、4 条为例):
110
+
111
+ ```bash
112
+ nx-sk source add <条目id> --text "明天下午三点在 3 楼会议室开季度复盘会,记得带 Q4 预算表"
113
+ nx-sk source add <条目id> --url "https://example.com/2026-q4-plan"
114
+ ```
115
+
110
116
  ```bash
111
117
  nx-sk entry add --section memo --data @item-1.json
112
118
  nx-sk entry add --section memo --data @item-2.json
@@ -160,7 +160,7 @@ nx-sk section dump <栏目> --json # 完整度与未填清单(袋子
160
160
  ## 常见坑
161
161
 
162
162
  - **同名条目**:一个栏目里两条同名时 `entry get` 会报 CONFLICT,用 id 定位。
163
- - **袋里没有 source 字段**:老栏目是模板实例化时建的,source 字段是后加的 → `nx-sk source ensure` 补齐。
163
+ - **袋里没有 source 字段**:老栏目是模板实例化时建的,source 字段是后加的 → 读一次 store 即自愈,或显式 `nx-sk source ensure` 补齐。
164
164
  - **KV 栏目(密钥)没有袋**:它的形状就是一行一个键值对,加字段会散掉。密钥走 `nx-sk key set`。
165
165
  - **`--mapped` 是数组**:可以 `--mapped a,b`(逗号分隔),也可以重复给。
166
166
  - **别用 `entry update --set source=...` 手写整袋 JSON**:那是给程序用的口子,人用 `source add` 更安全(少写错一个括号就毁掉整袋)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -517,16 +517,31 @@ export const SOURCE_FIELD = Object.freeze({
517
517
 
518
518
  export const SOURCE_GROUP = Object.freeze({ id: 'source', title: '原始资料' });
519
519
 
520
+ /**
521
+ * 「原始资料」字段的**唯一内置规则**:每个非 KV 栏目都自带 source 袋,缺了就补。
522
+ *
523
+ * 与 `syncSectionGroups` 同属「栏目不变量」,三处入口(instantiateTemplate /
524
+ * addSection / normalizeSection)都只调它,内置规则不可能有第二份——
525
+ * 这正是「每个结构自带」的含义:不是模板里的一个可选字段,而是**结构本身的一部分**。
526
+ *
527
+ * @param {{kv?: boolean}} [opts] KV 表不加:它的形状就是「一行一个键值对」,多一个字段就散了
528
+ */
529
+ export function ensureSourceBuiltin(groups, fields, { kv } = {}) {
530
+ const outGroups = [...(groups || [])];
531
+ const outFields = [...(fields || [])];
532
+ if (kv === true) return { groups: outGroups, fields: outFields };
533
+ if (!outGroups.some((g) => g.id === SOURCE_GROUP.id)) outGroups.push({ ...SOURCE_GROUP });
534
+ if (!outFields.some((f) => f.key === SOURCE_FIELD.key)) outFields.push({ ...SOURCE_FIELD });
535
+ return { groups: outGroups, fields: outFields };
536
+ }
537
+
520
538
  export function instantiateTemplate(templateId, overrides = {}) {
521
539
  const t = getTemplate(templateId);
522
540
  if (!t) return null;
523
- const groups = t.groups.map((g) => ({ ...g }));
524
- const fields = t.fields.map((x) => ({ ...x }));
525
- // KV 表不加:它的形状就是「一行一个键值对」,多一个字段就散了
526
- if (t.kv !== true) {
527
- if (!groups.some((g) => g.id === SOURCE_GROUP.id)) groups.push({ ...SOURCE_GROUP });
528
- if (!fields.some((f) => f.key === SOURCE_FIELD.key)) fields.push({ ...SOURCE_FIELD });
529
- }
541
+ const baseGroups = t.groups.map((g) => ({ ...g }));
542
+ const baseFields = t.fields.map((x) => ({ ...x }));
543
+ // 每个非 KV 栏目都自带「原始资料」袋(规则只此一份,见 ensureSourceBuiltin)
544
+ const { groups, fields } = ensureSourceBuiltin(baseGroups, baseFields, { kv: t.kv });
530
545
  return {
531
546
  id: overrides.id,
532
547
  title: overrides.title || t.label,
package/src/core/store.js CHANGED
@@ -4,7 +4,7 @@ import fsp from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { displayPath, migrationDir, storeFile } from './paths.js';
6
6
  import { ensureDir, pathExists, writeAtomic } from './fsx.js';
7
- import { instantiateTemplate, seedSections, syncSectionGroups } from './fields.js';
7
+ import { ensureSourceBuiltin, instantiateTemplate, seedSections, syncSectionGroups } from './fields.js';
8
8
  import { normalizeFileRecord } from './attachments.js';
9
9
  import { nowIso, stamp } from './ids.js';
10
10
 
@@ -55,6 +55,11 @@ function normalizeSection(raw) {
55
55
  // 会整组消失(真实事故:servers 栏目 groups 为空,Web 端白屏而 CLI 正常)。
56
56
  // 放在 normalize 收口——每条读写路径都会过这里,存量数据读一次就自愈。
57
57
  out.groups = syncSectionGroups(out.groups, out.fields);
58
+ // 「原始资料」袋是每个非 KV 栏目自带的结构(不是可选字段):存量栏目缺了
59
+ // 读一次就补上,与上面的分组自愈同一条收口路径。规则只此一份,见 ensureSourceBuiltin。
60
+ const ensured = ensureSourceBuiltin(out.groups, out.fields, { kv: out.kv });
61
+ out.groups = ensured.groups;
62
+ out.fields = ensured.fields;
58
63
  return out;
59
64
  }
60
65
 
@@ -162,6 +162,19 @@ export default function SectionView({ sectionId }) {
162
162
  await reload();
163
163
  });
164
164
 
165
+ // 改到一半关标签页 = 改动全丢。浏览器只允许弹一句「确定要离开吗」,
166
+ // 拦不住也改不了文案,但至少别让未保存的改动无声消失。
167
+ useEffect(() => {
168
+ if (!dirty.count) return undefined;
169
+ const onBeforeUnload = (e) => {
170
+ e.preventDefault();
171
+ e.returnValue = '';
172
+ return '';
173
+ };
174
+ window.addEventListener('beforeunload', onBeforeUnload);
175
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
176
+ }, [dirty.count]);
177
+
165
178
  // 「直接存 kv」的入口:敲一行 字段=值 回车即写入,字典里没有的键会自动登记
166
179
  const quickAdd = guard(async () => {
167
180
  if (!draft) { toast('先新建一条条目', 'bad'); return; }
@@ -443,7 +456,9 @@ export default function SectionView({ sectionId }) {
443
456
  <div className="card"><Empty>左边选一条,或新建一条</Empty></div>
444
457
  ) : (
445
458
  <div className="card">
446
- <div className="colhead">
459
+ {/* 顶栏固定在窗口右上角:长栏目滚到一半也能看见「保存」——
460
+ 否则改完一个字段要翻到表单最底点保存,翻页越多越容易漏保存。 */}
461
+ <div className="colhead sticky-head">
447
462
  <span
448
463
  className="crumb grow nowrap"
449
464
  title="点击复制条目定位(栏目›条目 + id + CLI 命令,贴给 agent 即可修改)"
@@ -456,6 +471,12 @@ export default function SectionView({ sectionId }) {
456
471
  </span>
457
472
  <span className="muted mono">{draft.id}</span>
458
473
  {current ? <Tag kind={current.missing.length ? '' : 'strong'}>完整度 {current.completeness.ratio}%</Tag> : null}
474
+ {dirty.count ? (
475
+ <>
476
+ <span className="hint nowrap">{dirty.count} 处改动未保存</span>
477
+ <button type="button" className="btn small" onClick={save} title="保存当前条目的改动">保存</button>
478
+ </>
479
+ ) : null}
459
480
  </div>
460
481
 
461
482
  <div className="field wide pad" style={{ borderBottom: '1px solid var(--soft-2)' }}>
@@ -76,6 +76,15 @@ export default {
76
76
  run: (ctx) => service.removeFile(ctx.ref, ctx),
77
77
  render: (d) => service.renderFileChange(d),
78
78
  },
79
+ {
80
+ id: 'file.cleanup',
81
+ cli: ['file', 'cleanup'],
82
+ http: ['POST', '/api/files/cleanup'],
83
+ summary: '一键清理**未被任何条目引用**的附件(孤儿附件,物理原件一并删除,不可撤销)',
84
+ flags: { 'dry-run': { type: 'boolean', hint: '只列出会删哪些,不真删' } },
85
+ run: (ctx) => service.cleanupUnreferenced(ctx),
86
+ render: (d) => service.renderFileCleanup(d),
87
+ },
79
88
  {
80
89
  id: 'file.content',
81
90
  cli: ['file', 'content'],
@@ -177,6 +177,40 @@ export async function removeFile(ref, patch = {}) {
177
177
  return { status: 'ok', removed: { id: target.id, name: target.name, category: target.category } };
178
178
  }
179
179
 
180
+ /**
181
+ * 一键清理**未被任何条目引用**的附件(孤儿附件)。
182
+ *
183
+ * 判据与「附件中心」的「未引用」筛选片**完全同一套**(`fileReferences` 为空):
184
+ * 没有任何条目的 file 字段引用它、正文里也没有引用它的图。
185
+ *
186
+ * 为什么值得一键做:附件库是「导入即进库」,批量导入 SOP 截图、临时传一份再删
187
+ * 都会留下孤儿——它们既占空间,又在附件中心混在真资料里。逐个删是折磨。
188
+ *
189
+ * 不可撤销(物理原件一起删),所以:
190
+ * - 面板侧先弹确认,列出将删的数量与体积;
191
+ * - CLI 侧有 --dry-run,先看清单再动手。
192
+ */
193
+ export async function cleanupUnreferenced(patch = {}) {
194
+ const dryRun = patch['dry-run'];
195
+ const store = await loadStore();
196
+ const orphans = store.files.filter((rec) => !fileReferences(store, rec).length);
197
+ if (!orphans.length) {
198
+ // 已经是期望状态(没有孤儿)→ skipped,不是失败
199
+ return { status: 'skipped', count: 0, removed: [], reason: '没有未引用的附件' };
200
+ }
201
+ const brief = orphans.map((r) => ({
202
+ id: r.id, name: r.name, category: r.category, bytes: r.bytes, path: displayPath(fileRecordAbs(r)),
203
+ }));
204
+ if (dryRun) {
205
+ return { status: 'skipped', dryRun: true, count: orphans.length, wouldRemove: brief };
206
+ }
207
+ // 先删物理文件(失败即抛,台账不动)——不能出现「台账没了、文件还在」的中间态
208
+ for (const rec of orphans) await deletePhysical(rec);
209
+ const ids = new Set(orphans.map((r) => r.id));
210
+ await mutateStore((s) => { s.files = s.files.filter((x) => !ids.has(x.id)); });
211
+ return { status: 'ok', count: orphans.length, removed: brief };
212
+ }
213
+
180
214
  /** CLI 的 file content:给出物理路径与链接(不直接吐二进制,终端里没法看)。 */
181
215
  export async function contentInfo(ref, { download } = {}) {
182
216
  const store = await loadStore();
@@ -271,6 +305,19 @@ export function renderFileChange(d) {
271
305
  return `已更新附件 ${d.name}(${d.id})`;
272
306
  }
273
307
 
308
+ export function renderFileCleanup(d) {
309
+ if (d.dryRun) {
310
+ const lines = [`试运行(未落盘):将清理 ${d.count} 个未引用的附件`];
311
+ for (const r of d.wouldRemove || []) lines.push(` ${r.id} ${r.name} ${fmtBytes(r.bytes)}`);
312
+ lines.push('确认无误后去掉 --dry-run 即可执行。');
313
+ return lines.join('\n');
314
+ }
315
+ if (d.status === 'skipped') return `没有未引用的附件,无需清理`;
316
+ const lines = [`已清理 ${d.count} 个未引用的附件(物理原件一并删除,不可撤销)`];
317
+ for (const r of d.removed || []) lines.push(` ${r.id} ${r.name} ${fmtBytes(r.bytes)}`);
318
+ return lines.join('\n');
319
+ }
320
+
274
321
  export function renderContent(d) {
275
322
  return `${d.name}(${d.id})\n路径: ${d.displayPath}\n浏览器链接: ${d.url}${d.download ? d.downloadUrl : ''}`;
276
323
  }