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,240 @@
1
+ import { useCallback, useEffect, useMemo, 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
+ import { FieldGroup } from '../../web/frontend/components/fieldEditor.jsx';
6
+ import KvTable from './kvTable.jsx';
7
+
8
+ const norm = (x) => (x === null || x === undefined ? '' : (Array.isArray(x) ? x.join('、') : String(x)));
9
+
10
+ /**
11
+ * 栏目详情视图。按栏目的形状分两种布局:
12
+ *
13
+ * - `kv: true`(密钥那种)→ 交给 [[kvTable]],**一行一个键值对**。没有条目列表、
14
+ * 没有字段表单、没有完整性——密钥就是一张表。
15
+ * - 其余栏目 → 模板建议的分组表单(求职那种需要中文标签/候选值/未填清单的场景)。
16
+ *
17
+ * 字段与分组全部来自后端,所以新增字段不需要改前端。
18
+ */
19
+ export default function SectionView({ sectionId }) {
20
+ const { ui, patchUi, toast, guard, dialog, reload } = useStore();
21
+ const [data, setData] = useState(null);
22
+ const [err, setErr] = useState(null);
23
+ const [busy, setBusy] = useState(false);
24
+ const [draft, setDraft] = useState(null);
25
+ const [modal, setModal] = useState(null);
26
+ const [quick, setQuick] = useState('');
27
+ const mask = !!ui.mask;
28
+
29
+ const load = useCallback(async (keepId) => {
30
+ setBusy(true);
31
+ setErr(null);
32
+ try {
33
+ const d = await api(`/api/sections/${encodeURIComponent(sectionId)}/dump${mask ? '?mask=1' : ''}`);
34
+ setData(d);
35
+ const wanted = keepId || ui.entry;
36
+ const hit = d.entries.find((e) => e.id === wanted) || d.entries[0] || null;
37
+ setDraft(hit ? { id: hit.id, title: hit.title, values: { ...hit.values } } : null);
38
+ if (hit && hit.id !== ui.entry) patchUi({ entry: hit.id });
39
+ } catch (e) {
40
+ setErr(e.message);
41
+ setData(null);
42
+ setDraft(null);
43
+ } finally {
44
+ setBusy(false);
45
+ }
46
+ // eslint-disable-next-line react-hooks/exhaustive-deps
47
+ }, [sectionId, mask]);
48
+
49
+ useEffect(() => { load(); }, [load]);
50
+
51
+ const current = useMemo(() => data?.entries.find((e) => e.id === draft?.id) || null, [data, draft]);
52
+ const groups = useMemo(() => {
53
+ if (!data) return [];
54
+ return data.groups.map((g) => ({ ...g, fields: data.fields.filter((f) => f.group === g.id) })).filter((g) => g.fields.length);
55
+ }, [data]);
56
+
57
+ const patchField = (key, value) => setDraft((d) => (d ? { ...d, values: { ...d.values, [key]: value } } : d));
58
+
59
+ const dirty = useMemo(() => {
60
+ if (!draft || !current) return { set: {}, titleChanged: false, count: 0 };
61
+ const set = {};
62
+ for (const f of data.fields) {
63
+ if (norm(current.values[f.key]) !== norm(draft.values[f.key])) set[f.key] = draft.values[f.key];
64
+ }
65
+ return { set, titleChanged: draft.title !== current.title, count: Object.keys(set).length + (draft.title !== current.title ? 1 : 0) };
66
+ }, [draft, current, data]);
67
+
68
+ const save = guard(async () => {
69
+ if (!draft || !current) return;
70
+ if (!dirty.count) { toast('没有改动'); return; }
71
+ const body = { set: dirty.set };
72
+ if (dirty.titleChanged) body.title = draft.title;
73
+ await api(`/api/entries/${encodeURIComponent(draft.id)}`, { method: 'PATCH', body });
74
+ toast(`已保存 ${dirty.count} 处改动`);
75
+ await load(draft.id);
76
+ await reload();
77
+ });
78
+
79
+ // 「直接存 kv」的入口:敲一行 字段=值 回车即写入,字典里没有的键会自动登记
80
+ const quickAdd = guard(async () => {
81
+ if (!draft) { toast('先新建一条条目', 'bad'); return; }
82
+ const i = quick.indexOf('=');
83
+ if (i <= 0) { toast('用 字段=值 的格式,例如 微信=zhaoliuxue', 'bad'); return; }
84
+ const k = quick.slice(0, i).trim();
85
+ const v = quick.slice(i + 1);
86
+ const r = await api(`/api/entries/${encodeURIComponent(draft.id)}`, { method: 'PATCH', body: { set: { [k]: v } } });
87
+ setQuick('');
88
+ toast(`已写入 ${k}${r.newFields?.length ? '(新字段已登记)' : ''}`);
89
+ await load(draft.id);
90
+ await reload();
91
+ });
92
+
93
+ const addEntry = guard(async () => {
94
+ const label = data?.section.titleLabel || '名称';
95
+ const name = await dialog({ message: `新建「${data?.section.title}」条目,${label}是?`, input: { placeholder: `例如 ${data?.section.titleField || '名称'}` } });
96
+ if (!name) return;
97
+ const body = { section: sectionId, title: name };
98
+ if (data?.section.titleField) body.set = { [data.section.titleField]: name };
99
+ const created = await api('/api/entries', { method: 'POST', body });
100
+ toast(`已新建:${created.title}`);
101
+ await load(created.id);
102
+ await reload();
103
+ });
104
+
105
+ const removeEntry = guard(async () => {
106
+ if (!current) return;
107
+ const ok = await dialog({
108
+ message: `删除条目「${current.title}」?删除前会自动留一份快照在 ~/nx-sk/backup。`,
109
+ danger: true,
110
+ confirmText: '删除',
111
+ });
112
+ if (!ok) return;
113
+ await api(`/api/entries/${encodeURIComponent(current.id)}`, { method: 'DELETE' });
114
+ toast('已删除');
115
+ await load('');
116
+ await reload();
117
+ });
118
+
119
+ const exportSection = guard(async () => {
120
+ const r = await api('/api/export', { method: 'POST', body: { section: sectionId, format: 'both' } });
121
+ setModal({
122
+ title: '导出结果',
123
+ body: <pre className="block">{`目录: ${r.dir}\n格式: ${r.format}\n条目: ${r.entries}\n凭据: ${r.withSecrets ? '含明文' : '已打码'}\n\n${r.files.map((f) => `${f.path} ${f.bytes} 字节`).join('\n')}`}</pre>,
124
+ });
125
+ });
126
+
127
+ if (err) {
128
+ return (
129
+ <div className="pad stack">
130
+ <div className="bad"><strong>栏目读不到</strong></div>
131
+ <pre className="block">{err}</pre>
132
+ <div className="hint">如果这个栏目刚被删掉,切到「栏目」tab 看看现有栏目。</div>
133
+ </div>
134
+ );
135
+ }
136
+ if (!data) return <div className="pad muted">{busy ? '加载中…' : '暂无数据'}</div>;
137
+
138
+ // KV 栏目单独一套界面(宿主的 hooks 都已经跑完,这里是合法提前返回)
139
+ if (data.section.kv) return <KvTable sectionId={sectionId} sectionTitle={data.section.title} />;
140
+
141
+ return (
142
+ <>
143
+ <div className="toolbar rowgap pad">
144
+ <strong>{data.section.title}</strong>
145
+ <Tag>{data.section.id}</Tag>
146
+ <span className="muted">{data.count} 条 / {data.section.fields} 字段</span>
147
+ {data.section.description ? <span className="desc muted nowrap grow">{data.section.description}</span> : <span className="grow" />}
148
+ <label className="inline hint">
149
+ <input type="checkbox" checked={mask} onChange={() => patchUi({ mask: !mask })} />
150
+ 打码显示(投屏/截图时用)
151
+ </label>
152
+ <button className="btn small ghost" onClick={() => load()}>刷新</button>
153
+ <button className="btn small ghost" onClick={exportSection}>导出本栏目</button>
154
+ <button className="btn small" onClick={addEntry}>新建条目</button>
155
+ </div>
156
+
157
+ <div className="cols">
158
+ <div className="col" style={{ flex: '0 0 300px' }}>
159
+ <div className="card">
160
+ <div className="colhead">条目列表<span className="right muted">{data.count}</span></div>
161
+ <div className="colbody">
162
+ {data.entries.length ? data.entries.map((e) => (
163
+ <div
164
+ key={e.id}
165
+ className={`row selectable ${e.id === draft?.id ? 'selected' : ''}`}
166
+ onClick={() => { patchUi({ entry: e.id }); setDraft({ id: e.id, title: e.title, values: { ...e.values } }); }}
167
+ >
168
+ <span className="name">{e.title || '(无名)'}</span>
169
+ <span className="acts">
170
+ {e.missing.length ? <span className="tag">{e.completeness.ratio}%</span> : <span className="tag strong">已填满</span>}
171
+ </span>
172
+ </div>
173
+ )) : <Empty>还没有条目。点右上角「新建条目」,或用 CLI:nx-sk entry add --section {data.section.id} --set ...</Empty>}
174
+ </div>
175
+ </div>
176
+ </div>
177
+
178
+ <div className="col">
179
+ {!draft ? (
180
+ <div className="card"><Empty>左边选一条,或新建一条</Empty></div>
181
+ ) : (
182
+ <div className="card">
183
+ <div className="colhead">
184
+ <span className="grow nowrap">{current?.title || draft.title}</span>
185
+ <span className="muted mono">{draft.id}</span>
186
+ {current ? <Tag kind={current.missing.length ? '' : 'strong'}>完整度 {current.completeness.ratio}%</Tag> : null}
187
+ </div>
188
+
189
+ <div className="field wide pad" style={{ borderBottom: '1px solid var(--soft-2)' }}>
190
+ <div className="flabel">
191
+ <span>{data.section.titleLabel || '名称'}</span>
192
+ <span className="bad">改动后必须保存</span>
193
+ </div>
194
+ <div className="fctrl">
195
+ <input value={draft.title} onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))} />
196
+ </div>
197
+ </div>
198
+
199
+ <div className="rowgap" style={{ padding: '6px 14px', borderBottom: '1px solid var(--soft-2)' }}>
200
+ <input
201
+ className="grow"
202
+ style={{ minWidth: 220 }}
203
+ placeholder="直接加字段:字段=值 (回车写入;没见过的键会自动登记)"
204
+ value={quick}
205
+ onChange={(e) => setQuick(e.target.value)}
206
+ onKeyDown={(e) => { if (e.key === 'Enter') quickAdd(); }}
207
+ />
208
+ <button className="btn small" onClick={quickAdd} disabled={!quick.includes('=')}>写入</button>
209
+ </div>
210
+
211
+ {groups.map((g) => (
212
+ <FieldGroup key={g.id} title={g.title} fields={g.fields} draft={draft.values} onPatch={patchField} />
213
+ ))}
214
+
215
+ <div className="rowgap pad">
216
+ <button className="btn" onClick={save} disabled={!dirty.count}>保存{dirty.count ? `(${dirty.count} 处改动)` : ''}</button>
217
+ <button className="btn ghost" onClick={() => load(draft.id)} disabled={!dirty.count}>放弃改动</button>
218
+ <button className="btn ghost" onClick={removeEntry}>删除条目</button>
219
+ {current?.missing.length ? (
220
+ <span className="hint">模板建议里还有 {current.missing.length} 项没填(不影响使用):{current.missing.slice(0, 5).map((m) => m.label).join('、')}{current.missing.length > 5 ? '…' : ''}</span>
221
+ ) : null}
222
+ {current?.excluded?.length ? (
223
+ <span className="hint">另有 {current.excluded.length} 项已标不填、不计入完整度:{current.excluded.slice(0, 6).map((m) => m.label).join('、')}{current.excluded.length > 6 ? '…' : ''}</span>
224
+ ) : null}
225
+ </div>
226
+ </div>
227
+ )}
228
+ </div>
229
+ </div>
230
+
231
+ <CliHints module="entries" />
232
+
233
+ {modal ? (
234
+ <Modal title={modal.title} onClose={() => setModal(null)}>
235
+ {modal.body}
236
+ </Modal>
237
+ ) : null}
238
+ </>
239
+ );
240
+ }
@@ -0,0 +1,39 @@
1
+ import * as service from './service.js';
2
+
3
+ export default {
4
+ id: 'export',
5
+ title: '导出',
6
+ order: 700,
7
+ view: () => import('./view.jsx'),
8
+ actions: [
9
+ {
10
+ id: 'export.run',
11
+ cli: ['export', 'run'],
12
+ http: ['POST', '/api/export'],
13
+ summary: '把全部(或单个)栏目导出成 JSON / Markdown;凭据默认含明文,--no-secrets 打码',
14
+ flags: {
15
+ out: { type: 'string', hint: '导出目录,缺省 ~/nx-sk/export' },
16
+ format: { type: 'string', enum: ['json', 'md', 'both'], hint: '缺省取设置里的 exportFormat' },
17
+ section: { type: 'string', hint: '只导出某个栏目' },
18
+ 'with-secrets': { type: 'boolean', hint: '含密钥明文字段(这是默认)' },
19
+ 'no-secrets': { type: 'boolean', hint: '打码后再导出(要分享给别人时用)' },
20
+ 'dry-run': { type: 'boolean' },
21
+ },
22
+ run: (ctx) => service.exportAll({
23
+ out: ctx.out, format: ctx.format, section: ctx.section,
24
+ withSecrets: ctx['with-secrets'] ? true : (ctx['no-secrets'] ? false : undefined),
25
+ 'dry-run': ctx['dry-run'],
26
+ }),
27
+ render: (d) => service.renderExport(d),
28
+ },
29
+ {
30
+ id: 'export.list',
31
+ cli: ['export', 'list'],
32
+ http: ['GET', '/api/exports'],
33
+ summary: '列出历次导出与导出目录内的文件',
34
+ flags: {},
35
+ run: () => service.listExports(),
36
+ render: (d) => service.renderExportList(d),
37
+ },
38
+ ],
39
+ };
@@ -0,0 +1,192 @@
1
+ // 导出(整体导出 / 单栏目导出)。
2
+ // 这是**动作集合**不是集合资源——它不产生实体,只对已有数据做事,所以不套 CRUD 五动词。
3
+ import fsp from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
5
+ import { displayPath, exportDir, VERSION } from '../../core/paths.js';
6
+ import { ensureDir, fileSize, pathExists, writeAtomic } from '../../core/fsx.js';
7
+ import { loadStore, mutateStore, MAX_EXPORT_HISTORY, storePathInUse } from '../../core/store.js';
8
+ import { displaySensitive, dumpSection, formatValue } from '../../core/render.js';
9
+ import { decryptValue, isCipherBlob } from '../../core/crypto.js';
10
+ import { resolveVaultKey, sensitiveViewer } from '../../core/vault.js';
11
+ import { badInput } from '../../core/errors.js';
12
+ import { nowIso, stamp } from '../../core/ids.js';
13
+ import { findSection, isSensitiveField, sectionNames } from '../../core/fields.js';
14
+ import { getSettings } from '../settings/service.js'; // 唯一允许的跨模块依赖:基础模块
15
+
16
+ const FORMATS = ['json', 'md', 'both'];
17
+
18
+ /** 需要明文时先把密文解开;解不开就抛 BLOCKED(带着「密钥来源变了」的明确原因)。 */
19
+ async function plainValues(section, values) {
20
+ const out = {};
21
+ for (const [k, v] of Object.entries(values)) {
22
+ if (isSensitiveField(section, k) && isCipherBlob(v)) {
23
+ const { key } = await resolveVaultKey();
24
+ out[k] = decryptValue(key, v);
25
+ } else {
26
+ out[k] = v;
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ function displayValues(section, values, decrypt, mask = false) {
33
+ const out = {};
34
+ for (const [k, v] of Object.entries(values)) {
35
+ out[k] = isSensitiveField(section, k) ? displaySensitive(v, { mask, decrypt }) : v;
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function renderBundleMarkdown(bundle) {
41
+ const lines = [];
42
+ lines.push('# nx-sk 个人资源导出');
43
+ lines.push('');
44
+ lines.push(`导出时间:${bundle.generatedAt}`);
45
+ lines.push(`栏目数:${bundle.sections.length} 条目数:${bundle.entries.length}`);
46
+ lines.push(`密文字段:${bundle.withSecrets ? '含明文' : '已打码'}`);
47
+ lines.push('');
48
+ for (const section of bundle.sections) {
49
+ lines.push(`## ${section.title}(${section.id})`);
50
+ lines.push('');
51
+ if (section.description) { lines.push(`> ${section.description}`); lines.push(''); }
52
+ const mine = bundle.entries.filter((e) => e.section === section.id);
53
+ if (!mine.length) { lines.push('_(本栏目暂无条目)_'); lines.push(''); continue; }
54
+ const groupTitle = (id) => section.groups.find((g) => g.id === id)?.title || id;
55
+ for (const e of mine) {
56
+ lines.push(`### ${e.title}(\`${e.id}\`)`);
57
+ lines.push('');
58
+ lines.push(`- 完整度:${e.completeness.filled}/${e.completeness.total}(${e.completeness.ratio}%)`);
59
+ if (e.updatedAt) lines.push(`- 更新于:${e.updatedAt}`);
60
+ lines.push('');
61
+ let cur = null;
62
+ for (const f of section.fields) {
63
+ if (f.group !== cur) { cur = f.group; lines.push(`**${groupTitle(cur)}**`); lines.push(''); }
64
+ lines.push(`- ${f.label}:${formatValue(e.values[f.key]) || '(未填写)'}`);
65
+ }
66
+ lines.push('');
67
+ }
68
+ }
69
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n');
70
+ }
71
+
72
+ export async function exportAll({ out, format, withSecrets, section, 'dry-run': dryRun } = {}) {
73
+ const settings = (await getSettings()).settings;
74
+ const fmt = format || settings.exportFormat || 'both';
75
+ if (!FORMATS.includes(fmt)) throw badInput(`--format 只能是 ${FORMATS.join(' / ')}`);
76
+
77
+ const store = await loadStore();
78
+ let sections = store.sections;
79
+ if (section) {
80
+ const s = findSection(store, section);
81
+ if (!s) throw badInput(`找不到栏目: ${section} —— 现有: ${sectionNames(store)}`);
82
+ sections = [s];
83
+ }
84
+ const ids = new Set(sections.map((s) => s.id));
85
+ const entries = store.entries.filter((e) => ids.has(e.section));
86
+
87
+ // 默认取设置;现在默认就是**含明文**(本机单人工具,自己看的东西不必先拦一道)
88
+ const includeSecrets = withSecrets === undefined ? !!settings.includeSecretsInExport : !!withSecrets;
89
+ const dir = out ? resolve(out) : (settings.exportDir ? resolve(settings.exportDir) : exportDir());
90
+ const generatedAt = nowIso();
91
+ const base = `${stamp()}-nx-sk`;
92
+
93
+ const bundle = {
94
+ app: 'nx-sk',
95
+ version: VERSION,
96
+ generatedAt,
97
+ storePath: displayPath(storePathInUse()),
98
+ withSecrets: includeSecrets,
99
+ counts: { sections: sections.length, entries: entries.length },
100
+ sections: sections.map((s) => ({
101
+ id: s.id, title: s.title, description: s.description, titleLabel: s.titleLabel,
102
+ groups: s.groups,
103
+ fields: s.fields.map((f) => ({ key: f.key, label: f.label, type: f.type, group: f.group, sensitive: isSensitiveField(s, f.key) })),
104
+ })),
105
+ entries: [],
106
+ };
107
+ for (const e of entries) {
108
+ const sec = sections.find((s) => s.id === e.section);
109
+ const decrypt = await sensitiveViewer(sec, [e]);
110
+ const values = includeSecrets ? await plainValues(sec, e.values) : displayValues(sec, e.values, decrypt, true);
111
+ const meta = dumpSection(sec, [e], { mask: !includeSecrets, decrypt }).entries[0];
112
+ bundle.entries.push({ id: e.id, section: e.section, sectionTitle: sec.title, title: e.title, tags: e.tags, createdAt: e.createdAt, updatedAt: e.updatedAt, completeness: meta.completeness, values });
113
+ }
114
+
115
+ const files = [];
116
+ if (fmt === 'json' || fmt === 'both') files.push({ kind: 'json', name: `${base}.json`, content: JSON.stringify(bundle, null, 2) + '\n' });
117
+ if (fmt === 'md' || fmt === 'both') files.push({ kind: 'md', name: `${base}.md`, content: renderBundleMarkdown(bundle) });
118
+
119
+ if (dryRun) {
120
+ return {
121
+ status: 'skipped', dryRun: true,
122
+ wouldWrite: { dir: displayPath(dir), files: files.map((f) => ({ name: f.name, bytes: Buffer.byteLength(f.content) })), withSecrets: includeSecrets, sections: sections.length, entries: entries.length },
123
+ };
124
+ }
125
+
126
+ await ensureDir(dir);
127
+ const written = [];
128
+ for (const f of files) {
129
+ const p = resolve(dir, f.name);
130
+ await writeAtomic(p, f.content);
131
+ written.push({ kind: f.kind, path: displayPath(p), pathRaw: p, bytes: await fileSize(p) });
132
+ }
133
+
134
+ await mutateStore((s) => {
135
+ s.exports.unshift({
136
+ at: generatedAt, dir: displayPath(dir), dirRaw: dir, format: fmt, withSecrets: includeSecrets,
137
+ sections: sections.length, entries: entries.length,
138
+ files: written.map((w) => ({ path: w.path, pathRaw: w.pathRaw })),
139
+ });
140
+ s.exports = s.exports.slice(0, MAX_EXPORT_HISTORY);
141
+ });
142
+
143
+ return { status: 'ok', dir: displayPath(dir), dirRaw: dir, format: fmt, withSecrets: includeSecrets, sections: sections.length, entries: entries.length, files: written };
144
+ }
145
+
146
+ export async function listExports() {
147
+ const store = await loadStore();
148
+ const history = await Promise.all(store.exports.map(async (h) => {
149
+ const files = await Promise.all((h.files || []).map(async (f) => {
150
+ const raw = typeof f === 'string' ? resolve(f) : f.pathRaw;
151
+ return { path: typeof f === 'string' ? f : f.path, bytes: await fileSize(raw), exists: await pathExists(raw) };
152
+ }));
153
+ return { ...h, files };
154
+ }));
155
+
156
+ // 扫「默认导出目录」+「历史里出现过的目录」:只扫默认目录的话,
157
+ // 用 --out 导出的文件会在列表里凭空消失,用户以为导出失败了。
158
+ const dirs = [...new Set([exportDir(), ...store.exports.map((h) => h.dirRaw).filter(Boolean)])];
159
+ const disk = [];
160
+ for (const d of dirs) {
161
+ let names = [];
162
+ try { names = (await fsp.readdir(d)).filter((n) => n.endsWith('.json') || n.endsWith('.md')).sort().reverse(); } catch { continue; }
163
+ for (const n of names) disk.push({ dir: displayPath(d), name: n });
164
+ }
165
+ return { count: history.length, history, dir: displayPath(exportDir()), dirs: dirs.map(displayPath), disk };
166
+ }
167
+
168
+ export function renderExport(d) {
169
+ if (d.status === 'skipped') {
170
+ const w = d.wouldWrite;
171
+ return `试运行(未写盘)\n目录: ${w.dir}\n文件: ${w.files.map((f) => `${f.name}(${f.bytes} 字节)`).join('、')}\n栏目 ${w.sections} · 条目 ${w.entries} · 明文密钥: ${w.withSecrets ? '是' : '否(打码)'}`;
172
+ }
173
+ const lines = [
174
+ `已导出 ${d.sections} 个栏目 / ${d.entries} 条条目 → ${d.dir}`,
175
+ `格式: ${d.format} 凭据: ${d.withSecrets ? '含明文' : '已打码'}`,
176
+ ];
177
+ for (const f of d.files) lines.push(` ${f.path} ${f.bytes} 字节`);
178
+ return lines.join('\n');
179
+ }
180
+
181
+ export function renderExportList(d) {
182
+ const lines = [`导出目录: ${d.dirs.join(' , ')} 历史 ${d.count} 条`];
183
+ for (const h of d.history) {
184
+ lines.push(` ${h.at} ${h.format} ${h.sections} 栏目 / ${h.entries} 条目 ${h.withSecrets ? '含明文' : '已打码'}`);
185
+ for (const f of h.files) lines.push(` ${f.path}${f.exists ? '' : '(已不在磁盘上)'}`);
186
+ }
187
+ if (d.disk.length) {
188
+ lines.push('', '目录内文件:');
189
+ for (const f of d.disk) lines.push(` ${f.dir}/${f.name}`);
190
+ }
191
+ return lines.join('\n');
192
+ }
@@ -0,0 +1,155 @@
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, Modal, Tag } from '../../web/frontend/components/ui.jsx';
5
+
6
+ export default function ExportView() {
7
+ const { boot, toast, guard } = useStore();
8
+ const [sections, setSections] = useState([]);
9
+ const [history, setHistory] = useState(null);
10
+ const [form, setForm] = useState({ format: 'both', section: '', out: '', withSecrets: false });
11
+ const [modal, setModal] = useState(null);
12
+
13
+ const load = useCallback(async () => {
14
+ try {
15
+ const [s, h] = await Promise.all([api('/api/sections'), api('/api/exports')]);
16
+ setSections(s.sections);
17
+ setHistory(h);
18
+ } catch (e) {
19
+ toast(e.message, 'bad');
20
+ }
21
+ }, [toast]);
22
+
23
+ useEffect(() => { load(); }, [load]);
24
+
25
+ useEffect(() => {
26
+ const s = boot?.settings;
27
+ if (!s) return;
28
+ setForm((f) => ({ ...f, format: s.exportFormat || 'both', withSecrets: !!s.includeSecretsInExport, out: s.exportDir || '' }));
29
+ }, [boot]);
30
+
31
+ const body = () => ({
32
+ format: form.format,
33
+ out: form.out.trim() || undefined,
34
+ section: form.section || undefined,
35
+ ...(form.withSecrets ? { 'with-secrets': true } : { 'no-secrets': true }),
36
+ });
37
+
38
+ const run = guard(async (dryRun) => {
39
+ const r = await api('/api/export', { method: 'POST', body: { ...body(), 'dry-run': !!dryRun } });
40
+ if (dryRun) {
41
+ setModal({
42
+ title: '试运行(没有写盘)',
43
+ body: <pre className="block">{`目录: ${r.wouldWrite.dir}\n栏目: ${r.wouldWrite.sections} 条目: ${r.wouldWrite.entries}\n凭据: ${r.wouldWrite.withSecrets ? '含明文' : '已打码'}\n\n${r.wouldWrite.files.map((f) => `${f.name} ${f.bytes} 字节`).join('\n')}`}</pre>,
44
+ });
45
+ return;
46
+ }
47
+ toast(`已导出到 ${r.dir}`);
48
+ setModal({
49
+ title: '导出完成',
50
+ body: (
51
+ <div className="stack">
52
+ <div>目录 <Copyable className="mono" text={r.dirRaw}>{r.dir}</Copyable></div>
53
+ <div className="muted">{r.sections} 个栏目 / {r.entries} 条条目 · 凭据:{r.withSecrets ? '含明文' : '已打码'}</div>
54
+ <pre className="block">{r.files.map((f) => `${f.path} ${f.bytes} 字节`).join('\n')}</pre>
55
+ </div>
56
+ ),
57
+ });
58
+ await load();
59
+ });
60
+
61
+ return (
62
+ <>
63
+ <div className="toolbar rowgap pad">
64
+ <strong>导出</strong>
65
+ <span className="muted">把栏目数据导成可带走、可备份的文件</span>
66
+ </div>
67
+
68
+ <div className="card">
69
+ <div className="colhead">导出选项</div>
70
+ <div className="pad">
71
+ <div className="grid">
72
+ <div className="field">
73
+ <div className="flabel"><span>格式</span></div>
74
+ <div className="fctrl">
75
+ <select value={form.format} onChange={(e) => setForm({ ...form, format: e.target.value })}>
76
+ <option value="both">JSON + Markdown</option>
77
+ <option value="json">仅 JSON</option>
78
+ <option value="md">仅 Markdown</option>
79
+ </select>
80
+ </div>
81
+ </div>
82
+ <div className="field">
83
+ <div className="flabel"><span>范围</span></div>
84
+ <div className="fctrl">
85
+ <select value={form.section} onChange={(e) => setForm({ ...form, section: e.target.value })}>
86
+ <option value="">全部栏目</option>
87
+ {sections.map((s) => <option key={s.id} value={s.id}>{s.title}({s.entries} 条)</option>)}
88
+ </select>
89
+ </div>
90
+ </div>
91
+ <div className="field">
92
+ <div className="flabel"><span>输出目录</span><span className="fkey">留空 = ~/nx-sk/export</span></div>
93
+ <div className="fctrl"><input value={form.out} onChange={(e) => setForm({ ...form, out: e.target.value })} placeholder="~/nx-sk/export" /></div>
94
+ </div>
95
+ <div className="field">
96
+ <div className="flabel"><span>凭据字段</span><span className="fkey">默认打码</span></div>
97
+ <div className="fctrl">
98
+ <label className="inline">
99
+ <input type="checkbox" checked={form.withSecrets} onChange={(e) => setForm({ ...form, withSecrets: e.target.checked })} />
100
+ <span className={form.withSecrets ? 'bad' : 'muted'}>
101
+ {form.withSecrets ? '导出明文密钥(导出文件将含真实凭据,注意存放位置)' : '只导出打码后的凭据'}
102
+ </span>
103
+ </label>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ <div className="rowgap" style={{ marginTop: 8 }}>
108
+ <button className="btn" onClick={() => run(false)}>开始导出</button>
109
+ <button className="btn ghost" onClick={() => run(true)}>试运行(不写盘)</button>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ <div className="card">
115
+ <div className="colhead">历次导出<span className="right muted">{history?.count ?? 0}</span></div>
116
+ <div className="colbody">
117
+ {history?.history?.length ? history.history.map((h, i) => (
118
+ <div key={`${h.at}-${i}`} className="row">
119
+ <span className="name">{h.at}</span>
120
+ <Tag>{h.format}</Tag>
121
+ {h.withSecrets ? <Tag kind="bad">含明文</Tag> : <Tag>已打码</Tag>}
122
+ <span className="desc">{h.dir}</span>
123
+ <span className="acts">
124
+ <span className="tag">{h.sections} 栏目</span>
125
+ <span className="tag">{h.entries} 条目</span>
126
+ </span>
127
+ </div>
128
+ )) : <Empty>还没有导出记录。</Empty>}
129
+ </div>
130
+ </div>
131
+
132
+ <div className="card">
133
+ <div className="colhead">导出目录里的文件<span className="right muted">{history?.disk?.length ?? 0}</span></div>
134
+ <div className="colbody">
135
+ {(history?.dirs || [history?.dir]).filter(Boolean).map((d) => (
136
+ <div key={d} className="row">
137
+ <span className="desc">目录</span>
138
+ <span className="acts"><Copyable className="mono" text={d}>{d}</Copyable></span>
139
+ </div>
140
+ ))}
141
+ {history?.disk?.length ? history.disk.map((f) => (
142
+ <div key={`${f.dir}/${f.name}`} className="row">
143
+ <span className="mono name">{f.name}</span>
144
+ <span className="desc mono nowrap">{f.dir}</span>
145
+ </div>
146
+ )) : <Empty>目录里还没有文件。</Empty>}
147
+ </div>
148
+ </div>
149
+
150
+ <CliHints module="export" />
151
+
152
+ {modal ? <Modal title={modal.title} onClose={() => setModal(null)}>{modal.body}</Modal> : null}
153
+ </>
154
+ );
155
+ }