nx-sk 0.8.2 → 0.9.1
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 +22 -0
- package/README.md +12 -1
- package/assets/nx-sk/SKILL.md +8 -0
- package/assets/nx-sk/references/10-sections-entries.md +2 -0
- package/package.json +1 -1
- package/src/core/store.js +3 -0
- package/src/modules/sections/view.jsx +1 -1
- package/src/modules/settings/service.js +57 -4
- package/src/modules/settings/view.jsx +87 -4
- package/src/web/frontend/App.jsx +106 -26
- package/src/web/frontend/styles.css +36 -0
- package/src/web/public/assets/{files-CHheSoYs.js → files-DiyZRSVm.js} +1 -1
- package/src/web/public/assets/index-BcT34EVd.js +41 -0
- package/src/web/public/assets/index-lmztA3cP.css +1 -0
- package/src/web/public/assets/{view-QFcTiWTj.js → view-BOGUtsmZ.js} +1 -1
- package/src/web/public/assets/{view-BIgIZKeA.js → view-BYPvk3A0.js} +1 -1
- package/src/web/public/assets/view-CUXbAbpt.js +1 -0
- package/src/web/public/assets/{view-9J6BDv3t.js → view-Cl9B8EO5.js} +1 -1
- package/src/web/public/assets/{view-DDcFN2V4.js → view-DcVKWhos.js} +1 -1
- package/src/web/public/assets/{view-BMVMLYNP.js → view-DiYF5ioY.js} +1 -1
- package/src/web/public/index.html +2 -2
- package/src/web/public/assets/index-LbNmGlYj.js +0 -41
- package/src/web/public/assets/index-lfnBM8pt.css +0 -1
- package/src/web/public/assets/view-CmID1t8A.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
|
|
4
4
|
|
|
5
|
+
## [0.9.1] - 2026-10-15
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **设置页保存无效:面板发的 body 键被静默丢掉**。HTTP body 经 `applySpec` 只保留**声明过的** args/flags(有意严格,防止多余字段流进 service),而设置页发的是平铺的 `{exportDir: …}` / `{navGroups: …}` → 全被丢掉 → service 收到空 patch,只报一句“至少要给一项:setting set --key …”(根因离现场很远)。改为走声明过的 `set`(HTTP 形态 `{set: {k: v}}`,`spec.js` 本来就写着)。**面板「默认行为」卡的保存此前同样是坏的**,一并修好。
|
|
10
|
+
- **分组下拉鼠标下滑就消失**:pill 与菜单之间那 4px 空隙被鼠标穿过时判成“离开”,菜单刚展开就消失——补一块透明桥盖住空隙,并在离开后延迟 140ms 再关(斜着从 pill 划到菜单行也不闪)。
|
|
11
|
+
- 面板文案里两处 markdown 的 `**` 直接显示了出来(JSX 不渲染 markdown)——改为 `<b>` / 纯文本。
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **一致性断言:视图写请求的 body 键必须在 action 里声明过**(含反证:喂一段未声明键的合成代码,断言规则确实判红)。扫全部模块 view,把“发了未声明的键 → 静默丢掉 → 下游报一个离根因很远的错”这类问题钉死在测试里。
|
|
16
|
+
|
|
17
|
+
## [0.9.0] - 2026-10-15
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **头部导航分组:设置里自己配,栏目收拢 + 悬浮展开**。栏目越建越多时,头部一排 pill 一眼分不出主次——现在可以在**设置页**(或 CLI)把同域栏目归成一组(如 项目经历 / 实习工作经历 / 校内职务实践 / 学校信息 / 申请相关 →「求职」),头部只显示**一个分组入口**(带成员条目数汇总),**悬浮展开上下列表**选具体栏目(点击行切换、当前栏目加粗;外点 / Esc 关闭;触屏点 pill 也能开)。
|
|
22
|
+
- 配置存在 `settings.navGroups`(`{title, sections[]}` 数组,**数组顺序 = 头部显示顺序**);缺省空数组 = 全部栏目平铺(老 store 读一次自动补默认,无需迁移)。
|
|
23
|
+
- 校验严格且明确:形状不对 / 栏目不存在 / 一个栏目进两个组 / 缺组名 / 空组 —— 都报 `INVALID_INPUT` 并说清是哪一组哪一项(栏目写**标题**也认,统一归一到 id)。
|
|
24
|
+
- 分组只是**显示配置**:不动栏目与条目数据;组内栏目被删掉时头部优雅略过(配置保留)。CLI:`nx-sk setting set --set 'navGroups=[…]'`,清空即恢复平铺。
|
|
25
|
+
- 未分组的栏目与系统工具(附件 / 导出与备份 / 设置)照旧平铺,顺序不变。
|
|
26
|
+
|
|
5
27
|
## [0.8.2] - 2026-10-15
|
|
6
28
|
|
|
7
29
|
### Fixed
|
package/README.md
CHANGED
|
@@ -198,6 +198,17 @@ nx-sk file get $FID # 反查「被引用:求职 /
|
|
|
198
198
|
> 若你的求职栏目是很早建的,跑一次 `nx-sk section sync job`(或面板「栏目 → 编辑 → 同步模板字段」)
|
|
199
199
|
> 就能把模板新增的「附件资料」组与 4 个 `file` 字段补进来,且不改动你已有的字段与数据。
|
|
200
200
|
|
|
201
|
+
**头部导航分组**(栏目多了以后收拢头部,一眼分得出主次):在设置里把同域栏目归成一组,
|
|
202
|
+
头部只显示组名,**悬浮展开上下列表**选具体栏目;不归组的栏目保持平铺。
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
# 面板:设置 → 头部导航分组(勾选栏目 / 改名 / 排序 / 增删组),保存后头部立即生效
|
|
206
|
+
nx-sk setting set --set 'navGroups=[{"title":"求职","sections":["projects","internships","campus"]}]'
|
|
207
|
+
nx-sk setting set --set 'navGroups=[]' # 清空 = 全部栏目恢复平铺
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
分组只是**显示配置**(存在 `settings.navGroups`):不动栏目与条目的数据,删掉栏目时头部自动略过。
|
|
211
|
+
|
|
201
212
|
整体导出(凭据默认**含明文**——本机自己用;要分享给别人时加 `--no-secrets` 打码):
|
|
202
213
|
|
|
203
214
|
```bash
|
|
@@ -267,7 +278,7 @@ nx-sk skill get nx-sk 10-sections-entries # 裸名 → references/10-sections-
|
|
|
267
278
|
## 开发
|
|
268
279
|
|
|
269
280
|
```bash
|
|
270
|
-
pnpm test # = lint + build + 冒烟(
|
|
281
|
+
pnpm test # = lint + build + 冒烟(109 项断言) + 单测(140 项)
|
|
271
282
|
```
|
|
272
283
|
|
|
273
284
|
| 目录 | 职责 |
|
package/assets/nx-sk/SKILL.md
CHANGED
|
@@ -99,6 +99,14 @@ nx-sk file get f_bbb # 反查「被引用:求职
|
|
|
99
99
|
所以模板**后来**新增的字段不会自动出现在已建好的栏目里。用 `nx-sk section sync <栏目>`(可先 `--dry-run`)
|
|
100
100
|
把模板新增的分组与字段补进来(只增不改不删);面板侧是「栏目 → 编辑 → 同步模板字段」。
|
|
101
101
|
|
|
102
|
+
栏目建得多了,可以在**设置里归组**收拢头部(同域栏目一个入口,悬浮展开上下列表;
|
|
103
|
+
纯**显示配置**,不动栏目与条目数据):
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
nx-sk setting set --set 'navGroups=[{"title":"求职","sections":["projects","internships"]}]'
|
|
107
|
+
nx-sk setting set --set 'navGroups=[]' # 清空 = 全部栏目恢复平铺
|
|
108
|
+
```
|
|
109
|
+
|
|
102
110
|
## ref 路由表(按需加载)
|
|
103
111
|
|
|
104
112
|
| ref | 何时读取 |
|
|
@@ -17,6 +17,8 @@ store.json(~/nx-sk/store.json 或 $NX_SK_STORE)
|
|
|
17
17
|
- 一个**栏目** = 一组同构条目 + 一份字段字典(**固定的结构**)。
|
|
18
18
|
- 一个**条目** = 一行数据,值放在 `values` 里,键是字段 `key`;写台账里没有的键会**自动登记**
|
|
19
19
|
(见第五节)——**登记过的键面板才渲染得出来**,没登记的值在界面上看不见。
|
|
20
|
+
- **头部分组是显示配置**:`settings.navGroups`(`[{title, sections:[栏目id…]}]`,设置页可配)只决定
|
|
21
|
+
头部怎么摆——同域栏目可收拢成一个分组入口(悬浮展开上下列表);**不动**栏目与条目的任何数据。
|
|
20
22
|
- 定位方式都放宽:栏目与条目**都接受 id 或名字**。条目名重名时报 `CONFLICT` 让你改用 id。
|
|
21
23
|
|
|
22
24
|
## 二、栏目命令(集合资源,五操作齐备)
|
package/package.json
CHANGED
package/src/core/store.js
CHANGED
|
@@ -21,6 +21,9 @@ export function defaultSettings() {
|
|
|
21
21
|
exportFormat: 'both', // json | md | both
|
|
22
22
|
includeSecretsInExport: true, // 默认含明文:本机单人工具,自己看不拦自己
|
|
23
23
|
defaultSection: 'job',
|
|
24
|
+
// 头部导航分组:[{ title, sections: [栏目 id…] }],数组顺序 = 显示顺序。
|
|
25
|
+
// 缺省空数组 = 全部栏目平铺(保持老行为);栏目建得多了再在设置页收拢。
|
|
26
|
+
navGroups: [],
|
|
24
27
|
};
|
|
25
28
|
}
|
|
26
29
|
|
|
@@ -273,7 +273,7 @@ function EditForm({ modal, onChange, onSave, onSync, onAddField, onRemoveField,
|
|
|
273
273
|
</div>
|
|
274
274
|
{full?.template ? (
|
|
275
275
|
<div className="hint">
|
|
276
|
-
本栏目由模板「{full.template}
|
|
276
|
+
本栏目由模板「{full.template}」创建。模板后来新增的字段<b>不会自动</b>出现在这里,
|
|
277
277
|
点「同步模板字段」补齐(例如求职模板新增的「附件资料」组 / 简历、头像等 file 字段)。
|
|
278
278
|
</div>
|
|
279
279
|
) : null}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// settings 是**基础模块**:允许被其它模块单向只读依赖(见 eslint 互依禁列——它故意不在里面)。
|
|
2
2
|
// 设置的唯一读写入口在这里,别绕过去直接改 store.settings。
|
|
3
3
|
import { loadStore, mutateStore, snapshotStore } from '../../core/store.js';
|
|
4
|
+
import { findSection } from '../../core/fields.js';
|
|
4
5
|
import { badInput, notFound } from '../../core/errors.js';
|
|
5
6
|
|
|
6
|
-
export const SETTING_KEYS = ['exportDir', 'exportFormat', 'includeSecretsInExport', 'defaultSection'];
|
|
7
|
+
export const SETTING_KEYS = ['exportDir', 'exportFormat', 'includeSecretsInExport', 'defaultSection', 'navGroups'];
|
|
7
8
|
|
|
8
9
|
const SCHEMA = {
|
|
9
10
|
exportDir: { type: 'string', hint: '导出目录,留空 = ~/nx-sk/export' },
|
|
10
11
|
exportFormat: { type: 'string', enum: ['json', 'md', 'both'], hint: '整体导出默认格式' },
|
|
11
12
|
includeSecretsInExport: { type: 'boolean', hint: '整体导出是否默认带密钥明文(默认否)' },
|
|
12
13
|
defaultSection: { type: 'string', hint: '默认栏目 id' },
|
|
14
|
+
navGroups: { type: 'json', hint: '头部导航分组:[{title, sections:[栏目id…]}],数组顺序 = 显示顺序' },
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
export function settingsSchema() {
|
|
@@ -21,9 +23,54 @@ export async function getSettings() {
|
|
|
21
23
|
return { settings: store.settings, schema: settingsSchema() };
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
/**
|
|
27
|
+
* 头部导航分组(`settings.navGroups`)的形状校验 + 归一。
|
|
28
|
+
*
|
|
29
|
+
* 分组是**显示配置**,写坏了头部就少一块,所以这里不静默丢任何东西:
|
|
30
|
+
* 栏目不存在 / 一个栏目进了两个组 / 空组 —— 一律报错并说清是哪一组哪一项。
|
|
31
|
+
* 归一:栏目可以写 id 也可以写标题(统一存成真实 id);组内去重保序。
|
|
32
|
+
*/
|
|
33
|
+
function coerceNavGroups(value, store) {
|
|
34
|
+
let arr = value;
|
|
35
|
+
if (typeof value === 'string') {
|
|
36
|
+
try {
|
|
37
|
+
arr = JSON.parse(value);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
throw badInput(`navGroups 需要 JSON 数组(${e.message}):[{"title":"求职","sections":["projects",…]}, …]`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(arr)) {
|
|
43
|
+
throw badInput('navGroups 需要是数组:[{"title":"求职","sections":["projects",…]}, …]');
|
|
44
|
+
}
|
|
45
|
+
const owner = new Map();
|
|
46
|
+
return arr.map((g, i) => {
|
|
47
|
+
if (!g || typeof g !== 'object') throw badInput(`第 ${i + 1} 个分组不是对象`);
|
|
48
|
+
const title = String(g.title ?? '').trim();
|
|
49
|
+
if (!title) throw badInput(`第 ${i + 1} 个分组缺少 title(组名)`);
|
|
50
|
+
const sections = [];
|
|
51
|
+
const inGroup = new Set();
|
|
52
|
+
for (const raw of (Array.isArray(g.sections) ? g.sections : [])) {
|
|
53
|
+
const ref = String(raw ?? '').trim();
|
|
54
|
+
if (!ref) continue;
|
|
55
|
+
const sec = findSection(store, ref);
|
|
56
|
+
if (!sec) throw badInput(`分组「${title}」里的栏目不存在: ${ref}`);
|
|
57
|
+
if (inGroup.has(sec.id)) continue; // 同组里 id / 标题混着写同一个栏目 → 去重
|
|
58
|
+
if (owner.has(sec.id)) {
|
|
59
|
+
throw badInput(`栏目 ${sec.id} 同时在分组「${owner.get(sec.id)}」与「${title}」里——一个栏目只能属于一个分组`);
|
|
60
|
+
}
|
|
61
|
+
inGroup.add(sec.id);
|
|
62
|
+
owner.set(sec.id, title);
|
|
63
|
+
sections.push(sec.id);
|
|
64
|
+
}
|
|
65
|
+
if (!sections.length) throw badInput(`分组「${title}」没有任何栏目——要么勾一个,要么删掉这个分组`);
|
|
66
|
+
return { title, sections };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function coerceSetting(key, value, store) {
|
|
25
71
|
const s = SCHEMA[key];
|
|
26
72
|
if (!s) throw notFound(`未知设置项: ${key}(可用: ${SETTING_KEYS.join(', ')})`);
|
|
73
|
+
if (s.type === 'json') return coerceNavGroups(value, store);
|
|
27
74
|
if (s.type === 'boolean') {
|
|
28
75
|
if (typeof value === 'boolean') return value;
|
|
29
76
|
const v = String(value).trim().toLowerCase();
|
|
@@ -45,8 +92,10 @@ export async function updateSettings({ set, key, value, 'dry-run': dryRun } = {}
|
|
|
45
92
|
const keys = Object.keys(patch);
|
|
46
93
|
if (!keys.length) throw badInput('至少要给一项:setting set --key <名> --value <值>,或 --set k=v(可重复)');
|
|
47
94
|
|
|
95
|
+
// 校验要拿 store 做参照(navGroups 里的栏目 id 必须真实存在)
|
|
96
|
+
const store = await loadStore();
|
|
48
97
|
const resolved = {};
|
|
49
|
-
for (const k of keys) resolved[k] = coerceSetting(k, patch[k]);
|
|
98
|
+
for (const k of keys) resolved[k] = coerceSetting(k, patch[k], store);
|
|
50
99
|
|
|
51
100
|
if (dryRun) return { status: 'skipped', dryRun: true, wouldSet: resolved };
|
|
52
101
|
|
|
@@ -63,7 +112,11 @@ export function renderSettings(data) {
|
|
|
63
112
|
const lines = ['当前设置(改:nx-sk setting set --key <名> --value <值>)'];
|
|
64
113
|
for (const s of data.schema) {
|
|
65
114
|
const v = data.settings[s.key];
|
|
66
|
-
const shown = s.type === 'boolean' ? (v ? '是' : '否')
|
|
115
|
+
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)));
|
|
67
120
|
lines.push(` ${s.key.padEnd(22)} ${shown.padEnd(10)} ${s.hint}`);
|
|
68
121
|
}
|
|
69
122
|
return lines.join('\n');
|
|
@@ -7,6 +7,7 @@ export default function SettingsView() {
|
|
|
7
7
|
const { boot, reload, toast, guard } = useStore();
|
|
8
8
|
const [data, setData] = useState(null);
|
|
9
9
|
const [form, setForm] = useState(null);
|
|
10
|
+
const [groups, setGroups] = useState([]);
|
|
10
11
|
// 面板地址不写死:直接取用户此刻访问的这个 origin,换端口/换 host 都自动跟上
|
|
11
12
|
const panelUrl = typeof window === 'undefined' ? '' : window.location.origin;
|
|
12
13
|
const panelPort = typeof window === 'undefined' ? '' : (window.location.port || '80');
|
|
@@ -16,6 +17,7 @@ export default function SettingsView() {
|
|
|
16
17
|
const d = await api('/api/settings');
|
|
17
18
|
setData(d);
|
|
18
19
|
setForm({ ...d.settings });
|
|
20
|
+
setGroups((d.settings.navGroups || []).map((g) => ({ title: g.title, sections: [...g.sections] })));
|
|
19
21
|
} catch (e) {
|
|
20
22
|
toast(e.message, 'bad');
|
|
21
23
|
}
|
|
@@ -24,11 +26,16 @@ export default function SettingsView() {
|
|
|
24
26
|
useEffect(() => { load(); }, [load]);
|
|
25
27
|
|
|
26
28
|
const save = guard(async () => {
|
|
29
|
+
// 必须包在 `set` 里发:HTTP 的 body 会经 applySpec **只保留声明过的键**,
|
|
30
|
+
// 平铺的 `{exportDir: …}` 会被静默丢掉 → service 收到空 patch 报「至少要给一项」。
|
|
31
|
+
// `set` 是 setting.set 声明过的 kv flag,HTTP 形态就是 `{set: {k: v}}`。
|
|
27
32
|
const body = {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
set: {
|
|
34
|
+
exportDir: form.exportDir,
|
|
35
|
+
exportFormat: form.exportFormat,
|
|
36
|
+
includeSecretsInExport: !!form.includeSecretsInExport,
|
|
37
|
+
defaultSection: form.defaultSection,
|
|
38
|
+
},
|
|
32
39
|
};
|
|
33
40
|
const r = await api('/api/settings', { method: 'PATCH', body });
|
|
34
41
|
toast('设置已保存');
|
|
@@ -36,6 +43,35 @@ export default function SettingsView() {
|
|
|
36
43
|
await reload();
|
|
37
44
|
});
|
|
38
45
|
|
|
46
|
+
// 头部导航分组:组顺序 = 头部显示顺序;一个栏目只能属于一个组(别组已勾的置灰)
|
|
47
|
+
const patchGroup = (gi, patch) => setGroups((gs) => gs.map((g, i) => (i === gi ? { ...g, ...patch } : g)));
|
|
48
|
+
|
|
49
|
+
const toggleMember = (gi, sid) => setGroups((gs) => gs.map((g, i) => {
|
|
50
|
+
if (i !== gi) return g;
|
|
51
|
+
const has = g.sections.includes(sid);
|
|
52
|
+
return { ...g, sections: has ? g.sections.filter((x) => x !== sid) : [...g.sections, sid] };
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const moveGroup = (gi, delta) => setGroups((gs) => {
|
|
56
|
+
const to = gi + delta;
|
|
57
|
+
if (to < 0 || to >= gs.length) return gs;
|
|
58
|
+
const next = [...gs];
|
|
59
|
+
[next[gi], next[to]] = [next[to], next[gi]];
|
|
60
|
+
return next;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const saveGroups = guard(async () => {
|
|
64
|
+
const naked = groups.find((g) => !String(g.title).trim());
|
|
65
|
+
if (naked) { toast('每个分组都要有组名', 'bad'); return; }
|
|
66
|
+
const empty = groups.find((g) => !g.sections.length);
|
|
67
|
+
if (empty) { toast(`分组「${empty.title}」还没有勾选栏目——要么勾一个,要么删掉这个分组`, 'bad'); return; }
|
|
68
|
+
const r = await api('/api/settings', { method: 'PATCH', body: { set: { navGroups: groups } } });
|
|
69
|
+
toast(groups.length ? `已保存 ${groups.length} 个导航分组` : '已取消分组,栏目恢复平铺');
|
|
70
|
+
setData({ ...data, settings: r.set });
|
|
71
|
+
setGroups((r.set.navGroups || []).map((g) => ({ title: g.title, sections: [...g.sections] })));
|
|
72
|
+
await reload();
|
|
73
|
+
});
|
|
74
|
+
|
|
39
75
|
if (!data || !form) return <div className="pad muted">加载中…</div>;
|
|
40
76
|
|
|
41
77
|
return (
|
|
@@ -92,6 +128,53 @@ export default function SettingsView() {
|
|
|
92
128
|
</div>
|
|
93
129
|
</div>
|
|
94
130
|
|
|
131
|
+
<div className="card">
|
|
132
|
+
<div className="colhead">
|
|
133
|
+
头部导航分组
|
|
134
|
+
<span className="right muted">{groups.length ? `${groups.length} 个分组` : '未分组(全部平铺)'}</span>
|
|
135
|
+
</div>
|
|
136
|
+
<div className="pad stack">
|
|
137
|
+
<div className="hint">
|
|
138
|
+
把同域栏目收拢成一个头部入口:头部只显示组名,悬浮展开上下列表选具体栏目。
|
|
139
|
+
不勾进任何分组的栏目保持平铺;组顺序 = 头部显示顺序;一个栏目只能属于一个分组。
|
|
140
|
+
</div>
|
|
141
|
+
{groups.map((g, gi) => (
|
|
142
|
+
<div className="stack" key={gi} style={{ borderTop: gi ? 'var(--border)' : 0, paddingTop: gi ? 8 : 0 }}>
|
|
143
|
+
<div className="rowgap">
|
|
144
|
+
<input
|
|
145
|
+
value={g.title}
|
|
146
|
+
placeholder="组名,如「求职」"
|
|
147
|
+
onChange={(e) => patchGroup(gi, { title: e.target.value })}
|
|
148
|
+
style={{ minWidth: 160 }}
|
|
149
|
+
/>
|
|
150
|
+
<button className="btn small ghost" disabled={gi === 0} onClick={() => moveGroup(gi, -1)}>上移</button>
|
|
151
|
+
<button className="btn small ghost" disabled={gi === groups.length - 1} onClick={() => moveGroup(gi, 1)}>下移</button>
|
|
152
|
+
<button className="btn small ghost" onClick={() => setGroups((gs) => gs.filter((_, i) => i !== gi))}>删除组</button>
|
|
153
|
+
<span className="muted">已选 {g.sections.length} 个栏目</span>
|
|
154
|
+
</div>
|
|
155
|
+
<div className="rowgap">
|
|
156
|
+
{(boot?.sections || []).map((s) => {
|
|
157
|
+
const checked = g.sections.includes(s.id);
|
|
158
|
+
const elsewhere = groups.some((o, oi) => oi !== gi && o.sections.includes(s.id));
|
|
159
|
+
return (
|
|
160
|
+
<label className="inline" key={s.id} title={elsewhere ? '已归入其它分组' : ''}>
|
|
161
|
+
<input type="checkbox" checked={checked} disabled={elsewhere} onChange={() => toggleMember(gi, s.id)} />
|
|
162
|
+
<span className={elsewhere ? 'muted' : ''}>{s.title}<span className="count"> {s.entries}</span></span>
|
|
163
|
+
</label>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
))}
|
|
169
|
+
{!groups.length ? <div className="muted">还没有分组——所有栏目都平铺在头部。</div> : null}
|
|
170
|
+
<div className="rowgap">
|
|
171
|
+
<button className="btn" onClick={saveGroups}>保存分组</button>
|
|
172
|
+
<button className="btn small ghost" onClick={() => setGroups((gs) => [...gs, { title: '', sections: [] }])}>新增分组</button>
|
|
173
|
+
{groups.length ? <button className="btn small ghost" onClick={() => setGroups([])}>清空(恢复平铺)</button> : null}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
95
178
|
<div className="card">
|
|
96
179
|
<div className="colhead">默认行为</div>
|
|
97
180
|
<div className="pad">
|
package/src/web/frontend/App.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { SECTION_VIEW, VIEWS } from './registry.js';
|
|
3
3
|
import { Copyable, DialogHost, ErrorBoundary, Toasts } from './components/ui.jsx';
|
|
4
4
|
import { useStore } from './store.jsx';
|
|
@@ -17,26 +17,35 @@ 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
|
|
21
|
-
//
|
|
22
|
-
const
|
|
23
|
-
const
|
|
20
|
+
// 栏目 tab 是**数据驱动**的(bootstrap.sections);分组来自 settings.navGroups(设置页可配);
|
|
21
|
+
// 静态 tab 来自前端注册表。渲染顺序:未分组栏目(按 order)→ 分组(按配置顺序)→ 工具(按 order)。
|
|
22
|
+
const nav = useMemo(() => {
|
|
23
|
+
const sections = (boot?.sections || []).map((s) => ({
|
|
24
24
|
id: s.id, title: s.title, order: s.order, kind: 'section', count: s.entries,
|
|
25
25
|
}));
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
// 分组是**显示配置**:成员栏目被删掉时优雅降级(过滤掉、配置保留),成员全没了就不显示这个组
|
|
27
|
+
const groups = (boot?.settings?.navGroups || [])
|
|
28
|
+
.map((g) => ({
|
|
29
|
+
title: g.title,
|
|
30
|
+
sections: (g.sections || [])
|
|
31
|
+
.map((sid) => sections.find((s) => s.id === sid))
|
|
32
|
+
.filter(Boolean),
|
|
33
|
+
}))
|
|
34
|
+
.filter((g) => g.sections.length);
|
|
35
|
+
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));
|
|
37
|
+
const stat = VIEWS.filter((v) => v.tab !== false)
|
|
38
|
+
.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))
|
|
40
|
+
.sort((a, b) => (a.order ?? 50) - (b.order ?? 50));
|
|
41
|
+
return { flat, groups, tools: stat, all: [...flat, ...groups.flatMap((g) => g.sections), ...stat] };
|
|
33
42
|
}, [boot]);
|
|
34
43
|
|
|
35
|
-
// 没 boot
|
|
44
|
+
// 没 boot 之前**不要**拿第一个 tab 兜底:那时动态栏目还没到,nav 里只有静态视图,
|
|
36
45
|
// 兜底会选中第一个静态视图并由下面的 effect 写进 ui —— 于是「默认视图 = 求职」被顶掉、
|
|
37
|
-
//
|
|
46
|
+
// 头部高亮也错位,连 `#/job` 这类深链都会被首屏覆盖。
|
|
38
47
|
const booted = !!boot;
|
|
39
|
-
const current =
|
|
48
|
+
const current = nav.all.find((t) => t.id === view) || (booted ? nav.all[0] : null) || null;
|
|
40
49
|
|
|
41
50
|
// hash 与 localStorage 双写对齐:只在挂载时对齐一次,之后交给 hashchange
|
|
42
51
|
useEffect(() => {
|
|
@@ -84,19 +93,26 @@ export default function App() {
|
|
|
84
93
|
</span>
|
|
85
94
|
</header>
|
|
86
95
|
|
|
87
|
-
{/*
|
|
88
|
-
|
|
96
|
+
{/* 导航栏:内容(栏目)与工具(附件/导出/设置/…)之间加一道细线分组。
|
|
97
|
+
同域栏目可以在**设置页**收拢成一个分组入口(settings.navGroups)——
|
|
98
|
+
悬浮或点击展开上下列表,栏目一多也不会连成一排分不出主次。 */}
|
|
89
99
|
<nav className="tabs">
|
|
90
|
-
{
|
|
91
|
-
<
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
{nav.flat.map((t) => (
|
|
101
|
+
<button key={t.id} className={`tab ${t.id === current?.id ? 'active' : ''}`} onClick={() => go(t.id)}>
|
|
102
|
+
{t.title}
|
|
103
|
+
{typeof t.count === 'number' ? <span className="count">{t.count}</span> : null}
|
|
104
|
+
</button>
|
|
105
|
+
))}
|
|
106
|
+
{nav.groups.map((g) => (
|
|
107
|
+
<GroupTab key={g.title} group={g} currentId={view} onGo={go} />
|
|
108
|
+
))}
|
|
109
|
+
{nav.tools.length ? <span className="tabs-sep" aria-hidden /> : null}
|
|
110
|
+
{nav.tools.map((t) => (
|
|
111
|
+
<button key={t.id} className={`tab ${t.id === current?.id ? 'active' : ''}`} onClick={() => go(t.id)}>
|
|
112
|
+
{t.title}
|
|
113
|
+
</button>
|
|
98
114
|
))}
|
|
99
|
-
{!
|
|
115
|
+
{!nav.all.length ? <span className="muted">正在装载…</span> : null}
|
|
100
116
|
</nav>
|
|
101
117
|
|
|
102
118
|
<main className="body">
|
|
@@ -130,3 +146,67 @@ function SectionPane({ sectionId }) {
|
|
|
130
146
|
const View = SECTION_VIEW;
|
|
131
147
|
return <View sectionId={sectionId} />;
|
|
132
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 头部的一个**分组入口**:一个 pill 收拢多个栏目(同域栏目不再挤成一排)。
|
|
152
|
+
* 悬浮展开、点击也能开(触屏没有 hover);点行切换栏目。
|
|
153
|
+
* 外点关闭用 `pointerdown`——`click` 与触发按钮自己的 onClick 同阶段冒泡,
|
|
154
|
+
* 会「点一下关了又弹开」;受控组件而非纯 CSS hover,是为了能用键盘 Esc 关。
|
|
155
|
+
*/
|
|
156
|
+
function GroupTab({ group, currentId, onGo }) {
|
|
157
|
+
const [open, setOpen] = useState(false);
|
|
158
|
+
const ref = useRef(null);
|
|
159
|
+
// 离开时**延迟**再关:鼠标斜着从 pill 划到菜单行会短暂出界,立刻关就「刚展开就消失」
|
|
160
|
+
const closeTimer = useRef(null);
|
|
161
|
+
const active = group.sections.some((s) => s.id === currentId);
|
|
162
|
+
const total = group.sections.reduce((n, s) => n + (s.count || 0), 0);
|
|
163
|
+
|
|
164
|
+
const cancelClose = () => {
|
|
165
|
+
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
|
|
166
|
+
};
|
|
167
|
+
const openNow = () => { cancelClose(); setOpen(true); };
|
|
168
|
+
const closeSoon = () => { cancelClose(); closeTimer.current = setTimeout(() => setOpen(false), 140); };
|
|
169
|
+
useEffect(() => cancelClose, []);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (!open) return undefined;
|
|
173
|
+
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
|
174
|
+
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
|
175
|
+
document.addEventListener('pointerdown', onDown);
|
|
176
|
+
document.addEventListener('keydown', onKey);
|
|
177
|
+
return () => {
|
|
178
|
+
document.removeEventListener('pointerdown', onDown);
|
|
179
|
+
document.removeEventListener('keydown', onKey);
|
|
180
|
+
};
|
|
181
|
+
}, [open]);
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<span
|
|
185
|
+
className="tab-group"
|
|
186
|
+
ref={ref}
|
|
187
|
+
onMouseEnter={openNow}
|
|
188
|
+
onMouseLeave={closeSoon}
|
|
189
|
+
>
|
|
190
|
+
<button className={`tab ${active ? 'active' : ''}`} aria-expanded={open} onClick={openNow}>
|
|
191
|
+
{group.title}
|
|
192
|
+
<span className="count">{total}</span>
|
|
193
|
+
<span className="caret" aria-hidden />
|
|
194
|
+
</button>
|
|
195
|
+
{open ? (
|
|
196
|
+
<div className="tab-menu" role="menu">
|
|
197
|
+
{group.sections.map((s) => (
|
|
198
|
+
<button
|
|
199
|
+
key={s.id}
|
|
200
|
+
role="menuitem"
|
|
201
|
+
className={`tab-menu-row ${s.id === currentId ? 'current' : ''}`}
|
|
202
|
+
onClick={() => { cancelClose(); setOpen(false); onGo(s.id); }}
|
|
203
|
+
>
|
|
204
|
+
<span className="grow nowrap">{s.title}</span>
|
|
205
|
+
{typeof s.count === 'number' ? <span className="count">{s.count}</span> : null}
|
|
206
|
+
</button>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
) : null}
|
|
210
|
+
</span>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -96,6 +96,42 @@ nav.tabs {
|
|
|
96
96
|
/* 分组细线:内容栏目 ‖ 系统工具 */
|
|
97
97
|
.tabs-sep { width: 1px; height: 18px; background: var(--soft-2); margin: 0 4px; }
|
|
98
98
|
|
|
99
|
+
/* ── 分组入口:一个 pill 收拢多个栏目(同域栏目不再挤成一排) ── */
|
|
100
|
+
.tab-group { position: relative; display: inline-flex; }
|
|
101
|
+
/* 展开指示用 CSS 三角,不引字符/图标 */
|
|
102
|
+
.tab-group .caret {
|
|
103
|
+
width: 0; height: 0; margin-left: 1px;
|
|
104
|
+
border-left: 4px solid transparent;
|
|
105
|
+
border-right: 4px solid transparent;
|
|
106
|
+
border-top: 4px solid var(--mid);
|
|
107
|
+
}
|
|
108
|
+
.tab-group .tab.active .caret { border-top-color: var(--ink); }
|
|
109
|
+
/* 上下列表:与面板同一套立体语言(单层 relief,别叠成岛屿) */
|
|
110
|
+
.tab-menu {
|
|
111
|
+
position: absolute; top: calc(100% + 4px); left: 0; z-index: 40;
|
|
112
|
+
min-width: 172px; padding: 4px;
|
|
113
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
114
|
+
background: var(--paper);
|
|
115
|
+
border: var(--border);
|
|
116
|
+
border-radius: var(--radius-btn);
|
|
117
|
+
box-shadow: var(--shadow-relief);
|
|
118
|
+
}
|
|
119
|
+
/* 桥:盖住 pill 与菜单之间那几像素的「死区」——鼠标下滑穿过它时不能被判成离开,
|
|
120
|
+
否则菜单刚探出头就消失(hover 菜单的经典坑) */
|
|
121
|
+
.tab-menu::before {
|
|
122
|
+
content: ''; position: absolute; left: 0; right: 0; top: -5px; height: 5px;
|
|
123
|
+
}
|
|
124
|
+
.tab-menu-row {
|
|
125
|
+
display: flex; align-items: center; gap: 10px; width: 100%;
|
|
126
|
+
height: var(--row-h); padding: 0 8px;
|
|
127
|
+
border: 0; border-radius: 6px; background: none;
|
|
128
|
+
color: var(--ink); font: inherit; font-size: var(--fs-core);
|
|
129
|
+
text-align: left; cursor: pointer;
|
|
130
|
+
}
|
|
131
|
+
.tab-menu-row:hover { background: var(--soft); }
|
|
132
|
+
.tab-menu-row.current { font-weight: 600; }
|
|
133
|
+
.tab-menu-row .count { color: var(--mid); font-size: 10px; font-variant-numeric: tabular-nums; }
|
|
134
|
+
|
|
99
135
|
main.body { flex: 1; min-height: 0; overflow: auto; }
|
|
100
136
|
|
|
101
137
|
/* ── tab:单层立体阴影,别叠 --shadow,叠两层就变岛屿 ── */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{a as l}from"./index-
|
|
1
|
+
import{a as l}from"./index-BcT34EVd.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};
|