nx-sk 0.9.2 → 0.10.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,6 +2,24 @@
2
2
 
3
3
  本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
4
4
 
5
+ ## [0.10.0] - 2026-10-15
6
+
7
+ ### Added
8
+
9
+ - **「栏目管理」重做成容器 + 拖拽**(设置页)。整页只有一个心智模型:一个栏目要么在「平铺」里、要么在某个分组里、要么在「隐藏」里;拖,就是把它从一个容器挪到另一个。
10
+ - **「隐藏」不再是一个开关,就是一个容器**:与分组同构,拖进去即不进头部(`#/栏目` 直达仍可用)——「显隐」与「归组」因此是同一套操作,不用记两种交互。
11
+ - **拖拽实时预览**:拖动过程中列表就按落点重排(被拖的行半透明停在新位置),松手直接提交这个顺序——所见即所得,不再「先松手再猜落在哪」。
12
+ - **平铺区也能拖排序**:新增 `settings.navOrder`(栏目 id 数组)——显式排过的按它排,没排过的新栏目按栏目自身 `order` 跟在后面,所以「从没拖过」= 老行为不变。
13
+ - 行内只留一个「移出」逃逸按钮(上一版的 上移/下移/显示中/隐藏中 全部去掉);分组把手可拖,换分组之间顺序。
14
+ - 设置页改为**一次保存**:标量设置与导航配置一起存,顶部只出现一次「有未保存的改动 / 保存 / 放弃改动」。
15
+
16
+ - **`settings.navHidden` / `settings.navOrder` 的后端校验**:与分组同构的「栏目 id 清单」校验(栏目必须真实存在、去重保序);CLI 三种写法都认:`--set navOrder=["a","b"]` / `navOrder=a,b` / `navHidden=a`。`setting get` 人类模式现在能一次看全「栏目 + 隐藏状态 + 平铺顺序 + 分组」。
17
+
18
+ ### Fixed
19
+
20
+ - **拖拽预览抖动**:两个自己推自己造成的——① 行内 `dragover` 冒泡到容器后会被当成「拖到空白处」翻成「追加到末尾」,指针压在被拖的行上时两种判断来回切;② 落点取自「预览后」的行号,预览一重排索引就变。现在行内就地拦下(`preventDefault` + `stopPropagation`,悬停在自己身上时保持落点不重算)、插入位取自原始配置(+ 指针在上半/下半决定前后)、值没变就不 setState。实测:停在落点边界采样 8 次顺序不变,慢速扫过整列只单调变化 4 次。
21
+ - **分组下拉鼠标下滑就消失**:pill 与菜单之间那 4px 空隙被判成「离开」——补透明桥 + 离开延迟 140ms。
22
+
5
23
  ## [0.9.2] - 2026-10-15
6
24
 
7
25
  ### Added
package/README.md CHANGED
@@ -198,8 +198,10 @@ nx-sk file get $FID # 反查「被引用:求职 /
198
198
  > 若你的求职栏目是很早建的,跑一次 `nx-sk section sync job`(或面板「栏目 → 编辑 → 同步模板字段」)
199
199
  > 就能把模板新增的「附件资料」组与 4 个 `file` 字段补进来,且不改动你已有的字段与数据。
200
200
 
201
- **头部导航分组**(栏目多了以后收拢头部,一眼分得出主次):在设置里把同域栏目归成一组,
202
- 头部只显示组名,**悬浮展开上下列表**选具体栏目;不归组的栏目保持平铺。
201
+ **栏目管理**(设置页):栏目多了以后收拢头部,一眼分得出主次——把同域栏目**拖**成一个分组,
202
+ 头部只显示组名、**悬浮展开上下列表**选具体栏目;不常用的栏目拖进「隐藏」容器即不进头部
203
+ (`#/栏目id` 直达仍可用)。整个管理页只有「容器 + 拖拽」一套操作:平铺 / 各分组 / 隐藏,
204
+ 拖进拖出就是归组、换位、显隐;改完统一「保存」(一次存全部设置)。
203
205
 
204
206
  ```bash
205
207
  # 面板:设置 → 头部导航分组(勾选栏目 / 组内排序 / 改名 / 增删组),保存后头部立即生效
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,178 @@
1
+ // 栏目管理:**容器 + 拖拽**。整页只有一个心智模型——
2
+ // 一个栏目要么在「平铺」里、要么在某个分组里、要么在「隐藏」里;
3
+ // 拖,就是把它从一个容器挪到另一个。
4
+ //
5
+ // 「隐藏」不再是一个开关,就是一个容器:拖进去 = 不出现在头部(`#/栏目id` 直达仍可用)。
6
+ // 这样「显隐」和「归组」是同一套操作,不用记两种交互。
7
+ //
8
+ // **受控组件**:数据(cfg)与保存都在设置页;这里只管画与调 onChange。
9
+ // 所有数据操作走 navModel 的纯函数(可单测),组件里不做状态体操。
10
+ import { useMemo, useState } from 'react';
11
+ import * as nav from '../../web/frontend/components/navModel.js';
12
+
13
+ export default function NavManager({ sections, value, onChange }) {
14
+ const [drag, setDrag] = useState(null); // { kind:'section', sid } | { kind:'group', gi }
15
+ const [over, setOver] = useState(null); // { key, index }:当前拖到哪个容器的第几位
16
+
17
+ // 注意顺序:preview 的依赖里用到 allIds / preview 的计算也用到预览配置,
18
+ // const 声明晚于使用就是 TDZ 崩("Cannot access … before initialization")
19
+ const allIds = sections.map((s) => s.id);
20
+ const sectionOf = (id) => sections.find((s) => s.id === id) || null;
21
+
22
+ const keyToTarget = (key) => {
23
+ if (key === 'pool') return { kind: 'pool' };
24
+ if (key === 'hidden') return { kind: 'hidden' };
25
+ const gi = Number(String(key).slice(1));
26
+ return Number.isFinite(gi) ? { kind: 'group', gi } : null;
27
+ };
28
+
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
+ const draggingSid = drag?.kind === 'section' ? drag.sid : null;
42
+ // 平铺容器的内容与顺序都来自模型:显式排过的(order)优先,其余按栏目自身顺序
43
+ const pool = nav.ungroupedIds(preview, allIds);
44
+
45
+ const endDrag = () => { setDrag(null); setOver(null); };
46
+
47
+ /** 松手即提交预览里的顺序(预览就是结果,不再重新算一遍)。 */
48
+ const commit = () => { if (preview !== value) onChange(preview); endDrag(); };
49
+
50
+ // 只在落点**真的变了**才 setState:dragover 每次鼠标移动都触发,
51
+ // 不做这层去重就是每次移动都重排一遍整页 → 抖
52
+ const setOverIfChanged = (next) => setOver((o) => (o && o.key === next.key && o.index === next.index ? o : next));
53
+
54
+ // 容器外壳:接住「拖到空白处 = 追加到末尾」,并显示落点高亮
55
+ const zone = (key, base = '') => ({
56
+ 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)),
59
+ onDrop: (e) => { e.preventDefault(); commit(); },
60
+ });
61
+
62
+ /**
63
+ * 行的落点判定。两条防抖铁律:
64
+ * ① 行内事件**就地拦下**(preventDefault + stopPropagation):一旦冒泡到容器,
65
+ * 容器会把它当成「拖到空白处」翻成「追加到末尾」——指针正好压在被拖的行上时,
66
+ * 两个判断来回切,页面就抖。
67
+ * ② 插入位取自**原始配置**里的位置(不随预览漂移)+ 指针在上半/下半决定前后。
68
+ * 若反过来用「预览后的行号」,预览一重排索引就变,等于自己推自己。
69
+ */
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
+ });
85
+
86
+ return (
87
+ <div className="stack">
88
+ {/* ① 平铺:不进任何容器的栏目(= 头部平铺显示) */}
89
+ <section {...zone('pool', 'nav-pool')}>
90
+ <div className="hint">平铺:不进分组也不隐藏的栏目,直接显示在头部(拖到这里 = 移出分组 / 取消隐藏)</div>
91
+ {pool.length ? (
92
+ <div className="nav-list">
93
+ {pool.map((sid) => (
94
+ <SectionRow key={sid} section={sectionOf(sid)} sid={sid}
95
+ dragging={draggingSid === sid}
96
+ {...rowProps('pool', sid)}
97
+ onDragEnd={endDrag}
98
+ actions={null} />
99
+ ))}
100
+ </div>
101
+ ) : <div className="muted">没有平铺的栏目。</div>}
102
+ </section>
103
+
104
+ {/* ② 分组:组名可改、组间可拖把手换位 */}
105
+ {preview.groups.map((g, gi) => (
106
+ <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
+ >
116
+ <span className="grip" title="拖动调整分组顺序" draggable
117
+ onDragStart={() => setDrag({ kind: 'group', gi })} onDragEnd={endDrag} />
118
+ <input className="nav-title" placeholder="组名,如「求职」" value={g.title}
119
+ onChange={(e) => onChange(nav.renameGroup(value, gi, e.target.value))} />
120
+ <span className="muted">{g.sections.length} 个栏目</span>
121
+ <span className="right rowgap">
122
+ <button className="btn small ghost" onClick={() => onChange(nav.removeGroup(value, gi))}>删除组</button>
123
+ </span>
124
+ </div>
125
+ {g.sections.length ? (
126
+ <div className="nav-list">
127
+ {g.sections.map((sid) => (
128
+ <SectionRow key={sid} section={sectionOf(sid)} sid={sid}
129
+ dragging={draggingSid === sid}
130
+ {...rowProps(`g${gi}`, sid)}
131
+ onDragEnd={endDrag}
132
+ actions={<button className="btn small ghost" onClick={() => onChange(nav.ungroupSection(value, sid))}>移出</button>} />
133
+ ))}
134
+ </div>
135
+ ) : <div className="row"><span className="desc">空分组——把栏目拖进来。</span></div>}
136
+ </section>
137
+ ))}
138
+
139
+ {/* ③ 隐藏:与分组同构的容器——拖进去即「不进头部」 */}
140
+ <section {...zone('hidden', 'card nav-group nav-hidden')}>
141
+ <div className="colhead">
142
+ 隐藏
143
+ <span className="muted">{preview.hidden.length} 个栏目</span>
144
+ <span className="right muted">不进头部 · #/栏目id 直达仍可用</span>
145
+ </div>
146
+ {preview.hidden.length ? (
147
+ <div className="nav-list">
148
+ {preview.hidden.map((sid) => (
149
+ <SectionRow key={sid} section={sectionOf(sid)} sid={sid}
150
+ dragging={draggingSid === sid}
151
+ {...rowProps('hidden', sid)}
152
+ onDragEnd={endDrag}
153
+ actions={<button className="btn small ghost" onClick={() => onChange(nav.ungroupSection(value, sid))}>移出</button>} />
154
+ ))}
155
+ </div>
156
+ ) : <div className="row"><span className="desc">没有隐藏的栏目——把不常用的拖进来。</span></div>}
157
+ </section>
158
+
159
+ <div className="rowgap">
160
+ <button className="btn" onClick={() => onChange(nav.addGroup(value))}>新增分组</button>
161
+ <span className="hint">组顺序 = 头部显示顺序;组内顺序 = 展开列表里的顺序</span>
162
+ </div>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ /** 一行栏目:把手(拖)+ 名称 + 计数 + 容器自己的动作(移出 / 无)。 */
168
+ function SectionRow({ section, sid, dragging, actions, onDragStart, onDragEnd, onDragOver, onDrop }) {
169
+ return (
170
+ <div className={`row nav-row ${dragging ? 'dragging' : ''}`} onDragOver={onDragOver} onDrop={onDrop}>
171
+ <span className="grip" title="拖动:换位 / 换容器" draggable onDragStart={onDragStart} onDragEnd={onDragEnd} />
172
+ <span className="name">{section ? section.title : `栏目已删除(${sid})`}</span>
173
+ <span className="desc">{section ? `${section.entries} 条` : '保存前请移出'}</span>
174
+ <span className="acts">{actions}</span>
175
+ </div>
176
+ );
177
+ }
178
+
@@ -4,14 +4,16 @@ import { loadStore, mutateStore, snapshotStore } from '../../core/store.js';
4
4
  import { findSection } from '../../core/fields.js';
5
5
  import { badInput, notFound } from '../../core/errors.js';
6
6
 
7
- export const SETTING_KEYS = ['exportDir', 'exportFormat', 'includeSecretsInExport', 'defaultSection', 'navGroups'];
7
+ export const SETTING_KEYS = ['exportDir', 'exportFormat', 'includeSecretsInExport', 'defaultSection', 'navGroups', 'navHidden', 'navOrder'];
8
8
 
9
9
  const SCHEMA = {
10
10
  exportDir: { type: 'string', hint: '导出目录,留空 = ~/nx-sk/export' },
11
11
  exportFormat: { type: 'string', enum: ['json', 'md', 'both'], hint: '整体导出默认格式' },
12
12
  includeSecretsInExport: { type: 'boolean', hint: '整体导出是否默认带密钥明文(默认否)' },
13
13
  defaultSection: { type: 'string', hint: '默认栏目 id' },
14
- navGroups: { type: 'json', hint: '头部导航分组:[{title, sections:[栏目id…]}],数组顺序 = 显示顺序' },
14
+ navGroups: { type: 'json', hint: '头部导航分组:[{title, sections:[栏目id…]}],数组顺序 = 显示顺序', coerce: coerceNavGroups },
15
+ navHidden: { type: 'json', hint: '头部隐藏的栏目 id(入口不显示,`#/栏目` 直达仍可用)', coerce: coerceNavHidden },
16
+ navOrder: { type: 'json', hint: '平铺区的显示顺序(栏目 id 数组;没列进去的按栏目自身顺序跟在后面)', coerce: coerceNavOrder },
15
17
  };
16
18
 
17
19
  export function settingsSchema() {
@@ -20,7 +22,16 @@ export function settingsSchema() {
20
22
 
21
23
  export async function getSettings() {
22
24
  const store = await loadStore();
23
- return { settings: store.settings, schema: settingsSchema() };
25
+ return {
26
+ settings: store.settings,
27
+ // 栏目清单随设置一起给:面板的「栏目管理」按它渲染,CLI 也能一次看全
28
+ sections: store.sections.map((s) => ({
29
+ id: s.id,
30
+ title: s.title,
31
+ entries: store.entries.filter((e) => e.section === s.id).length,
32
+ })),
33
+ schema: settingsSchema(),
34
+ };
24
35
  }
25
36
 
26
37
  /**
@@ -67,10 +78,49 @@ function coerceNavGroups(value, store) {
67
78
  });
68
79
  }
69
80
 
81
+ /**
82
+ * 「栏目 id 清单」校验(隐藏清单 / 平铺顺序共用):每一项都必须是真实存在的栏目;
83
+ * 去重保序,并归一成真实 id(写标题也认)。label 只影响报错措辞。
84
+ */
85
+ function coerceSectionIdList(value, store, label) {
86
+ // 三种写法都吃:数组(面板)、`a,b`(CLI 省事写法)、`["a","b"]`(CLI 的 JSON 写法)
87
+ let raw = value;
88
+ if (typeof raw === 'string') {
89
+ const s = raw.trim();
90
+ if (s.startsWith('[')) {
91
+ try {
92
+ raw = JSON.parse(s);
93
+ } catch (e) {
94
+ throw badInput(`${label}需要 JSON 数组或逗号分隔:${e.message}`);
95
+ }
96
+ }
97
+ }
98
+ const list = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
99
+ const out = [];
100
+ for (const item of list) {
101
+ const ref = String(item ?? '').trim();
102
+ if (!ref) continue;
103
+ const sec = findSection(store, ref);
104
+ if (!sec) throw badInput(`${label}里的栏目不存在: ${ref}`);
105
+ if (!out.includes(sec.id)) out.push(sec.id);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // 用**函数声明**(会提升):SCHEMA 在模块顶层就引用了它们,const 箭头函数会踩 TDZ
111
+ function coerceNavHidden(value, store) {
112
+ return coerceSectionIdList(value, store, '隐藏清单');
113
+ }
114
+
115
+ function coerceNavOrder(value, store) {
116
+ return coerceSectionIdList(value, store, '平铺顺序');
117
+ }
118
+
70
119
  function coerceSetting(key, value, store) {
71
120
  const s = SCHEMA[key];
72
121
  if (!s) throw notFound(`未知设置项: ${key}(可用: ${SETTING_KEYS.join(', ')})`);
73
- if (s.type === 'json') return coerceNavGroups(value, store);
122
+ // 结构化设置项(导航分组 / 隐藏)自带校验器;标量项走下面这套
123
+ if (s.coerce) return s.coerce(value, store);
74
124
  if (s.type === 'boolean') {
75
125
  if (typeof value === 'boolean') return value;
76
126
  const v = String(value).trim().toLowerCase();
@@ -109,16 +159,36 @@ export async function updateSettings({ set, key, value, 'dry-run': dryRun } = {}
109
159
  }
110
160
 
111
161
  export function renderSettings(data) {
112
- const lines = ['当前设置(改:nx-sk setting set --key <名> --value <值>)'];
162
+ const lines = ['当前设置(改:nx-sk setting set --set k=v)'];
113
163
  for (const s of data.schema) {
164
+ if (s.coerce) continue; // 结构化项(导航分组 / 隐藏)单独可读地列在下面
114
165
  const v = data.settings[s.key];
115
166
  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)));
167
+ : (v === '' || v === undefined ? '(空)' : String(v));
120
168
  lines.push(` ${s.key.padEnd(22)} ${shown.padEnd(10)} ${s.hint}`);
121
169
  }
170
+
171
+ const settings = data.settings || {};
172
+ const hidden = new Set(settings.navHidden || []);
173
+ if (data.sections?.length) {
174
+ lines.push('', `栏目(${data.sections.length})· 隐藏 ${hidden.size} 个`);
175
+ for (const s of data.sections) {
176
+ lines.push(` ${String(s.id).padEnd(18)} ${String(s.title).padEnd(14)} ${hidden.has(s.id) ? '隐藏' : '显示'}`);
177
+ }
178
+ }
179
+
180
+ const order = settings.navOrder || [];
181
+ if (data.sections?.length) {
182
+ const ranked = order.filter((id) => data.sections.some((s) => s.id === id));
183
+ if (ranked.length) lines.push(`平铺顺序(前 ${ranked.length} 个已显式排序): ${ranked.join(' → ')}`);
184
+ }
185
+
186
+ const groups = settings.navGroups || [];
187
+ lines.push('', groups.length
188
+ ? `分组(${groups.length})· 组顺序 = 头部顺序,组内顺序 = 展开列表顺序`
189
+ : '分组:无(全部栏目平铺在头部)');
190
+ for (const g of groups) lines.push(` ${g.title}: ${g.sections.join(' → ')}`);
191
+ lines.push('', '例:nx-sk setting set --set \'navGroups=[{"title":"求职","sections":["projects"]}]\' --dry-run');
122
192
  return lines.join('\n');
123
193
  }
124
194