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.
@@ -1,23 +1,33 @@
1
- import { useCallback, useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
2
  import { api } from '../../web/frontend/api/client.js';
3
3
  import { useStore } from '../../web/frontend/store.jsx';
4
4
  import { CliHints, Copyable, Empty, Tag } from '../../web/frontend/components/ui.jsx';
5
+ import * as nav from '../../web/frontend/components/navModel.js';
6
+ import NavManager from './navManager.jsx';
5
7
 
6
8
  export default function SettingsView() {
7
9
  const { boot, reload, toast, guard } = useStore();
8
10
  const [data, setData] = useState(null);
9
11
  const [form, setForm] = useState(null);
10
- const [groups, setGroups] = useState([]);
12
+ const [navCfg, setNavCfg] = useState(nav.emptyConfig());
13
+ // 保存后的基准:脏值判断与「放弃改动」都以它为准,不靠逐项对比
14
+ const [baseline, setBaseline] = useState(null);
11
15
  // 面板地址不写死:直接取用户此刻访问的这个 origin,换端口/换 host 都自动跟上
12
16
  const panelUrl = typeof window === 'undefined' ? '' : window.location.origin;
13
17
  const panelPort = typeof window === 'undefined' ? '' : (window.location.port || '80');
14
18
 
19
+ const sections = useMemo(() => data?.sections || [], [data]);
20
+ const sectionIds = useMemo(() => sections.map((s) => s.id), [sections]);
21
+
15
22
  const load = useCallback(async () => {
16
23
  try {
17
24
  const d = await api('/api/settings');
25
+ const nextForm = { ...d.settings };
26
+ const nextNav = nav.configFromSettings(d.settings);
18
27
  setData(d);
19
- setForm({ ...d.settings });
20
- setGroups((d.settings.navGroups || []).map((g) => ({ title: g.title, sections: [...g.sections] })));
28
+ setForm(nextForm);
29
+ setNavCfg(nextNav);
30
+ setBaseline({ form: nextForm, navCfg: nextNav });
21
31
  } catch (e) {
22
32
  toast(e.message, 'bad');
23
33
  }
@@ -25,63 +35,40 @@ export default function SettingsView() {
25
35
 
26
36
  useEffect(() => { load(); }, [load]);
27
37
 
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));
40
+
41
+ // 一次保存全部设置:标量项 + 导航(分组 / 隐藏)。分成两块各存各的,
42
+ // 用户就得记住「刚才那块存了没」——统一保存 + 统一的脏值提示才是清晰的。
28
43
  const save = guard(async () => {
29
- // 必须包在 `set` 里发:HTTP 的 body 会经 applySpec **只保留声明过的键**,
30
- // 平铺的 `{exportDir: …}` 会被静默丢掉 → service 收到空 patch 报「至少要给一项」。
31
- // `set` 是 setting.set 声明过的 kv flag,HTTP 形态就是 `{set: {k: v}}`。
32
- const body = {
33
- set: {
34
- exportDir: form.exportDir,
35
- exportFormat: form.exportFormat,
36
- includeSecretsInExport: !!form.includeSecretsInExport,
37
- defaultSection: form.defaultSection,
44
+ if (problems.length) { toast(`保存失败:${problems[0].message}`, 'bad'); return; }
45
+ const r = await api('/api/settings', {
46
+ method: 'PATCH',
47
+ body: {
48
+ set: {
49
+ exportDir: form.exportDir,
50
+ exportFormat: form.exportFormat,
51
+ includeSecretsInExport: !!form.includeSecretsInExport,
52
+ defaultSection: form.defaultSection,
53
+ ...nav.configToPayload(navCfg),
54
+ },
38
55
  },
39
- };
40
- const r = await api('/api/settings', { method: 'PATCH', body });
41
- toast('设置已保存');
56
+ });
57
+ const nextForm = { ...r.set };
58
+ const nextNav = nav.configFromSettings(r.set);
42
59
  setData({ ...data, settings: r.set });
60
+ setForm(nextForm);
61
+ setNavCfg(nextNav);
62
+ setBaseline({ form: nextForm, navCfg: nextNav });
63
+ toast('设置已保存');
43
64
  await reload();
44
65
  });
45
66
 
46
- // 头部导航分组:组顺序 = 头部显示顺序;组内栏目顺序 = 展开列表里的顺序(可上下移)
47
- const patchGroup = (gi, patch) => setGroups((gs) => gs.map((g, i) => (i === gi ? { ...g, ...patch } : g)));
48
-
49
- const addMember = (gi, sid) => setGroups((gs) => gs.map((g, i) => (
50
- i === gi && !g.sections.includes(sid) ? { ...g, sections: [...g.sections, sid] } : g
51
- )));
52
-
53
- const removeMember = (gi, sid) => setGroups((gs) => gs.map((g, i) => (
54
- i === gi ? { ...g, sections: g.sections.filter((x) => x !== sid) } : g
55
- )));
56
-
57
- const moveMember = (gi, si, delta) => setGroups((gs) => gs.map((g, i) => {
58
- if (i !== gi) return g;
59
- const to = si + delta;
60
- if (to < 0 || to >= g.sections.length) return g;
61
- const next = [...g.sections];
62
- [next[si], next[to]] = [next[to], next[si]];
63
- return { ...g, sections: next };
64
- }));
65
-
66
- const moveGroup = (gi, delta) => setGroups((gs) => {
67
- const to = gi + delta;
68
- if (to < 0 || to >= gs.length) return gs;
69
- const next = [...gs];
70
- [next[gi], next[to]] = [next[to], next[gi]];
71
- return next;
72
- });
73
-
74
- const saveGroups = guard(async () => {
75
- const naked = groups.find((g) => !String(g.title).trim());
76
- if (naked) { toast('每个分组都要有组名', 'bad'); return; }
77
- const empty = groups.find((g) => !g.sections.length);
78
- if (empty) { toast(`分组「${empty.title}」还没有勾选栏目——要么勾一个,要么删掉这个分组`, 'bad'); return; }
79
- const r = await api('/api/settings', { method: 'PATCH', body: { set: { navGroups: groups } } });
80
- toast(groups.length ? `已保存 ${groups.length} 个导航分组` : '已取消分组,栏目恢复平铺');
81
- setData({ ...data, settings: r.set });
82
- setGroups((r.set.navGroups || []).map((g) => ({ title: g.title, sections: [...g.sections] })));
83
- await reload();
84
- });
67
+ const discard = () => {
68
+ if (!baseline) return;
69
+ setForm({ ...baseline.form });
70
+ setNavCfg(structuredClone(baseline.navCfg));
71
+ };
85
72
 
86
73
  if (!data || !form) return <div className="pad muted">加载中…</div>;
87
74
 
@@ -91,116 +78,35 @@ export default function SettingsView() {
91
78
  <strong>设置</strong>
92
79
  <span className="muted">本机配置与运行状态</span>
93
80
  <span className="grow" />
81
+ {dirty ? (
82
+ <span className="rowgap">
83
+ <span className="tag">有未保存的改动</span>
84
+ <button className="btn small" onClick={() => { void save(); }}>保存</button>
85
+ <button className="btn small ghost" onClick={discard}>放弃改动</button>
86
+ </span>
87
+ ) : <span className="muted">已保存</span>}
94
88
  <button className="btn small ghost" onClick={load}>重新读取</button>
95
89
  </div>
96
90
 
97
- <div className="card">
98
- <div className="colhead">存储与运行</div>
99
- <div className="colbody">
100
- <InfoRow label="数据目录" value={boot?.home} copy={boot?.home} />
101
- <InfoRow label="store.json" value={boot?.storePath} copy={boot?.storePathRaw} />
102
- <InfoRow label="栏目 / 条目" value={boot ? `${boot.counts.sections} / ${boot.counts.entries}` : ''} />
103
- <InfoRow label="命令条数" value={boot ? String(boot.counts.commands) : ''} />
104
- <InfoRow label="app / node" value={boot ? `v${boot.app.version} · node ${boot.app.node} · ${boot.app.platform}` : ''} />
105
- <InfoRow
106
- label="环境变量覆盖"
107
- value={boot ? Object.entries(boot.envOverrides).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join(' ') || '(无)' : ''}
108
- />
109
- </div>
110
- </div>
111
-
112
- <div className="card">
113
- <div className="colhead">密钥(密文落盘)</div>
114
- <div className="colbody">
115
- <InfoRow label="密钥来源" value={boot?.vault?.source === 'env' ? '环境变量 NX_SK_PASSPHRASE(口令不落盘)' : '本机密钥文件'} />
116
- <InfoRow label="密钥文件" value={boot?.vault?.keyFile || boot?.vault?.saltFile || ''} copy={boot?.vault?.keyFile || boot?.vault?.saltFile} />
117
- <div className="row">
118
- <span className="desc">{boot?.vault?.note}</span>
119
- </div>
120
- <div className="row">
121
- <span className="desc bad">删掉密钥文件或换掉口令后,已存的密文都解不开了——导出前先确认密钥可用。</span>
122
- </div>
123
- </div>
124
- </div>
125
-
126
- <div className="card">
127
- <div className="colhead">面板与 CLI</div>
128
- <div className="colbody">
129
- <div className="row">
130
- <span className="name">面板地址</span>
131
- <span className="desc mono">{panelUrl}</span>
132
- <span className="acts"><Copyable className="mono" text={panelUrl}>复制</Copyable></span>
133
- </div>
134
- <div className="row">
135
- <span className="name">重启面板</span>
136
- <span className="desc">面板本身就是 serve 提供的;改代码后重启它即可</span>
137
- <span className="acts"><Copyable className="mono" text={`nx-sk serve --port ${panelPort}`}>{`nx-sk serve --port ${panelPort}`}</Copyable></span>
138
- </div>
139
- </div>
140
- </div>
141
-
142
91
  <div className="card">
143
92
  <div className="colhead">
144
- 头部导航分组
145
- <span className="right muted">{groups.length ? `${groups.length} 个分组` : '未分组(全部平铺)'}</span>
93
+ 栏目管理
94
+ <span className="right muted">
95
+ 头部显示 {sectionIds.length - navCfg.hidden.length} 个 · 隐藏 {navCfg.hidden.length} 个
96
+ {navCfg.groups.length ? ` · ${navCfg.groups.length} 个分组` : ''}
97
+ </span>
146
98
  </div>
147
99
  <div className="pad stack">
148
100
  <div className="hint">
149
- 把同域栏目收拢成一个头部入口:头部只显示组名,悬浮展开上下列表选具体栏目,
150
- <b>列表顺序就是这里的顺序</b>(用上移 / 下移调)。不归组的栏目保持平铺;
151
- 组顺序 = 头部显示顺序;一个栏目只能属于一个分组。
152
- </div>
153
- {groups.map((g, gi) => {
154
- const taken = new Set(groups.flatMap((o, oi) => (oi === gi ? [] : o.sections)));
155
- const available = (boot?.sections || []).filter((s) => !g.sections.includes(s.id) && !taken.has(s.id));
156
- const byId = (sid) => (boot?.sections || []).find((s) => s.id === sid) || null;
157
- return (
158
- <div className="stack" key={gi} style={{ borderTop: gi ? 'var(--border)' : 0, paddingTop: gi ? 8 : 0 }}>
159
- <div className="rowgap">
160
- <input
161
- value={g.title}
162
- placeholder="组名,如「求职」"
163
- onChange={(e) => patchGroup(gi, { title: e.target.value })}
164
- style={{ minWidth: 160 }}
165
- />
166
- <button className="btn small ghost" disabled={gi === 0} onClick={() => moveGroup(gi, -1)}>上移</button>
167
- <button className="btn small ghost" disabled={gi === groups.length - 1} onClick={() => moveGroup(gi, 1)}>下移</button>
168
- <button className="btn small ghost" onClick={() => setGroups((gs) => gs.filter((_, i) => i !== gi))}>删除组</button>
169
- <span className="muted">已选 {g.sections.length} 个栏目</span>
170
- </div>
171
- <div className="colbody">
172
- {g.sections.length ? g.sections.map((sid, si) => {
173
- const s = byId(sid);
174
- return (
175
- <div className="row" key={sid}>
176
- <span className="mono muted">{si + 1}</span>
177
- <span className={`name ${s ? '' : 'bad'}`}>{s ? s.title : `栏目已删除(${sid})`}</span>
178
- <span className="desc">{s ? `${s.entries} 条` : '保存前请移除'}</span>
179
- <span className="acts">
180
- <button className="btn small ghost" disabled={si === 0} onClick={() => moveMember(gi, si, -1)}>上移</button>
181
- <button className="btn small ghost" disabled={si === g.sections.length - 1} onClick={() => moveMember(gi, si, 1)}>下移</button>
182
- <button className="btn small ghost" onClick={() => removeMember(gi, sid)}>移除</button>
183
- </span>
184
- </div>
185
- );
186
- }) : <div className="muted">这个分组还没有栏目——从下面点一个加进来。</div>}
187
- </div>
188
- <div className="rowgap">
189
- {available.length ? available.map((s) => (
190
- <button className="btn small ghost" key={s.id} onClick={() => addMember(gi, s.id)}>
191
- + {s.title}<span className="count"> {s.entries}</span>
192
- </button>
193
- )) : <span className="muted">所有栏目都已归组</span>}
194
- </div>
195
- </div>
196
- );
197
- })}
198
- {!groups.length ? <div className="muted">还没有分组——所有栏目都平铺在头部。</div> : null}
199
- <div className="rowgap">
200
- <button className="btn" onClick={saveGroups}>保存分组</button>
201
- <button className="btn small ghost" onClick={() => setGroups((gs) => [...gs, { title: '', sections: [] }])}>新增分组</button>
202
- {groups.length ? <button className="btn small ghost" onClick={() => setGroups([])}>清空(恢复平铺)</button> : null}
101
+ 拖动就是把结构摆成你要的样子:栏目可以拖进分组、组内换位、拖回平铺区即移出;拖分组把手换分组顺序。
102
+ 被隐藏的栏目只是头部不显示入口,用 <code>#/栏目id</code> 直达仍可用。
203
103
  </div>
104
+ <NavManager sections={sections} value={navCfg} onChange={setNavCfg} />
105
+ {problems.length ? (
106
+ <div className="stack">
107
+ {problems.map((p) => <div key={p.field} className="hint bad">· {p.message}</div>)}
108
+ </div>
109
+ ) : null}
204
110
  </div>
205
111
  </div>
206
112
 
@@ -236,8 +142,50 @@ export default function SettingsView() {
236
142
  </div>
237
143
  </div>
238
144
  </div>
239
- <div className="rowgap" style={{ marginTop: 8 }}>
240
- <button className="btn" onClick={save}>保存设置</button>
145
+ </div>
146
+ </div>
147
+
148
+ <div className="card">
149
+ <div className="colhead">存储与运行</div>
150
+ <div className="colbody">
151
+ <InfoRow label="数据目录" value={boot?.home} copy={boot?.home} />
152
+ <InfoRow label="store.json" value={boot?.storePath} copy={boot?.storePathRaw} />
153
+ <InfoRow label="栏目 / 条目" value={boot ? `${boot.counts.sections} / ${boot.counts.entries}` : ''} />
154
+ <InfoRow label="命令条数" value={boot ? String(boot.counts.commands) : ''} />
155
+ <InfoRow label="app / node" value={boot ? `v${boot.app.version} · node ${boot.app.node} · ${boot.app.platform}` : ''} />
156
+ <InfoRow
157
+ label="环境变量覆盖"
158
+ value={boot ? Object.entries(boot.envOverrides).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join(' ') || '(无)' : ''}
159
+ />
160
+ </div>
161
+ </div>
162
+
163
+ <div className="card">
164
+ <div className="colhead">密钥(密文落盘)</div>
165
+ <div className="colbody">
166
+ <InfoRow label="密钥来源" value={boot?.vault?.source === 'env' ? '环境变量 NX_SK_PASSPHRASE(口令不落盘)' : '本机密钥文件'} />
167
+ <InfoRow label="密钥文件" value={boot?.vault?.keyFile || boot?.vault?.saltFile || ''} copy={boot?.vault?.keyFile || boot?.vault?.saltFile} />
168
+ <div className="row">
169
+ <span className="desc">{boot?.vault?.note}</span>
170
+ </div>
171
+ <div className="row">
172
+ <span className="desc bad">删掉密钥文件或换掉口令后,已存的密文都解不开了——导出前先确认密钥可用。</span>
173
+ </div>
174
+ </div>
175
+ </div>
176
+
177
+ <div className="card">
178
+ <div className="colhead">面板与 CLI</div>
179
+ <div className="colbody">
180
+ <div className="row">
181
+ <span className="name">面板地址</span>
182
+ <span className="desc mono">{panelUrl}</span>
183
+ <span className="acts"><Copyable className="mono" text={panelUrl}>复制</Copyable></span>
184
+ </div>
185
+ <div className="row">
186
+ <span className="name">重启面板</span>
187
+ <span className="desc">面板本身就是 serve 提供的;改代码后重启它即可</span>
188
+ <span className="acts"><Copyable className="mono" text={`nx-sk serve --port ${panelPort}`}>{`nx-sk serve --port ${panelPort}`}</Copyable></span>
241
189
  </div>
242
190
  </div>
243
191
  </div>
@@ -17,28 +17,38 @@ export default function App() {
17
17
  const { boot, bootError, ui, patchUi, reload } = useStore();
18
18
  const view = LEGACY_VIEW[ui.view] || ui.view;
19
19
 
20
- // 栏目 tab 是**数据驱动**的(bootstrap.sections);分组来自 settings.navGroups(设置页可配);
20
+ // 栏目 tab 是**数据驱动**的(bootstrap.sections);分组与隐藏来自 settings(设置页可配);
21
21
  // 静态 tab 来自前端注册表。渲染顺序:未分组栏目(按 order)→ 分组(按配置顺序)→ 工具(按 order)。
22
22
  const nav = useMemo(() => {
23
- const sections = (boot?.sections || []).map((s) => ({
23
+ const all = (boot?.sections || []).map((s) => ({
24
24
  id: s.id, title: s.title, order: s.order, kind: 'section', count: s.entries,
25
25
  }));
26
- // 分组是**显示配置**:成员栏目被删掉时优雅降级(过滤掉、配置保留),成员全没了就不显示这个组
26
+ // 隐藏**只隐藏入口**:`#/栏目id` 直达仍然渲染,所以 all 里保留全部栏目
27
+ const hidden = new Set(boot?.settings?.navHidden || []);
28
+ const visible = all.filter((s) => !hidden.has(s.id));
29
+
30
+ // 分组是**显示配置**:成员栏目被删掉时优雅降级(过滤掉、配置保留),成员全没了就不显示这个组;
31
+ // 被隐藏的成员在入口里同样不出现
27
32
  const groups = (boot?.settings?.navGroups || [])
28
33
  .map((g) => ({
29
34
  title: g.title,
30
35
  sections: (g.sections || [])
31
- .map((sid) => sections.find((s) => s.id === sid))
36
+ .map((sid) => visible.find((s) => s.id === sid))
32
37
  .filter(Boolean),
33
38
  }))
34
39
  .filter((g) => g.sections.length);
35
40
  const grouped = new Set(groups.flatMap((g) => g.sections.map((s) => s.id)));
36
- const flat = sections.filter((s) => !grouped.has(s.id)).sort((a, b) => (a.order ?? 50) - (b.order ?? 50));
41
+ // 平铺顺序:settings.navOrder 里显式排过的优先(按它的次序),其余按栏目自己的 order 跟在后面
42
+ const rank = new Map((boot?.settings?.navOrder || []).map((id, i) => [id, i]));
43
+ const rankOf = (s) => (rank.has(s.id) ? rank.get(s.id) : Number.MAX_SAFE_INTEGER);
44
+ const flat = visible.filter((s) => !grouped.has(s.id))
45
+ .sort((a, b) => rankOf(a) - rankOf(b) || (a.order ?? 50) - (b.order ?? 50));
37
46
  const stat = VIEWS.filter((v) => v.tab !== false)
38
47
  .map((v) => ({ id: v.id, title: v.title, order: v.order, kind: 'view', component: v.component }))
39
- .filter((v) => !sections.some((s) => s.id === v.id))
48
+ .filter((v) => !all.some((s) => s.id === v.id))
40
49
  .sort((a, b) => (a.order ?? 50) - (b.order ?? 50));
41
- return { flat, groups, tools: stat, all: [...flat, ...groups.flatMap((g) => g.sections), ...stat] };
50
+ const hiddenItems = all.filter((s) => hidden.has(s.id));
51
+ return { flat, groups, tools: stat, all: [...flat, ...groups.flatMap((g) => g.sections), ...stat, ...hiddenItems] };
42
52
  }, [boot]);
43
53
 
44
54
  // 没 boot 之前**不要**拿第一个 tab 兜底:那时动态栏目还没到,nav 里只有静态视图,
@@ -0,0 +1,226 @@
1
+ // 头部导航配置的**纯模型**:分组 + 隐藏。零 React、零 IO,可单测。
2
+ //
3
+ // 为什么单独一层:这块的逻辑(增删改、排序、可用性、校验)比渲染多得多,
4
+ // 混在 JSX 里既说不清也测不了。表现层只管画,动手改数据一律走这里——
5
+ // 于是「管理逻辑」只有一份,前端拦一道、后端(settings service)再拦一道,两边同口径。
6
+ //
7
+ // 配置形状(与 settings.navGroups / navHidden / navOrder 一一对应):
8
+ // { groups: [{ title, sections: [栏目id…] }], hidden: [栏目id…], order: [栏目id…] }
9
+ // 组顺序 = 头部显示顺序;组内顺序 = 展开列表顺序;hidden = 入口不显示(直达仍可用);
10
+ // order = **平铺区的显示顺序**(没列进去的栏目按栏目自己的 order 跟在后面)。
11
+
12
+ export const emptyConfig = () => ({ groups: [], hidden: [], order: [] });
13
+
14
+ /** 从后端 settings 取一份**工作副本**(深拷贝,编辑不污染原数据)。 */
15
+ export function configFromSettings(settings) {
16
+ return {
17
+ groups: (settings?.navGroups || []).map((g) => ({ title: String(g.title || ''), sections: [...(g.sections || [])] })),
18
+ hidden: [...(settings?.navHidden || [])],
19
+ order: [...(settings?.navOrder || [])],
20
+ };
21
+ }
22
+
23
+ /** 保存时发出去的形状(只带这两个键,其他设置项各自负责)。 */
24
+ export function configToPayload(cfg) {
25
+ return {
26
+ navGroups: cfg.groups.map((g) => ({ title: g.title.trim(), sections: [...g.sections] })),
27
+ navHidden: [...cfg.hidden],
28
+ navOrder: [...(cfg.order || [])],
29
+ };
30
+ }
31
+
32
+ export const isDirty = (a, b) => JSON.stringify(a) !== JSON.stringify(b);
33
+
34
+ // —— 分组:增删改 + 排序 ——
35
+
36
+ export const addGroup = (cfg, title = '') => ({ ...cfg, groups: [...cfg.groups, { title, sections: [] }] });
37
+
38
+ export const removeGroup = (cfg, gi) => ({ ...cfg, groups: cfg.groups.filter((_, i) => i !== gi) });
39
+
40
+ export const renameGroup = (cfg, gi, title) => ({
41
+ ...cfg,
42
+ groups: cfg.groups.map((g, i) => (i === gi ? { ...g, title } : g)),
43
+ });
44
+
45
+ /** 上移 / 下移一个分组(delta = ±1);越界原样返回。 */
46
+ export const moveGroup = (cfg, gi, delta) => {
47
+ const to = gi + delta;
48
+ if (to < 0 || to >= cfg.groups.length) return cfg;
49
+ const groups = [...cfg.groups];
50
+ [groups[gi], groups[to]] = [groups[to], groups[gi]];
51
+ return { ...cfg, groups };
52
+ };
53
+
54
+ // —— 组成员:加入 / 移除 / 排序 ——
55
+
56
+ /** 把栏目加进某个分组(已在该组或别的组里则原样返回)。 */
57
+ export function addSection(cfg, gi, sid) {
58
+ const target = cfg.groups[gi];
59
+ if (!target || target.sections.includes(sid)) return cfg;
60
+ if (cfg.groups.some((g) => g.sections.includes(sid))) return cfg; // 一个栏目只能属于一个分组
61
+ return { ...cfg, groups: cfg.groups.map((g, i) => (i === gi ? { ...g, sections: [...g.sections, sid] } : g)) };
62
+ }
63
+
64
+ export const removeSection = (cfg, gi, sid) => ({
65
+ ...cfg,
66
+ groups: cfg.groups.map((g, i) => (i === gi ? { ...g, sections: g.sections.filter((x) => x !== sid) } : g)),
67
+ });
68
+
69
+ export const moveSection = (cfg, gi, si, delta) => {
70
+ const g = cfg.groups[gi];
71
+ const to = si + delta;
72
+ if (!g || to < 0 || to >= g.sections.length) return cfg;
73
+ const sections = [...g.sections];
74
+ [sections[si], sections[to]] = [sections[to], sections[si]];
75
+ return { ...cfg, groups: cfg.groups.map((x, i) => (i === gi ? { ...g, sections } : x)) };
76
+ };
77
+
78
+ /**
79
+ * 把栏目放到指定分组的指定位置(拖拽编排的主力原语)。
80
+ * `index === null` 表示追加到末尾;越界按边界处理。
81
+ * 来源可以是别的分组、也可以是「未归组」——两种情况都先摘掉再插入,
82
+ * 所以「拖动即等于移动」,不会出现同一栏目短暂存在两份。
83
+ */
84
+ export function moveSectionTo(cfg, sid, gi, index = null) {
85
+ return moveToContainer(cfg, sid, { kind: 'group', gi }, index);
86
+ }
87
+
88
+ /**
89
+ * **容器**是这块的统一心智模型:一个栏目要么在「平铺」里、要么在某个分组里、要么在「隐藏」里。
90
+ * `{kind:'pool'} | {kind:'group', gi} | {kind:'hidden'}` —— 拖拽就是把栏目从一个容器挪到另一个。
91
+ */
92
+ /**
93
+ * 栏目现在在哪个容器里,以及在该容器里的位置(拖拽预览要用它做索引换算)。
94
+ * 三种容器都有位置:平铺区的位置来自 order(没排过的按栏目 order 排),分组与隐藏各自是数组。
95
+ */
96
+ export function containerOf(cfg, sid, allIds = []) {
97
+ if (cfg.hidden.includes(sid)) return { kind: 'hidden', index: cfg.hidden.indexOf(sid) };
98
+ const gi = cfg.groups.findIndex((g) => g.sections.includes(sid));
99
+ if (gi >= 0) return { kind: 'group', gi, index: cfg.groups[gi].sections.indexOf(sid) };
100
+ return { kind: 'pool', index: poolOrder(cfg, allIds).indexOf(sid) };
101
+ }
102
+
103
+ /**
104
+ * 平铺区的显示顺序:`order` 里显式排过的优先(按它的次序),
105
+ * 没排过的新栏目按传入的栏目顺序(= 栏目自己的 order)跟在后面。
106
+ * 于是「从没拖过」= 纯栏目 order(老行为不变),拖过之后以拖的为准。
107
+ */
108
+ export function poolOrder(cfg, allIds) {
109
+ const ranked = (cfg.order || []).filter((id) => allIds.includes(id));
110
+ const rest = allIds.filter((id) => !ranked.includes(id));
111
+ return [...ranked, ...rest];
112
+ }
113
+
114
+ /**
115
+ * 悬停行索引 → 真正的插入位。
116
+ * 落点语义是「插到悬停行**之前**」,但被拖走的自身会让位:
117
+ * 同容器内往下拖时悬停行索引要减 1,否则实时预览会比实际落点差一格。
118
+ * `hoveredIndex === null` 表示拖在容器空白处 = 追加到末尾,原样返回。
119
+ */
120
+ export function insertionIndex(cfg, sid, target, hoveredIndex, allIds = []) {
121
+ if (hoveredIndex === null || hoveredIndex === undefined) return null;
122
+ const from = containerOf(cfg, sid, allIds);
123
+ const sameContainer = target.kind === from.kind && (target.kind !== 'group' || target.gi === from.gi);
124
+ if (sameContainer && typeof from.index === 'number' && from.index < hoveredIndex) return hoveredIndex - 1;
125
+ return hoveredIndex;
126
+ }
127
+
128
+ /** 把栏目挪进目标容器(index = 在该容器里的插入位,null = 末尾)。 */
129
+ export function moveToContainer(cfg, sid, target, index = null, allIds = []) {
130
+ // 先从所有容器里摘掉(分组 + 隐藏 + 平铺顺序),再放进目标——「移动」永远只落一处
131
+ const groups = cfg.groups.map((g) => ({ ...g, sections: g.sections.filter((x) => x !== sid) }));
132
+ const hidden = cfg.hidden.filter((x) => x !== sid);
133
+ const base = { ...cfg, groups, hidden, order: (cfg.order || []).filter((x) => x !== sid) };
134
+
135
+ if (target.kind === 'hidden') return { ...base, hidden: [...hidden, sid] };
136
+
137
+ if (target.kind === 'pool') {
138
+ // 平铺区的顺序就是 order:先算出「去掉自己之后」的序列,再插到目标位
139
+ const isPool = (id) => !groups.some((g) => g.sections.includes(id)) && !hidden.includes(id);
140
+ const seq = poolOrder(base, allIds).filter((id) => id !== sid && isPool(id));
141
+ const at = index === null ? seq.length : Math.max(0, Math.min(index, seq.length));
142
+ return { ...base, order: [...seq.slice(0, at), sid, ...seq.slice(at)] };
143
+ }
144
+
145
+ const gi = target.gi;
146
+ const cur = groups[gi];
147
+ if (!cur) return cfg;
148
+ const at = index === null ? cur.sections.length : Math.max(0, Math.min(index, cur.sections.length));
149
+ const sections = [...cur.sections.slice(0, at), sid, ...cur.sections.slice(at)];
150
+ return { ...base, groups: groups.map((g, i) => (i === gi ? { ...cur, sections } : g)) };
151
+ }
152
+
153
+ /** 把栏目移出分组(回到头部的平铺区)。 */
154
+ export const ungroupSection = (cfg, sid) => moveToContainer(cfg, sid, { kind: 'pool' });
155
+
156
+ /** 分组内相邻交换(键盘/触摸的兜底路径,拖拽之外的退路)。 */
157
+ export const swapSection = (cfg, gi, i, j) => {
158
+ const g = cfg.groups[gi];
159
+ if (!g || i === j || i < 0 || j < 0 || i >= g.sections.length || j >= g.sections.length) return cfg;
160
+ const sections = [...g.sections];
161
+ [sections[i], sections[j]] = [sections[j], sections[i]];
162
+ return { ...cfg, groups: cfg.groups.map((x, k) => (k === gi ? { ...g, sections } : x)) };
163
+ };
164
+
165
+ // —— 隐藏:入口显不显示 ——
166
+
167
+ export const toggleHidden = (cfg, sid) => ({
168
+ ...cfg,
169
+ hidden: cfg.hidden.includes(sid) ? cfg.hidden.filter((x) => x !== sid) : [...cfg.hidden, sid],
170
+ });
171
+
172
+ export const isHidden = (cfg, sid) => cfg.hidden.includes(sid);
173
+
174
+ // —— 派生 ——
175
+
176
+ export const groupedIds = (cfg) => cfg.groups.flatMap((g) => g.sections);
177
+
178
+ /** 「平铺」容器里的栏目(既不在分组里、也没被隐藏),按平铺顺序。 */
179
+ export const ungroupedIds = (cfg, allIds) => {
180
+ const skip = new Set([...groupedIds(cfg), ...cfg.hidden]);
181
+ return poolOrder(cfg, allIds).filter((id) => !skip.has(id));
182
+ };
183
+
184
+ /** 某个分组**还能**加入哪些栏目:不在本组、也不在别的组。 */
185
+ export function availableFor(cfg, gi, allIds) {
186
+ const target = cfg.groups[gi];
187
+ if (!target) return [];
188
+ const others = new Set(cfg.groups.flatMap((g, i) => (i === gi ? [] : g.sections)));
189
+ return allIds.filter((id) => !target.sections.includes(id) && !others.has(id));
190
+ }
191
+
192
+ /**
193
+ * 前端先拦一道(后端 settings service 用同一套口径再拦一次)。
194
+ * 返回 [{ field, message }],空数组 = 可以保存。
195
+ */
196
+ export function validate(cfg, allIds) {
197
+ const known = new Set(allIds);
198
+ const problems = [];
199
+ const owner = new Map();
200
+
201
+ cfg.groups.forEach((g, i) => {
202
+ const label = g.title.trim() || `第 ${i + 1} 个分组`;
203
+ if (!g.title.trim()) problems.push({ field: `groups.${i}.title`, message: `${label}:每个分组都要有组名` });
204
+ if (!g.sections.length) problems.push({ field: `groups.${i}.sections`, message: `${label}:还没有栏目——加一个,或删掉这个分组` });
205
+ for (const sid of g.sections) {
206
+ if (!known.has(sid)) {
207
+ problems.push({ field: `groups.${i}.sections.${sid}`, message: `${label}:栏目 ${sid} 已不存在,请移除` });
208
+ continue;
209
+ }
210
+ const prev = owner.get(sid);
211
+ if (prev !== undefined) {
212
+ problems.push({ field: `groups.${i}.sections.${sid}`, message: `栏目 ${sid} 同时出现在「${prev}」与「${label}」里——一个栏目只能属于一个分组` });
213
+ continue;
214
+ }
215
+ owner.set(sid, label);
216
+ }
217
+ });
218
+
219
+ for (const sid of cfg.hidden) {
220
+ if (!known.has(sid)) problems.push({ field: `hidden.${sid}`, message: `要隐藏的栏目 ${sid} 已不存在` });
221
+ }
222
+ for (const sid of cfg.order || []) {
223
+ if (!known.has(sid)) problems.push({ field: `order.${sid}`, message: `平铺顺序里的栏目 ${sid} 已不存在` });
224
+ }
225
+ return problems;
226
+ }
@@ -435,3 +435,44 @@ a.file-name:hover { text-decoration: underline; }
435
435
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
436
436
  }
437
437
  .file-preview .file-preview-name:hover { text-decoration: underline; }
438
+
439
+ /* ── 栏目管理(设置页):拖拽编排 + 显隐 ─────────────────────
440
+ 结构用版面表达、改结构用拖拽表达——所以这里只有把手、输入框与两个动作,
441
+ 没有一排 上移/下移/移除 按钮(按钮越多越看不出「现在是什么结构」)。 */
442
+ .grip {
443
+ width: 10px;
444
+ height: 14px;
445
+ flex: 0 0 auto;
446
+ cursor: grab;
447
+ background-image: radial-gradient(circle, var(--mid) 1px, transparent 1px);
448
+ background-size: 4px 4px;
449
+ opacity: .5;
450
+ }
451
+ .grip:active { cursor: grabbing; }
452
+ .nav-title {
453
+ font: inherit;
454
+ font-size: var(--fs-core);
455
+ font-weight: 600;
456
+ min-width: 12ch;
457
+ padding: 2px 0;
458
+ background: none;
459
+ border: 0;
460
+ border-bottom: 1px dashed var(--soft-2);
461
+ }
462
+ .nav-title:focus { outline: none; border-bottom-color: var(--ink); }
463
+ .nav-group .colhead { display: flex; align-items: center; gap: 8px; }
464
+ .nav-pool { padding: 8px 10px; background: var(--paper); border: var(--border); border-radius: 8px; }
465
+ /* 拖拽落点:一眼看出「松手会放到这儿」 */
466
+ .nav-pool.drop-target, .nav-group.drop-target { outline: 2px dashed var(--ink); outline-offset: -2px; background: var(--soft); }
467
+ .nav-row.dragging { opacity: .45; }
468
+ /* 落点表达交给实时预览本身:被拖的行已停在新位置(.dragging 半透明),
469
+ 再叠一条"悬停行高亮"反而会和预览里的位置对不上(索引不同源)。 */
470
+ .nav-row.is-hidden .name { color: var(--mid); }
471
+
472
+ /* 容器内的行列表:行本身已有 1px 底线,这里只做包裹 */
473
+ .nav-list { display: flex; flex-direction: column; }
474
+ /* 拖动分组时的落点提示(分组头也可以是落点:换顺序) */
475
+ .nav-group .colhead.drop-target { background: var(--soft); }
476
+ /* 「隐藏」与分组同构,只是语气更弱:它是"不显示"的容器 */
477
+ .nav-hidden .colhead { color: var(--mid); }
478
+ .nav-hidden .row .name { color: var(--mid); }
@@ -1 +1 @@
1
- import{a as l}from"./index-VCOWbzUk.js";let r=null;function o(n=!1){return!n&&r||(r=l("/api/files").then(e=>e).catch(e=>{throw r=null,e})),r}function i(){return r=null,o(!0)}function u(n){return new Promise((e,a)=>{const t=new FileReader;t.onload=()=>e(t.result),t.onerror=()=>a(new Error("读取文件失败")),t.readAsDataURL(n)})}export{u as f,o as l,i as r};
1
+ import{a as l}from"./index-kQO8dtPe.js";let r=null;function o(n=!1){return!n&&r||(r=l("/api/files").then(e=>e).catch(e=>{throw r=null,e})),r}function i(){return r=null,o(!0)}function u(n){return new Promise((e,a)=>{const t=new FileReader;t.onload=()=>e(t.result),t.onerror=()=>a(new Error("读取文件失败")),t.readAsDataURL(n)})}export{u as f,o as l,i as r};