nx-sk 0.17.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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,51 @@
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`)。
6
50
 
7
51
  ## 0.17.0 (2026-10-25)
8
52
 
@@ -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.17.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
 
@@ -3,7 +3,7 @@
3
3
  import { loadStore, mutateStore } from '../../core/store.js';
4
4
  import { assertFieldKey, assertSafeId } from '../../core/paths.js';
5
5
  import { badInput, blocked, conflict, notFound } from '../../core/errors.js';
6
- import { FILL_POLICIES, FIELD_TYPES, SOURCE_FIELD, SOURCE_GROUP, findSection, getTemplate, groupFields, instantiateTemplate, mergeTemplate, sectionNames, syncSectionGroups, templateSummaries } from '../../core/fields.js';
6
+ import { FILL_POLICIES, FIELD_TYPES, ensureSourceBuiltin, findSection, getTemplate, groupFields, instantiateTemplate, mergeTemplate, sectionNames, syncSectionGroups, templateSummaries } from '../../core/fields.js';
7
7
  import { dumpSection, formatValue } from '../../core/render.js';
8
8
  import { sensitiveViewer } from '../../core/vault.js';
9
9
  import { nowIso } from '../../core/ids.js';
@@ -130,8 +130,9 @@ export async function addSection({ id, title, template, description, order, fiel
130
130
  }
131
131
  draft = t;
132
132
  } else {
133
- // 自定义栏目也自带「原始资料」袋:每个栏目都该有乱入的地方(见 core/fields.js 的说明)
134
- draft = { id: sid, title: title || sid, description: description || '', order: Number.isFinite(order) ? order : 50, template: null, kv: false, titleField: null, titleLabel: '名称', groups: [{ ...SOURCE_GROUP }], fields: [{ ...SOURCE_FIELD }] };
133
+ // 自定义栏目。groups/fields 先空着——用户的 --fields/--groups 应用完,
134
+ // 再由 ensureSourceBuiltin 统一补上自带的「原始资料」袋(见下方)。
135
+ draft = { id: sid, title: title || sid, description: description || '', order: Number.isFinite(order) ? order : 50, template: null, kv: false, titleField: null, titleLabel: '名称', groups: [], fields: [] };
135
136
  }
136
137
  if (Array.isArray(groups)) draft.groups = groups.map(validateGroupDef);
137
138
  if (Array.isArray(fields)) {
@@ -141,6 +142,11 @@ export async function addSection({ id, title, template, description, order, fiel
141
142
  // 与 normalize 同一条规则:--fields 引用了 --groups 没声明的分组时自动补上,
142
143
  // 否则面板按声明数组渲染会整组消失(CLI 从字段推导所以看不出来)。
143
144
  draft.groups = syncSectionGroups(draft.groups, draft.fields);
145
+ // 「每个结构自带」:无论模板实例还是自定义栏目(也无论是否传了 --fields/--groups),
146
+ // 非 KV 栏目都自带「原始资料」袋——杂乱资料先原样入袋,再按字段字典整理。
147
+ const ensured = ensureSourceBuiltin(draft.groups, draft.fields, { kv: draft.kv });
148
+ draft.groups = ensured.groups;
149
+ draft.fields = ensured.fields;
144
150
  draft.createdAt = nowIso();
145
151
  draft.updatedAt = draft.createdAt;
146
152
 
@@ -7,15 +7,21 @@
7
7
  //
8
8
  // **受控组件**:数据(cfg)与保存都在设置页;这里只管画与调 onChange。
9
9
  // 所有数据操作走 navModel 的纯函数(可单测),组件里不做状态体操。
10
- import { useMemo, useState } from 'react';
10
+ //
11
+ // **拖动时列表本身不动**。早前这里是「实时预览」:拖动过程中就把被拖的行挪到落点上。
12
+ // 那条路必然抖,原因很硬——预览一挪行,指针底下的元素就换了、下面的容器也整体位移一行
13
+ // (28px),dragover 于是算出另一个落点,再挪,再算……自己推自己,屏幕上就是闪屏;
14
+ // 跨容器时位移最大,闪得最厉害。
15
+ // 现在改为:行按**原样**待在原地(被拖的那行半透明),落点用一条**不占布局**的指示线
16
+ // (inset 阴影压在行边上)表示。列表在拖动期间是静止的 → 指针与行的对应关系恒定,
17
+ // 落点只随指针单调变化,怎么晃都不会跳。松手一次性提交,结果就是指示线指的位置。
18
+ import { useState } from 'react';
11
19
  import * as nav from '../../web/frontend/components/navModel.js';
12
20
 
13
21
  export default function NavManager({ sections, value, onChange }) {
14
22
  const [drag, setDrag] = useState(null); // { kind:'section', sid } | { kind:'group', gi }
15
- const [over, setOver] = useState(null); // { key, index }:当前拖到哪个容器的第几位
23
+ const [over, setOver] = useState(null); // { key, index }:落在哪个容器的第几行之前;组合 { key, group:true }
16
24
 
17
- // 注意顺序:preview 的依赖里用到 allIds / preview 的计算也用到预览配置,
18
- // const 声明晚于使用就是 TDZ 崩("Cannot access … before initialization")
19
25
  const allIds = sections.map((s) => s.id);
20
26
  const sectionOf = (id) => sections.find((s) => s.id === id) || null;
21
27
 
@@ -26,62 +32,91 @@ export default function NavManager({ sections, value, onChange }) {
26
32
  return Number.isFinite(gi) ? { kind: 'group', gi } : null;
27
33
  };
28
34
 
29
- /**
30
- * **实时预览**:拖动过程中就把落点算出来渲染,松手前就看到最终顺序(所见即所得)。
31
- * 插入位由 nav.insertionIndex 换算(同容器内往下拖要让位)——那条逻辑在模型里,有单测。
32
- */
33
- const preview = useMemo(() => {
34
- if (drag?.kind !== 'section' || !over) return value;
35
- const target = keyToTarget(over.key);
36
- if (!target) return value;
37
- const index = nav.insertionIndex(value, drag.sid, target, over.index, allIds);
38
- return nav.moveToContainer(value, drag.sid, target, index, allIds);
39
- }, [value, drag, over, allIds]);
40
-
41
35
  const draggingSid = drag?.kind === 'section' ? drag.sid : null;
42
36
  // 平铺容器的内容与顺序都来自模型:显式排过的(order)优先,其余按栏目自身顺序
43
- const pool = nav.ungroupedIds(preview, allIds);
37
+ const pool = nav.ungroupedIds(value, allIds);
44
38
 
45
39
  const endDrag = () => { setDrag(null); setOver(null); };
46
40
 
47
- /** 松手即提交预览里的顺序(预览就是结果,不再重新算一遍)。 */
48
- const commit = () => { if (preview !== value) onChange(preview); endDrag(); };
49
-
50
41
  // 只在落点**真的变了**才 setState:dragover 每次鼠标移动都触发,
51
- // 不做这层去重就是每次移动都重排一遍整页 → 抖
42
+ // 不做这层去重就是每次移动都重算一遍整页 → 抖
52
43
  const setOverIfChanged = (next) => setOver((o) => (o && o.key === next.key && o.index === next.index ? o : next));
53
44
 
54
- // 容器外壳:接住「拖到空白处 = 追加到末尾」,并显示落点高亮
45
+ /**
46
+ * 指针在容器里落在第几行之前(0..n)。
47
+ * 行位置每帧现取(行高/换行会随窗口变),判据交给 nav.slotAt:只比**中线**。
48
+ * 于是行的上下边缘、行间缝隙、容器内边距都归到最近的一侧,不再出现
49
+ * 「压在行上是一个落点、挪进缝隙变成追加到末尾」这种整列表跳位。
50
+ */
51
+ const slotOf = (el, y) => nav.slotAt(
52
+ [...el.querySelectorAll('[data-nav-row]')].map((row) => {
53
+ const r = row.getBoundingClientRect();
54
+ return r.top + r.height / 2;
55
+ }),
56
+ y,
57
+ );
58
+
59
+ /** 松手:按当前指示线落位。结构没变就不提交(否则拖一下就提示「有未保存的改动」)。 */
60
+ const commit = () => {
61
+ const target = over ? keyToTarget(over.key) : null;
62
+ if (drag?.kind === 'group') {
63
+ // 分组只能落到分组卡上(落到平铺 / 隐藏里没有意义,那两处不是「第几个分组」)
64
+ if (target?.kind === 'group') {
65
+ const next = nav.moveGroupTo(value, drag.gi, target.gi);
66
+ if (next !== value) onChange(next);
67
+ }
68
+ endDrag();
69
+ return;
70
+ }
71
+ if (draggingSid && target) {
72
+ const index = nav.insertionIndex(value, draggingSid, target, over.index, allIds);
73
+ const next = nav.moveToContainer(value, draggingSid, target, index, allIds);
74
+ if (!nav.sameStructure(next, value, allIds)) onChange(next);
75
+ }
76
+ endDrag();
77
+ };
78
+
79
+ // 容器:整块都是落点(含空白处 / 说明文字 / 分组头)。
80
+ // 拖栏目时落点位置由行中线算出;拖分组时整张分组卡都是落点——
81
+ // 只认那 28px 的分组头的话,手要瞄得很准,松手稍偏就白拖了。
55
82
  const zone = (key, base = '') => ({
56
83
  className: `${base} ${over?.key === key ? 'drop-target' : ''}`,
57
- onDragOver: (e) => { if (draggingSid) { e.preventDefault(); setOverIfChanged({ key, index: null }); } },
58
- onDragLeave: () => setOver((o) => (o?.key === key && o.index === null ? null : o)),
84
+ onDragOver: (e) => {
85
+ if (drag?.kind === 'group') {
86
+ if (keyToTarget(key)?.kind !== 'group') { setOver(null); return; }
87
+ e.preventDefault();
88
+ e.dataTransfer.dropEffect = 'move';
89
+ setOverIfChanged({ key, group: true });
90
+ return;
91
+ }
92
+ if (!draggingSid) return;
93
+ e.preventDefault(); // 不拦就没有 drop
94
+ e.dataTransfer.dropEffect = 'move';
95
+ setOverIfChanged({ key, index: slotOf(e.currentTarget, e.clientY) });
96
+ },
59
97
  onDrop: (e) => { e.preventDefault(); commit(); },
60
98
  });
61
99
 
62
100
  /**
63
- * 行的落点判定。两条防抖铁律:
64
- * ① 行内事件**就地拦下**(preventDefault + stopPropagation):一旦冒泡到容器,
65
- * 容器会把它当成「拖到空白处」翻成「追加到末尾」——指针正好压在被拖的行上时,
66
- * 两个判断来回切,页面就抖。
67
- * ② 插入位取自**原始配置**里的位置(不随预览漂移)+ 指针在上半/下半决定前后。
68
- * 若反过来用「预览后的行号」,预览一重排索引就变,等于自己推自己。
101
+ * 拖起一行。拖影取**整行**而不是那个 10px 的把手——把手小到看不出在拖什么,
102
+ * 松手前心里没底。setData 是给 Firefox 的:不设就不算启动拖拽。
69
103
  */
70
- const rowProps = (key, sid) => ({
71
- onDragOver: (e) => {
72
- if (!draggingSid) return;
73
- e.preventDefault();
74
- e.stopPropagation();
75
- if (draggingSid === sid) return; // 悬停在自己身上:保持当前落点,不重算
76
- const rect = e.currentTarget.getBoundingClientRect();
77
- const below = e.clientY > rect.top + rect.height / 2;
78
- const at = nav.containerOf(value, sid, allIds);
79
- const index = (typeof at.index === 'number' ? at.index : 0) + (below ? 1 : 0);
80
- setOverIfChanged({ key, index });
81
- },
82
- onDrop: (e) => { e.preventDefault(); e.stopPropagation(); commit(); },
83
- onDragStart: () => setDrag({ kind: 'section', sid }),
84
- });
104
+ const startSectionDrag = (e, sid) => {
105
+ e.dataTransfer.effectAllowed = 'move';
106
+ e.dataTransfer.setData('text/plain', sid);
107
+ const row = e.currentTarget.closest('.nav-row');
108
+ if (row) e.dataTransfer.setDragImage(row, 14, 14);
109
+ setDrag({ kind: 'section', sid });
110
+ setOver(null);
111
+ };
112
+
113
+ /** 行的落点指示:落在第 i 行之前画上边,落在末尾画最后一行的下边。 */
114
+ const markOf = (key, i, n) => {
115
+ if (over?.key !== key || typeof over.index !== 'number') return '';
116
+ if (over.index === i) return 'ins-before';
117
+ if (i === n - 1 && over.index >= n) return 'ins-after';
118
+ return '';
119
+ };
85
120
 
86
121
  return (
87
122
  <div className="stack">
@@ -90,10 +125,11 @@ export default function NavManager({ sections, value, onChange }) {
90
125
  <div className="hint">平铺:不进分组也不隐藏的栏目,直接显示在头部(拖到这里 = 移出分组 / 取消隐藏)</div>
91
126
  {pool.length ? (
92
127
  <div className="nav-list">
93
- {pool.map((sid) => (
128
+ {pool.map((sid, i) => (
94
129
  <SectionRow key={sid} section={sectionOf(sid)} sid={sid}
95
130
  dragging={draggingSid === sid}
96
- {...rowProps('pool', sid)}
131
+ mark={markOf('pool', i, pool.length)}
132
+ onDragStart={(e) => startSectionDrag(e, sid)}
97
133
  onDragEnd={endDrag}
98
134
  actions={null} />
99
135
  ))}
@@ -102,19 +138,17 @@ export default function NavManager({ sections, value, onChange }) {
102
138
  </section>
103
139
 
104
140
  {/* ② 分组:组名可改、组间可拖把手换位 */}
105
- {preview.groups.map((g, gi) => (
141
+ {value.groups.map((g, gi) => (
106
142
  <section key={gi} {...zone(`g${gi}`, 'card nav-group')}>
107
- <div
108
- className={`colhead ${over?.key === `g${gi}` && over.group ? 'drop-target' : ''}`}
109
- onDragOver={(e) => { if (drag?.kind === 'group') { e.preventDefault(); setOver({ key: `g${gi}`, group: true }); } }}
110
- onDrop={(e) => {
111
- e.preventDefault();
112
- if (drag?.kind === 'group' && drag.gi !== gi) onChange(nav.moveGroup(value, drag.gi, gi - drag.gi));
113
- endDrag();
114
- }}
115
- >
143
+ <div className={`colhead ${over?.key === `g${gi}` && over.group ? 'drop-target' : ''}`}>
116
144
  <span className="grip" title="拖动调整分组顺序" draggable
117
- onDragStart={() => setDrag({ kind: 'group', gi })} onDragEnd={endDrag} />
145
+ onDragStart={(e) => {
146
+ e.dataTransfer.effectAllowed = 'move';
147
+ e.dataTransfer.setData('text/plain', `g${gi}`);
148
+ setDrag({ kind: 'group', gi });
149
+ setOver(null);
150
+ }}
151
+ onDragEnd={endDrag} />
118
152
  <input className="nav-title" placeholder="组名,如「求职」" value={g.title}
119
153
  onChange={(e) => onChange(nav.renameGroup(value, gi, e.target.value))} />
120
154
  <span className="muted">{g.sections.length} 个栏目</span>
@@ -124,10 +158,11 @@ export default function NavManager({ sections, value, onChange }) {
124
158
  </div>
125
159
  {g.sections.length ? (
126
160
  <div className="nav-list">
127
- {g.sections.map((sid) => (
161
+ {g.sections.map((sid, i) => (
128
162
  <SectionRow key={sid} section={sectionOf(sid)} sid={sid}
129
163
  dragging={draggingSid === sid}
130
- {...rowProps(`g${gi}`, sid)}
164
+ mark={markOf(`g${gi}`, i, g.sections.length)}
165
+ onDragStart={(e) => startSectionDrag(e, sid)}
131
166
  onDragEnd={endDrag}
132
167
  actions={<button className="btn small ghost" onClick={() => onChange(nav.ungroupSection(value, sid))}>移出</button>} />
133
168
  ))}
@@ -140,15 +175,16 @@ export default function NavManager({ sections, value, onChange }) {
140
175
  <section {...zone('hidden', 'card nav-group nav-hidden')}>
141
176
  <div className="colhead">
142
177
  隐藏
143
- <span className="muted">{preview.hidden.length} 个栏目</span>
178
+ <span className="muted">{value.hidden.length} 个栏目</span>
144
179
  <span className="right muted">不进头部 · #/栏目id 直达仍可用</span>
145
180
  </div>
146
- {preview.hidden.length ? (
181
+ {value.hidden.length ? (
147
182
  <div className="nav-list">
148
- {preview.hidden.map((sid) => (
183
+ {value.hidden.map((sid, i) => (
149
184
  <SectionRow key={sid} section={sectionOf(sid)} sid={sid}
150
185
  dragging={draggingSid === sid}
151
- {...rowProps('hidden', sid)}
186
+ mark={markOf('hidden', i, value.hidden.length)}
187
+ onDragStart={(e) => startSectionDrag(e, sid)}
152
188
  onDragEnd={endDrag}
153
189
  actions={<button className="btn small ghost" onClick={() => onChange(nav.ungroupSection(value, sid))}>移出</button>} />
154
190
  ))}
@@ -165,9 +201,9 @@ export default function NavManager({ sections, value, onChange }) {
165
201
  }
166
202
 
167
203
  /** 一行栏目:把手(拖)+ 名称 + 计数 + 容器自己的动作(移出 / 无)。 */
168
- function SectionRow({ section, sid, dragging, actions, onDragStart, onDragEnd, onDragOver, onDrop }) {
204
+ function SectionRow({ section, sid, dragging, mark, actions, onDragStart, onDragEnd }) {
169
205
  return (
170
- <div className={`row nav-row ${dragging ? 'dragging' : ''}`} onDragOver={onDragOver} onDrop={onDrop}>
206
+ <div className={`row nav-row ${dragging ? 'dragging' : ''} ${mark}`} data-nav-row data-sid={sid}>
171
207
  <span className="grip" title="拖动:换位 / 换容器" draggable onDragStart={onDragStart} onDragEnd={onDragEnd} />
172
208
  <span className="name">{section ? section.title : `栏目已删除(${sid})`}</span>
173
209
  <span className="desc">{section ? `${section.entries} 条` : '保存前请移出'}</span>
@@ -175,4 +211,3 @@ function SectionRow({ section, sid, dragging, actions, onDragStart, onDragEnd, o
175
211
  </div>
176
212
  );
177
213
  }
178
-
@@ -36,7 +36,9 @@ export default function SettingsView() {
36
36
  useEffect(() => { load(); }, [load]);
37
37
 
38
38
  const problems = useMemo(() => (form ? nav.validate(navCfg, sectionIds) : []), [navCfg, sectionIds, form]);
39
- const dirty = !!baseline && (nav.isDirty(navCfg, baseline.navCfg) || nav.isDirty(form, baseline.form));
39
+ // 导航按「结构」判脏:拖出去又拖回原位不算改动(order 里会留下一串脚印,但不影响长相);
40
+ // 其余设置项逐键比
41
+ const dirty = !!baseline && (!nav.sameStructure(navCfg, baseline.navCfg, sectionIds) || nav.isDirty(form, baseline.form));
40
42
 
41
43
  // 一次保存全部设置:标量项 + 导航(分组 / 隐藏)。分成两块各存各的,
42
44
  // 用户就得记住「刚才那块存了没」——统一保存 + 统一的脏值提示才是清晰的。