nx-sk 0.3.4 → 0.4.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 (37) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +18 -2
  3. package/assets/nx-sk/SKILL.md +0 -1
  4. package/assets/nx-sk/references/00-design.md +1 -1
  5. package/assets/nx-sk/references/10-sections-entries.md +1 -1
  6. package/assets/nx-sk/references/30-export-backup.md +16 -0
  7. package/package.json +1 -1
  8. package/src/modules/backup/index.js +41 -0
  9. package/src/modules/backup/service.js +188 -0
  10. package/src/modules/backup/view.jsx +145 -0
  11. package/src/runtime/registry.js +2 -1
  12. package/src/web/frontend/App.jsx +1 -1
  13. package/src/web/frontend/index.html +4 -0
  14. package/src/web/frontend/public/favicon-16.png +0 -0
  15. package/src/web/frontend/public/favicon-32.png +0 -0
  16. package/src/web/frontend/public/favicon-48.png +0 -0
  17. package/src/web/frontend/public/favicon.ico +0 -0
  18. package/src/web/frontend/public/logo-rounded.png +0 -0
  19. package/src/web/frontend/public/logo.png +0 -0
  20. package/src/web/frontend/registry.js +1 -0
  21. package/src/web/frontend/styles.css +2 -1
  22. package/src/web/public/assets/{index-BnVuH1lC.js → index-Bbe3iqam.js} +7 -7
  23. package/src/web/public/assets/index-C1bxqdjl.css +1 -0
  24. package/src/web/public/assets/{view-DsyplQBh.js → view-Bnwx5y_O.js} +1 -1
  25. package/src/web/public/assets/{view-rvADq3vX.js → view-CekTHUPO.js} +1 -1
  26. package/src/web/public/assets/{view-BvyRxTgX.js → view-Djo-TKw9.js} +1 -1
  27. package/src/web/public/assets/{view-CDje4hqZ.js → view-DuKJm1oq.js} +1 -1
  28. package/src/web/public/assets/view-b8TAoyhU.js +3 -0
  29. package/src/web/public/favicon-16.png +0 -0
  30. package/src/web/public/favicon-32.png +0 -0
  31. package/src/web/public/favicon-48.png +0 -0
  32. package/src/web/public/favicon.ico +0 -0
  33. package/src/web/public/index.html +6 -2
  34. package/src/web/public/logo-rounded.png +0 -0
  35. package/src/web/public/logo.png +0 -0
  36. package/assets/nx-sk/references/40-extend.md +0 -75
  37. package/src/web/public/assets/index-CwF5hM2v.css +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.0] - 2026-10-08
8
+
9
+ ### Added
10
+
11
+ - **口令对称加密的整库备份 / 恢复(导入导出)**:新增「加密备份」模块(CLI `backup export|import`,Web 同名面板,HTTP `POST /api/backup` 与 `POST /api/backup/restore`)。
12
+ - 导出:设置口令 → 整份数据(栏目 / 条目 / 设置)经 **scrypt 口令派生密钥 + AES-256-GCM** 加密为单个 `.nxskbak` 文件并在浏览器下载;文件内含随机盐,**口令不落盘**。
13
+ - 导入:选择备份文件并输入口令 → 解密校验后整份恢复;**恢复前自动给当前数据留安全快照**(可回滚)。
14
+ - 口令错误或文件被篡改:GCM 认证失败、明确报 BLOCKED,且不改动当前数据;两个动作均支持 `--dry-run`(只演练 / 只解密校验)。
15
+ - 口令也可用环境变量 `NX_SK_BACKUP_PASSWORD` 提供;复用 `core/crypto.js` 原语,与字段级密钥库(`core/vault.js`)相互独立。
16
+
7
17
  ## [0.3.4] - 2026-09-26
8
18
 
9
19
  ### Added
package/README.md CHANGED
@@ -117,6 +117,8 @@ CLI 与 API 同源,skill 驱动 agent**。落到具体功能上:
117
117
  | `nx-sk entry fields [--section job]` | 字段字典 —— 填之前先看这个,别猜字段名 |
118
118
  | `nx-sk export run [--format json\|md\|both] [--section id] [--with-secrets] [--out DIR] [--dry-run]` | 整体导出 |
119
119
  | `nx-sk export list` | 历次导出与目录内文件 |
120
+ | `nx-sk backup export [--password 口令] [--out DIR] [--dry-run]` | 口令对称加密整库,导出单个 `.nxskbak`(口令不落盘) |
121
+ | `nx-sk backup import --file <备份> [--password 口令] [--dry-run]` | 输入口令解密、整份恢复(恢复前自动留安全快照) |
120
122
  | `nx-sk setting get \| set` | 全局设置 |
121
123
  | `nx-sk skill list \| install \| get` | 把内置 skill 装给 agent / 给外部 agent 取上下文 |
122
124
 
@@ -161,6 +163,18 @@ nx-sk export run --format both --out D:/备份/nx-sk
161
163
  nx-sk export list
162
164
  ```
163
165
 
166
+ 加密备份 / 恢复(口令对称加密,AES-256-GCM;导出设口令、恢复输口令):
167
+
168
+ ```bash
169
+ nx-sk backup export --password '你的备份口令' # 写成 ~/nx-sk/backup/*.nxskbak
170
+ nx-sk backup import --file ~/nx-sk/backup/xxx.nxskbak --password '你的备份口令' --dry-run # 先只解密校验
171
+ nx-sk backup import --file ~/nx-sk/backup/xxx.nxskbak --password '你的备份口令' # 整份恢复
172
+ # 口令也可用环境变量 NX_SK_BACKUP_PASSWORD 提供
173
+ ```
174
+
175
+ Web 面板的「加密备份」页同样支持:导出在浏览器下载 `.nxskbak`,导入选择文件并输入口令;
176
+ 口令错误或文件损坏会明确报错、不改动当前数据。
177
+
164
178
  ## 数据与安全
165
179
 
166
180
  | 项 | 位置 / 做法 |
@@ -170,6 +184,7 @@ nx-sk export list
170
184
  | 密文密钥 | 环境变量 `NX_SK_PASSPHRASE`(scrypt 派生 + 盐)优先,否则 `~/nx-sk/.vaultkey`(32 字节随机,0600) |
171
185
  | 快照 | 每次破坏性写入前整库备份到 `~/nx-sk/backup/`(最多 20 份),拷回即回滚 |
172
186
  | 导出 | `~/nx-sk/export/`,默认不含凭据明文 |
187
+ | 加密备份 | `.nxskbak`:整库经 scrypt(口令)+AES-256-GCM 加密,口令不落盘;恢复前自动留安全快照 |
173
188
  | 服务绑定 | 默认 `127.0.0.1`,不暴露到局域网;写操作校验 `Origin` |
174
189
  | 数据损坏 | `store.json` 解析失败会先被改名为 `.corrupt-<时间戳>.json`,不会静默覆盖 |
175
190
 
@@ -197,7 +212,6 @@ nx-sk skill get nx-sk 10-sections-entries # 裸名 → references/10-sections-
197
212
  | `10-sections-entries` | 填/改信息的完整命令与 AI 补全流程 |
198
213
  | `20-secrets` | 密文机制、密钥来源、排查「解不开」 |
199
214
  | `30-export-backup` | 导出、快照与回滚 |
200
- | `40-extend` | 改这个项目本身:闭环落点表与静默失效点 |
201
215
 
202
216
  ## 开发
203
217
 
@@ -215,7 +229,9 @@ pnpm test # = lint + build + 冒烟(56 项断言) + 单测(40 项
215
229
  | `assets/nx-sk/` | 随包分发的 skill(SKILL.md + references/) |
216
230
  | `tests/unit/` · `tests/smoke.mjs` | 一致性断言 + 端到端冒烟 |
217
231
 
218
- **加一个功能域要碰的 12 处**(其中 3 处没有任何断言会替你发现)见 `assets/nx-sk/references/40-extend.md`。
232
+ **加一个功能域要碰的 12 处**(其中 3 处没有任何断言会替你发现)属于**开发规范、不属于随包分发的
233
+ agent 手册** —— 它写在创建本项目的脚手架 skill `server-cli-web-scaffold` 的
234
+ `references/A07-extension-loop.md`,`assets/nx-sk/` 里只放「怎么用 nx-sk」。
219
235
 
220
236
  ## License
221
237
 
@@ -73,7 +73,6 @@ nx-sk key remove OPENAI_KEY # 删除,写前自动留快照
73
73
  | `10-sections-entries` | **动手填/改信息时必读**——栏目与条目的完整命令、AI 补全个人信息的推荐流程、字段字典怎么扩、读命令与写命令的参数陷阱 |
74
74
  | `20-secrets` | 存/读大模型 key、密文机制、密钥来源(环境变量 vs 本机密钥文件)、什么时候会解不开 |
75
75
  | `30-export-backup` | 整体导出、只导出某个栏目、快照与回滚、明文密钥的取舍 |
76
- | `40-extend` | 要**改这个项目本身**时读——加栏目模板、加功能域(含 12 项闭环表与「哪些地方漏了没有断言会发现」)、四条硬纪律、验证闸门、几个刻意不该改的地方 |
77
76
 
78
77
  取 ref 全文(外部 agent 用这条,不必进终端翻文件):
79
78
 
@@ -112,5 +112,5 @@ CLI 侧这四个取值分别渲染成不同的可读提示,退出码只有「
112
112
  | 删条目/栏目不留快照 | 误删即不可逆;这是用户的**唯一一份**个人信息 | 所有破坏性写操作先 `snapshotStore()` 到 `~/nx-sk/backup/` |
113
113
  | store.json 解析失败就当空库继续 | 下一次写入把用户数据永久覆盖 | 先把损坏文件改名为 `store.json.corrupt-<时间>.json`,再降级返回空结构 |
114
114
  | 前端 import 了 `node:*` 或 `core/` | Vite 把 Node 侧代码打进浏览器包,dev 模式白屏 | lint 的 `files` 同时覆盖 `view.jsx`,禁掉 `node:*` / `core` / `runtime` / `service.js` |
115
- | 新增模块忘了往 eslint 互依禁列里补一行 | **静默**:新模块变成「谁都可以依赖」,且规则随模块数增加持续衰减 | 把「加模块必补禁列」写进 `40-extend` 的闭环表,并在禁列旁留注释说明漏补是静默的 |
115
+ | 新增模块忘了往 eslint 互依禁列里补一行 | **静默**:新模块变成「谁都可以依赖」,且规则随模块数增加持续衰减 | 在禁列旁留注释说明它是**逐模块枚举**的、漏补是静默的;加模块前先读 `eslint.config.js` |
116
116
  | 代理前缀 `/api` 与前端源码目录 `api/` 撞车 | 只在 dev 模式整页白屏,报错却是 404 / ECONNREFUSED,离根因很远 | vite 代理加 `bypass`,按「是不是前端资源」分流;并用测试钉住 `shouldServeLocally` |
@@ -31,7 +31,7 @@ store.json(~/nx-sk/store.json 或 $NX_SK_STORE)
31
31
  - `remove` 目标不存在时**报 `NOT_FOUND`**(不静默成功),就是为了让「打错一个字」当场暴露。
32
32
  - `dump` 是「快速获得一个栏目的全部信息」的正解:字段字典 + 全部条目 + 每个条目的完整度与**未填清单**。
33
33
 
34
- 内置模板:`job`(求职 / 个人信息,约 70 字段 / 9 分组)、`secret`(密钥,**单字段 KV**——细节见 `20-secrets`)。
34
+ 内置模板 5 个:`job`(求职 / 个人信息,75 字段 / 9 分组)、`projects`(项目经历)、`internships`(实习/工作经历)、`campus`(校内职务/实践)、`secret`(密钥,**单字段 KV**——细节见 `20-secrets`)。三类经历栏目(projects / internships / campus)**一个项目 / 一段经历 = 一个条目**,角色、时间、技术栈都是独立字段——不要往 job 栏目里塞经历。
35
35
 
36
36
  ## 三、条目命令
37
37
 
@@ -78,3 +78,19 @@ cp ~/nx-sk/backup/20260924-144238-entry-remove-e_xxx.json ~/nx-sk/store.json
78
78
  | 内容 | 结构化 + 可读的 Markdown;凭据默认含明文(`--no-secrets` 可打码) | 原样的整库 JSON(含密文) |
79
79
  | 位置 | `~/nx-sk/export/`(可改) | `~/nx-sk/backup/`(固定) |
80
80
  | 回滚用 | 不能直接回滚(形状不同) | 能,拷回 store.json 即可 |
81
+
82
+ ## 五、口令加密备份 / 恢复(导入导出)
83
+
84
+ 整份数据用**口令派生密钥 + AES-256-GCM** 对称加密为单个 `.nxskbak` 文件;口令不落盘。
85
+
86
+ ```bash
87
+ nx-sk backup export --password '备份口令' # → ~/nx-sk/backup/nx-sk-backup-<时间>.nxskbak
88
+ nx-sk backup export --password '备份口令' --out D:/备份 # 指定输出目录
89
+ nx-sk backup import --file <备份>.nxskbak --password '备份口令' --dry-run # 只解密校验,不改动
90
+ nx-sk backup import --file <备份>.nxskbak --password '备份口令' # 整份恢复
91
+ ```
92
+
93
+ - 导出时文件内写入**随机盐**,密钥由 `scrypt(口令, 盐)` 派生;恢复时用同一口令派生同一把密钥。
94
+ - **口令错误 / 文件被篡改**:GCM 认证失败、明确报 BLOCKED,且不改动当前数据。
95
+ - 恢复(整份替换)前会**自动给当前数据另留一份安全快照**(`…-before-restore.json`),可回滚。
96
+ - 口令也可用环境变量 `NX_SK_BACKUP_PASSWORD` 提供;Web「加密备份」页提供同样能力(浏览器下载 / 选择文件导入)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ };
@@ -0,0 +1,188 @@
1
+ // 加密备份 / 恢复:整份数据用「口令派生密钥 + AES-256-GCM」对称加密。
2
+ //
3
+ // 导出:用户设置口令 → 随机盐写进备份文件,密钥由 scrypt(口令, 盐) 派生(口令不落盘)。
4
+ // 导入:用户输入口令 → 用文件里的盐派生同一把密钥解密;口令错误 GCM 校验失败、明确报错。
5
+ //
6
+ // 复用 core/crypto.js 的原语(deriveKey / encryptValue / decryptValue),
7
+ // 与字段级密钥库(core/vault.js)相互独立:备份口令是一次性的,不写进 vault。
8
+ import fsp from 'node:fs/promises';
9
+ import { randomBytes } from 'node:crypto';
10
+ import { resolve } from 'node:path';
11
+ import { displayPath, snapshotDir } from '../../core/paths.js';
12
+ import { ensureDir, fileSize, pathExists, writeAtomic } from '../../core/fsx.js';
13
+ import { loadStore, saveStore, snapshotStore } from '../../core/store.js';
14
+ import { deriveKey, encryptValue, decryptValue } from '../../core/crypto.js';
15
+ import { badInput, blocked } from '../../core/errors.js';
16
+ import { nowIso, stamp } from '../../core/ids.js';
17
+
18
+ export const BACKUP_KIND = 'encrypted-backup';
19
+ export const BACKUP_VERSION = 1;
20
+ export const ENV_BACKUP_PASSWORD = 'NX_SK_BACKUP_PASSWORD';
21
+ const EXT = '.nxskbak';
22
+
23
+ function passwordFrom(ctx) {
24
+ const pw = ctx?.password ?? process.env[ENV_BACKUP_PASSWORD];
25
+ const s = String(pw ?? '');
26
+ if (!s) {
27
+ throw badInput(
28
+ `备份口令不能为空(用 --password,或设置环境变量 ${ENV_BACKUP_PASSWORD})。` +
29
+ '口令用于加密备份文件,导入时需要再次输入。',
30
+ );
31
+ }
32
+ return s;
33
+ }
34
+
35
+ /** 生成加密备份:返回归档对象(HTTP 端据此让浏览器下载)与条目计数。 */
36
+ export async function buildBackup(ctx) {
37
+ const password = passwordFrom(ctx);
38
+ const store = await loadStore();
39
+ const payload = {
40
+ version: store.version,
41
+ settings: store.settings,
42
+ sections: store.sections,
43
+ entries: store.entries,
44
+ exports: store.exports,
45
+ };
46
+
47
+ const salt = randomBytes(16);
48
+ const key = deriveKey(password, salt);
49
+ const data = encryptValue(key, JSON.stringify(payload));
50
+ const archive = {
51
+ app: 'nx-sk',
52
+ kind: BACKUP_KIND,
53
+ v: BACKUP_VERSION,
54
+ createdAt: nowIso(),
55
+ kdf: 'scrypt',
56
+ salt: salt.toString('base64'),
57
+ data,
58
+ };
59
+ const counts = { sections: store.sections.length, entries: store.entries.length };
60
+ if (ctx['dry-run']) return { dryRun: true, counts };
61
+ return { archive, counts };
62
+ }
63
+
64
+ /** CLI 导出:把加密备份写成文件(缺省放到 ~/nx-sk/backup)。 */
65
+ export async function exportBackup(ctx) {
66
+ const built = await buildBackup(ctx);
67
+ if (built.dryRun) return { status: 'skipped', dryRun: true, ...built.counts };
68
+ const { archive, counts } = built;
69
+ const dir = ctx.out ? resolve(ctx.out) : snapshotDir();
70
+ const name = `nx-sk-backup-${stamp()}${EXT}`;
71
+ await ensureDir(dir);
72
+ const file = resolve(dir, name);
73
+ await writeAtomic(file, JSON.stringify(archive, null, 2) + '\n');
74
+ return {
75
+ status: 'ok',
76
+ file: displayPath(file),
77
+ fileRaw: file,
78
+ bytes: await fileSize(file),
79
+ encrypted: true,
80
+ ...counts,
81
+ };
82
+ }
83
+
84
+ function parseArchive(text) {
85
+ let archive;
86
+ try {
87
+ archive = JSON.parse(text);
88
+ } catch (e) {
89
+ throw badInput(`备份文件不是合法 JSON(${e.message})——请确认选择的是 nx-sk 导出的 ${EXT} 文件`);
90
+ }
91
+ if (!archive || archive.app !== 'nx-sk' || archive.kind !== BACKUP_KIND || !archive.data) {
92
+ throw badInput(`不是 nx-sk 的加密备份文件(缺少 app/kind 标记)`);
93
+ }
94
+ if (archive.v !== BACKUP_VERSION) {
95
+ throw badInput(`备份文件版本不受支持: v${archive.v}`);
96
+ }
97
+ return archive;
98
+ }
99
+
100
+ /** 用口令解密归档文本 → 备份时的 store 负载。口令错误在此抛 BLOCKED。 */
101
+ export function decryptBackup(text, password) {
102
+ const archive = parseArchive(text);
103
+ if (!String(password ?? '')) throw badInput('请输入备份时设置的口令。');
104
+ let payload;
105
+ try {
106
+ const key = deriveKey(password, archive.salt);
107
+ payload = JSON.parse(decryptValue(key, archive.data));
108
+ } catch (e) {
109
+ if (e && (e.code === 'BLOCKED' || /解密失败/.test(String(e.message)))) {
110
+ throw blocked('解密失败:口令不正确,或备份文件已损坏。请确认输入的是导出时设置的口令。');
111
+ }
112
+ throw e;
113
+ }
114
+ if (!payload || !Array.isArray(payload.sections) || !Array.isArray(payload.entries)) {
115
+ throw badInput('备份内容缺少 sections / entries,无法恢复。');
116
+ }
117
+ return { payload, createdAt: archive.createdAt };
118
+ }
119
+
120
+ /** 恢复:解密 → 先给当前数据留安全快照 → 整份替换(写入会过 normalize 迁移到当前版本)。 */
121
+ export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
122
+ const { payload, createdAt } = decryptBackup(String(content ?? ''), password);
123
+
124
+ if (dryRun) {
125
+ return {
126
+ status: 'dry-run', dryRun: true,
127
+ backupCreatedAt: createdAt || '',
128
+ sections: payload.sections.length,
129
+ entries: payload.entries.length,
130
+ };
131
+ }
132
+
133
+ // 覆盖前留一份可回滚快照(落到 backup/ 目录),避免误操作丢数据
134
+ const safety = await snapshotStore('before-restore');
135
+
136
+ await saveStore({
137
+ version: payload.version,
138
+ settings: payload.settings,
139
+ sections: payload.sections,
140
+ entries: payload.entries,
141
+ exports: Array.isArray(payload.exports) ? payload.exports : [],
142
+ });
143
+
144
+ return {
145
+ status: 'restored',
146
+ backupCreatedAt: createdAt || '',
147
+ sections: payload.sections.length,
148
+ entries: payload.entries.length,
149
+ safetySnapshot: displayPath(safety),
150
+ };
151
+ }
152
+
153
+ /** CLI 导入:从文件读取归档文本后恢复。 */
154
+ export async function importBackup(ctx) {
155
+ const file = resolve(String(ctx.file ?? ''));
156
+ if (!ctx.file || !(await pathExists(file))) {
157
+ throw badInput(`找不到备份文件: ${ctx.file || '(未用 --file 指定)'}`);
158
+ }
159
+ const text = await fsp.readFile(file, 'utf8');
160
+ return restoreBackup({
161
+ content: text,
162
+ password: ctx.password ?? process.env[ENV_BACKUP_PASSWORD],
163
+ 'dry-run': ctx['dry-run'],
164
+ });
165
+ }
166
+
167
+ export function renderBackup(d) {
168
+ if (d.status === 'skipped') {
169
+ return `试运行(未写盘、未下载)\n将加密备份: ${d.sections} 个栏目 / ${d.entries} 条条目(AES-256-GCM)`;
170
+ }
171
+ return [
172
+ `已生成加密备份(AES-256-GCM,口令派生密钥、口令不落盘)`,
173
+ `文件: ${d.file} ${d.bytes} 字节`,
174
+ `内容: ${d.sections} 个栏目 / ${d.entries} 条条目`,
175
+ `恢复时需要输入本次设置的口令。`,
176
+ ].join('\n');
177
+ }
178
+
179
+ export function renderRestore(d) {
180
+ if (d.status === 'dry-run') {
181
+ return `试运行(未改动当前数据)\n备份可正常解密: ${d.sections} 个栏目 / ${d.entries} 条条目,生成于 ${d.backupCreatedAt || '未知'}`;
182
+ }
183
+ return [
184
+ `已从加密备份恢复:${d.sections} 个栏目 / ${d.entries} 条条目`,
185
+ `备份生成于: ${d.backupCreatedAt || '未知'}`,
186
+ `恢复前的当前数据已留安全快照: ${d.safetySnapshot}`,
187
+ ].join('\n');
188
+ }
@@ -0,0 +1,145 @@
1
+ import { useRef, useState } from 'react';
2
+ import { api } from '../../web/frontend/api/client.js';
3
+ import { useStore } from '../../web/frontend/store.jsx';
4
+ import { CliHints, Empty, Modal } from '../../web/frontend/components/ui.jsx';
5
+
6
+ function fileStamp() {
7
+ const d = new Date();
8
+ const p = (n) => String(n).padStart(2, '0');
9
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
10
+ }
11
+
12
+ function downloadText(filename, text) {
13
+ const blob = new Blob([text], { type: 'application/json;charset=utf-8' });
14
+ const url = URL.createObjectURL(blob);
15
+ const a = document.createElement('a');
16
+ a.href = url;
17
+ a.download = filename;
18
+ document.body.appendChild(a);
19
+ a.click();
20
+ a.remove();
21
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
22
+ }
23
+
24
+ export default function BackupView() {
25
+ const { toast, guard, dialog, reload } = useStore();
26
+ const [pw, setPw] = useState('');
27
+ const [pw2, setPw2] = useState('');
28
+ const [restorePw, setRestorePw] = useState('');
29
+ const [picked, setPicked] = useState(null);
30
+ const [result, setResult] = useState(null);
31
+ const fileRef = useRef(null);
32
+
33
+ const doExport = guard(async () => {
34
+ if (!pw) { toast('请先设置备份口令', 'bad'); return; }
35
+ if (pw !== pw2) { toast('两次输入的口令不一致', 'bad'); return; }
36
+ const r = await api('/api/backup', { method: 'POST', body: { password: pw } });
37
+ const filename = `nx-sk-backup-${fileStamp()}.nxskbak`;
38
+ downloadText(filename, JSON.stringify(r.archive, null, 2) + '\n');
39
+ toast(`已生成加密备份:${r.counts.sections} 栏目 / ${r.counts.entries} 条目`);
40
+ setPw(''); setPw2('');
41
+ });
42
+
43
+ const onPick = async (e) => {
44
+ const file = e.target.files?.[0];
45
+ if (!file) return;
46
+ const text = await file.text();
47
+ setPicked({ name: file.name, bytes: file.size, text });
48
+ };
49
+
50
+ const doRestore = guard(async () => {
51
+ if (!picked) { toast('请先选择备份文件', 'bad'); return; }
52
+ if (!restorePw) { toast('请输入备份口令', 'bad'); return; }
53
+ const ok = await dialog({
54
+ danger: true,
55
+ confirmText: '解密并整份恢复',
56
+ message: '恢复会用备份内容整份替换当前数据。',
57
+ detail: `备份文件:${picked.name}\n恢复前会自动给当前数据留一份安全快照(可回滚)。`,
58
+ });
59
+ if (!ok) return;
60
+ const r = await api('/api/backup/restore', {
61
+ method: 'POST',
62
+ body: { password: restorePw, content: picked.text },
63
+ });
64
+ setResult(r);
65
+ setRestorePw('');
66
+ setPicked(null);
67
+ if (fileRef.current) fileRef.current.value = '';
68
+ await reload();
69
+ });
70
+
71
+ return (
72
+ <>
73
+ <div className="toolbar rowgap pad">
74
+ <strong>加密备份</strong>
75
+ <span className="muted">整份数据用口令对称加密(AES-256-GCM);导出设口令、恢复输口令,口令不落盘</span>
76
+ </div>
77
+
78
+ <div className="card">
79
+ <div className="colhead">加密导出</div>
80
+ <div className="pad stack">
81
+ <div className="grid">
82
+ <div className="field">
83
+ <div className="flabel"><span>设置备份口令</span><span className="fkey">建议至少 6 位</span></div>
84
+ <div className="fctrl">
85
+ <input type="password" autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} placeholder="备份口令" />
86
+ </div>
87
+ </div>
88
+ <div className="field">
89
+ <div className="flabel"><span>再输入一次</span></div>
90
+ <div className="fctrl">
91
+ <input type="password" autoComplete="new-password" value={pw2} onChange={(e) => setPw2(e.target.value)} placeholder="确认口令" />
92
+ </div>
93
+ </div>
94
+ </div>
95
+ <div className="hint">导出为单个 .nxskbak 文件并在浏览器下载;文件内含随机盐,密钥由口令经 scrypt 派生。恢复时需要输入同一口令。</div>
96
+ <div className="rowgap">
97
+ <button className="btn" onClick={doExport}>加密导出并下载</button>
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ <div className="card">
103
+ <div className="colhead">解密恢复(导入)</div>
104
+ <div className="pad stack">
105
+ <div className="grid">
106
+ <div className="field">
107
+ <div className="flabel"><span>备份文件</span><span className="fkey">.nxskbak</span></div>
108
+ <div className="fctrl">
109
+ <input ref={fileRef} type="file" accept=".nxskbak,application/json" onChange={onPick} />
110
+ </div>
111
+ </div>
112
+ <div className="field">
113
+ <div className="flabel"><span>输入备份口令</span></div>
114
+ <div className="fctrl">
115
+ <input type="password" autoComplete="off" value={restorePw} onChange={(e) => setRestorePw(e.target.value)} placeholder="导出时设置的口令" />
116
+ </div>
117
+ </div>
118
+ </div>
119
+ {picked ? (
120
+ <div className="row">
121
+ <span className="mono name">{picked.name}</span>
122
+ <span className="acts"><span className="tag">{picked.bytes} 字节</span></span>
123
+ </div>
124
+ ) : <Empty>还没有选择备份文件。</Empty>}
125
+ <div className="hint">口令错误或文件损坏会明确报错、不会改动当前数据;确认恢复前会先留安全快照。</div>
126
+ <div className="rowgap">
127
+ <button className="btn danger" onClick={doRestore}>解密并恢复</button>
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <CliHints module="backup" />
133
+
134
+ {result ? (
135
+ <Modal title="恢复完成" onClose={() => setResult(null)}>
136
+ <div className="stack">
137
+ <div>已从加密备份恢复:{result.sections} 个栏目 / {result.entries} 条条目</div>
138
+ <div className="muted">备份生成于 {result.backupCreatedAt || '未知'}</div>
139
+ <pre className="block">恢复前的安全快照:{result.safetySnapshot}</pre>
140
+ </div>
141
+ </Modal>
142
+ ) : null}
143
+ </>
144
+ );
145
+ }
@@ -6,12 +6,13 @@ import { cliPathsOf, usageOf } from './spec.js';
6
6
  import sections from '../modules/sections/index.js';
7
7
  import entries from '../modules/entries/index.js';
8
8
  import exportModule from '../modules/export/index.js';
9
+ import backup from '../modules/backup/index.js';
9
10
  import skill from '../modules/skill/index.js';
10
11
  import settings from '../modules/settings/index.js';
11
12
  import system from '../modules/system/index.js';
12
13
 
13
14
  // 顺序 = 面板 tab 顺序与 help 分组顺序(system 放最后,它是聚合模块)
14
- export const MODULES = [sections, entries, exportModule, skill, settings, system]
15
+ export const MODULES = [sections, entries, exportModule, backup, skill, settings, system]
15
16
  .slice()
16
17
  .sort((a, b) => (a.order || 50) - (b.order || 50));
17
18
 
@@ -54,7 +54,7 @@ export default function App() {
54
54
  return (
55
55
  <div className="app">
56
56
  <header className="top">
57
- <span className="brand">nx-sk</span>
57
+ <span className="brand"><img src="/logo-rounded.png" alt="" />nx-sk</span>
58
58
  <span className="meta">本机个人资源管理器{boot ? ` · v${boot.app.version}` : ''}</span>
59
59
  {boot ? (
60
60
  <span className="meta">
@@ -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
@@ -11,6 +11,7 @@ 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: 'export', title: '导出', order: 700, component: lazy(() => import('../../modules/export/view.jsx')) },
14
+ { id: 'backup', title: '加密备份', order: 750, component: lazy(() => import('../../modules/backup/view.jsx')) },
14
15
  { id: 'settings', title: '设置', order: 900, component: lazy(() => import('../../modules/settings/view.jsx')) },
15
16
  ];
16
17
 
@@ -63,7 +63,8 @@ header.top {
63
63
  border-bottom: var(--border);
64
64
  flex-shrink: 0;
65
65
  }
66
- header.top .brand { font-weight: 700; letter-spacing: .3px; }
66
+ header.top .brand { font-weight: 700; letter-spacing: .3px; display: inline-flex; align-items: center; gap: 7px; }
67
+ header.top .brand img { width: 20px; height: 20px; border-radius: 5px; display: block; }
67
68
  header.top .meta { color: var(--mid); font-size: 11px; }
68
69
 
69
70
  nav.tabs {