nx-sk 0.8.0 → 0.8.2

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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
4
4
 
5
+ ## [0.8.2] - 2026-10-15
6
+
7
+ ### Fixed
8
+
9
+ - **`file update --name` 改名不带扩展名时,物理文件丢扩展名**:磁盘上只剩 `身份证人像面-赵刘学`(无 `.jpg`),而台账 `ext` 字段还记着 `.jpg`——内联预览、系统默认程序打开、`backup --with-files` 还原出来的文件全部哑掉。现在 `relocateFile` 会在新名字没有扩展名时**自动接回原扩展名**;名字自带扩展名则原样保留(不重复追加)。
10
+ - **历史连写的附件值解析不出来**:0.8.0 之前 `--set 头像图片=f_a,f_b` 会把两个 id **连成一个字符串**存进 values,`fileIdsOf` 此前不拆串内分隔符 → 面板显示「未解析」。现在 `fileIdsOf` / 前端 `toIds` 对字符串统一按 `,,、;;` 拆开——单值、数组、连写串三种历史形态都能归一(附件名里带逗号的极端情况请改用 id 关联)。
11
+
12
+ ## [0.8.1] - 2026-10-15
13
+
14
+ ### Added
15
+
16
+ - **条目里的附件字段框支持拖拽上传**:把文件(可多个)直接拖进 `file` 字段框即上传并追加到列表,悬停时虚线描边提示(与「附件」中心同一套视觉语言);超过 `maxItems` 上限时拖入会明确提示。之前只有「附件」中心页支持拖拽。
17
+
5
18
  ## [0.8.0] - 2026-10-13
6
19
 
7
20
  ### Added
package/README.md CHANGED
@@ -221,7 +221,7 @@ nx-sk backup import --file ~/nx-sk/backup/xxx.nxskbak --password '你的备份
221
221
  导入完成给出完整报告。`--replace` 才整份替换(灾难恢复 / 跨机器迁移用)。
222
222
  两种方式都先自动留安全快照。
223
223
 
224
- Web 面板的「加密备份」页同样支持:导出在浏览器下载 `.nxskbak`(可勾选连附件一起打包),
224
+ Web 面板的「导出与备份」页同样支持:导出在浏览器下载 `.nxskbak`(可勾选连附件一起打包),
225
225
  导入选择文件并输入口令、报告以弹窗呈现;口令错误或文件损坏会明确报错、不改动当前数据。
226
226
 
227
227
  ## 数据与安全
@@ -101,4 +101,4 @@ nx-sk backup import --file <备份>.nxskbak --password '口令' --replace # 整
101
101
  - 两种方式都会**先自动留安全快照**(`…-before-import.json` / `…-before-restore.json`),可回滚。
102
102
  - 敏感字段在包内是明文(靠口令保护),导入时自动用**本机 vaultKey** 重新加密落盘——报告里的值一律打码,密钥明文不进报告。
103
103
  - 带 `--with-files` 的包导入时自动还原附件原件(sha256 校验;磁盘已有且内容不同则以磁盘为准);v2 旧包可导入,但不含原件,报告会如实列出缺哪些文件。
104
- - 口令也可用环境变量 `NX_SK_BACKUP_PASSWORD` 提供;Web「加密备份」页提供同样能力(浏览器下载 / 选择文件导入;超大包建议走 CLI)。
104
+ - 口令也可用环境变量 `NX_SK_BACKUP_PASSWORD` 提供;Web 面板「导出与备份」页提供同样能力(浏览器下载 / 选择文件导入;超大包建议走 CLI)。
@@ -97,8 +97,8 @@ nx-sk file restore f_xxxx --to "D:\资料" # Web 上传件没有原始
97
97
  - **组织方式由栏目驱动、不预定义分类**:筛选片按「哪些栏目在引用附件」动态生成(外加「未引用」),
98
98
  每张卡片显示被哪个栏目 / 条目 / 字段引用;
99
99
  - 编辑弹窗可改名称 / 分类 / 标签 / 备注(改分类或文件名时物理原件随之移动);
100
- - 条目表单里的 `file` 字段是**列表**:可**多选**上传一次挂多个、从库里逐个追加、逐行移除;
101
- 保存即建立链接。
100
+ - 条目表单里的 `file` 字段是**列表**:文件可直接**拖进字段框**(可多选)、点「上传文件」多选、
101
+ 从库里逐个追加、逐行移除;保存即建立链接。
102
102
 
103
103
  ## 七、看不到 `file` 上传字段 / 条目里解析不到附件
104
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -130,8 +130,9 @@ export function normalizeFileRecord(raw) {
130
130
  /**
131
131
  * 一个 `file` 字段的值 → 附件 id 数组(**全仓库唯一的归一化入口**)。
132
132
  *
133
- * 字段可以挂**多个**附件:新写入统一存数组(哪怕只有一个);历史数据里的单值是
134
- * 字符串,读的时候按「单元素列表」看待——所以这里两种情况都要吃。
133
+ * 字段可以挂**多个**附件:新写入统一存数组(哪怕只有一个)。要吃下三种历史形态:
134
+ * 单值字符串、id 数组、以及旧版把多个 id **连写成一个字符串**(`"f_a,f_b"`——
135
+ * 0.8.0 之前 `--set` 不拆逗号),所以字符串统一按分隔符拆开。
135
136
  * 去空、去重、保序:同一个附件在一个字段里出现两次没有意义。
136
137
  */
137
138
  export function fileIdsOf(value) {
@@ -140,9 +141,11 @@ export function fileIdsOf(value) {
140
141
  : (Array.isArray(value) ? value : [value]);
141
142
  const out = [];
142
143
  for (const x of raw) {
143
- const s = String(x ?? '').trim();
144
- if (!s || out.includes(s)) continue;
145
- out.push(s);
144
+ for (const piece of String(x ?? '').split(/[,,、;;]+/)) {
145
+ const id = piece.trim();
146
+ if (!id || out.includes(id)) continue;
147
+ out.push(id);
148
+ }
146
149
  }
147
150
  return out;
148
151
  }
@@ -326,7 +329,13 @@ export async function importContent(store, opts = {}) {
326
329
  */
327
330
  export async function relocateFile(store, rec, { category, name }) {
328
331
  const cat = category || rec.category;
329
- const fileName = safeFileName(name || rec.name);
332
+ // 改名时**不带扩展名**就把原扩展名接回去——否则物理文件丢掉 .jpg/.pdf,
333
+ // 一切按扩展名认类型的地方(内联预览 / Content-Type / 系统默认程序打开 /
334
+ // backup --with-files 还原后的文件)都会哑掉,而台账里的 ext 字段还骗人说没丢。
335
+ let wanted = name || rec.name;
336
+ const originalExt = rec.ext || extname(rec.name).toLowerCase();
337
+ if (name && originalExt && !extname(wanted)) wanted += originalExt;
338
+ const fileName = safeFileName(wanted);
330
339
  const same = cat === rec.category && fileName === rec.name;
331
340
  const rel = same ? rec.rel : uniqueRel(store, cat, fileName, rec.id);
332
341
  if (!same) {
@@ -1,7 +1,7 @@
1
1
  import { useRef, useState } from 'react';
2
2
  import { api } from '../../web/frontend/api/client.js';
3
3
  import { useStore } from '../../web/frontend/store.jsx';
4
- import { CliHints, Empty, Modal } from '../../web/frontend/components/ui.jsx';
4
+ import { Empty, Modal } from '../../web/frontend/components/ui.jsx';
5
5
 
6
6
  // Web 上传走 JSON 文本体,接口上限 32MB——留出余量在前端先拦,
7
7
  // 超限建议走 CLI(命令行没有请求体上限)。
@@ -93,7 +93,9 @@ function ReplaceReport({ r }) {
93
93
  );
94
94
  }
95
95
 
96
- export default function BackupView() {
96
+ // 备份半页:由「导出与备份」合并页(export 模块的 view.jsx)组合渲染,
97
+ // 所以不带页面头与 CLI 提示(CliHints 在合并页统一出一条)。tab: false,不单独出现在导航。
98
+ export default function BackupPanel() {
97
99
  const { toast, guard, dialog, reload } = useStore();
98
100
  const [pw, setPw] = useState('');
99
101
  const [pw2, setPw2] = useState('');
@@ -177,13 +179,8 @@ export default function BackupView() {
177
179
 
178
180
  return (
179
181
  <>
180
- <div className="toolbar rowgap pad">
181
- <strong>加密备份</strong>
182
- <span className="muted">整份数据用口令对称加密(AES-256-GCM);导出设口令、恢复输口令,口令不落盘</span>
183
- </div>
184
-
185
182
  <div className="card">
186
- <div className="colhead">加密导出</div>
183
+ <div className="colhead">加密备份(口令 .nxskbak)</div>
187
184
  <div className="pad stack">
188
185
  <div className="grid">
189
186
  <div className="field">
@@ -211,7 +208,7 @@ export default function BackupView() {
211
208
  </div>
212
209
 
213
210
  <div className="card">
214
- <div className="colhead">解密导入</div>
211
+ <div className="colhead">解密导入(.nxskbak)</div>
215
212
  <div className="pad stack">
216
213
  <div className="grid">
217
214
  <div className="field">
@@ -241,8 +238,6 @@ export default function BackupView() {
241
238
  </div>
242
239
  </div>
243
240
 
244
- <CliHints module="backup" />
245
-
246
241
  {result ? (
247
242
  <Modal
248
243
  title={result.mode === 'replace' ? '导入完成(整份替换)' : '导入完成(合并)'}
@@ -2,7 +2,11 @@ import { useCallback, useEffect, useState } from 'react';
2
2
  import { api } from '../../web/frontend/api/client.js';
3
3
  import { useStore } from '../../web/frontend/store.jsx';
4
4
  import { CliHints, Copyable, Empty, Modal, Tag } from '../../web/frontend/components/ui.jsx';
5
+ import BackupPanel from '../backup/view.jsx';
5
6
 
7
+ // 「导出与备份」合并页:明文导出(带走可读文件)与口令加密备份(整份可还原)
8
+ // 对用户来说是同一件事——把数据倒出去、再倒回来,所以并成一个 tab。
9
+ // 后端仍是 export / backup 两个模块、两组 CLI 命令;页脚的 CliHints 把两组合成一条提示。
6
10
  export default function ExportView() {
7
11
  const { boot, toast, guard } = useStore();
8
12
  const [sections, setSections] = useState([]);
@@ -61,12 +65,12 @@ export default function ExportView() {
61
65
  return (
62
66
  <>
63
67
  <div className="toolbar rowgap pad">
64
- <strong>导出</strong>
65
- <span className="muted">把栏目数据导成可带走、可备份的文件</span>
68
+ <strong>导出与备份</strong>
69
+ <span className="muted">明文导出走可读文件(JSON / Markdown),加密备份走口令保护的 .nxskbak(整份可还原);都只在本机落盘</span>
66
70
  </div>
67
71
 
68
72
  <div className="card">
69
- <div className="colhead">导出选项</div>
73
+ <div className="colhead">导出为 JSON / Markdown</div>
70
74
  <div className="pad">
71
75
  <div className="grid">
72
76
  <div className="field">
@@ -111,6 +115,8 @@ export default function ExportView() {
111
115
  </div>
112
116
  </div>
113
117
 
118
+ <BackupPanel />
119
+
114
120
  <div className="card">
115
121
  <div className="colhead">历次导出<span className="right muted">{history?.count ?? 0}</span></div>
116
122
  <div className="colbody">
@@ -147,7 +153,7 @@ export default function ExportView() {
147
153
  </div>
148
154
  </div>
149
155
 
150
- <CliHints module="export" />
156
+ <CliHints module={['export', 'backup']} />
151
157
 
152
158
  {modal ? <Modal title={modal.title} onClose={() => setModal(null)}>{modal.body}</Modal> : null}
153
159
  </>
@@ -5,11 +5,17 @@ import { useStore } from './store.jsx';
5
5
 
6
6
  function viewFromHash() {
7
7
  const m = /^#\/?([^?/]*)/.exec(window.location.hash || '');
8
- return m ? decodeURIComponent(m[1]) : '';
8
+ const id = m ? decodeURIComponent(m[1]) : '';
9
+ return LEGACY_VIEW[id] || id;
9
10
  }
10
11
 
12
+ // 加密备份并入「导出与备份」合并页后,旧书签(#/backup)与 localStorage 里
13
+ // 残留的 ui.view 都要落到合并页——否则会被「找不到 tab」的兜底甩到第一个栏目。
14
+ const LEGACY_VIEW = { backup: 'export' };
15
+
11
16
  export default function App() {
12
17
  const { boot, bootError, ui, patchUi, reload } = useStore();
18
+ const view = LEGACY_VIEW[ui.view] || ui.view;
13
19
 
14
20
  // 栏目 tab 是**数据驱动**的(来自 bootstrap.sections),静态 tab 来自前端注册表。
15
21
  // 两者的 id 可能撞车时静态 tab 不参与第一优先级的排序——栏目 order 是 10/20,静态是 500+。
@@ -30,13 +36,13 @@ export default function App() {
30
36
  // 兜底会选中第一个静态视图并由下面的 effect 写进 ui —— 于是「默认视图 = 求职」被顶掉、
31
37
  // 头部高亮也错位成「栏目」,连 `#/job` 这类深链都会被首屏覆盖。
32
38
  const booted = !!boot;
33
- const current = tabs.find((t) => t.id === ui.view) || (booted ? tabs[0] : null) || null;
39
+ const current = tabs.find((t) => t.id === view) || (booted ? tabs[0] : null) || null;
34
40
 
35
41
  // hash 与 localStorage 双写对齐:只在挂载时对齐一次,之后交给 hashchange
36
42
  useEffect(() => {
37
43
  const fromHash = viewFromHash();
38
- if (fromHash && fromHash !== ui.view) patchUi({ view: fromHash });
39
- else if (!window.location.hash) window.location.hash = `#/${ui.view}`;
44
+ if (fromHash && fromHash !== view) patchUi({ view: fromHash });
45
+ else if (!window.location.hash) window.location.hash = `#/${view}`;
40
46
  // eslint-disable-next-line react-hooks/exhaustive-deps
41
47
  }, []);
42
48
 
@@ -47,8 +53,8 @@ export default function App() {
47
53
  }, [patchUi]);
48
54
 
49
55
  useEffect(() => {
50
- if (booted && current && current.id !== ui.view) patchUi({ view: current.id });
51
- }, [booted, current, ui.view, patchUi]);
56
+ if (booted && current && current.id !== view) patchUi({ view: current.id });
57
+ }, [booted, current, view, patchUi]);
52
58
 
53
59
  const go = (id) => {
54
60
  patchUi({ view: id });
@@ -37,15 +37,18 @@ function AutoTextarea({ value, onChange, minRows = 2, placeholder, disabled, max
37
37
 
38
38
  /**
39
39
  * 字段值 → 附件 id 数组。
40
- * 一个 `file` 字段可挂**多个**附件:新写入统一存数组,历史数据里的单值是字符串
41
- * (读时按单元素处理)。前端不能 import core,所以这里自带一份同口径的归一化。
40
+ * 一个 `file` 字段可挂**多个**附件:新写入统一存数组;历史形态有单值字符串、
41
+ * 甚至旧版连写的 `"f_a,f_b"`(0.8.0 前 `--set` 不拆逗号)——统一拆分隔符归一。
42
+ * 前端不能 import core,所以这里自带一份同口径的归一化。
42
43
  */
43
44
  function toIds(value) {
44
45
  const raw = value === null || value === undefined ? [] : (Array.isArray(value) ? value : [value]);
45
46
  const out = [];
46
47
  for (const x of raw) {
47
- const s = String(x ?? '').trim();
48
- if (s && !out.includes(s)) out.push(s);
48
+ for (const piece of String(x ?? '').split(/[,,、;;]+/)) {
49
+ const id = piece.trim();
50
+ if (id && !out.includes(id)) out.push(id);
51
+ }
49
52
  }
50
53
  return out;
51
54
  }
@@ -62,7 +65,10 @@ function toIds(value) {
62
65
  export function FileFieldControl({ def, value, onChange }) {
63
66
  const { toast, guard, patchUi } = useStore();
64
67
  const [data, setData] = useState(null);
68
+ const [dragOver, setDragOver] = useState(false);
65
69
  const uploadRef = useRef(null);
70
+ // dragenter/dragleave 在子元素间穿梭时会成对触发,用深度计数避免「还没离开就熄掉高亮」
71
+ const dragDepth = useRef(0);
66
72
  const ids = toIds(value);
67
73
  const max = Number(def.maxItems) || 0;
68
74
  const full = max > 0 && ids.length >= max;
@@ -99,8 +105,21 @@ export function FileFieldControl({ def, value, onChange }) {
99
105
  const add = (id) => { if (id && !ids.includes(id)) onChange([...ids, id]); };
100
106
  const removeAt = (id) => onChange(ids.filter((x) => x !== id));
101
107
 
108
+ // 拖拽文件进字段框 = 直接上传并追加(与「附件」中心同一套手感)
109
+ const onDrop = guard(async (e) => {
110
+ dragDepth.current = 0;
111
+ setDragOver(false);
112
+ await upload(e.dataTransfer?.files);
113
+ });
114
+
102
115
  return (
103
- <div className="file-picker">
116
+ <div
117
+ className={`file-picker ${dragOver ? 'drag' : ''}`}
118
+ onDragEnter={(e) => { e.preventDefault(); dragDepth.current += 1; setDragOver(true); }}
119
+ onDragOver={(e) => e.preventDefault()}
120
+ onDragLeave={() => { dragDepth.current -= 1; if (dragDepth.current <= 0) { dragDepth.current = 0; setDragOver(false); } }}
121
+ onDrop={onDrop}
122
+ >
104
123
  {ids.map((id) => {
105
124
  const rec = byId(id);
106
125
  if (!rec) {
@@ -120,9 +120,12 @@ export function DialogHost() {
120
120
  }
121
121
 
122
122
  /** 「这个页面每个按钮都有同构 CLI 命令」——提示**由数据派生**,不手写(手写必然与实际命令漂移)。 */
123
+ // module 可以是单个 id,也可以是数组——合并页(如「导出与备份」)一个页面承载
124
+ // 多个模块的命令,提示合成一条而不是每半页各来一句。
123
125
  export function CliHints({ module }) {
124
126
  const { boot } = useStore();
125
- const cmds = (boot?.commands || []).filter((c) => c.module === module);
127
+ const mods = [].concat(module);
128
+ const cmds = (boot?.commands || []).filter((c) => mods.includes(c.module));
126
129
  if (!cmds.length) return null;
127
130
  return (
128
131
  <div className="cli-hint">
@@ -11,8 +11,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
13
  { id: 'files', title: '附件', order: 650, component: lazy(() => import('../../modules/files/view.jsx')) },
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
+ { id: 'export', title: '导出与备份', order: 700, component: lazy(() => import('../../modules/export/view.jsx')) },
15
+ // 加密备份已并入「导出与备份」合并页(export 的 view 组合渲染本视图),
16
+ // 所以 tab: false——与 entries 同款:不出导航,但登记与懒加载必须保留。
17
+ { id: 'backup', title: '加密备份', order: 750, tab: false, component: lazy(() => import('../../modules/backup/view.jsx')) },
16
18
  { id: 'settings', title: '设置', order: 900, component: lazy(() => import('../../modules/settings/view.jsx')) },
17
19
  ];
18
20
 
@@ -384,6 +384,8 @@ a.file-name:hover { text-decoration: underline; }
384
384
 
385
385
  /* ── 字段里的附件选择器 ─────────────────────────────── */
386
386
  .file-picker { display: flex; flex-direction: column; gap: 4px; }
387
+ /* 拖文件悬停在字段框上:与「附件」中心同一套视觉语言(虚线描边 + 浅底) */
388
+ .file-picker.drag { outline: 2px dashed var(--ink); outline-offset: -4px; background: var(--soft); }
387
389
  .file-picker .file-pick-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
388
390
  .file-picker select { flex: 1; min-width: 120px; }
389
391
  .file-preview {
@@ -1 +1 @@
1
- import{a as l}from"./index-DzD8wu27.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};
1
+ import{a as l}from"./index-LbNmGlYj.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};