nx-sk 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +15 -8
- package/assets/nx-sk/references/30-export-backup.md +12 -4
- package/package.json +1 -1
- package/src/modules/backup/index.js +9 -4
- package/src/modules/backup/service.js +461 -51
- package/src/modules/backup/view.jsx +133 -22
- package/src/web/public/assets/{files-BNh48qeo.js → files-DcDiKjwc.js} +1 -1
- package/src/web/public/assets/{index-B7CtqgoG.js → index-BygHBMGo.js} +2 -2
- package/src/web/public/assets/{view-BAYyPWdU.js → view-B1Vx3EvJ.js} +1 -1
- package/src/web/public/assets/{view-1kwWQYbz.js → view-COgrD5Um.js} +1 -1
- package/src/web/public/assets/{view-Bu70Q6GO.js → view-Cct73k6i.js} +1 -1
- package/src/web/public/assets/{view-D5LfHr7W.js → view-CnZk33Lp.js} +1 -1
- package/src/web/public/assets/view-CoUwzqKQ.js +5 -0
- package/src/web/public/assets/{view-BIdVqIGe.js → view-h-QmH_sE.js} +1 -1
- package/src/web/public/index.html +1 -1
- package/src/web/public/assets/view-DXqI-4_F.js +0 -3
|
@@ -3,6 +3,11 @@ import { api } from '../../web/frontend/api/client.js';
|
|
|
3
3
|
import { useStore } from '../../web/frontend/store.jsx';
|
|
4
4
|
import { CliHints, Empty, Modal } from '../../web/frontend/components/ui.jsx';
|
|
5
5
|
|
|
6
|
+
// Web 上传走 JSON 文本体,接口上限 32MB——留出余量在前端先拦,
|
|
7
|
+
// 超限建议走 CLI(命令行没有请求体上限)。
|
|
8
|
+
const MAX_PICK_BYTES = 28 * 1024 * 1024;
|
|
9
|
+
const REPORT_CAP = 20;
|
|
10
|
+
|
|
6
11
|
function fileStamp() {
|
|
7
12
|
const d = new Date();
|
|
8
13
|
const p = (n) => String(n).padStart(2, '0');
|
|
@@ -21,10 +26,78 @@ function downloadText(filename, text) {
|
|
|
21
26
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
/** 报告小节:有内容才渲染,超过封顶给「…共 N 处」尾行。 */
|
|
30
|
+
function ReportList({ title, items, render, total }) {
|
|
31
|
+
if (!items || !items.length) return null;
|
|
32
|
+
const over = total != null && total > items.length;
|
|
33
|
+
return (
|
|
34
|
+
<div>
|
|
35
|
+
<div className="flabel"><span>{title}</span>{over ? <span className="fkey">共 {total} 处</span> : null}</div>
|
|
36
|
+
<ul className="stack" style={{ margin: '4px 0 0', paddingLeft: 18 }}>
|
|
37
|
+
{items.slice(0, REPORT_CAP).map((x, i) => <li key={i}>{render(x)}</li>)}
|
|
38
|
+
</ul>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function MergeReport({ r }) {
|
|
44
|
+
const files = r.files || {};
|
|
45
|
+
return (
|
|
46
|
+
<div className="stack">
|
|
47
|
+
<div>
|
|
48
|
+
新增栏目 {(r.addedSections || []).length} 个 / 新增条目 {r.addedEntriesTotal || 0} 条;
|
|
49
|
+
补空 {r.filledTotal || 0} 处;冲突 {r.conflictsTotal || 0} 处
|
|
50
|
+
({r.counts ? `栏目 ${r.counts.sectionsBefore} → ${r.counts.sectionsAfter},条目 ${r.counts.entriesBefore} → ${r.counts.entriesAfter}` : ''})
|
|
51
|
+
</div>
|
|
52
|
+
<ReportList title="补空明细(本地留空,已用备份值填上)" items={r.filled} total={r.filledTotal}
|
|
53
|
+
render={(x) => <span>{x.section} / {x.entry} · {x.label} = {x.value || '(空)'}</span>} />
|
|
54
|
+
<ReportList title="冲突(两边都有值且不同,已保留本地,请手动核对)" items={r.conflicts} total={r.conflictsTotal}
|
|
55
|
+
render={(x) => <span>{x.section} / {x.entry} · {x.label}: 本地 {x.localValue || '(空)'} | 备份 {x.backupValue || '(空)'}</span>} />
|
|
56
|
+
<ReportList title="结构补齐(只增:备份多出的分组/字段已补进本地栏目)" items={r.structureAdded}
|
|
57
|
+
render={(x) => <span>{x.id}: +{x.fields.length} 字段 [{x.fields.join(', ')}]{x.groups.length ? `,+分组 [${x.groups.join(', ')}]` : ''}</span>} />
|
|
58
|
+
<ReportList title="本地独有字段(备份里没有,已原样保留)" items={r.structureOnlyLocal}
|
|
59
|
+
render={(x) => <span>{x.id}: [{x.fields.join(', ')}]</span>} />
|
|
60
|
+
{(r.kvMismatch || []).length ? <div className="muted">kv 标记与备份不一致、未合并结构的栏目: {r.kvMismatch.join(', ')}</div> : null}
|
|
61
|
+
{(r.settingsFilled || []).length ? <div>设置补空: {r.settingsFilled.join(', ')}</div> : null}
|
|
62
|
+
<div>{renderFilesLine(files)}</div>
|
|
63
|
+
{r.exportsSkipped ? <div className="muted">备份里的导出历史 {r.exportsSkipped} 条未并入(保留本机记录)</div> : null}
|
|
64
|
+
{r.resealed ? <div className="muted">用本机密钥库重新加密的密钥字段: {r.resealed} 个</div> : null}
|
|
65
|
+
<div className="muted">备份生成于 {r.backupCreatedAt || '未知'}</div>
|
|
66
|
+
<pre className="block">导入前的安全快照:{r.safetySnapshot}</pre>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderFilesLine(f) {
|
|
72
|
+
const parts = [];
|
|
73
|
+
if (f.toAdd != null) parts.push(`台账新增 ${f.toAdd} 条、沿用本地 ${f.kept || 0} 条`);
|
|
74
|
+
if (f.written) parts.push(`落盘原件 ${f.written} 个`);
|
|
75
|
+
if (f.healed) parts.push(`补齐缺失原件 ${f.healed} 个`);
|
|
76
|
+
if (f.skippedExisting) parts.push(`磁盘已有但内容不同、保留磁盘 ${f.skippedExisting} 个`);
|
|
77
|
+
if (f.checksumMismatch) parts.push(`校验不符被拒 ${f.checksumMismatch} 个`);
|
|
78
|
+
if (f.missingBinaryTotal) parts.push(`${f.missingBinaryTotal} 条台账无原件(备份未携带)`);
|
|
79
|
+
if (!parts.length) return '附件: 台账已并入,原件均已就位';
|
|
80
|
+
return `附件: ${parts.join(';')}${f.bytes ? `(共 ${f.bytes} 字节)` : ''}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function ReplaceReport({ r }) {
|
|
84
|
+
const f = r.filesStats || {};
|
|
85
|
+
return (
|
|
86
|
+
<div className="stack">
|
|
87
|
+
<div>已整份替换为备份内容:{r.sections} 个栏目 / {r.entries} 条条目(附件台账 {r.filesInBackup || 0} 条)</div>
|
|
88
|
+
<div>{renderFilesLine({ ...f })}</div>
|
|
89
|
+
{r.resealed ? <div className="muted">用本机密钥库重新加密的密钥字段: {r.resealed} 个</div> : null}
|
|
90
|
+
<div className="muted">备份生成于 {r.backupCreatedAt || '未知'}</div>
|
|
91
|
+
<pre className="block">恢复前的安全快照:{r.safetySnapshot}</pre>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
24
96
|
export default function BackupView() {
|
|
25
97
|
const { toast, guard, dialog, reload } = useStore();
|
|
26
98
|
const [pw, setPw] = useState('');
|
|
27
99
|
const [pw2, setPw2] = useState('');
|
|
100
|
+
const [withFiles, setWithFiles] = useState(false);
|
|
28
101
|
const [restorePw, setRestorePw] = useState('');
|
|
29
102
|
const [picked, setPicked] = useState(null);
|
|
30
103
|
const [result, setResult] = useState(null);
|
|
@@ -33,10 +106,14 @@ export default function BackupView() {
|
|
|
33
106
|
const doExport = guard(async () => {
|
|
34
107
|
if (!pw) { toast('请先设置备份口令', 'bad'); return; }
|
|
35
108
|
if (pw !== pw2) { toast('两次输入的口令不一致', 'bad'); return; }
|
|
36
|
-
const r = await api('/api/backup', { method: 'POST', body: { password: pw } });
|
|
109
|
+
const r = await api('/api/backup', { method: 'POST', body: { password: pw, 'with-files': withFiles } });
|
|
37
110
|
const filename = `nx-sk-backup-${fileStamp()}.nxskbak`;
|
|
38
111
|
downloadText(filename, JSON.stringify(r.archive, null, 2) + '\n');
|
|
39
|
-
|
|
112
|
+
const c = r.counts;
|
|
113
|
+
const filesBit = withFiles
|
|
114
|
+
? `;嵌入原件 ${c.filesEmbedded} 个`
|
|
115
|
+
: `;附件台账 ${c.files || 0} 条未嵌原件`;
|
|
116
|
+
toast(`已生成加密备份:${c.sections} 栏目 / ${c.entries} 条目${filesBit}`);
|
|
40
117
|
setPw(''); setPw2('');
|
|
41
118
|
});
|
|
42
119
|
|
|
@@ -47,25 +124,55 @@ export default function BackupView() {
|
|
|
47
124
|
setPicked({ name: file.name, bytes: file.size, text });
|
|
48
125
|
};
|
|
49
126
|
|
|
127
|
+
const precheck = () => {
|
|
128
|
+
if (!picked) { toast('请先选择备份文件', 'bad'); return false; }
|
|
129
|
+
if (!restorePw) { toast('请输入备份口令', 'bad'); return false; }
|
|
130
|
+
if (picked.bytes > MAX_PICK_BYTES) {
|
|
131
|
+
toast('备份文件超过 28MB,可能超过接口上限;请改用 CLI 导入:nx-sk backup import', 'bad');
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const postImport = (r) => {
|
|
138
|
+
setResult(r);
|
|
139
|
+
setRestorePw('');
|
|
140
|
+
setPicked(null);
|
|
141
|
+
if (fileRef.current) fileRef.current.value = '';
|
|
142
|
+
return reload();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// 合并导入(缺省):只增补、不覆盖,冲突保留本地并列进报告
|
|
50
146
|
const doRestore = guard(async () => {
|
|
51
|
-
if (!
|
|
52
|
-
if (!restorePw) { toast('请输入备份口令', 'bad'); return; }
|
|
147
|
+
if (!precheck()) return;
|
|
53
148
|
const ok = await dialog({
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
detail: `备份文件:${picked.name}\n恢复前会自动给当前数据留一份安全快照(可回滚)。`,
|
|
149
|
+
confirmText: '解密并合并导入',
|
|
150
|
+
message: '合并导入:只把备份里有、而当前缺失或留空的内容补进来,不会覆盖你已填过的值,也不会删改本地栏目结构。',
|
|
151
|
+
detail: `备份文件:${picked.name}\n冲突(两边都有值且不同)会保留本地、列在导入报告里。\n导入前会自动给当前数据留一份安全快照(可回滚)。`,
|
|
58
152
|
});
|
|
59
153
|
if (!ok) return;
|
|
60
154
|
const r = await api('/api/backup/restore', {
|
|
61
155
|
method: 'POST',
|
|
62
156
|
body: { password: restorePw, content: picked.text },
|
|
63
157
|
});
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
158
|
+
await postImport(r);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// 整份替换(旧行为):灾难恢复 / 跨机器迁移用,危险操作
|
|
162
|
+
const doReplace = guard(async () => {
|
|
163
|
+
if (!precheck()) return;
|
|
164
|
+
const ok = await dialog({
|
|
165
|
+
danger: true,
|
|
166
|
+
confirmText: '解密并整份替换',
|
|
167
|
+
message: '整份替换:当前数据会被备份内容整体覆盖,包括删掉备份里没有的条目。',
|
|
168
|
+
detail: `备份文件:${picked.name}\n替换前会自动给当前数据留一份安全快照(可回滚)。`,
|
|
169
|
+
});
|
|
170
|
+
if (!ok) return;
|
|
171
|
+
const r = await api('/api/backup/restore', {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
body: { password: restorePw, content: picked.text, replace: true },
|
|
174
|
+
});
|
|
175
|
+
await postImport(r);
|
|
69
176
|
});
|
|
70
177
|
|
|
71
178
|
return (
|
|
@@ -92,6 +199,10 @@ export default function BackupView() {
|
|
|
92
199
|
</div>
|
|
93
200
|
</div>
|
|
94
201
|
</div>
|
|
202
|
+
<label className="row">
|
|
203
|
+
<input type="checkbox" checked={withFiles} onChange={(e) => setWithFiles(e.target.checked)} />
|
|
204
|
+
<span>连附件原件一起打包(体积显著变大;附件台账始终随包)</span>
|
|
205
|
+
</label>
|
|
95
206
|
<div className="hint">导出为单个 .nxskbak 文件并在浏览器下载;文件内含随机盐,密钥由口令经 scrypt 派生。恢复时需要输入同一口令。</div>
|
|
96
207
|
<div className="rowgap">
|
|
97
208
|
<button className="btn" onClick={doExport}>加密导出并下载</button>
|
|
@@ -100,7 +211,7 @@ export default function BackupView() {
|
|
|
100
211
|
</div>
|
|
101
212
|
|
|
102
213
|
<div className="card">
|
|
103
|
-
<div className="colhead"
|
|
214
|
+
<div className="colhead">解密导入</div>
|
|
104
215
|
<div className="pad stack">
|
|
105
216
|
<div className="grid">
|
|
106
217
|
<div className="field">
|
|
@@ -122,9 +233,10 @@ export default function BackupView() {
|
|
|
122
233
|
<span className="acts"><span className="tag">{picked.bytes} 字节</span></span>
|
|
123
234
|
</div>
|
|
124
235
|
) : <Empty>还没有选择备份文件。</Empty>}
|
|
125
|
-
<div className="hint"
|
|
236
|
+
<div className="hint">缺省「合并」:只增补、不覆盖你已填过的值,冲突保留本地并列入报告。口令错误或文件损坏会明确报错、不会改动当前数据;导入前会先留安全快照。</div>
|
|
126
237
|
<div className="rowgap">
|
|
127
|
-
<button className="btn
|
|
238
|
+
<button className="btn" onClick={doRestore}>解密并导入(合并)</button>
|
|
239
|
+
<button className="btn danger" onClick={doReplace}>整份替换…</button>
|
|
128
240
|
</div>
|
|
129
241
|
</div>
|
|
130
242
|
</div>
|
|
@@ -132,12 +244,11 @@ export default function BackupView() {
|
|
|
132
244
|
<CliHints module="backup" />
|
|
133
245
|
|
|
134
246
|
{result ? (
|
|
135
|
-
<Modal
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
</div>
|
|
247
|
+
<Modal
|
|
248
|
+
title={result.mode === 'replace' ? '导入完成(整份替换)' : '导入完成(合并)'}
|
|
249
|
+
onClose={() => setResult(null)}
|
|
250
|
+
>
|
|
251
|
+
{result.mode === 'replace' ? <ReplaceReport r={result} /> : <MergeReport r={result} />}
|
|
141
252
|
</Modal>
|
|
142
253
|
) : null}
|
|
143
254
|
</>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{a as l}from"./index-
|
|
1
|
+
import{a as l}from"./index-BygHBMGo.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};
|