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.
Files changed (57) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +62 -4
  3. package/assets/nx-sk/SKILL.md +31 -2
  4. package/assets/nx-sk/references/00-design.md +4 -3
  5. package/assets/nx-sk/references/10-sections-entries.md +10 -2
  6. package/assets/nx-sk/references/30-export-backup.md +16 -0
  7. package/assets/nx-sk/references/40-files.md +104 -0
  8. package/package.json +1 -1
  9. package/src/core/attachments.js +378 -0
  10. package/src/core/fields.js +58 -2
  11. package/src/core/paths.js +5 -0
  12. package/src/core/render.js +1 -0
  13. package/src/core/store.js +3 -0
  14. package/src/modules/backup/index.js +41 -0
  15. package/src/modules/backup/service.js +283 -0
  16. package/src/modules/backup/view.jsx +145 -0
  17. package/src/modules/entries/service.js +35 -2
  18. package/src/modules/files/index.js +114 -0
  19. package/src/modules/files/service.js +276 -0
  20. package/src/modules/files/view.jsx +342 -0
  21. package/src/modules/sections/index.js +10 -0
  22. package/src/modules/sections/service.js +55 -2
  23. package/src/modules/sections/view.jsx +30 -2
  24. package/src/runtime/api.js +10 -1
  25. package/src/runtime/registry.js +3 -1
  26. package/src/web/frontend/App.jsx +36 -18
  27. package/src/web/frontend/api/files.js +28 -0
  28. package/src/web/frontend/components/fieldEditor.jsx +72 -1
  29. package/src/web/frontend/index.html +4 -0
  30. package/src/web/frontend/public/favicon-16.png +0 -0
  31. package/src/web/frontend/public/favicon-32.png +0 -0
  32. package/src/web/frontend/public/favicon-48.png +0 -0
  33. package/src/web/frontend/public/favicon.ico +0 -0
  34. package/src/web/frontend/public/logo-rounded.png +0 -0
  35. package/src/web/frontend/public/logo.png +0 -0
  36. package/src/web/frontend/registry.js +2 -0
  37. package/src/web/frontend/styles.css +104 -6
  38. package/src/web/public/assets/files-BNh48qeo.js +1 -0
  39. package/src/web/public/assets/{index-BnVuH1lC.js → index-B7CtqgoG.js} +10 -9
  40. package/src/web/public/assets/index-DLtjhflt.css +1 -0
  41. package/src/web/public/assets/view-1kwWQYbz.js +7 -0
  42. package/src/web/public/assets/view-BAYyPWdU.js +1 -0
  43. package/src/web/public/assets/{view-BvyRxTgX.js → view-BIdVqIGe.js} +1 -1
  44. package/src/web/public/assets/{view-rvADq3vX.js → view-Bu70Q6GO.js} +1 -1
  45. package/src/web/public/assets/view-D5LfHr7W.js +1 -0
  46. package/src/web/public/assets/view-DXqI-4_F.js +3 -0
  47. package/src/web/public/favicon-16.png +0 -0
  48. package/src/web/public/favicon-32.png +0 -0
  49. package/src/web/public/favicon-48.png +0 -0
  50. package/src/web/public/favicon.ico +0 -0
  51. package/src/web/public/index.html +6 -2
  52. package/src/web/public/logo-rounded.png +0 -0
  53. package/src/web/public/logo.png +0 -0
  54. package/assets/nx-sk/references/40-extend.md +0 -75
  55. package/src/web/public/assets/index-CwF5hM2v.css +0 -1
  56. package/src/web/public/assets/view-CDje4hqZ.js +0 -1
  57. package/src/web/public/assets/view-DsyplQBh.js +0 -7
@@ -0,0 +1,276 @@
1
+ // 附件(file):统一附件库的业务编排。物理文件操作在 core/attachments.js,
2
+ // 台账(store.files)的读写走 store.js——和 entries 一样的「两趟」结构:
3
+ // 先在事务外完成文件移动/复制,再在 mutateStore 事务里改台账。
4
+ import { loadStore, mutateStore, snapshotStore } from '../../core/store.js';
5
+ import { displayPath, filesDir } from '../../core/paths.js';
6
+ import { badInput, external, notFound } from '../../core/errors.js';
7
+ import { nowIso } from '../../core/ids.js';
8
+ import { openPath } from '../../core/open.js';
9
+ import {
10
+ CATEGORY_IDS, collectFileSections, describeFile, fileReferences,
11
+ deletePhysical, fileRecordAbs, findFileRecord, importContent, importLocalFile,
12
+ relocateFile, restoreFile, streamRecord,
13
+ } from '../../core/attachments.js';
14
+
15
+ function mustFind(store, ref) {
16
+ const r = findFileRecord(store, ref);
17
+ if (!r) throw notFound(`找不到附件: ${ref}(用 'nx-sk file list' 看现有附件)`);
18
+ return r;
19
+ }
20
+
21
+ function assertCategory(cat) {
22
+ if (!cat) return null;
23
+ if (!CATEGORY_IDS.includes(cat)) {
24
+ throw badInput(`分类只能是 ${CATEGORY_IDS.join(' / ')},收到 ${JSON.stringify(cat)}`);
25
+ }
26
+ return cat;
27
+ }
28
+
29
+ /**
30
+ * 列附件。组织方式**完全由栏目驱动**:`sections` 给出「哪些栏目在引用附件」,
31
+ * `section` 参数按栏目过滤(`none` = 只列没有任何条目引用的)。不预定义分类/槽位。
32
+ */
33
+ export async function listFiles({ category, q, section } = {}) {
34
+ const store = await loadStore();
35
+ assertCategory(category);
36
+ const withRefs = store.files.map((rec) => ({ rec, usedBy: fileReferences(store, rec) }));
37
+ let list = withRefs;
38
+ if (category) list = list.filter((x) => x.rec.category === category);
39
+ if (section === 'none' || section === 'unlinked') {
40
+ list = list.filter((x) => !x.usedBy.length);
41
+ } else if (section) {
42
+ const sec = store.sections.find((s) => s.id === section);
43
+ if (!sec) throw notFound(`找不到栏目: ${section}`);
44
+ list = list.filter((x) => x.usedBy.some((u) => u.sectionId === sec.id));
45
+ }
46
+ if (q) {
47
+ const needle = String(q).toLowerCase();
48
+ list = list.filter((x) => [
49
+ x.rec.name, x.rec.note, x.rec.originalName, ...x.rec.tags,
50
+ ...x.usedBy.map((u) => `${u.sectionTitle} ${u.entryTitle} ${u.fieldLabel}`),
51
+ ].join('\n').toLowerCase().includes(needle));
52
+ }
53
+ list = list.slice().sort((a, b) => String(b.rec.createdAt || '').localeCompare(String(a.rec.createdAt || '')));
54
+ let totalBytes = 0;
55
+ for (const r of store.files) totalBytes += r.bytes;
56
+ // 分组素材:带 file 字段的栏目(栏目驱动)及其引用数
57
+ const sections = collectFileSections(store).map((s) => ({
58
+ ...s, count: withRefs.filter((x) => x.usedBy.some((u) => u.sectionId === s.id)).length,
59
+ }));
60
+ return {
61
+ count: list.length,
62
+ total: store.files.length,
63
+ totalBytes,
64
+ unlinked: withRefs.filter((x) => !x.usedBy.length).length,
65
+ root: displayPath(filesDir()),
66
+ rootRaw: filesDir(),
67
+ sections,
68
+ files: list.map((x) => describeFile(x.rec, store)),
69
+ };
70
+ }
71
+
72
+ export async function getFile(ref) {
73
+ const store = await loadStore();
74
+ // 带 store:顺带反查「被哪些条目引用」
75
+ return describeFile(mustFind(store, ref), store);
76
+ }
77
+
78
+ export async function addFile(patch = {}) {
79
+ const dryRun = patch['dry-run'];
80
+ assertCategory(patch.category);
81
+ const store = await loadStore();
82
+
83
+ let outcome;
84
+ if (patch.path) {
85
+ outcome = await importLocalFile(store, {
86
+ path: patch.path,
87
+ category: patch.category || undefined,
88
+ copy: patch.copy === true,
89
+ tags: patch.tags,
90
+ note: patch.note,
91
+ name: patch.name,
92
+ dryRun,
93
+ });
94
+ } else if (patch.content !== undefined) {
95
+ outcome = await importContent(store, {
96
+ content: patch.content,
97
+ name: patch.name,
98
+ category: patch.category || undefined,
99
+ tags: patch.tags,
100
+ note: patch.note,
101
+ dryRun,
102
+ });
103
+ } else {
104
+ throw badInput('导入附件需要:--path <本机路径>(默认移动,--copy 仅复制),或 Web 上传内容 --content <base64> --name <文件名>');
105
+ }
106
+
107
+ if (outcome.duplicate) {
108
+ return { status: 'ok', duplicated: true, id: outcome.record.id, ...describeFile(outcome.record) };
109
+ }
110
+ if (dryRun) return { status: 'skipped', dryRun: true, wouldAdd: describeFile(outcome.record) };
111
+
112
+ await mutateStore((s) => { s.files.push(outcome.record); });
113
+ return {
114
+ status: 'ok', created: true, moved: outcome.moved === true,
115
+ ...describeFile(outcome.record),
116
+ };
117
+ }
118
+
119
+ export async function updateFile(ref, patch = {}) {
120
+ const dryRun = patch['dry-run'];
121
+ assertCategory(patch.category);
122
+ const store = await loadStore();
123
+ const target = mustFind(store, ref);
124
+
125
+ const hasPhysicalChange = patch.category !== undefined || patch.name !== undefined;
126
+ const hasMetaChange = patch.tags !== undefined || patch.note !== undefined;
127
+ if (!hasPhysicalChange && !hasMetaChange) {
128
+ throw badInput('没有要改的内容:--name / --category / --tags / --note');
129
+ }
130
+ if (dryRun) {
131
+ return {
132
+ status: 'skipped', dryRun: true,
133
+ wouldChange: {
134
+ category: patch.category ?? target.category, name: patch.name ?? target.name,
135
+ tags: patch.tags ?? target.tags, note: patch.note ?? target.note,
136
+ },
137
+ };
138
+ }
139
+
140
+ const snapshot = await snapshotStore(`file-update-${target.id}`);
141
+ // 物理文件先改(失败即抛,台账不动)
142
+ const relocated = hasPhysicalChange
143
+ ? await relocateFile(store, target, {
144
+ category: patch.category ?? target.category,
145
+ name: patch.name ?? target.name,
146
+ })
147
+ : target;
148
+ await mutateStore((s) => {
149
+ const r = mustFind(s, target.id);
150
+ r.rel = relocated.rel;
151
+ r.name = relocated.name;
152
+ r.category = relocated.category;
153
+ if (patch.tags !== undefined) r.tags = patch.tags.map(String);
154
+ if (patch.note !== undefined) r.note = String(patch.note);
155
+ r.updatedAt = nowIso();
156
+ });
157
+ const after = await loadStore();
158
+ return { status: 'ok', id: target.id, ...describeFile(mustFind(after, target.id)), snapshot };
159
+ }
160
+
161
+ export async function removeFile(ref, patch = {}) {
162
+ const dryRun = patch['dry-run'];
163
+ const store = await loadStore();
164
+ const target = mustFind(store, ref);
165
+ if (dryRun) {
166
+ return { status: 'skipped', dryRun: true, wouldRemove: { id: target.id, name: target.name, category: target.category } };
167
+ }
168
+ const snapshot = await snapshotStore(`file-remove-${target.id}`);
169
+ // 先删物理文件(Windows 上文件被占用会直接抛错,台账保持不变)
170
+ await deletePhysical(target);
171
+ await mutateStore((s) => { s.files = s.files.filter((x) => x.id !== target.id); });
172
+ return { status: 'ok', removed: { id: target.id, name: target.name, category: target.category }, snapshot };
173
+ }
174
+
175
+ /** CLI 的 file content:给出物理路径与链接(不直接吐二进制,终端里没法看)。 */
176
+ export async function contentInfo(ref, { download } = {}) {
177
+ const store = await loadStore();
178
+ return { ...describeFile(mustFind(store, ref)), download: !!download };
179
+ }
180
+
181
+ /** HTTP 原始流:由 api.js 在 action.raw 时直接委派。 */
182
+ export async function serveRaw(ctx, { res }) {
183
+ const store = await loadStore();
184
+ const rec = mustFind(store, ctx.ref);
185
+ await streamRecord(res, rec, { download: ctx.download === true });
186
+ }
187
+
188
+ export async function restoreFileAction(ref, patch = {}) {
189
+ const dryRun = patch['dry-run'];
190
+ const store = await loadStore();
191
+ const target = mustFind(store, ref);
192
+ const exportCopy = patch.copy === true;
193
+ if (!target.sourcePath && !patch.to && !exportCopy) {
194
+ throw badInput('这是 Web 上传的附件、没有原始位置:给 --to <目标目录>,或加 --copy 只导出副本');
195
+ }
196
+ if (dryRun) {
197
+ return {
198
+ status: 'skipped', dryRun: true,
199
+ wouldRestore: { to: patch.to || (target.sourcePath ? displayPath(target.sourcePath) : ''), copy: exportCopy },
200
+ };
201
+ }
202
+ const snapshot = await snapshotStore(`file-restore-${target.id}`);
203
+ const r = await restoreFile(store, target, { to: patch.to, copy: exportCopy });
204
+ if (!exportCopy) {
205
+ await mutateStore((s) => { s.files = s.files.filter((x) => x.id !== target.id); });
206
+ }
207
+ // restored 标记:renderFileChange 靠它区分「还原」与「更新」——
208
+ // 两者的 payload 都带 path,只看 path 会把 update 误报成还原(旧 bug)。
209
+ return { status: 'ok', restored: true, exported: exportCopy, kept: r.kept, path: r.path, displayPath: r.displayPath, snapshot };
210
+ }
211
+
212
+ export async function openFileAction(ref) {
213
+ const store = await loadStore();
214
+ const rec = mustFind(store, ref);
215
+ const ok = await openPath(fileRecordAbs(rec));
216
+ if (!ok) throw external(`系统拒绝打开: ${rec.name}`);
217
+ return { status: 'ok', opened: rec.name, path: displayPath(fileRecordAbs(rec)) };
218
+ }
219
+
220
+ // —— CLI 人读渲染 ——
221
+ function fmtBytes(n) {
222
+ if (n < 1024) return `${n} B`;
223
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
224
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
225
+ }
226
+
227
+ function refLabel(u) {
228
+ return `${u.sectionTitle} / ${u.entryTitle || '(无名)'} / ${u.fieldLabel}`;
229
+ }
230
+
231
+ export function renderFileList(d) {
232
+ const lines = [`附件库 ${d.total} 个 · 共 ${fmtBytes(d.totalBytes)} · 目录 ${d.root}`];
233
+ lines.push(`被条目引用 ${d.total - d.unlinked} / ${d.total}(未引用 ${d.unlinked} 个)`);
234
+ if (d.sections.length) lines.push(`栏目:${d.sections.map((s) => `${s.title}(${s.count})`).join(' ')}`);
235
+ for (const r of d.files) {
236
+ lines.push(` ${r.id} [${r.categoryLabel}] ${r.name} ${fmtBytes(r.bytes)}`);
237
+ if (r.usedBy?.length) lines.push(` 被引用: ${r.usedBy.map(refLabel).join('、')}`);
238
+ if (r.note) lines.push(` 备注: ${r.note}`);
239
+ }
240
+ lines.push('', '导入: nx-sk file add --path <文件>(默认移动) 链接: 在栏目条目的 file 字段填本附件 id');
241
+ return lines.join('\n');
242
+ }
243
+
244
+ export function renderFileGet(d) {
245
+ const lines = [
246
+ `${d.name}(${d.id})· ${d.categoryLabel} · ${fmtBytes(d.bytes)}`,
247
+ `路径: ${d.displayPath}`,
248
+ `链接: ${d.url}`,
249
+ ];
250
+ if (d.usedBy?.length) lines.push(`被引用: ${d.usedBy.map(refLabel).join('、')}`);
251
+ else lines.push('被引用: (没有条目引用;在栏目的 file 字段里填本附件 id 即建立链接)');
252
+ if (d.sourceDisplay) lines.push(`原始: ${d.sourceDisplay}(${d.mode === 'move' ? '移动导入' : '复制导入'})`);
253
+ if (d.tags.length) lines.push(`标签: ${d.tags.join('、')}`);
254
+ if (d.note) lines.push(`备注: ${d.note}`);
255
+ return lines.join('\n');
256
+ }
257
+
258
+ export function renderFileChange(d) {
259
+ if (d.status === 'skipped') return `试运行(未落盘):${JSON.stringify(d.wouldAdd || d.wouldChange || d.wouldRemove || d.wouldRestore)}`;
260
+ if (d.removed) return `已删除附件 ${d.removed.name}(${d.removed.id})\n快照: ${d.snapshot}`;
261
+ if (d.duplicated) return `库中已有相同文件,未重复存储:${d.name}(${d.id})`;
262
+ if (d.created) {
263
+ return `已导入附件 ${d.name}(${d.id})· ${d.categoryLabel}${d.moved ? ' · 原件已移入(nx-sk file restore 可还原)' : ''}`;
264
+ }
265
+ if (d.restored && d.exported) return `已导出副本到 ${d.displayPath}(库中保留原件)`;
266
+ if (d.restored) return `已还原到 ${d.displayPath}(台账记录已撤销)${d.snapshot ? `\n快照: ${d.snapshot}` : ''}`;
267
+ return `已更新附件 ${d.name}(${d.id})\n快照: ${d.snapshot}`;
268
+ }
269
+
270
+ export function renderContent(d) {
271
+ return `${d.name}(${d.id})\n路径: ${d.displayPath}\n浏览器链接: ${d.url}${d.download ? d.downloadUrl : ''}`;
272
+ }
273
+
274
+ export function renderOpen(d) {
275
+ return `已用系统默认程序打开:${d.opened} ${d.path}`;
276
+ }
@@ -0,0 +1,342 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { api } from '../../web/frontend/api/client.js';
3
+ import { fileToDataUrl, refreshAttachments } from '../../web/frontend/api/files.js';
4
+ import { useStore } from '../../web/frontend/store.jsx';
5
+ import { CliHints, Copyable, Empty, Modal, Tag } from '../../web/frontend/components/ui.jsx';
6
+
7
+ function fmtBytes(n) {
8
+ if (n < 1024) return `${n} B`;
9
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
10
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
11
+ }
12
+
13
+ const refText = (u) => `${u.sectionTitle} / ${u.entryTitle || '(无名)'} / ${u.fieldLabel}`;
14
+
15
+ /**
16
+ * 附件工作台:统一**展示与库存管理**(上传 / 本机路径导入、预览、下载、编辑、还原、删除)。
17
+ *
18
+ * 组织方式**完全由栏目驱动**——按「哪些栏目在引用附件」筛选,不预定义任何分类或槽位。
19
+ * 真正的「上传并挂到某条条目」发生在本栏目条目的 `file` 字段里;这里只是所有原件的总览。
20
+ */
21
+ export default function FilesView() {
22
+ const { toast, guard, dialog, reload } = useStore();
23
+ const [data, setData] = useState(null);
24
+ const [err, setErr] = useState(null);
25
+ const [filter, setFilter] = useState(''); // '' 全部 | 'none' 未引用 | <栏目 id>
26
+ const [q, setQ] = useState('');
27
+ const [dragOver, setDragOver] = useState(false);
28
+ const [modal, setModal] = useState(null);
29
+ const pickerRef = useRef(null);
30
+ const dragDepth = useRef(0);
31
+
32
+ const load = useCallback(async () => {
33
+ try {
34
+ const params = new URLSearchParams();
35
+ if (filter) params.set('section', filter);
36
+ if (q) params.set('q', q);
37
+ const qs = params.toString();
38
+ const d = await api(`/api/files${qs ? `?${qs}` : ''}`);
39
+ setData(d);
40
+ setErr(null);
41
+ } catch (e) {
42
+ setErr(e.message);
43
+ setData(null);
44
+ }
45
+ }, [filter, q]);
46
+
47
+ useEffect(() => { load(); }, [load]);
48
+
49
+ const uploadFiles = useCallback(async (fileList) => {
50
+ const files = Array.from(fileList || []);
51
+ if (!files.length) return;
52
+ let ok = 0;
53
+ for (const file of files) {
54
+ try {
55
+ const content = await fileToDataUrl(file);
56
+ await api('/api/files', { method: 'POST', body: { content, name: file.name } });
57
+ ok += 1;
58
+ } catch (e) {
59
+ toast(`${file.name} 上传失败:${e.message}`, 'bad');
60
+ }
61
+ }
62
+ if (ok) toast(`已导入 ${ok} 个附件(到栏目条目里选它即可链接)`);
63
+ await refreshAttachments();
64
+ await load();
65
+ await reload();
66
+ }, [load, reload, toast]);
67
+
68
+ const onDrop = useCallback((e) => {
69
+ e.preventDefault();
70
+ dragDepth.current = 0;
71
+ setDragOver(false);
72
+ uploadFiles(e.dataTransfer.files);
73
+ }, [uploadFiles]);
74
+
75
+ const openPathImport = guard(async () => {
76
+ setModal({ kind: 'path', form: { path: '', copy: false } });
77
+ });
78
+
79
+ const submitPathImport = guard(async (form) => {
80
+ if (!form.path.trim()) { toast('请填本机文件的绝对路径', 'bad'); return; }
81
+ const r = await api('/api/files', { method: 'POST', body: { path: form.path.trim(), copy: form.copy } });
82
+ setModal(null);
83
+ toast(r.duplicated ? '库中已有相同文件' : (r.moved ? '已移动进附件库(可还原)' : '已复制进附件库'));
84
+ await refreshAttachments();
85
+ await load();
86
+ await reload();
87
+ });
88
+
89
+ const openEdit = guard(async (file) => {
90
+ setModal({
91
+ kind: 'edit',
92
+ id: file.id,
93
+ form: { name: file.name, tags: file.tags.join('、'), note: file.note },
94
+ });
95
+ });
96
+
97
+ const submitEdit = guard(async (id, form) => {
98
+ await api(`/api/files/${encodeURIComponent(id)}`, {
99
+ method: 'PATCH',
100
+ body: {
101
+ name: form.name,
102
+ tags: form.tags.split(/[,,、;;]+/).map((x) => x.trim()).filter(Boolean),
103
+ note: form.note,
104
+ },
105
+ });
106
+ setModal(null);
107
+ toast('附件已更新');
108
+ await refreshAttachments();
109
+ await load();
110
+ });
111
+
112
+ const restore = guard(async (file) => {
113
+ const answer = await dialog({
114
+ message: `把「${file.name}」还原到哪?`,
115
+ detail: file.sourceDisplay
116
+ ? `默认移回原始位置并撤销台账:${file.sourceDisplay}`
117
+ : '该附件没有原始位置记录,请在下一步填写目标目录。',
118
+ input: file.sourceDisplay ? undefined : { placeholder: '目标目录绝对路径,例如 D:\\资料' },
119
+ confirmText: '移回并撤销台账',
120
+ cancelText: '取消',
121
+ });
122
+ if (answer === null) return;
123
+ const body = {};
124
+ if (answer && typeof answer === 'string') body.to = answer.trim();
125
+ const r = await api(`/api/files/${encodeURIComponent(file.id)}/restore`, { method: 'POST', body });
126
+ toast(`已还原到 ${r.displayPath}`);
127
+ await refreshAttachments();
128
+ await load();
129
+ await reload();
130
+ });
131
+
132
+ const exportCopy = guard(async (file) => {
133
+ const to = await dialog({
134
+ message: `导出「${file.name}」的副本到哪个目录?库中原件保留。`,
135
+ input: { placeholder: '目标目录绝对路径,例如 D:\\资料' },
136
+ confirmText: '导出副本',
137
+ });
138
+ if (!to) return;
139
+ const r = await api(`/api/files/${encodeURIComponent(file.id)}/restore`, {
140
+ method: 'POST',
141
+ body: { to: String(to).trim(), copy: true },
142
+ });
143
+ toast(`已导出到 ${r.displayPath}`);
144
+ });
145
+
146
+ const remove = guard(async (file) => {
147
+ const ok = await dialog({
148
+ message: `删除附件「${file.name}」?物理原件会一起删除,删除前自动留台账快照。`,
149
+ detail: '想保留原件、只撤销台账,请改用「还原」。',
150
+ danger: true,
151
+ confirmText: '删除',
152
+ });
153
+ if (!ok) return;
154
+ await api(`/api/files/${encodeURIComponent(file.id)}`, { method: 'DELETE' });
155
+ toast('已删除');
156
+ await refreshAttachments();
157
+ await load();
158
+ await reload();
159
+ });
160
+
161
+ if (err) {
162
+ return <div className="pad stack"><div className="bad"><strong>附件库读不到</strong></div><pre className="block">{err}</pre></div>;
163
+ }
164
+ if (!data) return <div className="pad muted">加载中…</div>;
165
+
166
+ const sections = data.sections || [];
167
+
168
+ return (
169
+ <div
170
+ className={`files-page ${dragOver ? 'drag-over' : ''}`}
171
+ onDragEnter={(e) => { e.preventDefault(); dragDepth.current += 1; setDragOver(true); }}
172
+ onDragOver={(e) => e.preventDefault()}
173
+ onDragLeave={(e) => {
174
+ e.preventDefault();
175
+ dragDepth.current -= 1;
176
+ if (dragDepth.current <= 0) { dragDepth.current = 0; setDragOver(false); }
177
+ }}
178
+ onDrop={onDrop}
179
+ >
180
+ <div className="toolbar rowgap pad">
181
+ <strong>附件</strong>
182
+ <span className="muted">{data.total} 个 · {fmtBytes(data.totalBytes)}</span>
183
+ <span className="hint" title="被栏目条目的 file 字段引用的附件数">被引用 {data.total - data.unlinked}/{data.total}</span>
184
+ <Copyable className="hint" text={data.rootRaw} title="点击复制附件库目录">{data.root}</Copyable>
185
+ <span className="grow" />
186
+ <input
187
+ style={{ width: 180 }}
188
+ placeholder="搜索名称 / 备注 / 标签 / 引用它的条目"
189
+ value={q}
190
+ onChange={(e) => setQ(e.target.value)}
191
+ />
192
+ <button className="btn small ghost" onClick={load}>刷新</button>
193
+ <button className="btn small ghost" onClick={openPathImport}>本机路径导入</button>
194
+ <button className="btn small" onClick={() => pickerRef.current?.click()}>上传文件</button>
195
+ <input
196
+ ref={pickerRef}
197
+ type="file"
198
+ multiple
199
+ style={{ display: 'none' }}
200
+ onChange={(e) => { uploadFiles(e.target.files); e.target.value = ''; }}
201
+ />
202
+ </div>
203
+
204
+ {/* 筛选按**栏目**驱动:栏目有 file 字段就会出现一个筛选片,不预定义任何分类 */}
205
+ <div className="rowgap pad file-filters">
206
+ <button className={`tab small ${!filter ? 'active' : ''}`} onClick={() => setFilter('')}>
207
+ 全部<span className="count">{data.total}</span>
208
+ </button>
209
+ {sections.map((s) => (
210
+ <button
211
+ key={s.id}
212
+ className={`tab small ${filter === s.id ? 'active' : ''}`}
213
+ onClick={() => setFilter(s.id)}
214
+ title={`被「${s.title}」栏目引用的附件`}
215
+ >
216
+ {s.title}<span className="count">{s.count}</span>
217
+ </button>
218
+ ))}
219
+ <button className={`tab small ${filter === 'none' ? 'active' : ''}`} onClick={() => setFilter('none')}>
220
+ 未引用<span className="count">{data.unlinked}</span>
221
+ </button>
222
+ </div>
223
+
224
+ {dragOver ? <div className="drop-hint">松开鼠标,文件直接导入附件库</div> : null}
225
+
226
+ {data.files.length ? (
227
+ <div className="file-grid pad">
228
+ {data.files.map((file) => (
229
+ <div key={file.id} className="file-card">
230
+ <a className="file-thumb" href={file.url} target="_blank" rel="noreferrer" title="在浏览器中预览">
231
+ {file.isImage
232
+ ? <img src={file.url} alt={file.name} loading="lazy" />
233
+ : <span className="ext-badge">{file.ext ? file.ext.slice(1).toUpperCase() : 'FILE'}</span>}
234
+ </a>
235
+ <div className="file-meta">
236
+ <a className="file-name" href={file.url} target="_blank" rel="noreferrer" title={file.name}>{file.name}</a>
237
+ <div className="rowgap" style={{ gap: 4 }}>
238
+ <span className="hint">{fmtBytes(file.bytes)}</span>
239
+ {file.mode === 'move' ? <Tag>移动导入</Tag> : null}
240
+ </div>
241
+ {file.usedBy?.length
242
+ ? <div className="hint" title="条目的 file 字段引用了本附件">被引用:{file.usedBy.map(refText).join('、')}</div>
243
+ : <div className="hint">未被任何条目引用(到栏目条目里选它即可链接)</div>}
244
+ </div>
245
+ <div className="file-acts">
246
+ <a className="btn small ghost" href={file.downloadUrl}>下载</a>
247
+ <button className="btn small ghost" onClick={() => exportCopy(file)}>导出副本</button>
248
+ <button className="btn small ghost" onClick={() => restore(file)}>还原</button>
249
+ <button className="btn small ghost" onClick={() => openEdit(file)}>编辑</button>
250
+ <button className="btn small ghost danger-ghost" onClick={() => remove(file)}>删除</button>
251
+ <span className="grow" />
252
+ <Copyable className="hint" text={file.path} title="点击复制物理路径">路径</Copyable>
253
+ <Copyable className="hint" text={file.url} title="点击复制链接">链接</Copyable>
254
+ </div>
255
+ </div>
256
+ ))}
257
+ </div>
258
+ ) : (
259
+ <div className="pad">
260
+ <Empty>还没有附件。把文件拖到这里、点「上传文件」,或用「本机路径导入」把原件移动进库;随后到栏目的条目里选它。</Empty>
261
+ <div className="hint pad">CLI:nx-sk file add --path "D:\简历\张三-简历.pdf"(默认移动;--copy 仅复制)</div>
262
+ </div>
263
+ )}
264
+
265
+ <CliHints module="files" />
266
+
267
+ {modal ? (
268
+ <Modal title={modal.kind === 'edit' ? '编辑附件' : '从本机路径导入'} onClose={() => setModal(null)}>
269
+ {modal.kind === 'path' ? (
270
+ <PathImportForm
271
+ form={modal.form}
272
+ onChange={(f) => setModal((m) => ({ ...m, form: { ...m.form, ...f } }))}
273
+ onSubmit={() => submitPathImport(modal.form)}
274
+ />
275
+ ) : (
276
+ <EditForm
277
+ form={modal.form}
278
+ onChange={(f) => setModal((m) => ({ ...m, form: { ...m.form, ...f } }))}
279
+ onSubmit={() => submitEdit(modal.id, modal.form)}
280
+ />
281
+ )}
282
+ </Modal>
283
+ ) : null}
284
+ </div>
285
+ );
286
+ }
287
+
288
+ function PathImportForm({ form, onChange, onSubmit }) {
289
+ return (
290
+ <div className="stack">
291
+ <div className="field wide">
292
+ <div className="flabel"><span>本机文件的绝对路径</span></div>
293
+ <div className="fctrl">
294
+ <input
295
+ value={form.path}
296
+ placeholder='例如 D:\简历\张三-简历.pdf'
297
+ onChange={(e) => onChange({ path: e.target.value })}
298
+ />
299
+ </div>
300
+ </div>
301
+ <div className="field wide">
302
+ <div className="flabel"><span>导入方式</span></div>
303
+ <div className="fctrl">
304
+ <label className="inline">
305
+ <input
306
+ type="checkbox"
307
+ checked={form.copy}
308
+ onChange={(e) => onChange({ copy: e.target.checked })}
309
+ />
310
+ <span className="hint">仅复制、保留原件(默认把原件移动进库,可用「还原」撤销)</span>
311
+ </label>
312
+ </div>
313
+ </div>
314
+ <div className="rowgap">
315
+ <button className="btn" onClick={onSubmit}>导入</button>
316
+ </div>
317
+ </div>
318
+ );
319
+ }
320
+
321
+ function EditForm({ form, onChange, onSubmit }) {
322
+ return (
323
+ <div className="stack">
324
+ <div className="field wide">
325
+ <div className="flabel"><span>文件名</span></div>
326
+ <div className="fctrl"><input value={form.name} onChange={(e) => onChange({ name: e.target.value })} /></div>
327
+ </div>
328
+ <div className="field wide">
329
+ <div className="flabel"><span>标签</span><span className="fkey">多个用「、」分隔</span></div>
330
+ <div className="fctrl"><input value={form.tags} onChange={(e) => onChange({ tags: e.target.value })} /></div>
331
+ </div>
332
+ <div className="field wide">
333
+ <div className="flabel"><span>备注</span></div>
334
+ <div className="fctrl"><textarea rows={2} value={form.note} onChange={(e) => onChange({ note: e.target.value })} /></div>
335
+ </div>
336
+ <div className="rowgap">
337
+ <button className="btn" onClick={onSubmit}>保存</button>
338
+ <span className="hint">改文件名时,物理原件会随之移动。要链接到条目,请在栏目条目的 file 字段里选本附件。</span>
339
+ </div>
340
+ </div>
341
+ );
342
+ }
@@ -87,6 +87,16 @@ export default {
87
87
  run: (ctx) => service.updateSection(ctx.ref, ctx),
88
88
  render: (d) => service.renderSectionChange(d),
89
89
  },
90
+ {
91
+ id: 'section.sync',
92
+ cli: ['section', 'sync'],
93
+ http: ['POST', '/api/sections/:ref/sync'],
94
+ summary: '按模板补齐字段:把模板新增、本栏目还缺的分组与字段补进来(只增不改不删)。模板演进不会回溯到已建栏目,用它追上',
95
+ args: ['ref'],
96
+ flags: { 'dry-run': { type: 'boolean', hint: '只列出会补哪些分组/字段,不落盘' } },
97
+ run: (ctx) => service.syncSection(ctx.ref, ctx),
98
+ render: (d) => service.renderSectionChange(d),
99
+ },
90
100
  {
91
101
  id: 'section.remove',
92
102
  cli: ['section', 'remove'],