nx-sk 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/LICENSE +21 -0
  3. package/README.md +222 -0
  4. package/assets/nx-sk/SKILL.md +102 -0
  5. package/assets/nx-sk/references/00-design.md +116 -0
  6. package/assets/nx-sk/references/10-sections-entries.md +165 -0
  7. package/assets/nx-sk/references/20-secrets.md +170 -0
  8. package/assets/nx-sk/references/30-export-backup.md +80 -0
  9. package/assets/nx-sk/references/40-extend.md +75 -0
  10. package/bin/nx-sk.mjs +11 -0
  11. package/package.json +56 -0
  12. package/src/core/crypto.js +73 -0
  13. package/src/core/errors.js +52 -0
  14. package/src/core/fields.js +345 -0
  15. package/src/core/fsx.js +103 -0
  16. package/src/core/ids.js +19 -0
  17. package/src/core/open.js +32 -0
  18. package/src/core/paths.js +141 -0
  19. package/src/core/render.js +144 -0
  20. package/src/core/store.js +317 -0
  21. package/src/core/vault.js +109 -0
  22. package/src/index.js +14 -0
  23. package/src/modules/entries/index.js +152 -0
  24. package/src/modules/entries/kvTable.jsx +158 -0
  25. package/src/modules/entries/service.js +539 -0
  26. package/src/modules/entries/view.jsx +240 -0
  27. package/src/modules/export/index.js +39 -0
  28. package/src/modules/export/service.js +192 -0
  29. package/src/modules/export/view.jsx +155 -0
  30. package/src/modules/sections/index.js +104 -0
  31. package/src/modules/sections/service.js +337 -0
  32. package/src/modules/sections/view.jsx +311 -0
  33. package/src/modules/settings/index.js +34 -0
  34. package/src/modules/settings/service.js +78 -0
  35. package/src/modules/settings/view.jsx +193 -0
  36. package/src/modules/skill/index.js +51 -0
  37. package/src/modules/skill/service.js +200 -0
  38. package/src/modules/system/index.js +42 -0
  39. package/src/modules/system/service.js +155 -0
  40. package/src/runtime/api.js +101 -0
  41. package/src/runtime/cli.js +266 -0
  42. package/src/runtime/registry.js +65 -0
  43. package/src/runtime/server.js +80 -0
  44. package/src/runtime/spec.js +158 -0
  45. package/src/web/frontend/App.jsx +108 -0
  46. package/src/web/frontend/api/client.js +26 -0
  47. package/src/web/frontend/components/fieldEditor.jsx +85 -0
  48. package/src/web/frontend/components/ui.jsx +143 -0
  49. package/src/web/frontend/index.html +12 -0
  50. package/src/web/frontend/main.jsx +13 -0
  51. package/src/web/frontend/registry.js +20 -0
  52. package/src/web/frontend/store.jsx +87 -0
  53. package/src/web/frontend/styles.css +301 -0
  54. package/src/web/public/assets/index-BWGLG7Qa.js +40 -0
  55. package/src/web/public/assets/index-CwF5hM2v.css +1 -0
  56. package/src/web/public/assets/view-B6gPh1NQ.js +1 -0
  57. package/src/web/public/assets/view-CfXXYwW2.js +1 -0
  58. package/src/web/public/assets/view-CxzKrdA3.js +7 -0
  59. package/src/web/public/assets/view-DjO1C54o.js +7 -0
  60. package/src/web/public/index.html +13 -0
@@ -0,0 +1,311 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { api } from '../../web/frontend/api/client.js';
3
+ import { useStore } from '../../web/frontend/store.jsx';
4
+ import { CliHints, Empty, Modal, Tag } from '../../web/frontend/components/ui.jsx';
5
+
6
+ const TYPES = ['text', 'textarea', 'number', 'date', 'month', 'bool', 'select', 'tags', 'secret'];
7
+
8
+ export default function SectionsView() {
9
+ const { reload, toast, guard, dialog } = useStore();
10
+ const [data, setData] = useState(null);
11
+ const [templates, setTemplates] = useState([]);
12
+ const [err, setErr] = useState(null);
13
+ const [modal, setModal] = useState(null);
14
+
15
+ const load = useCallback(async () => {
16
+ try {
17
+ const [list, tpl] = await Promise.all([api('/api/sections'), api('/api/sections/templates')]);
18
+ setData(list);
19
+ setTemplates(tpl.templates);
20
+ setErr(null);
21
+ } catch (e) {
22
+ setErr(e.message);
23
+ }
24
+ }, []);
25
+
26
+ useEffect(() => { load(); }, [load]);
27
+
28
+ const openDump = guard(async (s) => {
29
+ const d = await api(`/api/sections/${encodeURIComponent(s.id)}/dump`);
30
+ setModal({ kind: 'dump', title: `${s.title}(${s.id})的全部信息`, body: <pre className="block">{JSON.stringify(d, null, 2)}</pre> });
31
+ });
32
+
33
+ const openEdit = guard(async (s) => {
34
+ const full = await api(`/api/sections/${encodeURIComponent(s.id)}`);
35
+ setModal({ kind: 'edit', id: s.id, form: {
36
+ title: full.title,
37
+ description: full.description,
38
+ order: String(full.order),
39
+ titleLabel: full.titleLabel,
40
+ titleField: full.titleField || '',
41
+ }, full });
42
+ });
43
+
44
+ const create = guard(async (form) => {
45
+ const body = { id: form.id.trim(), title: form.title.trim() || form.id.trim(), template: form.template || undefined, description: form.description, order: Number(form.order) || 50 };
46
+ await api('/api/sections', { method: 'POST', body });
47
+ toast('栏目已创建');
48
+ setModal(null);
49
+ await load();
50
+ await reload();
51
+ });
52
+
53
+ const saveEdit = guard(async (m) => {
54
+ const body = {
55
+ title: m.form.title,
56
+ description: m.form.description,
57
+ order: Number(m.form.order) || 50,
58
+ titleLabel: m.form.titleLabel,
59
+ titleField: m.form.titleField,
60
+ };
61
+ await api(`/api/sections/${encodeURIComponent(m.id)}`, { method: 'PATCH', body });
62
+ toast('栏目已更新');
63
+ const fresh = await api(`/api/sections/${encodeURIComponent(m.id)}`);
64
+ setModal({ ...m, full: fresh });
65
+ await load();
66
+ await reload();
67
+ });
68
+
69
+ const addField = guard(async (m, def) => {
70
+ await api(`/api/sections/${encodeURIComponent(m.id)}`, { method: 'PATCH', body: { 'add-field': def } });
71
+ toast(`已新增字段 ${def.key}`);
72
+ const fresh = await api(`/api/sections/${encodeURIComponent(m.id)}`);
73
+ setModal({ ...m, full: fresh });
74
+ await load();
75
+ });
76
+
77
+ const removeField = guard(async (m, key) => {
78
+ const ok = await dialog({ message: `从字段字典里删掉「${key}」?已填的值会留在条目里(只是不再显示/校验)。`, danger: true, confirmText: '删除字段' });
79
+ if (!ok) return;
80
+ await api(`/api/sections/${encodeURIComponent(m.id)}`, { method: 'PATCH', body: { 'remove-field': key } });
81
+ toast(`已删字段 ${key}`);
82
+ const fresh = await api(`/api/sections/${encodeURIComponent(m.id)}`);
83
+ setModal({ ...m, full: fresh });
84
+ await load();
85
+ });
86
+
87
+ const removeSection = guard(async (s) => {
88
+ const ok = await dialog({
89
+ message: `删除栏目「${s.title}」,连带其中 ${s.entries} 条条目?删除前会自动留快照。`,
90
+ danger: true,
91
+ confirmText: '连带删除',
92
+ });
93
+ if (!ok) return;
94
+ // 注意:DELETE 的参数走**请求体**,不是 query —— api.js 对写操作只合并 body
95
+ await api(`/api/sections/${encodeURIComponent(s.id)}`, { method: 'DELETE', body: { force: true } });
96
+ toast('栏目已删除');
97
+ await load();
98
+ await reload();
99
+ });
100
+
101
+ if (err) return <div className="pad stack"><div className="bad"><strong>读不到栏目</strong></div><pre className="block">{err}</pre></div>;
102
+ if (!data) return <div className="pad muted">加载中…</div>;
103
+
104
+ return (
105
+ <>
106
+ <div className="toolbar rowgap pad">
107
+ <strong>栏目</strong>
108
+ <span className="muted">共 {data.count} 个 · 条目 {data.totalEntries} 条</span>
109
+ <span className="grow" />
110
+ <button className="btn small ghost" onClick={load}>刷新</button>
111
+ <button className="btn small" onClick={() => setModal({ kind: 'create', form: { id: '', title: '', template: templates[0]?.id || '', description: '', order: '50' } })}>新建栏目</button>
112
+ </div>
113
+
114
+ <div className="card">
115
+ <div className="colhead">全部栏目</div>
116
+ <div className="colbody">
117
+ {data.sections.length ? data.sections.map((s) => (
118
+ <div key={s.id} className="row">
119
+ <span className="name">{s.title}</span>
120
+ <span className="mono muted">{s.id}</span>
121
+ {s.template ? <Tag>{s.template}</Tag> : <Tag>自定义</Tag>}
122
+ <span className="desc">{s.description}</span>
123
+ <span className="acts">
124
+ <span className="tag">{s.fields} 字段</span>
125
+ <span className="tag">{s.entries} 条目</span>
126
+ <button className="btn small ghost" onClick={() => openDump(s)}>全部信息</button>
127
+ <button className="btn small ghost" onClick={() => openEdit(s)}>编辑</button>
128
+ <button className="btn small ghost" onClick={() => removeSection(s)}>删除</button>
129
+ </span>
130
+ </div>
131
+ )) : <Empty>还没有栏目。点「新建栏目」,用内置模板(求职 / 密钥)起步。</Empty>}
132
+ </div>
133
+ </div>
134
+
135
+ <div className="pad hint">
136
+ 栏目 = 一组同构条目 + 一份字段字典。字典是数据,所以这里加一个字段,CLI 与面板立刻就都能填它。
137
+ </div>
138
+
139
+ <CliHints module="sections" />
140
+
141
+ {modal ? (
142
+ <Modal title={modalTitle(modal)} onClose={() => setModal(null)}>
143
+ {modal.kind === 'dump' ? modal.body : null}
144
+ {modal.kind === 'create' ? (
145
+ <CreateForm
146
+ templates={templates}
147
+ form={modal.form}
148
+ onChange={(f) => setModal((m) => ({ ...m, form: { ...m.form, ...f } }))}
149
+ onSubmit={() => {
150
+ if (!/^[a-z][a-z0-9_-]{0,63}$/.test(modal.form.id.trim())) { toast('栏目 id 必须是小写字母开头,仅含 a-z 0-9 _ -', 'bad'); return; }
151
+ create(modal.form);
152
+ }}
153
+ />
154
+ ) : null}
155
+ {modal.kind === 'edit' ? (
156
+ <EditForm
157
+ modal={modal}
158
+ onChange={(f) => setModal((m) => ({ ...m, form: { ...m.form, ...f } }))}
159
+ onSave={() => saveEdit(modal)}
160
+ onAddField={(def) => addField(modal, def)}
161
+ onRemoveField={(key) => removeField(modal, key)}
162
+ onClose={() => setModal(null)}
163
+ />
164
+ ) : null}
165
+ </Modal>
166
+ ) : null}
167
+ </>
168
+ );
169
+ }
170
+
171
+ function modalTitle(m) {
172
+ if (m.kind === 'dump') return m.title;
173
+ if (m.kind === 'create') return '新建栏目';
174
+ return `编辑栏目 ${m.id}`;
175
+ }
176
+
177
+ function CreateForm({ templates, form, onChange, onSubmit }) {
178
+ return (
179
+ <div className="stack">
180
+ <div className="grid">
181
+ <div className="field">
182
+ <div className="flabel"><span>栏目 id</span><span className="fkey">小写字母开头,仅 a-z 0-9 _ -</span></div>
183
+ <div className="fctrl"><input value={form.id} onChange={(e) => onChange({ id: e.target.value })} placeholder="例如 projects" /></div>
184
+ </div>
185
+ <div className="field">
186
+ <div className="flabel"><span>标题</span></div>
187
+ <div className="fctrl"><input value={form.title} onChange={(e) => onChange({ title: e.target.value })} placeholder="例如 项目档案" /></div>
188
+ </div>
189
+ <div className="field">
190
+ <div className="flabel"><span>从模板起步</span></div>
191
+ <div className="fctrl">
192
+ <select value={form.template} onChange={(e) => onChange({ template: e.target.value })}>
193
+ <option value="">(空栏目,自定义字段)</option>
194
+ {templates.map((t) => <option key={t.id} value={t.id}>{t.label}({t.fields} 字段)</option>)}
195
+ </select>
196
+ </div>
197
+ </div>
198
+ <div className="field">
199
+ <div className="flabel"><span>排序</span><span className="fkey">越小越靠前</span></div>
200
+ <div className="fctrl"><input type="number" value={form.order} onChange={(e) => onChange({ order: e.target.value })} /></div>
201
+ </div>
202
+ </div>
203
+ <div className="field wide">
204
+ <div className="flabel"><span>描述</span></div>
205
+ <div className="fctrl"><input value={form.description} onChange={(e) => onChange({ description: e.target.value })} /></div>
206
+ </div>
207
+ <div className="rowgap">
208
+ <button className="btn" onClick={onSubmit}>创建</button>
209
+ <span className="hint">同 id 已存在会直接报冲突,不会静默覆盖。</span>
210
+ </div>
211
+ </div>
212
+ );
213
+ }
214
+
215
+ function EditForm({ modal, onChange, onSave, onAddField, onRemoveField, onClose }) {
216
+ const [nf, setNf] = useState({ key: '', label: '', type: 'text', group: '' });
217
+ const full = modal.full;
218
+ const groups = full?.groupList || [];
219
+ return (
220
+ <div className="stack">
221
+ <div className="grid">
222
+ <div className="field">
223
+ <div className="flabel"><span>标题</span></div>
224
+ <div className="fctrl"><input value={modal.form.title} onChange={(e) => onChange({ title: e.target.value })} /></div>
225
+ </div>
226
+ <div className="field">
227
+ <div className="flabel"><span>排序</span></div>
228
+ <div className="fctrl"><input type="number" value={modal.form.order} onChange={(e) => onChange({ order: e.target.value })} /></div>
229
+ </div>
230
+ <div className="field">
231
+ <div className="flabel"><span>条目名字段</span><span className="fkey">列表里显示哪个字段</span></div>
232
+ <div className="fctrl">
233
+ <select value={modal.form.titleField} onChange={(e) => onChange({ titleField: e.target.value })}>
234
+ <option value="">(用条目 title)</option>
235
+ {(full?.fieldList || []).map((f) => <option key={f.key} value={f.key}>{f.label}({f.key})</option>)}
236
+ </select>
237
+ </div>
238
+ </div>
239
+ <div className="field">
240
+ <div className="flabel"><span>名称标签</span><span className="fkey">新建条目时问什么</span></div>
241
+ <div className="fctrl"><input value={modal.form.titleLabel} onChange={(e) => onChange({ titleLabel: e.target.value })} /></div>
242
+ </div>
243
+ </div>
244
+ <div className="field wide">
245
+ <div className="flabel"><span>描述</span></div>
246
+ <div className="fctrl"><input value={modal.form.description} onChange={(e) => onChange({ description: e.target.value })} /></div>
247
+ </div>
248
+ <div className="rowgap">
249
+ <button className="btn" onClick={onSave}>保存栏目属性</button>
250
+ <button className="btn ghost" onClick={onClose}>关闭</button>
251
+ </div>
252
+
253
+ <div className="card" style={{ border: '1px solid var(--soft-2)', borderRadius: 4 }}>
254
+ <div className="colhead">字段字典({full?.fieldList?.length || 0})</div>
255
+ <div className="colbody">
256
+ {groups.map((g) => (
257
+ <div key={g.id}>
258
+ <div className="group-title">{g.title}({g.id})</div>
259
+ {(full.fieldList || []).filter((f) => f.group === g.id).map((f) => (
260
+ <div key={f.key} className="row">
261
+ <span className="name">{f.label}</span>
262
+ <span className="mono muted">{f.key}</span>
263
+ <Tag>{f.type}</Tag>
264
+ {f.sensitive ? <Tag kind="bad">密文</Tag> : null}
265
+ <span className="desc">{f.hint || ''}</span>
266
+ <span className="acts"><button className="btn small ghost" onClick={() => onRemoveField(f.key)}>删除</button></span>
267
+ </div>
268
+ ))}
269
+ </div>
270
+ ))}
271
+ {(full?.fieldList || []).filter((f) => !groups.some((g) => g.id === f.group)).map((f) => (
272
+ <div key={f.key} className="row">
273
+ <span className="name">{f.label}</span>
274
+ <span className="mono muted">{f.key}</span>
275
+ <Tag>{f.type}</Tag>
276
+ <span className="desc">分组 {f.group}(不在 groups 列表里)</span>
277
+ <span className="acts"><button className="btn small ghost" onClick={() => onRemoveField(f.key)}>删除</button></span>
278
+ </div>
279
+ ))}
280
+ </div>
281
+ </div>
282
+
283
+ <div className="card" style={{ border: '1px solid var(--soft-2)', borderRadius: 4 }}>
284
+ <div className="colhead">新增字段</div>
285
+ <div className="pad rowgap">
286
+ <input style={{ width: 150 }} placeholder="key(英文字母开头)" value={nf.key} onChange={(e) => setNf({ ...nf, key: e.target.value })} />
287
+ <input style={{ width: 150 }} placeholder="标签(中文名)" value={nf.label} onChange={(e) => setNf({ ...nf, label: e.target.value })} />
288
+ <select style={{ width: 110 }} value={nf.type} onChange={(e) => setNf({ ...nf, type: e.target.value })}>
289
+ {TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
290
+ </select>
291
+ <select style={{ width: 150 }} value={nf.group} onChange={(e) => setNf({ ...nf, group: e.target.value })}>
292
+ <option value="">(首个分组)</option>
293
+ {groups.map((g) => <option key={g.id} value={g.id}>{g.title}</option>)}
294
+ </select>
295
+ <button
296
+ className="btn small"
297
+ onClick={() => {
298
+ const def = { key: nf.key.trim(), label: nf.label.trim() || nf.key.trim(), type: nf.type };
299
+ if (nf.group) def.group = nf.group;
300
+ onAddField(def);
301
+ setNf({ key: '', label: '', type: 'text', group: nf.group });
302
+ }}
303
+ >
304
+ 加入字典
305
+ </button>
306
+ <span className="hint">加完立刻生效:CLI 与面板都能填它。</span>
307
+ </div>
308
+ </div>
309
+ </div>
310
+ );
311
+ }
@@ -0,0 +1,34 @@
1
+ import * as service from './service.js';
2
+
3
+ export default {
4
+ id: 'settings',
5
+ title: '设置',
6
+ order: 900,
7
+ view: () => import('./view.jsx'),
8
+ // 单例配置:全局只有一份,读出来改回去 → 退化为 get / set,不硬凑 CRUD 五操作
9
+ actions: [
10
+ {
11
+ id: 'setting.get',
12
+ cli: ['setting', 'get'],
13
+ http: ['GET', '/api/settings'],
14
+ summary: '读取全部设置项',
15
+ flags: {},
16
+ run: () => service.getSettings(),
17
+ render: (d) => service.renderSettings(d),
18
+ },
19
+ {
20
+ id: 'setting.set',
21
+ cli: ['setting', 'set'],
22
+ http: ['PATCH', '/api/settings'],
23
+ summary: '修改设置(PATCH 语义,只改传入项)',
24
+ flags: {
25
+ key: { type: 'string', hint: '设置项名' },
26
+ value: { type: 'string', hint: '取值' },
27
+ set: { type: 'kv', hint: 'k=v,可重复' },
28
+ 'dry-run': { type: 'boolean' },
29
+ },
30
+ run: (ctx) => service.updateSettings(ctx),
31
+ render: (d) => service.renderSettingSet(d),
32
+ },
33
+ ],
34
+ };
@@ -0,0 +1,78 @@
1
+ // settings 是**基础模块**:允许被其它模块单向只读依赖(见 eslint 互依禁列——它故意不在里面)。
2
+ // 设置的唯一读写入口在这里,别绕过去直接改 store.settings。
3
+ import { loadStore, mutateStore, snapshotStore } from '../../core/store.js';
4
+ import { badInput, notFound } from '../../core/errors.js';
5
+
6
+ export const SETTING_KEYS = ['exportDir', 'exportFormat', 'includeSecretsInExport', 'defaultSection'];
7
+
8
+ const SCHEMA = {
9
+ exportDir: { type: 'string', hint: '导出目录,留空 = ~/nx-sk/export' },
10
+ exportFormat: { type: 'string', enum: ['json', 'md', 'both'], hint: '整体导出默认格式' },
11
+ includeSecretsInExport: { type: 'boolean', hint: '整体导出是否默认带密钥明文(默认否)' },
12
+ defaultSection: { type: 'string', hint: '默认栏目 id' },
13
+ };
14
+
15
+ export function settingsSchema() {
16
+ return Object.entries(SCHEMA).map(([key, s]) => ({ key, ...s }));
17
+ }
18
+
19
+ export async function getSettings() {
20
+ const store = await loadStore();
21
+ return { settings: store.settings, schema: settingsSchema() };
22
+ }
23
+
24
+ function coerceSetting(key, value) {
25
+ const s = SCHEMA[key];
26
+ if (!s) throw notFound(`未知设置项: ${key}(可用: ${SETTING_KEYS.join(', ')})`);
27
+ if (s.type === 'boolean') {
28
+ if (typeof value === 'boolean') return value;
29
+ const v = String(value).trim().toLowerCase();
30
+ if (['是', 'true', '1', 'yes'].includes(v)) return true;
31
+ if (['否', 'false', '0', 'no', ''].includes(v)) return false;
32
+ throw badInput(`设置项 ${key} 需要是/否,收到 ${JSON.stringify(value)}`);
33
+ }
34
+ const str = String(value);
35
+ if (s.enum && !s.enum.includes(str)) throw badInput(`设置项 ${key} 只能是 ${s.enum.join(' / ')}`);
36
+ return str;
37
+ }
38
+
39
+ /** PATCH 语义:只改传入的项,未传的保持原值。 */
40
+ export async function updateSettings({ set, key, value, 'dry-run': dryRun } = {}) {
41
+ let patch = {};
42
+ if (set && typeof set === 'object') patch = { ...set };
43
+ if (key !== undefined) patch[key] = value;
44
+
45
+ const keys = Object.keys(patch);
46
+ if (!keys.length) throw badInput('至少要给一项:setting set --key <名> --value <值>,或 --set k=v(可重复)');
47
+
48
+ const resolved = {};
49
+ for (const k of keys) resolved[k] = coerceSetting(k, patch[k]);
50
+
51
+ if (dryRun) return { status: 'skipped', dryRun: true, wouldSet: resolved };
52
+
53
+ const snapshot = await snapshotStore('setting-set');
54
+ const result = await mutateStore((s) => {
55
+ const before = {};
56
+ for (const [k, v] of Object.entries(resolved)) { before[k] = s.settings[k]; s.settings[k] = v; }
57
+ return { before, after: { ...resolved } };
58
+ });
59
+ return { status: 'ok', set: result.after, before: result.before, snapshot, path: '~/nx-sk/store.json' };
60
+ }
61
+
62
+ export function renderSettings(data) {
63
+ const lines = ['当前设置(改:nx-sk setting set --key <名> --value <值>)'];
64
+ for (const s of data.schema) {
65
+ const v = data.settings[s.key];
66
+ const shown = s.type === 'boolean' ? (v ? '是' : '否') : (v === '' ? '(空)' : String(v));
67
+ lines.push(` ${s.key.padEnd(22)} ${shown.padEnd(10)} ${s.hint}`);
68
+ }
69
+ return lines.join('\n');
70
+ }
71
+
72
+ export function renderSettingSet(d) {
73
+ if (d.status === 'skipped') return `试运行:将设置 ${JSON.stringify(d.wouldSet)}(未落盘)`;
74
+ const lines = ['已更新设置:'];
75
+ for (const [k, v] of Object.entries(d.set)) lines.push(` ${k}: ${JSON.stringify(d.before[k])} -> ${JSON.stringify(v)}`);
76
+ if (d.snapshot) lines.push(`快照: ${d.snapshot}`);
77
+ return lines.join('\n');
78
+ }
@@ -0,0 +1,193 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { api } from '../../web/frontend/api/client.js';
3
+ import { useStore } from '../../web/frontend/store.jsx';
4
+ import { CliHints, Copyable, Empty, Tag } from '../../web/frontend/components/ui.jsx';
5
+
6
+ export default function SettingsView() {
7
+ const { boot, reload, toast, guard } = useStore();
8
+ const [data, setData] = useState(null);
9
+ const [form, setForm] = useState(null);
10
+ // 面板地址不写死:直接取用户此刻访问的这个 origin,换端口/换 host 都自动跟上
11
+ const panelUrl = typeof window === 'undefined' ? '' : window.location.origin;
12
+ const panelPort = typeof window === 'undefined' ? '' : (window.location.port || '80');
13
+
14
+ const load = useCallback(async () => {
15
+ try {
16
+ const d = await api('/api/settings');
17
+ setData(d);
18
+ setForm({ ...d.settings });
19
+ } catch (e) {
20
+ toast(e.message, 'bad');
21
+ }
22
+ }, [toast]);
23
+
24
+ useEffect(() => { load(); }, [load]);
25
+
26
+ const save = guard(async () => {
27
+ const body = {
28
+ exportDir: form.exportDir,
29
+ exportFormat: form.exportFormat,
30
+ includeSecretsInExport: !!form.includeSecretsInExport,
31
+ defaultSection: form.defaultSection,
32
+ };
33
+ const r = await api('/api/settings', { method: 'PATCH', body });
34
+ toast('设置已保存');
35
+ setData({ ...data, settings: r.set });
36
+ await reload();
37
+ });
38
+
39
+ if (!data || !form) return <div className="pad muted">加载中…</div>;
40
+
41
+ return (
42
+ <>
43
+ <div className="toolbar rowgap pad">
44
+ <strong>设置</strong>
45
+ <span className="muted">本机配置与运行状态</span>
46
+ <span className="grow" />
47
+ <button className="btn small ghost" onClick={load}>重新读取</button>
48
+ </div>
49
+
50
+ <div className="card">
51
+ <div className="colhead">存储与运行</div>
52
+ <div className="colbody">
53
+ <InfoRow label="数据目录" value={boot?.home} copy={boot?.home} />
54
+ <InfoRow label="store.json" value={boot?.storePath} copy={boot?.storePathRaw} />
55
+ <InfoRow label="栏目 / 条目" value={boot ? `${boot.counts.sections} / ${boot.counts.entries}` : ''} />
56
+ <InfoRow label="命令条数" value={boot ? String(boot.counts.commands) : ''} />
57
+ <InfoRow label="app / node" value={boot ? `v${boot.app.version} · node ${boot.app.node} · ${boot.app.platform}` : ''} />
58
+ <InfoRow
59
+ label="环境变量覆盖"
60
+ value={boot ? Object.entries(boot.envOverrides).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join(' ') || '(无)' : ''}
61
+ />
62
+ </div>
63
+ </div>
64
+
65
+ <div className="card">
66
+ <div className="colhead">密钥(密文落盘)</div>
67
+ <div className="colbody">
68
+ <InfoRow label="密钥来源" value={boot?.vault?.source === 'env' ? '环境变量 NX_SK_PASSPHRASE(口令不落盘)' : '本机密钥文件'} />
69
+ <InfoRow label="密钥文件" value={boot?.vault?.keyFile || boot?.vault?.saltFile || ''} copy={boot?.vault?.keyFile || boot?.vault?.saltFile} />
70
+ <div className="row">
71
+ <span className="desc">{boot?.vault?.note}</span>
72
+ </div>
73
+ <div className="row">
74
+ <span className="desc bad">删掉密钥文件或换掉口令后,已存的密文都解不开了——导出前先确认密钥可用。</span>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <div className="card">
80
+ <div className="colhead">面板与 CLI</div>
81
+ <div className="colbody">
82
+ <div className="row">
83
+ <span className="name">面板地址</span>
84
+ <span className="desc mono">{panelUrl}</span>
85
+ <span className="acts"><Copyable className="mono" text={panelUrl}>复制</Copyable></span>
86
+ </div>
87
+ <div className="row">
88
+ <span className="name">重启面板</span>
89
+ <span className="desc">面板本身就是 serve 提供的;改代码后重启它即可</span>
90
+ <span className="acts"><Copyable className="mono" text={`nx-sk serve --port ${panelPort}`}>{`nx-sk serve --port ${panelPort}`}</Copyable></span>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ <div className="card">
96
+ <div className="colhead">默认行为</div>
97
+ <div className="pad">
98
+ <div className="grid">
99
+ <div className="field">
100
+ <div className="flabel"><span>默认导出格式</span></div>
101
+ <div className="fctrl">
102
+ <select value={form.exportFormat} onChange={(e) => setForm({ ...form, exportFormat: e.target.value })}>
103
+ <option value="both">JSON + Markdown</option>
104
+ <option value="json">仅 JSON</option>
105
+ <option value="md">仅 Markdown</option>
106
+ </select>
107
+ </div>
108
+ </div>
109
+ <div className="field">
110
+ <div className="flabel"><span>默认导出目录</span><span className="fkey">留空 = ~/nx-sk/export</span></div>
111
+ <div className="fctrl"><input value={form.exportDir} onChange={(e) => setForm({ ...form, exportDir: e.target.value })} /></div>
112
+ </div>
113
+ <div className="field">
114
+ <div className="flabel"><span>默认栏目</span><span className="fkey">CLI 未指定 --section 时的首选</span></div>
115
+ <div className="fctrl"><input value={form.defaultSection} onChange={(e) => setForm({ ...form, defaultSection: e.target.value })} /></div>
116
+ </div>
117
+ <div className="field">
118
+ <div className="flabel"><span>导出默认带明文密钥</span><span className="fkey">默认开;要分享出去时关掉</span></div>
119
+ <div className="fctrl">
120
+ <label className="inline">
121
+ <input type="checkbox" checked={!!form.includeSecretsInExport} onChange={(e) => setForm({ ...form, includeSecretsInExport: e.target.checked })} />
122
+ <span className={form.includeSecretsInExport ? 'muted' : 'bad'}>{form.includeSecretsInExport ? '开(导出文件含真实凭据,注意别外发)' : '关(凭据打码后再导出)'}</span>
123
+ </label>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ <div className="rowgap" style={{ marginTop: 8 }}>
128
+ <button className="btn" onClick={save}>保存设置</button>
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ <div className="card">
134
+ <div className="colhead">自动快照<span className="right muted">{boot?.snapshots?.length ?? 0}</span></div>
135
+ <div className="colbody">
136
+ <div className="row">
137
+ <span className="desc">每次删除条目 / 栏目、修改设置前,都会在 ~/nx-sk/backup 留一份可回滚的整库快照。</span>
138
+ </div>
139
+ {boot?.snapshots?.length ? boot.snapshots.map((s) => (
140
+ <div key={s.name} className="row">
141
+ <span className="mono name">{s.name}</span>
142
+ <span className="desc">{s.at}</span>
143
+ <span className="acts"><span className="tag">{Math.ceil(s.bytes / 1024)} KB</span></span>
144
+ </div>
145
+ )) : <Empty>还没有快照。</Empty>}
146
+ </div>
147
+ </div>
148
+
149
+ {boot?.migration ? (
150
+ <div className="card">
151
+ <div className="colhead">数据迁移</div>
152
+ <div className="colbody">
153
+ <div className="row">
154
+ <span className="desc">store 已从 v{boot.migration.from} 迁到 v{boot.migration.to}:密钥栏目字段 {boot.migration.fieldsBefore} → {boot.migration.fieldsAfter},{boot.migration.entries} 条条目</span>
155
+ </div>
156
+ <div className="row">
157
+ <span className="desc">迁移前的原始文件</span>
158
+ <span className="acts"><Copyable className="mono" text={boot.migration.snapshotRaw || ''}>{boot.migration.snapshot}</Copyable></span>
159
+ </div>
160
+ {boot.migration.legacyValuesKept ? (
161
+ <div className="row">
162
+ <span className="desc">另保留了 {boot.migration.legacyValuesKept} 个旧字段的值(不再显示,但没删)</span>
163
+ </div>
164
+ ) : null}
165
+ </div>
166
+ </div>
167
+ ) : null}
168
+
169
+ {boot?.corrupt ? (
170
+ <div className="card">
171
+ <div className="colhead">上次读取异常</div>
172
+ <div className="colbody">
173
+ <div className="row">
174
+ <span className="desc bad">store.json 解析失败,已挪到 {boot.corrupt.to}(原文件没有被覆盖)</span>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ ) : null}
179
+
180
+ <CliHints module="settings" />
181
+ </>
182
+ );
183
+ }
184
+
185
+ function InfoRow({ label, value, copy }) {
186
+ return (
187
+ <div className="row">
188
+ <span className="name">{label}</span>
189
+ <span className="desc mono">{value || '(空)'}</span>
190
+ {copy ? <span className="acts"><Copyable className="mono" text={copy}>复制</Copyable></span> : <Tag> </Tag>}
191
+ </div>
192
+ );
193
+ }