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.
- package/CHANGELOG.md +133 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/assets/nx-sk/SKILL.md +102 -0
- package/assets/nx-sk/references/00-design.md +116 -0
- package/assets/nx-sk/references/10-sections-entries.md +165 -0
- package/assets/nx-sk/references/20-secrets.md +170 -0
- package/assets/nx-sk/references/30-export-backup.md +80 -0
- package/assets/nx-sk/references/40-extend.md +75 -0
- package/bin/nx-sk.mjs +11 -0
- package/package.json +56 -0
- package/src/core/crypto.js +73 -0
- package/src/core/errors.js +52 -0
- package/src/core/fields.js +345 -0
- package/src/core/fsx.js +103 -0
- package/src/core/ids.js +19 -0
- package/src/core/open.js +32 -0
- package/src/core/paths.js +141 -0
- package/src/core/render.js +144 -0
- package/src/core/store.js +317 -0
- package/src/core/vault.js +109 -0
- package/src/index.js +14 -0
- package/src/modules/entries/index.js +152 -0
- package/src/modules/entries/kvTable.jsx +158 -0
- package/src/modules/entries/service.js +539 -0
- package/src/modules/entries/view.jsx +240 -0
- package/src/modules/export/index.js +39 -0
- package/src/modules/export/service.js +192 -0
- package/src/modules/export/view.jsx +155 -0
- package/src/modules/sections/index.js +104 -0
- package/src/modules/sections/service.js +337 -0
- package/src/modules/sections/view.jsx +311 -0
- package/src/modules/settings/index.js +34 -0
- package/src/modules/settings/service.js +78 -0
- package/src/modules/settings/view.jsx +193 -0
- package/src/modules/skill/index.js +51 -0
- package/src/modules/skill/service.js +200 -0
- package/src/modules/system/index.js +42 -0
- package/src/modules/system/service.js +155 -0
- package/src/runtime/api.js +101 -0
- package/src/runtime/cli.js +266 -0
- package/src/runtime/registry.js +65 -0
- package/src/runtime/server.js +80 -0
- package/src/runtime/spec.js +158 -0
- package/src/web/frontend/App.jsx +108 -0
- package/src/web/frontend/api/client.js +26 -0
- package/src/web/frontend/components/fieldEditor.jsx +85 -0
- package/src/web/frontend/components/ui.jsx +143 -0
- package/src/web/frontend/index.html +12 -0
- package/src/web/frontend/main.jsx +13 -0
- package/src/web/frontend/registry.js +20 -0
- package/src/web/frontend/store.jsx +87 -0
- package/src/web/frontend/styles.css +301 -0
- package/src/web/public/assets/index-BWGLG7Qa.js +40 -0
- package/src/web/public/assets/index-CwF5hM2v.css +1 -0
- package/src/web/public/assets/view-B6gPh1NQ.js +1 -0
- package/src/web/public/assets/view-CfXXYwW2.js +1 -0
- package/src/web/public/assets/view-CxzKrdA3.js +7 -0
- package/src/web/public/assets/view-DjO1C54o.js +7 -0
- package/src/web/public/index.html +13 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as service from './service.js';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
id: 'sections',
|
|
5
|
+
title: '栏目',
|
|
6
|
+
order: 500,
|
|
7
|
+
view: () => import('./view.jsx'),
|
|
8
|
+
// 一组同级实体,每条有标识,用户会想改其中一条或删其中一条 → 集合资源,CRUD 五操作齐备
|
|
9
|
+
resource: 'section',
|
|
10
|
+
actions: [
|
|
11
|
+
{
|
|
12
|
+
id: 'section.list',
|
|
13
|
+
cli: ['section', 'list'],
|
|
14
|
+
http: ['GET', '/api/sections'],
|
|
15
|
+
summary: '列出全部栏目(含字段数与条目数)',
|
|
16
|
+
flags: {},
|
|
17
|
+
run: () => service.listSections(),
|
|
18
|
+
render: (d) => service.renderSectionList(d),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'section.templates',
|
|
22
|
+
cli: ['section', 'templates'],
|
|
23
|
+
http: ['GET', '/api/sections/templates'],
|
|
24
|
+
summary: '列出内置栏目模板(求职 / 密钥)',
|
|
25
|
+
flags: {},
|
|
26
|
+
run: () => service.listTemplates(),
|
|
27
|
+
render: (d) => service.renderTemplates(d),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'section.get',
|
|
31
|
+
cli: ['section', 'get'],
|
|
32
|
+
http: ['GET', '/api/sections/:ref'],
|
|
33
|
+
summary: '读单个栏目(id 或标题都接受),含完整字段字典',
|
|
34
|
+
args: ['ref'],
|
|
35
|
+
flags: {},
|
|
36
|
+
run: (ctx) => service.getSection(ctx.ref),
|
|
37
|
+
render: (d) => service.renderSectionGet(d),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'section.dump',
|
|
41
|
+
cli: ['section', 'dump'],
|
|
42
|
+
http: ['GET', '/api/sections/:ref/dump'],
|
|
43
|
+
summary: '一个栏目的全部信息:字段台账 + 所有条目 + 每个条目的未填清单',
|
|
44
|
+
args: ['ref'],
|
|
45
|
+
flags: { mask: { type: 'boolean', hint: '密文字段打码显示(投屏/截图时用)' } },
|
|
46
|
+
run: (ctx) => service.dump(ctx.ref, ctx),
|
|
47
|
+
render: (d) => service.renderSectionDump(d),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'section.add',
|
|
51
|
+
cli: ['section', 'add'],
|
|
52
|
+
http: ['POST', '/api/sections'],
|
|
53
|
+
summary: '新建栏目;--template 从内置模板起步(求职 / 密钥)。重复 id 报 CONFLICT',
|
|
54
|
+
args: ['id'],
|
|
55
|
+
flags: {
|
|
56
|
+
title: { type: 'string', hint: '栏目标题' },
|
|
57
|
+
template: { type: 'string', hint: 'job|secret' },
|
|
58
|
+
description: { type: 'string' },
|
|
59
|
+
order: { type: 'number', default: 50 },
|
|
60
|
+
fields: { type: 'json', hint: '字段定义数组的 JSON' },
|
|
61
|
+
groups: { type: 'json', hint: '分组定义数组的 JSON' },
|
|
62
|
+
'dry-run': { type: 'boolean' },
|
|
63
|
+
},
|
|
64
|
+
run: (ctx) => service.addSection(ctx),
|
|
65
|
+
render: (d) => service.renderSectionChange(d),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'section.update',
|
|
69
|
+
cli: ['section', 'update'],
|
|
70
|
+
http: ['PATCH', '/api/sections/:ref'],
|
|
71
|
+
summary: '改栏目(PATCH 语义:只改传入项)。字段台账可用 add-field / remove-field / fields / fill;--kv 标成 KV 表',
|
|
72
|
+
args: ['ref'],
|
|
73
|
+
flags: {
|
|
74
|
+
title: { type: 'string' },
|
|
75
|
+
description: { type: 'string' },
|
|
76
|
+
order: { type: 'number' },
|
|
77
|
+
titleField: { type: 'string', hint: '用哪个字段做条目名' },
|
|
78
|
+
titleLabel: { type: 'string' },
|
|
79
|
+
kv: { type: 'boolean', hint: '标成 KV 表(密钥那种);取消写 --kv=false' },
|
|
80
|
+
fields: { type: 'json', hint: '整体替换字段字典' },
|
|
81
|
+
groups: { type: 'json', hint: '整体替换分组' },
|
|
82
|
+
'add-field': { type: 'json', hint: '{"key":"x","label":"X","type":"text"},可重复' },
|
|
83
|
+
'remove-field': { type: 'array', hint: '字段 key,可重复传(一次删一个)' },
|
|
84
|
+
fill: { type: 'kv', hint: '字段=normal|optional|avoid,可重复。optional 不必填、avoid 填了可能减分,两者都不计入完整度;改回照常填传 normal' },
|
|
85
|
+
'dry-run': { type: 'boolean' },
|
|
86
|
+
},
|
|
87
|
+
run: (ctx) => service.updateSection(ctx.ref, ctx),
|
|
88
|
+
render: (d) => service.renderSectionChange(d),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'section.remove',
|
|
92
|
+
cli: ['section', 'remove'],
|
|
93
|
+
http: ['DELETE', '/api/sections/:ref'],
|
|
94
|
+
summary: '删栏目(连带其条目)。栏目非空时必须加 --force。目标不存在时报 NOT_FOUND,不静默成功',
|
|
95
|
+
args: ['ref'],
|
|
96
|
+
flags: {
|
|
97
|
+
force: { type: 'boolean', hint: '确认连带删除栏目下的条目' },
|
|
98
|
+
'dry-run': { type: 'boolean' },
|
|
99
|
+
},
|
|
100
|
+
run: (ctx) => service.removeSection(ctx.ref, ctx),
|
|
101
|
+
render: (d) => service.renderSectionChange(d),
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// 栏目(section):一组同构条目的容器 + 一份字段字典。
|
|
2
|
+
// 字典是**数据**不是代码分支,所以 agent 与面板看到的字段永远一致。
|
|
3
|
+
import { loadStore, mutateStore, snapshotStore } from '../../core/store.js';
|
|
4
|
+
import { assertFieldKey, assertSafeId } from '../../core/paths.js';
|
|
5
|
+
import { badInput, blocked, conflict, notFound } from '../../core/errors.js';
|
|
6
|
+
import { FILL_POLICIES, FIELD_TYPES, findSection, groupFields, instantiateTemplate, sectionNames, templateSummaries } from '../../core/fields.js';
|
|
7
|
+
import { dumpSection, formatValue } from '../../core/render.js';
|
|
8
|
+
import { sensitiveViewer } from '../../core/vault.js';
|
|
9
|
+
import { nowIso } from '../../core/ids.js';
|
|
10
|
+
|
|
11
|
+
// 定位方式放宽:内部 id 与用户手上的标题都接受(实现在 core/fields.js,避免模块互依)。
|
|
12
|
+
export { findSection };
|
|
13
|
+
|
|
14
|
+
function mustFind(store, ref) {
|
|
15
|
+
const s = findSection(store, ref);
|
|
16
|
+
if (!s) throw notFound(`找不到栏目: ${ref} —— 现有: ${sectionNames(store)}`);
|
|
17
|
+
return s;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function countEntries(store, id) {
|
|
21
|
+
return store.entries.filter((e) => e.section === id).length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function summarize(store, s) {
|
|
25
|
+
return {
|
|
26
|
+
id: s.id,
|
|
27
|
+
title: s.title,
|
|
28
|
+
description: s.description,
|
|
29
|
+
order: s.order,
|
|
30
|
+
template: s.template,
|
|
31
|
+
kv: s.kv === true,
|
|
32
|
+
titleLabel: s.titleLabel,
|
|
33
|
+
groups: s.groups.length,
|
|
34
|
+
fields: s.fields.length,
|
|
35
|
+
entries: countEntries(store, s.id),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateFieldDef(raw, { allowGroup } = {}) {
|
|
40
|
+
if (!raw || typeof raw !== 'object') throw badInput('字段定义必须是对象');
|
|
41
|
+
const key = assertFieldKey(raw.key);
|
|
42
|
+
const type = raw.type || 'text';
|
|
43
|
+
if (!FIELD_TYPES.includes(type)) throw badInput(`字段 ${key} 的 type 非法: ${type}(可用: ${FIELD_TYPES.join(' / ')})`);
|
|
44
|
+
const def = {
|
|
45
|
+
key,
|
|
46
|
+
label: String(raw.label || key),
|
|
47
|
+
type,
|
|
48
|
+
group: String(raw.group || allowGroup || 'other'),
|
|
49
|
+
};
|
|
50
|
+
if (raw.hint) def.hint = String(raw.hint);
|
|
51
|
+
if (Array.isArray(raw.options) && raw.options.length) def.options = raw.options.map(String);
|
|
52
|
+
if (raw.sensitive === true) def.sensitive = true;
|
|
53
|
+
if (Number.isFinite(raw.maxItems)) def.maxItems = raw.maxItems;
|
|
54
|
+
// 填写策略:**必须显式放行**。这个函数是白名单,没写在这里的键会被静默丢掉,
|
|
55
|
+
// 而「静默丢掉」在这里的后果是「用户设了不填、完整度照旧算它」——很难查。
|
|
56
|
+
if (raw.fill !== undefined) {
|
|
57
|
+
if (!FILL_POLICIES.includes(raw.fill)) {
|
|
58
|
+
throw badInput(`字段 ${key} 的 fill 非法: ${JSON.stringify(raw.fill)}(可用: ${FILL_POLICIES.join(' / ')})`);
|
|
59
|
+
}
|
|
60
|
+
// 只有非 normal 才落盘:normal 是缺省值,写上去只是噪音,也会让「读」多一种形态
|
|
61
|
+
if (raw.fill !== 'normal') def.fill = raw.fill;
|
|
62
|
+
}
|
|
63
|
+
return def;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateGroupDef(raw, idx) {
|
|
67
|
+
if (!raw || typeof raw !== 'object') throw badInput('分组定义必须是对象');
|
|
68
|
+
const id = assertSafeId(raw.id || `g${idx + 1}`, '分组 id');
|
|
69
|
+
return { id, title: String(raw.title || id) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function listSections() {
|
|
73
|
+
const store = await loadStore();
|
|
74
|
+
return {
|
|
75
|
+
count: store.sections.length,
|
|
76
|
+
totalEntries: store.entries.length,
|
|
77
|
+
sections: store.sections.map((s) => summarize(store, s)),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function listTemplates() {
|
|
82
|
+
return { count: templateSummaries().length, templates: templateSummaries() };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getSection(ref) {
|
|
86
|
+
const store = await loadStore();
|
|
87
|
+
const s = mustFind(store, ref);
|
|
88
|
+
return {
|
|
89
|
+
...summarize(store, s),
|
|
90
|
+
titleField: s.titleField,
|
|
91
|
+
createdAt: s.createdAt,
|
|
92
|
+
updatedAt: s.updatedAt,
|
|
93
|
+
groupList: s.groups,
|
|
94
|
+
fieldList: s.fields,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function addSection({ id, title, template, description, order, fields, groups, 'dry-run': dryRun } = {}) {
|
|
99
|
+
const sid = assertSafeId(id, '栏目 id');
|
|
100
|
+
const store = await loadStore();
|
|
101
|
+
if (findSection(store, sid)) throw conflict(`栏目已存在: ${sid}(重复创建不会静默覆盖——换个 id,或用 section update)`);
|
|
102
|
+
|
|
103
|
+
let draft;
|
|
104
|
+
if (template) {
|
|
105
|
+
const t = instantiateTemplate(template, { id: sid, title, description, order });
|
|
106
|
+
if (!t) {
|
|
107
|
+
const avail = templateSummaries().map((x) => x.id).join(' / ');
|
|
108
|
+
throw badInput(`未知模板: ${template}(可用: ${avail})`);
|
|
109
|
+
}
|
|
110
|
+
draft = t;
|
|
111
|
+
} else {
|
|
112
|
+
draft = { id: sid, title: title || sid, description: description || '', order: Number.isFinite(order) ? order : 50, template: null, kv: false, titleField: null, titleLabel: '名称', groups: [], fields: [] };
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(groups)) draft.groups = groups.map(validateGroupDef);
|
|
115
|
+
if (Array.isArray(fields)) {
|
|
116
|
+
const defaultGroup = draft.groups[0]?.id || 'other';
|
|
117
|
+
draft.fields = fields.map((x) => validateFieldDef(x, { allowGroup: defaultGroup }));
|
|
118
|
+
}
|
|
119
|
+
draft.createdAt = nowIso();
|
|
120
|
+
draft.updatedAt = draft.createdAt;
|
|
121
|
+
|
|
122
|
+
if (dryRun) return { status: 'skipped', dryRun: true, wouldCreate: summarize(store, draft) };
|
|
123
|
+
|
|
124
|
+
const snapshot = await snapshotStore('section-add');
|
|
125
|
+
await mutateStore((s) => { s.sections.push(draft); s.sections.sort((a, b) => a.order - b.order); });
|
|
126
|
+
return { status: 'ok', created: true, section: summarize(store, draft), snapshot };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function updateSection(ref, patch = {}) {
|
|
130
|
+
const dryRun = patch['dry-run'];
|
|
131
|
+
const store = await loadStore();
|
|
132
|
+
const target = mustFind(store, ref);
|
|
133
|
+
|
|
134
|
+
const changes = {};
|
|
135
|
+
if (patch.title !== undefined) changes.title = String(patch.title);
|
|
136
|
+
if (patch.description !== undefined) changes.description = String(patch.description);
|
|
137
|
+
if (patch.order !== undefined) changes.order = Number(patch.order);
|
|
138
|
+
if (patch.titleLabel !== undefined) changes.titleLabel = String(patch.titleLabel);
|
|
139
|
+
// 标记/取消「本栏目是一张 KV 表」。取消要显式写 `--kv=false`(boolean flag 的 `=` 形式)
|
|
140
|
+
if (patch.kv !== undefined) changes.kv = patch.kv === true;
|
|
141
|
+
if (patch.titleField !== undefined) changes.titleField = patch.titleField ? assertFieldKey(patch.titleField) : null;
|
|
142
|
+
if (patch.groups !== undefined) changes.groups = Array.isArray(patch.groups) ? patch.groups.map(validateGroupDef) : [];
|
|
143
|
+
if (patch.fields !== undefined) {
|
|
144
|
+
if (!Array.isArray(patch.fields)) throw badInput('--fields 需要是字段定义数组的 JSON');
|
|
145
|
+
const fallback = (changes.groups || target.groups)[0]?.id || 'other';
|
|
146
|
+
changes.fields = patch.fields.map((x) => validateFieldDef(x, { allowGroup: fallback }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const addField = patch['add-field'];
|
|
150
|
+
const removeField = patch['remove-field'];
|
|
151
|
+
const nextFields = [...(changes.fields || target.fields)];
|
|
152
|
+
const added = [];
|
|
153
|
+
const removed = [];
|
|
154
|
+
if (addField !== undefined) {
|
|
155
|
+
const list = Array.isArray(addField) ? addField : [addField];
|
|
156
|
+
for (const raw of list) {
|
|
157
|
+
const def = validateFieldDef(raw, { allowGroup: nextFields[0]?.group || 'other' });
|
|
158
|
+
if (nextFields.some((x) => x.key === def.key)) throw conflict(`字段已存在: ${def.key}`);
|
|
159
|
+
nextFields.push(def);
|
|
160
|
+
added.push(def.key);
|
|
161
|
+
}
|
|
162
|
+
changes.fields = nextFields;
|
|
163
|
+
}
|
|
164
|
+
if (removeField !== undefined) {
|
|
165
|
+
const keys = Array.isArray(removeField) ? removeField : [removeField];
|
|
166
|
+
for (const k of keys) {
|
|
167
|
+
const idx = nextFields.findIndex((x) => x.key === k || x.label === k);
|
|
168
|
+
if (idx < 0) throw notFound(`字段不存在: ${k}`);
|
|
169
|
+
removed.push(nextFields[idx].key);
|
|
170
|
+
nextFields.splice(idx, 1);
|
|
171
|
+
}
|
|
172
|
+
changes.fields = nextFields;
|
|
173
|
+
}
|
|
174
|
+
// —— 填写策略:--fill 字段=normal|optional|avoid ——
|
|
175
|
+
// 放在 add/remove 之后,这样同一次调用里新加的字段也能顺手设策略。
|
|
176
|
+
// 注意必须**复制**再改:nextFields 里是与 store 共享同一批对象引用的,
|
|
177
|
+
// 就地改 def.fill 会在 snapshotStore() **之前**污染真实数据,快照就不再是「改动前」的了。
|
|
178
|
+
const fillMap = patch.fill;
|
|
179
|
+
const fillChanged = [];
|
|
180
|
+
if (fillMap !== undefined && fillMap !== null) {
|
|
181
|
+
if (typeof fillMap !== 'object' || Array.isArray(fillMap)) {
|
|
182
|
+
throw badInput('--fill 要用 字段=策略 的写法,例如 --fill 入党时间=avoid');
|
|
183
|
+
}
|
|
184
|
+
for (const [ref0, policyRaw] of Object.entries(fillMap)) {
|
|
185
|
+
const policy = String(policyRaw);
|
|
186
|
+
if (!FILL_POLICIES.includes(policy)) {
|
|
187
|
+
throw badInput(`--fill ${ref0} 的策略非法: ${JSON.stringify(policyRaw)}(可用: ${FILL_POLICIES.join(' / ')})`);
|
|
188
|
+
}
|
|
189
|
+
const idx = nextFields.findIndex((x) => x.key === ref0 || x.label === ref0);
|
|
190
|
+
if (idx < 0) {
|
|
191
|
+
throw notFound(`字段不存在: ${ref0}(用 'nx-sk entry fields --section ${target.id}' 看现有字段)`);
|
|
192
|
+
}
|
|
193
|
+
const def = { ...nextFields[idx] };
|
|
194
|
+
if (policy === 'normal') {
|
|
195
|
+
if (def.fill === undefined) continue; // 已是缺省,不制造无意义的改动
|
|
196
|
+
delete def.fill;
|
|
197
|
+
} else {
|
|
198
|
+
if (def.fill === policy) continue;
|
|
199
|
+
def.fill = policy;
|
|
200
|
+
}
|
|
201
|
+
nextFields[idx] = def;
|
|
202
|
+
fillChanged.push(`${def.key}=${policy}`);
|
|
203
|
+
changes.fields = nextFields;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!Object.keys(changes).length) {
|
|
208
|
+
throw badInput('没有要修改的内容(可改 title / description / order / fields / groups / add-field / remove-field / fill)');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (dryRun) return { status: 'skipped', dryRun: true, wouldChange: changes, added, removed, fillChanged };
|
|
212
|
+
|
|
213
|
+
const snapshot = await snapshotStore('section-update');
|
|
214
|
+
await mutateStore((s) => {
|
|
215
|
+
const t = mustFind(s, ref);
|
|
216
|
+
Object.assign(t, changes);
|
|
217
|
+
t.updatedAt = nowIso();
|
|
218
|
+
s.sections.sort((a, b) => a.order - b.order);
|
|
219
|
+
});
|
|
220
|
+
const after = await loadStore();
|
|
221
|
+
return { status: 'ok', id: target.id, changed: Object.keys(changes), added, removed, fillChanged, section: summarize(after, mustFind(after, target.id)), snapshot };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* remove 的幂等性选择:**报 NOT_FOUND**(默认推荐)——能从错别字里救用户一把。
|
|
226
|
+
* 这条写进 summary,两端一致。
|
|
227
|
+
*/
|
|
228
|
+
export async function removeSection(ref, { force, 'dry-run': dryRun } = {}) {
|
|
229
|
+
const store = await loadStore();
|
|
230
|
+
const target = mustFind(store, ref);
|
|
231
|
+
const owned = store.entries.filter((e) => e.section === target.id);
|
|
232
|
+
|
|
233
|
+
if (owned.length && !force) {
|
|
234
|
+
throw blocked(`栏目「${target.title}」下还有 ${owned.length} 条条目,删除会一并删掉。确认就加 --force。`);
|
|
235
|
+
}
|
|
236
|
+
if (dryRun) {
|
|
237
|
+
return { status: 'skipped', dryRun: true, wouldRemove: { id: target.id, title: target.title, entries: owned.length } };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const snapshot = await snapshotStore(`section-remove-${target.id}`);
|
|
241
|
+
await mutateStore((s) => {
|
|
242
|
+
s.sections = s.sections.filter((x) => x.id !== target.id);
|
|
243
|
+
s.entries = s.entries.filter((e) => e.section !== target.id);
|
|
244
|
+
});
|
|
245
|
+
return { status: 'ok', removed: { id: target.id, title: target.title, entries: owned.length }, snapshot };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 一个栏目的**全部信息**:字段字典 + 所有条目 + 未填清单。 */
|
|
249
|
+
export async function dump(ref, { mask } = {}) {
|
|
250
|
+
const store = await loadStore();
|
|
251
|
+
const s = mustFind(store, ref);
|
|
252
|
+
const mine = store.entries.filter((e) => e.section === s.id);
|
|
253
|
+
const decrypt = await sensitiveViewer(s, mine);
|
|
254
|
+
const data = dumpSection(s, store.entries, { mask: !!mask, decrypt });
|
|
255
|
+
return {
|
|
256
|
+
...data,
|
|
257
|
+
generatedAt: nowIso(),
|
|
258
|
+
mask: !!mask,
|
|
259
|
+
groupIndex: groupFields(s.fields).map((g) => ({
|
|
260
|
+
id: g.id,
|
|
261
|
+
title: s.groups.find((x) => x.id === g.id)?.title || g.id,
|
|
262
|
+
fields: g.fields.map((x) => x.key),
|
|
263
|
+
})),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// —— CLI 人读渲染 ——
|
|
268
|
+
export function renderSectionList(d) {
|
|
269
|
+
const lines = [`${d.count} 个栏目 · 共 ${d.totalEntries} 条条目`];
|
|
270
|
+
for (const s of d.sections) {
|
|
271
|
+
const tag = s.kv ? '[KV 表] ' : '';
|
|
272
|
+
lines.push(` ${s.id.padEnd(12)} ${s.title} ${tag}${s.entries} 条 / ${s.fields} 字段 ${s.description}`);
|
|
273
|
+
}
|
|
274
|
+
lines.push('', '看某栏目全部信息: nx-sk section dump <id>');
|
|
275
|
+
return lines.join('\n');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function renderTemplates(d) {
|
|
279
|
+
const lines = ['可用栏目模板:'];
|
|
280
|
+
for (const t of d.templates) lines.push(` ${t.id.padEnd(10)} ${t.label} ${t.groups} 组 / ${t.fields} 字段 ${t.description}`);
|
|
281
|
+
lines.push('', '用法: nx-sk section add <id> --title <标题> --template <模板id>');
|
|
282
|
+
return lines.join('\n');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function renderSectionGet(d) {
|
|
286
|
+
const lines = [
|
|
287
|
+
`栏目 ${d.id} · ${d.title}`,
|
|
288
|
+
`描述: ${d.description}`,
|
|
289
|
+
`字段: ${d.fields} 个 / ${d.groups} 组 · 条目 ${d.entries} 条`,
|
|
290
|
+
`名称字段: ${d.titleField || '(未设)'}`,
|
|
291
|
+
];
|
|
292
|
+
for (const g of groupFields(d.fieldList)) {
|
|
293
|
+
const gtitle = d.groupList.find((x) => x.id === g.id)?.title || g.id;
|
|
294
|
+
lines.push('', `${gtitle}(${g.id})`);
|
|
295
|
+
for (const x of g.fields) {
|
|
296
|
+
lines.push(` ${x.key.padEnd(24)} ${x.label}${x.sensitive ? ' [密文]' : ''}${x.hint ? ` ${x.hint}` : ''}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return lines.join('\n');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function renderSectionDump(d) {
|
|
303
|
+
const groupTitle = (id) => (d.groups.find((x) => x.id === id)?.title) || id;
|
|
304
|
+
const lines = [
|
|
305
|
+
`栏目 ${d.section.id} · ${d.section.title} ${d.count} 条条目 · ${d.section.fields} 字段${d.mask ? ' · 已打码' : ''}`,
|
|
306
|
+
d.section.description,
|
|
307
|
+
];
|
|
308
|
+
for (const e of d.entries) {
|
|
309
|
+
lines.push('', `── ${e.title || e.id}(${e.id}) 完整度 ${e.completeness.filled}/${e.completeness.total} = ${e.completeness.ratio}%`);
|
|
310
|
+
let cur = null;
|
|
311
|
+
for (const f of d.fields) {
|
|
312
|
+
if (f.group !== cur) { cur = f.group; lines.push(` ${groupTitle(cur)}`); }
|
|
313
|
+
const v = e.values[f.key];
|
|
314
|
+
lines.push(` ${f.label}: ${formatValue(v) || '(未填写)'}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
lines.push('', '机器可读: 加 --json');
|
|
318
|
+
return lines.join('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function renderSectionChange(d) {
|
|
322
|
+
if (d.status === 'skipped') return `试运行(未落盘):${JSON.stringify(d.wouldCreate || d.wouldChange || d.wouldRemove)}`;
|
|
323
|
+
// `removed` 在两条路径上形状不同:remove 给的是**对象** {id,title,entries},
|
|
324
|
+
// update 给的是**被删字段 key 的数组**。数组恒为真值,所以必须显式排除数组——
|
|
325
|
+
// 否则 `section update` 会打印「已删除栏目 undefined」,
|
|
326
|
+
// 而 `.entries` 还会取到 Array.prototype.entries 变成 `function entries() { [native code] }`。
|
|
327
|
+
if (d.removed && !Array.isArray(d.removed)) {
|
|
328
|
+
return `已删除栏目 ${d.removed.id}(连带 ${d.removed.entries} 条条目)\n快照: ${d.snapshot}`;
|
|
329
|
+
}
|
|
330
|
+
if (d.created) return `已创建栏目 ${d.section.id} · ${d.section.title}(${d.section.fields} 字段)\n快照: ${d.snapshot}`;
|
|
331
|
+
const extra = [
|
|
332
|
+
d.added?.length ? `新增字段 ${d.added.join(', ')}` : '',
|
|
333
|
+
d.removed?.length ? `删除字段 ${d.removed.join(', ')}` : '',
|
|
334
|
+
d.fillChanged?.length ? `填写策略 ${d.fillChanged.join(', ')}` : '',
|
|
335
|
+
].filter(Boolean).join(';');
|
|
336
|
+
return `已更新栏目 ${d.id}:${d.changed.join(', ')}${extra ? `\n${extra}` : ''}\n快照: ${d.snapshot}`;
|
|
337
|
+
}
|