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
@@ -3,7 +3,7 @@
3
3
  import { loadStore, mutateStore, snapshotStore } from '../../core/store.js';
4
4
  import { assertFieldKey, assertSafeId } from '../../core/paths.js';
5
5
  import { badInput, blocked, conflict, notFound } from '../../core/errors.js';
6
- import { FILL_POLICIES, FIELD_TYPES, findSection, groupFields, instantiateTemplate, sectionNames, syncSectionGroups, templateSummaries } from '../../core/fields.js';
6
+ import { FILL_POLICIES, FIELD_TYPES, findSection, getTemplate, groupFields, instantiateTemplate, mergeTemplate, sectionNames, syncSectionGroups, templateSummaries } from '../../core/fields.js';
7
7
  import { dumpSection, formatValue } from '../../core/render.js';
8
8
  import { sensitiveViewer } from '../../core/vault.js';
9
9
  import { nowIso } from '../../core/ids.js';
@@ -207,6 +207,15 @@ export async function updateSection(ref, patch = {}) {
207
207
  }
208
208
  }
209
209
 
210
+ // 不变量:分组声明 ⊇ 字段引用。add-field 若引用了未声明的分组,
211
+ // 必须在这里补上声明——否则面板读声明数组渲染,整组字段会静默消失
212
+ // (CLI 从字段推导所以看不出来,这个坑在 servers 栏目踩过一次)。
213
+ if (changes.fields) {
214
+ const base = changes.groups || target.groups;
215
+ const synced = syncSectionGroups(base, changes.fields);
216
+ if (synced.length !== base.length) changes.groups = synced;
217
+ }
218
+
210
219
  if (!Object.keys(changes).length) {
211
220
  throw badInput('没有要修改的内容(可改 title / description / order / fields / groups / add-field / remove-field / fill)');
212
221
  }
@@ -224,6 +233,41 @@ export async function updateSection(ref, patch = {}) {
224
233
  return { status: 'ok', id: target.id, changed: Object.keys(changes), added, removed, fillChanged, section: summarize(after, mustFind(after, target.id)), snapshot };
225
234
  }
226
235
 
236
+ /**
237
+ * 按模板补齐栏目字段:把模板里新增、而本栏目还缺的分组与字段补进来(只增不改不删)。
238
+ *
239
+ * 用途:栏目字段字典是持久化数据,模板演进不会回溯。用户在模板里加了字段
240
+ * (例如给求职加了 `file` 类型的「附件资料」组),但**已经建好的栏目**看不到——
241
+ * 跑一次 `section sync` 就能把缺口补上,且不动用户已有的定义与数据。
242
+ */
243
+ export async function syncSection(ref, { 'dry-run': dryRun } = {}) {
244
+ const store = await loadStore();
245
+ const target = mustFind(store, ref);
246
+ if (!target.template) {
247
+ throw badInput(`栏目「${target.title}」不是由模板创建的(template 为空),没有可同步的模板;加字段请用 section update --add-field。`);
248
+ }
249
+ const tpl = getTemplate(target.template);
250
+ if (!tpl) throw badInput(`栏目记录了模板 ${target.template},但该模板已不存在(可能被重命名或移除)。`);
251
+
252
+ const { groups, fields, addedGroups, addedFields } = mergeTemplate(target, tpl);
253
+ if (!addedGroups.length && !addedFields.length) {
254
+ return { status: 'ok', upToDate: true, id: target.id, addedGroups: [], addedFields: [], section: summarize(store, target) };
255
+ }
256
+ if (dryRun) {
257
+ return { status: 'skipped', dryRun: true, wouldSync: { groups: addedGroups, fields: addedFields } };
258
+ }
259
+
260
+ const snapshot = await snapshotStore(`section-sync-${target.id}`);
261
+ await mutateStore((s) => {
262
+ const t = mustFind(s, ref);
263
+ t.groups = groups;
264
+ t.fields = fields;
265
+ t.updatedAt = nowIso();
266
+ });
267
+ const after = await loadStore();
268
+ return { status: 'ok', id: target.id, addedGroups, addedFields, section: summarize(after, mustFind(after, target.id)), snapshot };
269
+ }
270
+
227
271
  /**
228
272
  * remove 的幂等性选择:**报 NOT_FOUND**(默认推荐)——能从错别字里救用户一把。
229
273
  * 这条写进 summary,两端一致。
@@ -322,7 +366,7 @@ export function renderSectionDump(d) {
322
366
  }
323
367
 
324
368
  export function renderSectionChange(d) {
325
- if (d.status === 'skipped') return `试运行(未落盘):${JSON.stringify(d.wouldCreate || d.wouldChange || d.wouldRemove)}`;
369
+ if (d.status === 'skipped') return `试运行(未落盘):${JSON.stringify(d.wouldCreate || d.wouldChange || d.wouldRemove || d.wouldSync)}`;
326
370
  // `removed` 在两条路径上形状不同:remove 给的是**对象** {id,title,entries},
327
371
  // update 给的是**被删字段 key 的数组**。数组恒为真值,所以必须显式排除数组——
328
372
  // 否则 `section update` 会打印「已删除栏目 undefined」,
@@ -330,7 +374,16 @@ export function renderSectionChange(d) {
330
374
  if (d.removed && !Array.isArray(d.removed)) {
331
375
  return `已删除栏目 ${d.removed.id}(连带 ${d.removed.entries} 条条目)\n快照: ${d.snapshot}`;
332
376
  }
377
+ if (d.upToDate) return `栏目 ${d.id} 已是最新:模板里没有需要补的分组或字段。`;
333
378
  if (d.created) return `已创建栏目 ${d.section.id} · ${d.section.title}(${d.section.fields} 字段)\n快照: ${d.snapshot}`;
379
+ // sync:只增不改——把模板新增、栏目还缺的分组/字段补进来
380
+ if (d.addedFields || d.addedGroups) {
381
+ const bits = [
382
+ d.addedGroups?.length ? `新增分组 ${d.addedGroups.join(', ')}` : '',
383
+ d.addedFields?.length ? `新增字段 ${d.addedFields.join(', ')}` : '',
384
+ ].filter(Boolean).join(';');
385
+ return `已同步栏目 ${d.id} 的模板字段:${bits}\n快照: ${d.snapshot}`;
386
+ }
334
387
  const extra = [
335
388
  d.added?.length ? `新增字段 ${d.added.join(', ')}` : '',
336
389
  d.removed?.length ? `删除字段 ${d.removed.join(', ')}` : '',
@@ -3,7 +3,7 @@ import { api } from '../../web/frontend/api/client.js';
3
3
  import { useStore } from '../../web/frontend/store.jsx';
4
4
  import { CliHints, Empty, Modal, Tag } from '../../web/frontend/components/ui.jsx';
5
5
 
6
- const TYPES = ['text', 'textarea', 'number', 'date', 'month', 'bool', 'select', 'tags', 'secret'];
6
+ const TYPES = ['text', 'textarea', 'number', 'date', 'month', 'bool', 'select', 'tags', 'secret', 'file'];
7
7
 
8
8
  export default function SectionsView() {
9
9
  const { reload, toast, guard, dialog } = useStore();
@@ -74,6 +74,24 @@ export default function SectionsView() {
74
74
  await load();
75
75
  });
76
76
 
77
+ // 按模板补齐:模板新增的字段不会回溯到已建栏目,这里把缺口补上(只增不改不删)
78
+ const syncFields = guard(async (m) => {
79
+ const r = await api(`/api/sections/${encodeURIComponent(m.id)}/sync`, { method: 'POST' });
80
+ if (r.upToDate) {
81
+ toast('已是最新,模板里没有需要补的字段');
82
+ return;
83
+ }
84
+ const bits = [
85
+ r.addedGroups?.length ? `分组 ${r.addedGroups.join('、')}` : '',
86
+ r.addedFields?.length ? `字段 ${r.addedFields.join('、')}` : '',
87
+ ].filter(Boolean).join(';');
88
+ toast(`已按模板补齐:${bits || '无变化'}`);
89
+ const fresh = await api(`/api/sections/${encodeURIComponent(m.id)}`);
90
+ setModal({ ...m, full: fresh });
91
+ await load();
92
+ await reload();
93
+ });
94
+
77
95
  const removeField = guard(async (m, key) => {
78
96
  const ok = await dialog({ message: `从字段字典里删掉「${key}」?已填的值会留在条目里(只是不再显示/校验)。`, danger: true, confirmText: '删除字段' });
79
97
  if (!ok) return;
@@ -157,6 +175,7 @@ export default function SectionsView() {
157
175
  modal={modal}
158
176
  onChange={(f) => setModal((m) => ({ ...m, form: { ...m.form, ...f } }))}
159
177
  onSave={() => saveEdit(modal)}
178
+ onSync={() => syncFields(modal)}
160
179
  onAddField={(def) => addField(modal, def)}
161
180
  onRemoveField={(key) => removeField(modal, key)}
162
181
  onClose={() => setModal(null)}
@@ -212,7 +231,7 @@ function CreateForm({ templates, form, onChange, onSubmit }) {
212
231
  );
213
232
  }
214
233
 
215
- function EditForm({ modal, onChange, onSave, onAddField, onRemoveField, onClose }) {
234
+ function EditForm({ modal, onChange, onSave, onSync, onAddField, onRemoveField, onClose }) {
216
235
  const [nf, setNf] = useState({ key: '', label: '', type: 'text', group: '' });
217
236
  const full = modal.full;
218
237
  const groups = full?.groupList || [];
@@ -247,8 +266,17 @@ function EditForm({ modal, onChange, onSave, onAddField, onRemoveField, onClose
247
266
  </div>
248
267
  <div className="rowgap">
249
268
  <button className="btn" onClick={onSave}>保存栏目属性</button>
269
+ {full?.template ? (
270
+ <button className="btn ghost" onClick={onSync} title={`按模板「${full.template}」补齐新增的分组与字段(只增不改不删)`}>同步模板字段</button>
271
+ ) : null}
250
272
  <button className="btn ghost" onClick={onClose}>关闭</button>
251
273
  </div>
274
+ {full?.template ? (
275
+ <div className="hint">
276
+ 本栏目由模板「{full.template}」创建。模板后来新增的字段**不会自动**出现在这里,
277
+ 点「同步模板字段」补齐(例如求职模板新增的「附件资料」组 / 简历、头像等 file 字段)。
278
+ </div>
279
+ ) : null}
252
280
 
253
281
  <div className="card" style={{ border: '1px solid var(--soft-2)', borderRadius: 4 }}>
254
282
  <div className="colhead">字段字典({full?.fieldList?.length || 0})</div>
@@ -5,7 +5,8 @@ import { AppError, CODES, httpStatusOf, toErrorShape } from '../core/errors.js';
5
5
  import { ACTIONS } from './registry.js';
6
6
  import { applySpec, compareRoutes, compileRoute, usageOf } from './spec.js';
7
7
 
8
- const MAX_BODY = 4 * 1024 * 1024;
8
+ // 附件可能是扫描件/照片,base64 再膨胀约 1/3;32MB 上限覆盖绝大多数简历、成绩单与头像
9
+ const MAX_BODY = 32 * 1024 * 1024;
9
10
 
10
11
  export const ROUTES = ACTIONS
11
12
  .filter((a) => Array.isArray(a.http))
@@ -88,6 +89,14 @@ export async function handleApi(req, res, url) {
88
89
 
89
90
  try {
90
91
  const ctx = applySpec(route.action, raw);
92
+ // raw action:直接把文件本体流式写回,不走 JSON 包装(预览/下载附件用)
93
+ if (route.action.raw === true) {
94
+ if (typeof route.action.serveRaw !== 'function') {
95
+ throw new AppError(CODES.INTERNAL, `raw action 缺少 serveRaw: ${route.action.id}`);
96
+ }
97
+ await route.action.serveRaw(ctx, { req, res, url, transport: 'http' });
98
+ return;
99
+ }
91
100
  const data = await route.action.run(ctx, { transport: 'http' });
92
101
  return send(res, 200, { ok: true, data: data === undefined ? null : data });
93
102
  } catch (err) {
@@ -5,13 +5,15 @@ import { cliPathsOf, usageOf } from './spec.js';
5
5
 
6
6
  import sections from '../modules/sections/index.js';
7
7
  import entries from '../modules/entries/index.js';
8
+ import files from '../modules/files/index.js';
8
9
  import exportModule from '../modules/export/index.js';
10
+ import backup from '../modules/backup/index.js';
9
11
  import skill from '../modules/skill/index.js';
10
12
  import settings from '../modules/settings/index.js';
11
13
  import system from '../modules/system/index.js';
12
14
 
13
15
  // 顺序 = 面板 tab 顺序与 help 分组顺序(system 放最后,它是聚合模块)
14
- export const MODULES = [sections, entries, exportModule, skill, settings, system]
16
+ export const MODULES = [sections, entries, files, exportModule, backup, skill, settings, system]
15
17
  .slice()
16
18
  .sort((a, b) => (a.order || 50) - (b.order || 50));
17
19
 
@@ -1,4 +1,4 @@
1
- import { Suspense, useEffect, useMemo } from 'react';
1
+ import { Fragment, Suspense, useEffect, useMemo } from 'react';
2
2
  import { SECTION_VIEW, VIEWS } from './registry.js';
3
3
  import { Copyable, DialogHost, ErrorBoundary, Toasts } from './components/ui.jsx';
4
4
  import { useStore } from './store.jsx';
@@ -26,7 +26,11 @@ export default function App() {
26
26
  .filter((t) => (seen.has(t.id) ? false : (seen.add(t.id), true)));
27
27
  }, [boot]);
28
28
 
29
- const current = tabs.find((t) => t.id === ui.view) || tabs[0] || null;
29
+ // 没 boot 之前**不要**拿 tabs[0] 兜底:那时动态栏目还没到,tabs 里只有静态视图,
30
+ // 兜底会选中第一个静态视图并由下面的 effect 写进 ui —— 于是「默认视图 = 求职」被顶掉、
31
+ // 头部高亮也错位成「栏目」,连 `#/job` 这类深链都会被首屏覆盖。
32
+ const booted = !!boot;
33
+ const current = tabs.find((t) => t.id === ui.view) || (booted ? tabs[0] : null) || null;
30
34
 
31
35
  // hash 与 localStorage 双写对齐:只在挂载时对齐一次,之后交给 hashchange
32
36
  useEffect(() => {
@@ -43,8 +47,8 @@ export default function App() {
43
47
  }, [patchUi]);
44
48
 
45
49
  useEffect(() => {
46
- if (current && current.id !== ui.view) patchUi({ view: current.id });
47
- }, [current, ui.view, patchUi]);
50
+ if (booted && current && current.id !== ui.view) patchUi({ view: current.id });
51
+ }, [booted, current, ui.view, patchUi]);
48
52
 
49
53
  const go = (id) => {
50
54
  patchUi({ view: id });
@@ -53,27 +57,40 @@ export default function App() {
53
57
 
54
58
  return (
55
59
  <div className="app">
60
+ {/* 身份栏:左边只说「我是谁」,右边才是「存储在哪 / 能做什么」。
61
+ 原先 4 段信息(品牌 / 副标题 / 版本 / 存储路径)平铺成一行、字号相近,
62
+ 读起来分不出主次;现在按 品牌 ▸ 定位 ▸ 版本 三级收拢,元信息整体右移。 */}
56
63
  <header className="top">
57
- <span className="brand">nx-sk</span>
58
- <span className="meta">本机个人资源管理器{boot ? ` · v${boot.app.version}` : ''}</span>
59
- {boot ? (
60
- <span className="meta">
61
- 存储 <Copyable className="mono" text={boot.storePathRaw}>{boot.storePath}</Copyable>
62
- </span>
63
- ) : null}
64
+ <span className="brand">
65
+ <img src="/logo-rounded.png" alt="" />
66
+ nx-sk
67
+ </span>
68
+ <span className="tagline">本机个人资源管理器</span>
69
+ {boot ? <span className="ver mono">v{boot.app.version}</span> : null}
64
70
  <span className="right rowgap">
71
+ {boot ? (
72
+ <span className="store" title={`存储目录:${boot.storePathRaw}`}>
73
+ <span className="store-label">存储</span>
74
+ <Copyable className="mono nowrap" text={boot.storePathRaw}>{boot.storePath}</Copyable>
75
+ </span>
76
+ ) : null}
65
77
  <button className="btn small ghost" onClick={() => reload()}>刷新数据</button>
66
78
  </span>
67
79
  </header>
68
80
 
81
+ {/* 导航栏:内容(栏目)在前、工具(栏目/附件/导出/…)在后,中间加一道细线分组,
82
+ 否则十几个同级 pill 连成一排,看不出「哪些是自己的数据、哪些是系统功能」。 */}
69
83
  <nav className="tabs">
70
- {tabs.map((t) => (
71
- <button key={t.id} className={`tab ${t.id === current?.id ? 'active' : ''}`} onClick={() => go(t.id)}>
72
- {t.title}
73
- {typeof t.count === 'number' ? <span className="count">{t.count}</span> : null}
74
- </button>
84
+ {tabs.map((t, i) => (
85
+ <Fragment key={t.id}>
86
+ {i > 0 && tabs[i - 1].kind !== t.kind ? <span className="tabs-sep" aria-hidden /> : null}
87
+ <button className={`tab ${t.id === current?.id ? 'active' : ''}`} onClick={() => go(t.id)}>
88
+ {t.title}
89
+ {typeof t.count === 'number' ? <span className="count">{t.count}</span> : null}
90
+ </button>
91
+ </Fragment>
75
92
  ))}
76
- {!tabs.length ? <span className="meta">正在装载…</span> : null}
93
+ {!tabs.length ? <span className="muted">正在装载…</span> : null}
77
94
  </nav>
78
95
 
79
96
  <main className="body">
@@ -84,7 +101,8 @@ export default function App() {
84
101
  <div className="hint">确认 nx-sk serve 正在运行;或点右上角「刷新数据」重试。</div>
85
102
  </div>
86
103
  ) : null}
87
- {!bootError && current ? (
104
+ {!bootError && !booted ? <div className="pad muted">正在装载…</div> : null}
105
+ {!bootError && booted && current ? (
88
106
  <ErrorBoundary key={current.id}>
89
107
  <Suspense fallback={<div className="pad muted">加载中…</div>}>
90
108
  {current.kind === 'section'
@@ -0,0 +1,28 @@
1
+ // 附件库的前端读取:带缓存的列表,供字段编辑器等多处复用。
2
+ // 工作台导入/删除后调 refreshAttachments() 失效缓存。
3
+ import { api } from './client.js';
4
+
5
+ let pending = null;
6
+
7
+ export function listAttachments(force = false) {
8
+ if (!force && pending) return pending;
9
+ pending = api('/api/files')
10
+ .then((d) => d)
11
+ .catch((e) => { pending = null; throw e; });
12
+ return pending;
13
+ }
14
+
15
+ export function refreshAttachments() {
16
+ pending = null;
17
+ return listAttachments(true);
18
+ }
19
+
20
+ /** 读取 File 对象为 base64(data URL)。 */
21
+ export function fileToDataUrl(file) {
22
+ return new Promise((resolve, reject) => {
23
+ const reader = new FileReader();
24
+ reader.onload = () => resolve(reader.result);
25
+ reader.onerror = () => reject(new Error('读取文件失败'));
26
+ reader.readAsDataURL(file);
27
+ });
28
+ }
@@ -1,9 +1,80 @@
1
1
  // 字段编辑器:按字段字典的 type 决定控件。
2
2
  // 字典来自后端(section.fields),所以**面板能填的字段与 CLI 能填的字段天然一致**。
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { api } from '../api/client.js';
5
+ import { fileToDataUrl, listAttachments, refreshAttachments } from '../api/files.js';
6
+ import { Copyable } from './ui.jsx';
7
+ import { useStore } from '../store.jsx';
8
+
9
+ /**
10
+ * file 字段控件:值 = 附件 id。
11
+ * **上传 / 选择就发生在这里**(栏目条目的字段里)——「文件在栏目中链接」;
12
+ * 「附件」中心只负责统一展示与库存管理,不在这里做指派,也不预定义任何槽位。
13
+ */
14
+ export function FileFieldControl({ def, value, onChange }) {
15
+ const { toast, guard, patchUi } = useStore();
16
+ const [data, setData] = useState(null);
17
+ const uploadRef = useRef(null);
18
+ const id = value || '';
19
+
20
+ const load = useCallback(async () => {
21
+ try { setData(await listAttachments()); } catch { /* 附件库读不到时仍可上传 */ }
22
+ }, []);
23
+ useEffect(() => { load(); }, [load]);
24
+
25
+ const current = (data?.files || []).find((f) => f.id === id) || null;
26
+
27
+ const upload = guard(async (fileList) => {
28
+ const file = fileList?.[0];
29
+ if (!file) return;
30
+ const content = await fileToDataUrl(file);
31
+ const r = await api('/api/files', { method: 'POST', body: { content, name: file.name } });
32
+ onChange(r.id);
33
+ toast(r.duplicated ? '库中已有相同文件,已直接引用' : '已上传并关联');
34
+ setData(await refreshAttachments());
35
+ });
36
+
37
+ return (
38
+ <div className="file-picker">
39
+ {current ? (
40
+ <div className="file-preview">
41
+ {current.isImage
42
+ ? <img src={current.url} alt={current.name} />
43
+ : <span className="ext-badge" style={{ fontSize: 13, width: 40, textAlign: 'center' }}>{current.ext ? current.ext.slice(1).toUpperCase() : 'FILE'}</span>}
44
+ <a className="file-preview-name grow" href={current.url} target="_blank" rel="noreferrer" title={current.displayPath}>{current.name}</a>
45
+ <a className="btn small ghost" href={current.downloadUrl}>下载</a>
46
+ <Copyable className="hint" text={current.path} title="点击复制物理路径">路径</Copyable>
47
+ <button type="button" className="btn small ghost" onClick={() => onChange('')}>清除</button>
48
+ </div>
49
+ ) : null}
50
+ <div className="file-pick-row">
51
+ <select value={id} onChange={(e) => onChange(e.target.value)}>
52
+ <option value="">(未选择:从附件库选一个)</option>
53
+ {(data?.files || []).map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}
54
+ </select>
55
+ <button type="button" className="btn small" onClick={() => uploadRef.current?.click()}>上传文件</button>
56
+ <button type="button" className="btn small ghost" onClick={() => patchUi({ view: 'files' })}>附件中心</button>
57
+ <input
58
+ ref={uploadRef}
59
+ type="file"
60
+ style={{ display: 'none' }}
61
+ onChange={(e) => { upload(e.target.files); e.target.value = ''; }}
62
+ />
63
+ </div>
64
+ {id && !current ? <span className="hint bad">引用的附件已不存在({id})—— 重新上传,或清除。</span> : null}
65
+ {def.hint ? <span className="hint">{def.hint}</span> : null}
66
+ </div>
67
+ );
68
+ }
69
+
3
70
  export function FieldControl({ def, value, onChange, disabled }) {
4
71
  const type = def.type || 'text';
5
72
  const v = value === null || value === undefined ? '' : value;
6
73
 
74
+ if (type === 'file') {
75
+ return <FileFieldControl def={def} value={v} onChange={onChange} />;
76
+ }
77
+
7
78
  if (type === 'bool') {
8
79
  const checked = v === true || v === 'true' || v === '是';
9
80
  return (
@@ -64,7 +135,7 @@ export function FieldGroup({ title, fields, draft, onPatch }) {
64
135
  <div className="pad">
65
136
  <div className="grid">
66
137
  {fields.map((def) => (
67
- <div key={def.key} className={`field ${def.type === 'textarea' ? 'wide' : ''}`}>
138
+ <div key={def.key} className={`field ${def.type === 'textarea' || def.type === 'file' ? 'wide' : ''}`}>
68
139
  <div className="flabel">
69
140
  <span>{def.label}</span>
70
141
  {def.sensitive ? <span className="tag bad">密文</span> : null}
@@ -4,6 +4,10 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>nx-sk · 个人资源管理器</title>
7
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
8
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
9
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
10
+ <link rel="apple-touch-icon" href="/logo-rounded.png" />
7
11
  </head>
8
12
  <body>
9
13
  <div id="root"></div>
Binary file
@@ -10,7 +10,9 @@ import { lazy } from 'react';
10
10
  export const VIEWS = [
11
11
  { id: 'sections', title: '栏目', order: 500, component: lazy(() => import('../../modules/sections/view.jsx')) },
12
12
  { id: 'entries', title: '条目', order: 600, tab: false, component: lazy(() => import('../../modules/entries/view.jsx')) },
13
+ { id: 'files', title: '附件', order: 650, component: lazy(() => import('../../modules/files/view.jsx')) },
13
14
  { id: 'export', title: '导出', order: 700, component: lazy(() => import('../../modules/export/view.jsx')) },
15
+ { id: 'backup', title: '加密备份', order: 750, component: lazy(() => import('../../modules/backup/view.jsx')) },
14
16
  { id: 'settings', title: '设置', order: 900, component: lazy(() => import('../../modules/settings/view.jsx')) },
15
17
  ];
16
18
 
@@ -58,22 +58,43 @@ header.top {
58
58
  display: flex;
59
59
  flex-wrap: wrap;
60
60
  align-items: center;
61
- gap: 10px;
62
- padding: 6px 14px;
61
+ gap: 12px;
62
+ padding: 10px 14px;
63
63
  border-bottom: var(--border);
64
64
  flex-shrink: 0;
65
65
  }
66
- header.top .brand { font-weight: 700; letter-spacing: .3px; }
67
- header.top .meta { color: var(--mid); font-size: 11px; }
66
+ /* 品牌:头部唯一的「标题级」元素,字号/字重都要压过其它所有文字 */
67
+ header.top .brand { font-size: 15px; font-weight: 700; letter-spacing: .2px; line-height: 1; display: inline-flex; align-items: center; gap: 8px; }
68
+ header.top .brand img { width: 22px; height: 22px; border-radius: 6px; display: block; }
69
+ /* 定位语:紧贴品牌但从属——更小更淡,别和品牌抢第一眼 */
70
+ header.top .tagline { color: var(--mid); font-size: 11px; }
71
+ /* 版本:做成 chip,避免「本机个人资源管理器 v0.5.0」连成一句话读 */
72
+ header.top .ver {
73
+ font-size: 10px;
74
+ color: var(--mid);
75
+ background: var(--soft);
76
+ border: var(--border);
77
+ border-radius: 999px;
78
+ padding: 1px 8px;
79
+ line-height: 1.7;
80
+ }
81
+ /* 右侧元信息 + 操作:存储路径降级成弱信息,别在头部最显眼处**
82
+ 挤一段长路径(超长时省略号截断,完整值看 title) */
83
+ header.top .store { display: inline-flex; align-items: center; gap: 6px; min-width: 0; font-size: 11px; color: var(--mid); }
84
+ header.top .store .store-label { color: var(--mid); }
85
+ header.top .store .copyable { max-width: 30ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
68
86
 
69
87
  nav.tabs {
70
88
  display: flex;
71
89
  flex-wrap: wrap;
90
+ align-items: center;
72
91
  gap: 6px;
73
92
  padding: 6px 14px;
74
93
  border-bottom: var(--border);
75
94
  flex-shrink: 0;
76
95
  }
96
+ /* 分组细线:内容栏目 ‖ 系统工具 */
97
+ .tabs-sep { width: 1px; height: 18px; background: var(--soft-2); margin: 0 4px; }
77
98
 
78
99
  main.body { flex: 1; min-height: 0; overflow: auto; }
79
100
 
@@ -90,9 +111,10 @@ main.body { flex: 1; min-height: 0; overflow: auto; }
90
111
  cursor: pointer;
91
112
  display: inline-flex;
92
113
  align-items: center;
93
- gap: 6px;
114
+ gap: 7px;
94
115
  }
95
- .tab .count { color: var(--mid); font-size: 11px; }
116
+ /* 计数是「元数据」不是名字的一部分:更小 + 等宽数字,让它退到标签之后 */
117
+ .tab .count { color: var(--mid); font-size: 10px; font-variant-numeric: tabular-nums; }
96
118
  .tab.active { background: var(--paper); box-shadow: var(--shadow-relief); font-weight: 600; }
97
119
 
98
120
  /* ── 按钮:凸起 → 按下内嵌 + 下沉 1px ── */
@@ -299,3 +321,79 @@ pre.block {
299
321
  .col { flex: 1; min-width: 0; }
300
322
  .desc { color: var(--mid); }
301
323
  .cols > .col > .card { border: var(--border); border-radius: 4px; }
324
+
325
+ /* ── 附件工作台 ─────────────────────────────────────── */
326
+ .files-page { min-height: 100%; position: relative; }
327
+ .files-page.drag-over { outline: 2px dashed var(--ink); outline-offset: -8px; background: var(--soft); }
328
+ .tab.small { height: 22px; padding: 0 8px; font-size: 11px; border-radius: 6px; }
329
+ .file-filters { padding-top: 4px; padding-bottom: 4px; }
330
+ .drop-hint {
331
+ margin: 0 14px; padding: 18px; text-align: center;
332
+ border: 1px dashed var(--ink); border-radius: 8px;
333
+ background: var(--paper); font-weight: 600;
334
+ }
335
+
336
+ .file-grid {
337
+ display: grid;
338
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
339
+ gap: 10px;
340
+ }
341
+ .file-card {
342
+ display: flex;
343
+ flex-direction: column;
344
+ border: var(--border);
345
+ border-radius: 8px;
346
+ overflow: hidden;
347
+ background: var(--paper);
348
+ }
349
+ .file-thumb {
350
+ display: flex;
351
+ align-items: center;
352
+ justify-content: center;
353
+ height: 120px;
354
+ background: var(--soft);
355
+ border-bottom: var(--border);
356
+ }
357
+ .file-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
358
+ .ext-badge {
359
+ font-size: 22px;
360
+ font-weight: 700;
361
+ letter-spacing: 1px;
362
+ color: var(--mid);
363
+ font-family: ui-monospace, Consolas, monospace;
364
+ }
365
+ .file-meta { padding: 8px 10px 4px; display: flex; flex-direction: column; gap: 4px; }
366
+ a.file-name {
367
+ color: var(--ink);
368
+ font-weight: 600;
369
+ text-decoration: none;
370
+ white-space: nowrap;
371
+ overflow: hidden;
372
+ text-overflow: ellipsis;
373
+ }
374
+ a.file-name:hover { text-decoration: underline; }
375
+ .file-acts {
376
+ display: flex;
377
+ flex-wrap: wrap;
378
+ align-items: center;
379
+ gap: 4px;
380
+ padding: 6px 10px 8px;
381
+ margin-top: auto;
382
+ }
383
+ .btn.ghost.danger-ghost { color: var(--danger); }
384
+
385
+ /* ── 字段里的附件选择器 ─────────────────────────────── */
386
+ .file-picker { display: flex; flex-direction: column; gap: 4px; }
387
+ .file-picker .file-pick-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
388
+ .file-picker select { flex: 1; min-width: 120px; }
389
+ .file-preview {
390
+ display: flex; align-items: center; gap: 8px;
391
+ border: var(--border); border-radius: 6px; padding: 4px 8px;
392
+ background: var(--soft);
393
+ }
394
+ .file-preview img { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; border: var(--border); background: #fff; }
395
+ .file-preview .file-preview-name {
396
+ color: var(--ink); text-decoration: none; font-weight: 600;
397
+ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
398
+ }
399
+ .file-preview .file-preview-name:hover { text-decoration: underline; }
@@ -0,0 +1 @@
1
+ import{a as l}from"./index-B7CtqgoG.js";let r=null;function o(n=!1){return!n&&r||(r=l("/api/files").then(e=>e).catch(e=>{throw r=null,e})),r}function i(){return r=null,o(!0)}function u(n){return new Promise((e,a)=>{const t=new FileReader;t.onload=()=>e(t.result),t.onerror=()=>a(new Error("读取文件失败")),t.readAsDataURL(n)})}export{u as f,o as l,i as r};