nx-sk 0.3.4 → 0.6.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 +47 -1
- package/README.md +62 -4
- package/assets/nx-sk/SKILL.md +31 -2
- package/assets/nx-sk/references/00-design.md +4 -3
- package/assets/nx-sk/references/10-sections-entries.md +10 -2
- package/assets/nx-sk/references/30-export-backup.md +16 -0
- package/assets/nx-sk/references/40-files.md +104 -0
- package/package.json +1 -1
- package/src/core/attachments.js +378 -0
- package/src/core/fields.js +58 -2
- package/src/core/paths.js +5 -0
- package/src/core/render.js +1 -0
- package/src/core/store.js +3 -0
- package/src/modules/backup/index.js +41 -0
- package/src/modules/backup/service.js +283 -0
- package/src/modules/backup/view.jsx +145 -0
- package/src/modules/entries/service.js +35 -2
- package/src/modules/files/index.js +114 -0
- package/src/modules/files/service.js +276 -0
- package/src/modules/files/view.jsx +342 -0
- package/src/modules/sections/index.js +10 -0
- package/src/modules/sections/service.js +55 -2
- package/src/modules/sections/view.jsx +30 -2
- package/src/runtime/api.js +10 -1
- package/src/runtime/registry.js +3 -1
- package/src/web/frontend/App.jsx +36 -18
- package/src/web/frontend/api/files.js +28 -0
- package/src/web/frontend/components/fieldEditor.jsx +72 -1
- package/src/web/frontend/index.html +4 -0
- package/src/web/frontend/public/favicon-16.png +0 -0
- package/src/web/frontend/public/favicon-32.png +0 -0
- package/src/web/frontend/public/favicon-48.png +0 -0
- package/src/web/frontend/public/favicon.ico +0 -0
- package/src/web/frontend/public/logo-rounded.png +0 -0
- package/src/web/frontend/public/logo.png +0 -0
- package/src/web/frontend/registry.js +2 -0
- package/src/web/frontend/styles.css +104 -6
- package/src/web/public/assets/files-BNh48qeo.js +1 -0
- package/src/web/public/assets/{index-BnVuH1lC.js → index-B7CtqgoG.js} +10 -9
- package/src/web/public/assets/index-DLtjhflt.css +1 -0
- package/src/web/public/assets/view-1kwWQYbz.js +7 -0
- package/src/web/public/assets/view-BAYyPWdU.js +1 -0
- package/src/web/public/assets/{view-BvyRxTgX.js → view-BIdVqIGe.js} +1 -1
- package/src/web/public/assets/{view-rvADq3vX.js → view-Bu70Q6GO.js} +1 -1
- package/src/web/public/assets/view-D5LfHr7W.js +1 -0
- package/src/web/public/assets/view-DXqI-4_F.js +3 -0
- package/src/web/public/favicon-16.png +0 -0
- package/src/web/public/favicon-32.png +0 -0
- package/src/web/public/favicon-48.png +0 -0
- package/src/web/public/favicon.ico +0 -0
- package/src/web/public/index.html +6 -2
- package/src/web/public/logo-rounded.png +0 -0
- package/src/web/public/logo.png +0 -0
- package/assets/nx-sk/references/40-extend.md +0 -75
- package/src/web/public/assets/index-CwF5hM2v.css +0 -1
- package/src/web/public/assets/view-CDje4hqZ.js +0 -1
- package/src/web/public/assets/view-DsyplQBh.js +0 -7
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// 附件(二进制原件)的磁盘管理:~/nx-sk/files/ 下存储,元数据随 store.json 落盘。
|
|
2
|
+
//
|
|
3
|
+
// 设计要点:
|
|
4
|
+
// - 本机路径导入**默认移动**(用户明确要求:提交附件 = 把文件搬进 nx-sk 目录),
|
|
5
|
+
// `--copy` 才保留原件;Web 上传只有「复制」语义(浏览器删不掉任意路径的文件)。
|
|
6
|
+
// - 「可逆」:`file restore` 能把附件移回原始位置(或指定目录),并撤销台账记录;
|
|
7
|
+
// `--copy` 则只导出一份副本、库中保留。
|
|
8
|
+
// - 同 sha256 去重:完全相同的文件不重复占空间,直接回已有记录。
|
|
9
|
+
// - 元数据是**台账**:物理文件永远以台账 rel 为准;所有移动先改物理文件、再在事务里改台账。
|
|
10
|
+
// - 链接方式:条目里 `file` 字段的值 = 附件 id(或唯一文件名)。id 全局唯一,
|
|
11
|
+
// 天然实现「各条目各自的附件空间互不串台」;同一份原件也可被多条条目复用(去重后同 id)。
|
|
12
|
+
// 附件**不预定义任何分类/槽位结构**:附件中心按「哪些栏目在用它」动态分组(见 fileReferences)。
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import fsp from 'node:fs/promises';
|
|
15
|
+
import { createReadStream } from 'node:fs';
|
|
16
|
+
import { basename, dirname, extname, join, parse, resolve, sep } from 'node:path';
|
|
17
|
+
import { badInput } from './errors.js';
|
|
18
|
+
import { displayPath, filesDir, isInside, toPosix } from './paths.js';
|
|
19
|
+
import { ensureDir, hashFile } from './fsx.js';
|
|
20
|
+
import { newId, nowIso } from './ids.js';
|
|
21
|
+
|
|
22
|
+
// —— 分类 ——
|
|
23
|
+
export const FILE_CATEGORIES = [
|
|
24
|
+
{ id: 'resume', label: '简历' },
|
|
25
|
+
{ id: 'avatar', label: '头像' },
|
|
26
|
+
{ id: 'transcript', label: '成绩单' },
|
|
27
|
+
{ id: 'certificate', label: '证书' },
|
|
28
|
+
{ id: 'document', label: '文档' },
|
|
29
|
+
{ id: 'other', label: '其他' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const CATEGORY_IDS = FILE_CATEGORIES.map((c) => c.id);
|
|
33
|
+
export const categoryLabel = (id) => FILE_CATEGORIES.find((c) => c.id === id)?.label || '其他';
|
|
34
|
+
|
|
35
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.ico']);
|
|
36
|
+
const DOC_EXT = new Set(['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md', '.csv', '.wps', '.ofd']);
|
|
37
|
+
|
|
38
|
+
export function isImageExt(ext) {
|
|
39
|
+
return IMAGE_EXT.has(String(ext || '').toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 按扩展名猜分类;猜不准归「其他」(头像/简历这种语义由调用方显式给)。 */
|
|
43
|
+
export function categoryFromExt(ext) {
|
|
44
|
+
const e = String(ext || '').toLowerCase();
|
|
45
|
+
if (isImageExt(e)) return 'other';
|
|
46
|
+
if (DOC_EXT.has(e)) return 'document';
|
|
47
|
+
return 'other';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// —— 流式输出的 MIME ——
|
|
51
|
+
export const MIME_TYPES = {
|
|
52
|
+
'.html': 'text/html; charset=utf-8',
|
|
53
|
+
'.pdf': 'application/pdf',
|
|
54
|
+
'.doc': 'application/msword',
|
|
55
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
56
|
+
'.xls': 'application/vnd.ms-excel',
|
|
57
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
58
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
59
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
60
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
61
|
+
'.md': 'text/plain; charset=utf-8',
|
|
62
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
63
|
+
'.svg': 'image/svg+xml',
|
|
64
|
+
'.png': 'image/png',
|
|
65
|
+
'.jpg': 'image/jpeg',
|
|
66
|
+
'.jpeg': 'image/jpeg',
|
|
67
|
+
'.webp': 'image/webp',
|
|
68
|
+
'.gif': 'image/gif',
|
|
69
|
+
'.bmp': 'image/bmp',
|
|
70
|
+
'.avif': 'image/avif',
|
|
71
|
+
'.ico': 'image/x-icon',
|
|
72
|
+
'.wps': 'application/octet-stream',
|
|
73
|
+
'.ofd': 'application/octet-stream',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Windows 保留设备名,做文件名会让文件无法正常访问。
|
|
77
|
+
const RESERVED_STEMS = new Set(['con', 'prn', 'aux', 'nul',
|
|
78
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
79
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`)]);
|
|
80
|
+
|
|
81
|
+
/** 把任意来源的名字收成跨平台可用的单段文件名(保留中文)。 */
|
|
82
|
+
export function safeFileName(name) {
|
|
83
|
+
let s = String(name ?? '')
|
|
84
|
+
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
|
85
|
+
.replace(/\s+/g, ' ')
|
|
86
|
+
.trim()
|
|
87
|
+
.replace(/^\.+/, '');
|
|
88
|
+
if (!s) s = 'file';
|
|
89
|
+
const stem = parse(s).name;
|
|
90
|
+
if (RESERVED_STEMS.has(stem.toLowerCase())) s = `_${s}`;
|
|
91
|
+
return s.slice(0, 140);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 在台账里为「分类 + 文件名」找一个不冲突的 posix 相对路径(`(2)` 递增)。 */
|
|
95
|
+
function uniqueRel(store, category, fileName, excludeId = null) {
|
|
96
|
+
const { name: stem, ext } = parse(fileName);
|
|
97
|
+
let rel = toPosix(join(category, fileName));
|
|
98
|
+
let n = 1;
|
|
99
|
+
while (store.files.some((r) => r.id !== excludeId && r.rel === rel)) {
|
|
100
|
+
n += 1;
|
|
101
|
+
rel = toPosix(join(category, `${stem} (${n})${ext}`));
|
|
102
|
+
}
|
|
103
|
+
return rel;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// —— 台账记录 ——
|
|
107
|
+
export function normalizeFileRecord(raw) {
|
|
108
|
+
if (!raw || typeof raw !== 'object' || !raw.id || !raw.rel) return null;
|
|
109
|
+
const name = String(raw.name || basename(String(raw.rel)));
|
|
110
|
+
return {
|
|
111
|
+
id: String(raw.id),
|
|
112
|
+
// 分类只是**磁盘分目录**用的内部属性(`files/<分类>/`),不是用户要维护的「槽位」。
|
|
113
|
+
category: CATEGORY_IDS.includes(raw.category) ? raw.category : 'other',
|
|
114
|
+
name,
|
|
115
|
+
rel: toPosix(String(raw.rel)),
|
|
116
|
+
originalName: String(raw.originalName || name),
|
|
117
|
+
ext: String(raw.ext || extname(name).toLowerCase()),
|
|
118
|
+
bytes: Number(raw.bytes) || 0,
|
|
119
|
+
sha256: String(raw.sha256 || ''),
|
|
120
|
+
// 原始绝对路径(posix);Web 上传没有,留空——还原时需显式给目标目录
|
|
121
|
+
sourcePath: String(raw.sourcePath || ''),
|
|
122
|
+
mode: ['move', 'copy', 'upload'].includes(raw.mode) ? raw.mode : 'upload',
|
|
123
|
+
tags: Array.isArray(raw.tags) ? raw.tags.map(String) : [],
|
|
124
|
+
note: String(raw.note || ''),
|
|
125
|
+
createdAt: raw.createdAt || null,
|
|
126
|
+
updatedAt: raw.updatedAt || null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 附件定位:id 优先,其次唯一文件名。 */
|
|
131
|
+
export function findFileRecord(store, ref) {
|
|
132
|
+
const key = String(ref ?? '').trim();
|
|
133
|
+
if (!key) return null;
|
|
134
|
+
const byId = store.files.find((r) => r.id === key);
|
|
135
|
+
if (byId) return byId;
|
|
136
|
+
const byName = store.files.filter((r) => r.name === key);
|
|
137
|
+
if (byName.length === 1) return byName[0];
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 反向链接:哪些条目的 `file` 字段引用了这个附件。
|
|
143
|
+
* 条目里 `file` 字段的值 = 附件 id(或唯一文件名),所以按记录反查即可。
|
|
144
|
+
* 附件中心据此显示「被哪些栏目 / 条目 / 字段用到」——**完全由栏目驱动**,不预定义结构。
|
|
145
|
+
*/
|
|
146
|
+
export function fileReferences(store, rec) {
|
|
147
|
+
if (!rec) return [];
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const sec of store.sections || []) {
|
|
150
|
+
const fileFields = (sec.fields || []).filter((f) => f.type === 'file');
|
|
151
|
+
if (!fileFields.length) continue;
|
|
152
|
+
for (const e of store.entries || []) {
|
|
153
|
+
if (e.section !== sec.id) continue;
|
|
154
|
+
for (const f of fileFields) {
|
|
155
|
+
const v = String(e.values?.[f.key] ?? '').trim();
|
|
156
|
+
if (!v) continue;
|
|
157
|
+
if (findFileRecord(store, v) !== rec) continue;
|
|
158
|
+
out.push({
|
|
159
|
+
sectionId: sec.id, sectionTitle: sec.title,
|
|
160
|
+
entryId: e.id, entryTitle: e.title,
|
|
161
|
+
fieldKey: f.key, fieldLabel: f.label,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 附件中心的分组素材:哪些栏目带 `file` 字段。
|
|
171
|
+
* 中心据此按栏目筛选 / 分组 —— 组织方式**完全由栏目驱动,不预定义任何分类或槽位**。
|
|
172
|
+
*/
|
|
173
|
+
export function collectFileSections(store) {
|
|
174
|
+
return (store.sections || [])
|
|
175
|
+
.filter((s) => (s.fields || []).some((f) => f.type === 'file'))
|
|
176
|
+
.map((s) => ({ id: s.id, title: s.title }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function fileRecordAbs(rec) {
|
|
180
|
+
return join(filesDir(), rec.rel.split('/').join(sep));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function fileLinks(rec) {
|
|
184
|
+
const u = `/api/files/${encodeURIComponent(rec.id)}/content`;
|
|
185
|
+
return { url: u, downloadUrl: `${u}?download=1` };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 对外(CLI / Web)的完整描述:物理路径 + 展示路径 + 链接(+ 可选的反向引用)。 */
|
|
189
|
+
export function describeFile(rec, store = null) {
|
|
190
|
+
const abs = fileRecordAbs(rec);
|
|
191
|
+
return {
|
|
192
|
+
...rec,
|
|
193
|
+
categoryLabel: categoryLabel(rec.category),
|
|
194
|
+
isImage: isImageExt(rec.ext),
|
|
195
|
+
path: abs,
|
|
196
|
+
displayPath: displayPath(abs),
|
|
197
|
+
sourceDisplay: rec.sourcePath ? displayPath(rec.sourcePath) : '',
|
|
198
|
+
usedBy: store ? fileReferences(store, rec) : [],
|
|
199
|
+
...fileLinks(rec),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildRecord({ id, category, rel, srcAbs, originalName, ext: e, bytes, sha, mode, tags, note }) {
|
|
204
|
+
return normalizeFileRecord({
|
|
205
|
+
id, category, name: basename(rel), rel,
|
|
206
|
+
originalName: originalName || basename(rel),
|
|
207
|
+
ext: e, bytes, sha256: sha,
|
|
208
|
+
sourcePath: srcAbs ? toPosix(srcAbs) : '',
|
|
209
|
+
mode,
|
|
210
|
+
tags: Array.isArray(tags) ? tags.map(String) : [],
|
|
211
|
+
note: String(note || ''),
|
|
212
|
+
createdAt: nowIso(), updatedAt: nowIso(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 从本机路径导入。默认**移动**,copy=true 才复制。
|
|
218
|
+
* dryRun 只做读取与落点计算,不碰目标位置。
|
|
219
|
+
*/
|
|
220
|
+
export async function importLocalFile(store, opts = {}) {
|
|
221
|
+
const { path: src, category, copy, tags, note, name, dryRun } = opts;
|
|
222
|
+
if (!src) throw badInput('缺少文件路径:--path <本机文件路径>');
|
|
223
|
+
const srcAbs = resolve(String(src));
|
|
224
|
+
let st;
|
|
225
|
+
try { st = await fsp.stat(srcAbs); } catch {
|
|
226
|
+
throw badInput(`读不到文件: ${displayPath(srcAbs)}`);
|
|
227
|
+
}
|
|
228
|
+
if (!st.isFile()) throw badInput(`不是文件: ${displayPath(srcAbs)}`);
|
|
229
|
+
if (isInside(filesDir(), srcAbs)) {
|
|
230
|
+
throw badInput('该文件已经在附件库目录里,无需重复导入');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const sha = await hashFile(srcAbs);
|
|
234
|
+
const dup = store.files.find((r) => r.sha256 === sha);
|
|
235
|
+
if (dup) return { record: dup, duplicate: true, dryRun: false };
|
|
236
|
+
|
|
237
|
+
const e = extname(srcAbs).toLowerCase();
|
|
238
|
+
const cat = category || categoryFromExt(e);
|
|
239
|
+
const fileName = safeFileName(name || basename(srcAbs));
|
|
240
|
+
const rel = uniqueRel(store, cat, fileName);
|
|
241
|
+
const record = buildRecord({
|
|
242
|
+
id: newId('f'), category: cat, rel, srcAbs, originalName: basename(srcAbs),
|
|
243
|
+
ext: e, bytes: st.size, sha, mode: copy ? 'copy' : 'move', tags, note,
|
|
244
|
+
});
|
|
245
|
+
if (dryRun) return { record, duplicate: false, dryRun: true };
|
|
246
|
+
|
|
247
|
+
const dst = join(filesDir(), rel.split('/').join(sep));
|
|
248
|
+
await ensureDir(dirname(dst));
|
|
249
|
+
if (copy) {
|
|
250
|
+
await fsp.copyFile(srcAbs, dst);
|
|
251
|
+
} else {
|
|
252
|
+
try {
|
|
253
|
+
await fsp.rename(srcAbs, dst);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// EXDEV:跨卷 rename 不被支持,退成「复制 + 删原件」,语义不变
|
|
256
|
+
if (err && err.code === 'EXDEV') {
|
|
257
|
+
await fsp.copyFile(srcAbs, dst);
|
|
258
|
+
await fsp.unlink(srcAbs);
|
|
259
|
+
} else throw err;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { record, duplicate: false, dryRun: false, moved: !copy };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function decodeBase64(content) {
|
|
266
|
+
let b64 = String(content || '');
|
|
267
|
+
const comma = b64.indexOf(',');
|
|
268
|
+
if (b64.startsWith('data:') && comma >= 0) b64 = b64.slice(comma + 1);
|
|
269
|
+
let buf;
|
|
270
|
+
try { buf = Buffer.from(b64, 'base64'); } catch {
|
|
271
|
+
throw badInput('上传内容不是合法的 base64');
|
|
272
|
+
}
|
|
273
|
+
if (!buf.length) throw badInput('上传内容为空');
|
|
274
|
+
return buf;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Web 上传:base64(或 data URL)内容直接写进库。 */
|
|
278
|
+
export async function importContent(store, opts = {}) {
|
|
279
|
+
const { content, name, category, tags, note, dryRun } = opts;
|
|
280
|
+
if (content === undefined || content === null) throw badInput('缺少上传内容');
|
|
281
|
+
if (!name) throw badInput('上传必须带文件名');
|
|
282
|
+
const buf = decodeBase64(content);
|
|
283
|
+
const sha = createHash('sha256').update(buf).digest('hex');
|
|
284
|
+
const dup = store.files.find((r) => r.sha256 === sha);
|
|
285
|
+
if (dup) return { record: dup, duplicate: true, dryRun: false };
|
|
286
|
+
|
|
287
|
+
const originalName = safeFileName(name);
|
|
288
|
+
const e = extname(originalName).toLowerCase();
|
|
289
|
+
const cat = category || categoryFromExt(e);
|
|
290
|
+
const rel = uniqueRel(store, cat, originalName);
|
|
291
|
+
const record = buildRecord({
|
|
292
|
+
id: newId('f'), category: cat, rel, srcAbs: null, originalName,
|
|
293
|
+
ext: e, bytes: buf.length, sha, mode: 'upload', tags, note,
|
|
294
|
+
});
|
|
295
|
+
if (dryRun) return { record, duplicate: false, dryRun: true };
|
|
296
|
+
|
|
297
|
+
const dst = join(filesDir(), rel.split('/').join(sep));
|
|
298
|
+
await ensureDir(dirname(dst));
|
|
299
|
+
await fsp.writeFile(dst, buf);
|
|
300
|
+
return { record, duplicate: false, dryRun: false, moved: false };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 改分类 / 文件名:物理文件先搬到新落点,返回新记录(由 service 在事务里落台账)。
|
|
305
|
+
*/
|
|
306
|
+
export async function relocateFile(store, rec, { category, name }) {
|
|
307
|
+
const cat = category || rec.category;
|
|
308
|
+
const fileName = safeFileName(name || rec.name);
|
|
309
|
+
const same = cat === rec.category && fileName === rec.name;
|
|
310
|
+
const rel = same ? rec.rel : uniqueRel(store, cat, fileName, rec.id);
|
|
311
|
+
if (!same) {
|
|
312
|
+
const oldAbs = fileRecordAbs(rec);
|
|
313
|
+
const newAbs = join(filesDir(), rel.split('/').join(sep));
|
|
314
|
+
await ensureDir(dirname(newAbs));
|
|
315
|
+
await fsp.copyFile(oldAbs, newAbs);
|
|
316
|
+
await fsp.unlink(oldAbs);
|
|
317
|
+
}
|
|
318
|
+
return normalizeFileRecord({ ...rec, category: cat, name: basename(rel), rel, updatedAt: nowIso() });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 删除物理原件;文件本就不存在视为成功(台账是权威)。 */
|
|
322
|
+
export async function deletePhysical(rec) {
|
|
323
|
+
try {
|
|
324
|
+
await fsp.unlink(fileRecordAbs(rec));
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (err && err.code === 'ENOENT') return;
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function nonClashPath(dir, fileName) {
|
|
332
|
+
const { name: stem, ext } = parse(fileName);
|
|
333
|
+
let dst = join(dir, fileName);
|
|
334
|
+
let n = 1;
|
|
335
|
+
const exists = async (p) => { try { await fsp.access(p); return true; } catch { return false; } };
|
|
336
|
+
return (async () => {
|
|
337
|
+
while (await exists(dst)) { n += 1; dst = join(dir, `${stem} (${n})${ext}`); }
|
|
338
|
+
return dst;
|
|
339
|
+
})();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 还原 / 导出:把附件复制到目标位置;copy=false(默认)时再删掉库内原件
|
|
344
|
+
* (由 service 负责撤销台账记录)。
|
|
345
|
+
*/
|
|
346
|
+
export async function restoreFile(store, rec, { to, copy } = {}) {
|
|
347
|
+
const targetDir = resolve(String(to || (rec.sourcePath ? dirname(rec.sourcePath) : '')));
|
|
348
|
+
if (!targetDir || targetDir === sep) {
|
|
349
|
+
throw badInput('该附件没有原始位置,需要给目标目录:--to <目录>');
|
|
350
|
+
}
|
|
351
|
+
const dst = await nonClashPath(targetDir, rec.name);
|
|
352
|
+
if (!copy && isInside(filesDir(), dst)) {
|
|
353
|
+
throw badInput('还原落点在附件库目录内,会自己覆盖自己;换一个目录');
|
|
354
|
+
}
|
|
355
|
+
await ensureDir(targetDir);
|
|
356
|
+
await fsp.copyFile(fileRecordAbs(rec), dst);
|
|
357
|
+
if (!copy) await fsp.unlink(fileRecordAbs(rec));
|
|
358
|
+
return { path: dst, displayPath: displayPath(dst), kept: copy === true };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** 以正确的 Content-Type 把附件流式输出给浏览器;download=true 时强制下载。 */
|
|
362
|
+
export function streamRecord(res, rec, { download = false } = {}) {
|
|
363
|
+
const file = fileRecordAbs(rec);
|
|
364
|
+
return new Promise((resolve, reject) => {
|
|
365
|
+
const rs = createReadStream(file);
|
|
366
|
+
rs.once('error', reject);
|
|
367
|
+
rs.once('open', () => {
|
|
368
|
+
res.writeHead(200, {
|
|
369
|
+
'content-type': MIME_TYPES[rec.ext] || 'application/octet-stream',
|
|
370
|
+
'content-disposition': `${download ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(rec.name)}`,
|
|
371
|
+
'cache-control': 'no-cache',
|
|
372
|
+
});
|
|
373
|
+
rs.pipe(res);
|
|
374
|
+
rs.once('end', resolve);
|
|
375
|
+
rs.once('error', reject);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
}
|
package/src/core/fields.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// 字段字典:栏目的字段模板是**数据**,不是代码分支。
|
|
2
2
|
// agent 与面板都从同一份声明渲染,所以「CLI 能填的字段」与「面板能填的字段」不可能分叉。
|
|
3
3
|
//
|
|
4
|
-
// type 取值:text | textarea | number | date | month | bool | select | tags | secret
|
|
4
|
+
// type 取值:text | textarea | number | date | month | bool | select | tags | secret | file
|
|
5
5
|
|
|
6
|
-
export const FIELD_TYPES = ['text', 'textarea', 'number', 'date', 'month', 'bool', 'select', 'tags', 'secret'];
|
|
6
|
+
export const FIELD_TYPES = ['text', 'textarea', 'number', 'date', 'month', 'bool', 'select', 'tags', 'secret', 'file'];
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* 字段的**填写策略**——回答「这个字段值不值得填」,与 type 正交。
|
|
@@ -128,6 +128,12 @@ const JOB_FIELDS = [
|
|
|
128
128
|
f('acceptAdjustment', '是否接受调剂', 'bool', 'job', { hint: '是否接受岗位或城市调剂' }),
|
|
129
129
|
f('jobNote', '求职备注', 'textarea', 'job', { hint: '补充说明' }),
|
|
130
130
|
|
|
131
|
+
// —— 附件资料(统一附件库;值 = 附件 id,直接在字段里上传或从附件库选一个) ——
|
|
132
|
+
f('resumeFile', '简历附件', 'file', 'files', { kind: 'doc', hint: '简历 PDF/Word 原件;在本字段直接上传,或从附件库选一个' }),
|
|
133
|
+
f('avatar', '头像图片', 'file', 'files', { kind: 'image', hint: '证件照/头像图片原件' }),
|
|
134
|
+
f('transcript', '成绩单', 'file', 'files', { kind: 'doc', hint: '在校成绩单原件' }),
|
|
135
|
+
f('certificateScan', '证书扫描件', 'file', 'files', { kind: 'doc', fill: 'optional', hint: '获奖/资格证书扫描件(不必填)' }),
|
|
136
|
+
|
|
131
137
|
// —— 其他 ——
|
|
132
138
|
f('religion', '宗教信仰', 'text', 'extra', { hint: '没有就写「无」' }),
|
|
133
139
|
f('unionVolunteer', '是否自愿加入公司工会', 'bool', 'extra'),
|
|
@@ -186,6 +192,7 @@ const TEMPLATES = {
|
|
|
186
192
|
{ id: 'emergency', title: '紧急联系' },
|
|
187
193
|
{ id: 'about', title: '自我描述' },
|
|
188
194
|
{ id: 'job', title: '求职意向' },
|
|
195
|
+
{ id: 'files', title: '附件资料' },
|
|
189
196
|
{ id: 'extra', title: '其他' },
|
|
190
197
|
],
|
|
191
198
|
fields: JOB_FIELDS,
|
|
@@ -313,6 +320,55 @@ export function syncSectionGroups(groups, fields) {
|
|
|
313
320
|
return out;
|
|
314
321
|
}
|
|
315
322
|
|
|
323
|
+
/**
|
|
324
|
+
* 模板增量合并:把模板里**尚未出现在栏目**的分组与字段补进去(只增,绝不改/删)。
|
|
325
|
+
*
|
|
326
|
+
* 为什么需要它:栏目实例化时字段字典是**深拷贝**(见 instantiateTemplate),
|
|
327
|
+
* 从此「栏目自己的字段集独立演化」。于是模板后续新增的字段(例如给求职加
|
|
328
|
+
* 「附件资料」组的 4 个 `file` 字段)**不会回溯**到已经存在的栏目——
|
|
329
|
+
* 面板按栏目自己的字典渲染,老栏目里就永远看不到上传入口。
|
|
330
|
+
*
|
|
331
|
+
* 合并规则(尽量少动用户数据):
|
|
332
|
+
* - 分组:按模板顺序排列;栏目已有的分组**沿用栏目自己的定义**(标题可能被改过),
|
|
333
|
+
* 模板没有的自定义分组原顺序附在后面。
|
|
334
|
+
* - 字段:栏目已有的字段原样保留(含用户改过的 label/hint/fill);缺的模板字段
|
|
335
|
+
* 插到「模板顺序里它前一个字段」之后,未在模板中的自定义字段保持原位。
|
|
336
|
+
*/
|
|
337
|
+
export function mergeTemplate(section, template) {
|
|
338
|
+
const existingGroups = section?.groups || [];
|
|
339
|
+
const existingFields = section?.fields || [];
|
|
340
|
+
const groupById = new Map(existingGroups.map((g) => [g.id, g]));
|
|
341
|
+
const fieldKeys = new Set(existingFields.map((f) => f.key));
|
|
342
|
+
|
|
343
|
+
const groups = [];
|
|
344
|
+
const seenGroup = new Set();
|
|
345
|
+
for (const g of template?.groups || []) {
|
|
346
|
+
groups.push(groupById.get(g.id) || { ...g });
|
|
347
|
+
seenGroup.add(g.id);
|
|
348
|
+
}
|
|
349
|
+
for (const g of existingGroups) if (!seenGroup.has(g.id)) groups.push(g);
|
|
350
|
+
const addedGroups = (template?.groups || []).filter((g) => !existingGroups.some((x) => x.id === g.id)).map((g) => g.id);
|
|
351
|
+
|
|
352
|
+
const tplOrder = new Map((template?.fields || []).map((f, i) => [f.key, i]));
|
|
353
|
+
const fields = [...existingFields];
|
|
354
|
+
const addedFields = [];
|
|
355
|
+
for (const tf of template?.fields || []) {
|
|
356
|
+
if (fieldKeys.has(tf.key)) continue;
|
|
357
|
+
// 插到第一个「模板序更靠后」的既有字段之前;找不到就追加到末尾
|
|
358
|
+
const ti = tplOrder.get(tf.key);
|
|
359
|
+
let insertAt = fields.length;
|
|
360
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
361
|
+
const oi = tplOrder.get(fields[i].key);
|
|
362
|
+
if (oi !== undefined && oi > ti) { insertAt = i; break; }
|
|
363
|
+
}
|
|
364
|
+
fields.splice(insertAt, 0, { ...tf });
|
|
365
|
+
fieldKeys.add(tf.key);
|
|
366
|
+
addedFields.push(tf.key);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { groups, fields, addedGroups, addedFields };
|
|
370
|
+
}
|
|
371
|
+
|
|
316
372
|
export function fieldByKey(section, key) {
|
|
317
373
|
return (section?.fields || []).find((x) => x.key === key) || null;
|
|
318
374
|
}
|
package/src/core/paths.js
CHANGED
|
@@ -50,6 +50,11 @@ export function exportDir() {
|
|
|
50
50
|
return join(appHome(), 'export');
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** 附件统一存储目录:`~/nx-sk/files/`(简历、头像、成绩单等二进制原件都落在这里)。 */
|
|
54
|
+
export function filesDir() {
|
|
55
|
+
return join(appHome(), 'files');
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
export function snapshotDir() {
|
|
54
59
|
return join(appHome(), 'backup');
|
|
55
60
|
}
|
package/src/core/render.js
CHANGED
|
@@ -133,6 +133,7 @@ export function dumpSection(section, entries, { mask = false, decrypt } = {}) {
|
|
|
133
133
|
group: x.group,
|
|
134
134
|
hint: x.hint || '',
|
|
135
135
|
options: x.options || null,
|
|
136
|
+
kind: x.kind || '',
|
|
136
137
|
sensitive: isSensitiveField(section, x.key),
|
|
137
138
|
// 填写策略要跟着字段字典出去,否则面板与 agent 不知道哪个格子「不用填」
|
|
138
139
|
fill: fillPolicy(x),
|
package/src/core/store.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { displayPath, snapshotDir, storeFile } from './paths.js';
|
|
6
6
|
import { ensureDir, pathExists, writeAtomic, writeJsonAtomic } from './fsx.js';
|
|
7
7
|
import { instantiateTemplate, seedSections, syncSectionGroups } from './fields.js';
|
|
8
|
+
import { normalizeFileRecord } from './attachments.js';
|
|
8
9
|
import { nowIso, stamp } from './ids.js';
|
|
9
10
|
|
|
10
11
|
/** v2:密钥栏目从 8 字段模板改成单字段 KV(名称 → 值)。
|
|
@@ -32,6 +33,7 @@ function emptyStore(seed) {
|
|
|
32
33
|
// 说明用户自己删光了栏目——这时候再播种会把它变回来,等于删不掉。
|
|
33
34
|
sections: seed ? seedSections(at) : [],
|
|
34
35
|
entries: [],
|
|
36
|
+
files: [],
|
|
35
37
|
exports: [],
|
|
36
38
|
};
|
|
37
39
|
}
|
|
@@ -176,6 +178,7 @@ export function normalize(data) {
|
|
|
176
178
|
delete base.settings.kvSection;
|
|
177
179
|
base.sections = (Array.isArray(data.sections) ? data.sections : []).map(normalizeSection).filter(Boolean);
|
|
178
180
|
base.entries = (Array.isArray(data.entries) ? data.entries : []).map(normalizeEntry).filter(Boolean);
|
|
181
|
+
base.files = (Array.isArray(data.files) ? data.files : []).map(normalizeFileRecord).filter(Boolean);
|
|
179
182
|
base.exports = Array.isArray(data.exports) ? data.exports.slice(0, MAX_EXPORT_HISTORY) : [];
|
|
180
183
|
if (oldVersion < 2) base._migration = migrateSecretToKv(base);
|
|
181
184
|
if (oldVersion < 3) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as service from './service.js';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
id: 'backup',
|
|
5
|
+
title: '加密备份',
|
|
6
|
+
order: 750,
|
|
7
|
+
view: () => import('./view.jsx'),
|
|
8
|
+
actions: [
|
|
9
|
+
{
|
|
10
|
+
id: 'backup.export',
|
|
11
|
+
cli: ['backup', 'export'],
|
|
12
|
+
http: ['POST', '/api/backup'],
|
|
13
|
+
summary: '把整份数据用口令对称加密(AES-256-GCM)导出为单个备份文件;口令不落盘',
|
|
14
|
+
flags: {
|
|
15
|
+
password: { type: 'string', hint: '备份口令(也可用环境变量 NX_SK_BACKUP_PASSWORD),恢复时需再次输入' },
|
|
16
|
+
out: { type: 'string', hint: '输出目录,缺省 ~/nx-sk/backup' },
|
|
17
|
+
'dry-run': { type: 'boolean', hint: '只演练:生成密文但不写文件、不下载' },
|
|
18
|
+
},
|
|
19
|
+
run: (ctx, meta) => (meta?.transport === 'http'
|
|
20
|
+
? service.buildBackup(ctx)
|
|
21
|
+
: service.exportBackup(ctx)),
|
|
22
|
+
render: (d) => service.renderBackup(d),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'backup.import',
|
|
26
|
+
cli: ['backup', 'import'],
|
|
27
|
+
http: ['POST', '/api/backup/restore'],
|
|
28
|
+
summary: '选择加密备份文件并输入口令,解密后整份恢复;恢复前自动给当前数据留安全快照',
|
|
29
|
+
flags: {
|
|
30
|
+
password: { type: 'string', hint: '导出时设置的口令(也可用环境变量 NX_SK_BACKUP_PASSWORD)' },
|
|
31
|
+
file: { type: 'string', hint: '备份文件路径(CLI 用)' },
|
|
32
|
+
content: { type: 'string', hint: '备份文件内容(Web 上传时自动带)' },
|
|
33
|
+
'dry-run': { type: 'boolean', hint: '只解密校验,不替换数据、不留快照' },
|
|
34
|
+
},
|
|
35
|
+
run: (ctx) => (ctx.content !== undefined
|
|
36
|
+
? service.restoreBackup({ content: ctx.content, password: ctx.password, 'dry-run': ctx['dry-run'] })
|
|
37
|
+
: service.importBackup(ctx)),
|
|
38
|
+
render: (d) => service.renderRestore(d),
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|