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,109 @@
1
+ // 密钥解析:明文口令永不落盘,落盘的只有随机密钥文件 / 盐。
2
+ //
3
+ // 两种来源,优先级固定:
4
+ // 1. 环境变量 NX_SK_PASSPHRASE → scrypt(口令, ~/nx-sk/.vaultsalt)
5
+ // 2. ~/nx-sk/.vaultkey(32 字节随机密钥,首次使用时自动生成,尽量收紧权限)
6
+ //
7
+ // 同一份密文只能由**同一个**密钥解开。密钥指纹写进密文(core/crypto.js 的 kid),
8
+ // 所以换来源时会得到明确的 BLOCKED 报错,而不是静默解出一堆乱码。
9
+ import { randomBytes } from 'node:crypto';
10
+ import fsp from 'node:fs/promises';
11
+ import { dirname } from 'node:path';
12
+ import { ENV_PASSPHRASE, displayPath, vaultKeyFile, vaultSaltFile } from './paths.js';
13
+ import { chmodPrivate, ensureDir, pathExists } from './fsx.js';
14
+ import { KEY_BYTES, decryptValue, deriveKey, isCipherBlob, keyId, newKey } from './crypto.js';
15
+
16
+ export const SOURCE_ENV = 'env';
17
+ export const SOURCE_KEYFILE = 'keyfile';
18
+
19
+ async function loadOrCreateSalt() {
20
+ const p = vaultSaltFile();
21
+ if (await pathExists(p)) {
22
+ const raw = (await fsp.readFile(p, 'utf8')).trim();
23
+ if (raw) return raw;
24
+ }
25
+ const salt = randomBytes(16);
26
+ await ensureDir(dirname(p));
27
+ await fsp.writeFile(p, salt.toString('base64') + '\n', 'utf8');
28
+ await chmodPrivate(p);
29
+ return salt.toString('base64');
30
+ }
31
+
32
+ async function loadOrCreateKeyFile() {
33
+ const p = vaultKeyFile();
34
+ if (await pathExists(p)) {
35
+ const raw = (await fsp.readFile(p, 'utf8')).trim();
36
+ if (raw) {
37
+ const buf = Buffer.from(raw, 'base64');
38
+ if (buf.length >= KEY_BYTES) return { key: buf.subarray(0, KEY_BYTES), created: false, path: p };
39
+ }
40
+ }
41
+ const key = newKey();
42
+ await ensureDir(dirname(p));
43
+ await fsp.writeFile(p, key.toString('base64') + '\n', { encoding: 'utf8', mode: 0o600 });
44
+ await chmodPrivate(p);
45
+ return { key, created: true, path: p };
46
+ }
47
+
48
+ /** 解析当前生效的密钥。会**按需创建**本地密钥文件——所以第一次就能直接用。 */
49
+ export async function resolveVaultKey() {
50
+ const passphrase = process.env[ENV_PASSPHRASE];
51
+ if (passphrase) {
52
+ const salt = await loadOrCreateSalt();
53
+ const key = deriveKey(passphrase, salt);
54
+ return { key, source: SOURCE_ENV, saltFile: vaultSaltFile(), keyFile: null, created: false };
55
+ }
56
+ const { key, created, path } = await loadOrCreateKeyFile();
57
+ return { key, source: SOURCE_KEYFILE, saltFile: null, keyFile: path, created };
58
+ }
59
+
60
+ /** 只读状态查询:不创建任何文件,供 bootstrap / 面板展示。 */
61
+ export async function vaultStatus() {
62
+ const passphrase = !!process.env[ENV_PASSPHRASE];
63
+ if (passphrase) {
64
+ const salt = vaultSaltFile();
65
+ return {
66
+ ready: true,
67
+ source: SOURCE_ENV,
68
+ keyFile: null,
69
+ saltFile: displayPath(salt),
70
+ saltExists: await pathExists(salt),
71
+ note: '密钥由环境变量 NX_SK_PASSPHRASE 派生(口令不落盘)。',
72
+ };
73
+ }
74
+ const p = vaultKeyFile();
75
+ const exists = await pathExists(p);
76
+ return {
77
+ ready: true,
78
+ source: SOURCE_KEYFILE,
79
+ keyFile: displayPath(p),
80
+ saltFile: null,
81
+ saltExists: false,
82
+ note: exists
83
+ ? '密钥来自本机密钥文件;删掉它会让已存密文无法解密。'
84
+ : '本机密钥文件尚未生成,首次写入密文字段时自动创建。',
85
+ };
86
+ }
87
+
88
+ /** 当前指纹,用于排查「密钥换过没有」。 */
89
+ export async function currentKeyId() {
90
+ const { key } = await resolveVaultKey();
91
+ return keyId(key);
92
+ }
93
+
94
+ /**
95
+ * 造一个 `decrypt(blob) => 明文`,交给 core/render.js 做展示层解密。
96
+ *
97
+ * 为什么放在这里:render 是纯函数(可单测、无 IO),而「什么时候允许解密」
98
+ * 是安全决策——把它收成**一个**函数,就不会出现「某个出口忘了打码」。
99
+ * 只有当栏目里真有密文字段、且条目里真有密文时才去解析密钥(避免无谓地生成密钥文件)。
100
+ */
101
+ export async function sensitiveViewer(section, entries = []) {
102
+ const sensitive = (section?.fields || []).filter((f) => f.sensitive || f.type === 'secret');
103
+ if (!sensitive.length) return undefined;
104
+ const keys = sensitive.map((f) => f.key);
105
+ const hasBlob = entries.some((e) => keys.some((k) => isCipherBlob(e?.values?.[k])));
106
+ if (!hasBlob) return undefined;
107
+ const { key } = await resolveVaultKey();
108
+ return (blob) => decryptValue(key, blob);
109
+ }
package/src/index.js ADDED
@@ -0,0 +1,14 @@
1
+ // 库入口:导出各模块 service,供程序化调用(别的项目可以 `import { entries } from 'nx-sk'`)。
2
+ export * as system from './modules/system/service.js';
3
+ export * as settings from './modules/settings/service.js';
4
+ export * as sections from './modules/sections/service.js';
5
+ export * as entries from './modules/entries/service.js';
6
+ // `export` 是保留字,不能直接当绑定名,用别名导出
7
+ export * as exportApi from './modules/export/service.js';
8
+ export * as skill from './modules/skill/service.js';
9
+
10
+ export { CODES, AppError } from './core/errors.js';
11
+ export { APP_NAME, VERSION, appHome, storeFile } from './core/paths.js';
12
+ export { loadStore, saveStore, mutateStore, storePathInUse } from './core/store.js';
13
+ export { MODULES, ACTIONS, commandEntry } from './runtime/registry.js';
14
+ export { ALL_COMMANDS } from './runtime/cli.js';
@@ -0,0 +1,152 @@
1
+ import * as service from './service.js';
2
+
3
+ export default {
4
+ id: 'entries',
5
+ title: '条目',
6
+ order: 600,
7
+ // 有视图,但**不是独立 tab**——它由「栏目」动态挂载(每个栏目一个 tab,共用这个视图)
8
+ view: () => import('./view.jsx'),
9
+ tab: false,
10
+ // 一组同级实体,每条有标识,用户会想改其中一条或删其中一条 → 集合资源
11
+ resource: 'entry',
12
+ actions: [
13
+ {
14
+ id: 'entry.list',
15
+ cli: ['entry', 'list'],
16
+ http: ['GET', '/api/entries'],
17
+ summary: '列条目(可按栏目过滤、按关键字搜索);返回完整度与未填清单',
18
+ flags: {
19
+ // 读命令的过滤 flag 不带 default:「不传」代表「全部栏目」
20
+ section: { type: 'string', hint: '栏目 id 或标题' },
21
+ q: { type: 'string', hint: '关键字' },
22
+ },
23
+ run: (ctx) => service.listEntries(ctx),
24
+ render: (d) => service.renderEntryList(d),
25
+ },
26
+ {
27
+ id: 'entry.fields',
28
+ cli: ['entry', 'fields'],
29
+ http: ['GET', '/api/entries/fields'],
30
+ summary: '已知字段的台账:模板给的建议字段 + 你实际写过的字段(写入不受它限制,没见过的键会自动登记)',
31
+ flags: { section: { type: 'string' } },
32
+ run: (ctx) => service.fieldDictionary(ctx),
33
+ render: (d) => service.renderFields(d),
34
+ },
35
+ {
36
+ id: 'entry.get',
37
+ cli: ['entry', 'get'],
38
+ http: ['GET', '/api/entries/:ref'],
39
+ summary: '读单条(id 或条目名都接受);密文字段默认原文,--mask 才打码',
40
+ args: ['ref'],
41
+ flags: {
42
+ mask: { type: 'boolean', hint: '密文字段打码显示(投屏/截图时用)' },
43
+ section: { type: 'string', hint: '限定栏目,解决同名歧义' },
44
+ },
45
+ run: (ctx) => service.getEntry(ctx.ref, ctx),
46
+ render: (d) => service.renderEntryGet(d),
47
+ },
48
+ {
49
+ id: 'entry.add',
50
+ cli: ['entry', 'add'],
51
+ http: ['POST', '/api/entries'],
52
+ summary: '新增条目。同栏目同名报 CONFLICT(不静默 upsert);字段值用 --set 字段=值 或 --data @file.json,没见过的键会自动登记',
53
+ flags: {
54
+ section: { type: 'string', required: true, hint: '栏目 id 或标题' },
55
+ title: { type: 'string', hint: '条目名,缺省取栏目的名称字段' },
56
+ set: { type: 'kv', hint: '字段=值,可重复;键可用 key 或中文标签' },
57
+ data: { type: 'json', hint: '整份 JSON,或 @文件路径' },
58
+ tags: { type: 'array' },
59
+ 'dry-run': { type: 'boolean' },
60
+ },
61
+ run: (ctx) => service.addEntry(ctx),
62
+ render: (d) => service.renderEntryChange(d),
63
+ },
64
+ {
65
+ id: 'entry.update',
66
+ cli: ['entry', 'update'],
67
+ http: ['PATCH', '/api/entries/:ref'],
68
+ summary: '改条目(PATCH 语义:只改传入字段)。字典里没有的字段会自动登记,直接写就行',
69
+ args: ['ref'],
70
+ flags: {
71
+ set: { type: 'kv', hint: '字段=值,可重复;键可以用 key 或中文标签,没见过的键会自动登记' },
72
+ data: { type: 'json', hint: '整份 JSON,或 @文件路径' },
73
+ unset: { type: 'array', hint: '要清空的字段 key,逗号分隔' },
74
+ title: { type: 'string' },
75
+ tags: { type: 'array' },
76
+ section: { type: 'string' },
77
+ 'dry-run': { type: 'boolean' },
78
+ },
79
+ run: (ctx) => service.updateEntry(ctx.ref, ctx),
80
+ render: (d) => service.renderEntryChange(d),
81
+ },
82
+ {
83
+ id: 'entry.remove',
84
+ cli: ['entry', 'remove'],
85
+ http: ['DELETE', '/api/entries/:ref'],
86
+ summary: '删条目。目标不存在报 NOT_FOUND(不静默成功)。写前自动留快照',
87
+ args: ['ref'],
88
+ flags: {
89
+ section: { type: 'string' },
90
+ 'dry-run': { type: 'boolean' },
91
+ },
92
+ run: (ctx) => service.removeEntry(ctx.ref, ctx),
93
+ render: (d) => service.renderEntryChange(d),
94
+ },
95
+
96
+ // —— 密钥 KV 直通命令 ——
97
+ // 作用在**标了 kv 的栏目**(密钥模板自带 kv: true)上,可以用 --section 指定别的。
98
+ // 它们是同一套 service 函数的语法糖,不是第二份业务逻辑。
99
+ {
100
+ id: 'key.list',
101
+ cli: ['key', 'list'],
102
+ http: ['GET', '/api/keys'],
103
+ summary: '列出全部密钥(值默认原文,要打码加 --mask)',
104
+ flags: {
105
+ mask: { type: 'boolean' },
106
+ section: { type: 'string', hint: '指定 KV 栏目,缺省用标了 kv 的那个' },
107
+ },
108
+ run: (ctx) => service.keyList(ctx),
109
+ render: (d) => service.renderKeyList(d),
110
+ },
111
+ {
112
+ id: 'key.get',
113
+ cli: ['key', 'get'],
114
+ http: ['GET', '/api/keys/:name'],
115
+ summary: '取一个密钥的值(默认原文;投屏/截图时加 --mask 打码)',
116
+ args: ['name'],
117
+ flags: {
118
+ mask: { type: 'boolean' },
119
+ section: { type: 'string' },
120
+ },
121
+ run: (ctx) => service.keyGet(ctx.name, ctx),
122
+ render: (d) => service.renderKeyGet(d),
123
+ },
124
+ {
125
+ id: 'key.set',
126
+ cli: ['key', 'set'],
127
+ http: ['PUT', '/api/keys/:name'],
128
+ summary: '写入密钥:不存在则新建,存在则覆盖(KV 的 SET 语义,返回值里带 created)。值以密文落盘',
129
+ args: ['name', 'value'],
130
+ flags: {
131
+ value: { type: 'string', hint: '值的另一种给法(值以 - 开头时用 --value=<值>)' },
132
+ section: { type: 'string' },
133
+ 'dry-run': { type: 'boolean' },
134
+ },
135
+ run: (ctx) => service.keySet(ctx.name, ctx.value, ctx),
136
+ render: (d) => service.renderKeySet(d),
137
+ },
138
+ {
139
+ id: 'key.remove',
140
+ cli: ['key', 'remove'],
141
+ http: ['DELETE', '/api/keys/:name'],
142
+ summary: '删一个密钥。目标不存在报 NOT_FOUND;写前自动留快照',
143
+ args: ['name'],
144
+ flags: {
145
+ section: { type: 'string' },
146
+ 'dry-run': { type: 'boolean' },
147
+ },
148
+ run: (ctx) => service.keyRemove(ctx.name, ctx),
149
+ render: (d) => service.renderKeyRemove(d),
150
+ },
151
+ ],
152
+ };
@@ -0,0 +1,158 @@
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 } from '../../web/frontend/components/ui.jsx';
5
+
6
+ /**
7
+ * KV 表格:`kv: true` 栏目的**唯一**编辑界面。
8
+ *
9
+ * 一行 = 一个键值对(名称 → 值)。**没有**条目列表、没有字段表单、没有完整性百分比 ——
10
+ * 密钥就是一张表,别的一概不需要。
11
+ *
12
+ * 读写走 `key *` 那套接口(它本来就是 entry 命令的语法糖,共用同一份 service),
13
+ * 所以这里每个操作都有等价的 CLI 命令,且不可能和 CLI 分叉。
14
+ */
15
+ export default function KvTable({ sectionId, sectionTitle }) {
16
+ const { ui, patchUi, toast, guard, dialog, reload } = useStore();
17
+ const [data, setData] = useState(null);
18
+ const [draft, setDraft] = useState({}); // 名称 → 正在编辑的值
19
+ const [added, setAdded] = useState({ name: '', value: '' });
20
+ const mask = !!ui.mask;
21
+
22
+ const load = useCallback(async () => {
23
+ try {
24
+ const q = `section=${encodeURIComponent(sectionId)}${mask ? '&mask=1' : ''}`;
25
+ const d = await api(`/api/keys?${q}`);
26
+ setData(d);
27
+ const next = {};
28
+ for (const k of d.keys) next[k.name] = k.value ?? '';
29
+ setDraft(next);
30
+ } catch (e) {
31
+ toast(e.message, 'bad');
32
+ setData(null);
33
+ }
34
+ }, [sectionId, mask, toast]);
35
+
36
+ useEffect(() => { load(); }, [load]);
37
+
38
+ const saveValue = guard(async (row) => {
39
+ const next = draft[row.name];
40
+ if ((row.value ?? '') === next) return; // 没改就不发请求
41
+ // 写操作的参数必须放 body —— 后端对非 GET 只合并 body(与 ?query= 无关)
42
+ await api(`/api/keys/${encodeURIComponent(row.name)}`, {
43
+ method: 'PUT', body: { value: next, section: sectionId },
44
+ });
45
+ toast(`已保存 ${row.name}`);
46
+ await load();
47
+ });
48
+
49
+ const rename = guard(async (row, newName) => {
50
+ const name = String(newName || '').trim();
51
+ if (!name || name === row.name) { setDraft((d) => ({ ...d, [row.name]: d[row.name] })); return; }
52
+ if (data.keys.some((k) => k.name === name)) { toast(`已经有叫「${name}」的键了`, 'bad'); await load(); return; }
53
+ await api(`/api/entries/${encodeURIComponent(row.id)}`, { method: 'PATCH', body: { title: name } });
54
+ toast(`已重命名为 ${name}`);
55
+ await load();
56
+ await reload();
57
+ });
58
+
59
+ const addKey = guard(async () => {
60
+ const name = added.name.trim();
61
+ if (!name) { toast('先填键名,例如 OPENAI_KEY', 'bad'); return; }
62
+ if (data.keys.some((k) => k.name === name)) { toast(`已经有叫「${name}」的键了——直接在上面那行改值即可`, 'bad'); return; }
63
+ await api(`/api/keys/${encodeURIComponent(name)}`, {
64
+ method: 'PUT', body: { value: added.value, section: sectionId },
65
+ });
66
+ setAdded({ name: '', value: '' });
67
+ toast(`已新增 ${name}`);
68
+ await load();
69
+ await reload();
70
+ });
71
+
72
+ const removeKey = guard(async (row) => {
73
+ const ok = await dialog({
74
+ message: `删除「${row.name}」?删除前会自动留一份整库快照在 ~/nx-sk/backup。`,
75
+ danger: true, confirmText: '删除',
76
+ });
77
+ if (!ok) return;
78
+ await api(`/api/keys/${encodeURIComponent(row.name)}`, { method: 'DELETE', body: { section: sectionId } });
79
+ toast(`已删除 ${row.name}`);
80
+ await load();
81
+ await reload();
82
+ });
83
+
84
+ if (!data) return <div className="pad muted">加载中…</div>;
85
+
86
+ return (
87
+ <>
88
+ <div className="toolbar rowgap pad">
89
+ <strong>{sectionTitle}</strong>
90
+ <span className="muted">{data.count} 个键 · 名称 → 值</span>
91
+ <span className="grow" />
92
+ <label className="inline hint">
93
+ <input type="checkbox" checked={mask} onChange={() => patchUi({ mask: !mask })} />
94
+ 打码显示(投屏/截图时用)
95
+ </label>
96
+ <button className="btn small ghost" onClick={load}>刷新</button>
97
+ </div>
98
+
99
+ <div className="card">
100
+ <div className="colhead">
101
+ <span style={{ width: 200 }}>名称</span>
102
+ <span className="grow">值</span>
103
+ </div>
104
+ <div className="colbody">
105
+ {data.keys.length ? data.keys.map((row) => (
106
+ <div key={row.id} className="row">
107
+ <input
108
+ className="mono"
109
+ style={{ width: 200 }}
110
+ defaultValue={row.name}
111
+ title="改完按回车或点开别处即重命名"
112
+ onBlur={(e) => rename(row, e.target.value)}
113
+ onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }}
114
+ />
115
+ <input
116
+ className="mono grow"
117
+ value={draft[row.name] ?? ''}
118
+ onChange={(e) => setDraft((d) => ({ ...d, [row.name]: e.target.value }))}
119
+ onBlur={() => saveValue(row)}
120
+ onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }}
121
+ />
122
+ <span className="acts">
123
+ <Copyable className="tag" text={draft[row.name] ?? ''} title="点击复制值">复制</Copyable>
124
+ <button className="btn small ghost" onClick={() => removeKey(row)}>删除</button>
125
+ </span>
126
+ </div>
127
+ )) : <Empty>还没有密钥。在下面一行填「名称 / 值」按添加,或 CLI:nx-sk key set OPENAI_KEY sk-xxx</Empty>}
128
+
129
+ <div className="row">
130
+ <input
131
+ className="mono"
132
+ style={{ width: 200 }}
133
+ placeholder="新键名,例如 OPENAI_KEY"
134
+ value={added.name}
135
+ onChange={(e) => setAdded((a) => ({ ...a, name: e.target.value }))}
136
+ onKeyDown={(e) => { if (e.key === 'Enter') addKey(); }}
137
+ />
138
+ <input
139
+ className="mono grow"
140
+ placeholder="值(留空也行,之后再填)"
141
+ value={added.value}
142
+ onChange={(e) => setAdded((a) => ({ ...a, value: e.target.value }))}
143
+ onKeyDown={(e) => { if (e.key === 'Enter') addKey(); }}
144
+ />
145
+ <span className="acts"><button className="btn small" onClick={addKey}>添加</button></span>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <div className="pad hint">
151
+ 改值:直接在「值」列改完按回车(或点开别处)即保存。改键名同理。
152
+ 值以密文落盘,这里显示的是原文;要打码勾上方那个框。删除前自动整库快照。
153
+ </div>
154
+
155
+ <CliHints module="entries" />
156
+ </>
157
+ );
158
+ }