dscs-server 0.0.0-stage → 0.1.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/web/setup.html ADDED
@@ -0,0 +1,591 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
7
+ <title>初始化 dsh-session-cloudsync</title>
8
+ <style>
9
+ :root {
10
+ color-scheme: dark;
11
+ --bg: #0f1115;
12
+ --bg-soft: #14171d;
13
+ --panel: #1a1e25;
14
+ --panel-2: #20252e;
15
+ --line: #2a303a;
16
+ --line-strong: #3a4350;
17
+ --text: #e9edf3;
18
+ --muted: #9aa5b5;
19
+ --faint: #6f7b8c;
20
+ --accent: #5b88ff;
21
+ --accent-2: #7ba4ff;
22
+ --accent-soft: rgba(91, 136, 255, .14);
23
+ --good: #5ed392;
24
+ --good-soft: rgba(94, 211, 146, .12);
25
+ --bad: #ef7777;
26
+ --bad-soft: rgba(239, 119, 119, .12);
27
+ --radius: 16px;
28
+ --shadow: 0 28px 80px rgba(0, 0, 0, .4);
29
+ }
30
+ * { box-sizing: border-box; }
31
+ html, body { min-height: 100%; }
32
+ body {
33
+ margin: 0;
34
+ background:
35
+ radial-gradient(900px 520px at 15% -12%, rgba(91, 136, 255, .14), transparent 64%),
36
+ radial-gradient(700px 420px at 100% 0%, rgba(94, 211, 146, .06), transparent 60%),
37
+ var(--bg);
38
+ color: var(--text);
39
+ font: 14px/1.6 "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
40
+ -webkit-font-smoothing: antialiased;
41
+ }
42
+ button, input, select { font: inherit; }
43
+ button { cursor: pointer; }
44
+ [hidden] { display: none !important; }
45
+ code { font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; }
46
+ .muted { color: var(--muted); }
47
+ .faint { color: var(--faint); }
48
+
49
+ .setup-page {
50
+ min-height: 100vh;
51
+ display: grid;
52
+ place-items: center;
53
+ padding: 30px 18px;
54
+ }
55
+ .wizard-wrap { width: min(640px, 100%); }
56
+
57
+ .btn {
58
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
59
+ min-height: 36px; padding: 8px 15px;
60
+ border: 1px solid transparent; border-radius: 9px;
61
+ background: var(--accent); color: #fff;
62
+ font-size: 13px; font-weight: 600; white-space: nowrap;
63
+ transition: background .16s, border-color .16s, opacity .16s, transform .16s;
64
+ }
65
+ .btn:hover { background: #6b95ff; }
66
+ .btn:active { transform: translateY(1px); }
67
+ .btn:disabled { opacity: .45; cursor: default; transform: none; }
68
+ .btn.secondary { background: var(--panel-2); border-color: var(--line); color: var(--text); }
69
+ .btn.secondary:hover { background: #29303a; }
70
+ .btn.ghost { background: transparent; border-color: var(--line); color: var(--muted); }
71
+ .btn.ghost:hover { color: var(--text); border-color: var(--line-strong); background: rgba(255,255,255,.03); }
72
+ .btn.small { min-height: 30px; padding: 5px 10px; font-size: 12px; }
73
+ .btn.wide { width: 100%; }
74
+
75
+ input, select {
76
+ width: 100%; min-height: 39px;
77
+ padding: 8px 11px;
78
+ border: 1px solid var(--line-strong); border-radius: 9px;
79
+ background: var(--bg-soft); color: var(--text);
80
+ outline: none;
81
+ transition: border-color .16s, box-shadow .16s;
82
+ }
83
+ input:hover, select:hover { border-color: #4a5566; }
84
+ input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
85
+ input::placeholder { color: var(--faint); }
86
+ label { display: block; color: var(--muted); font-size: 12px; font-weight: 500; margin: 16px 0 6px; }
87
+
88
+ .wizard {
89
+ position: relative;
90
+ overflow: hidden;
91
+ border: 1px solid var(--line);
92
+ border-radius: 20px;
93
+ background: linear-gradient(180deg, rgba(31,36,45,.95), rgba(24,28,35,.97));
94
+ box-shadow: var(--shadow);
95
+ padding: 27px 29px 24px;
96
+ animation: wizard-in .42s cubic-bezier(.16,1,.3,1) both;
97
+ }
98
+ .wizard:before {
99
+ content: "";
100
+ position: absolute; inset: 0 0 auto 0; height: 1px;
101
+ background: linear-gradient(90deg, transparent, rgba(123,164,255,.52), transparent);
102
+ }
103
+ .brandline { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
104
+ .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
105
+ .brand img { width: 30px; height: 30px; }
106
+ .brand b { font-size: 13px; font-weight: 650; letter-spacing: -.01em; }
107
+ .brand span { display: block; color: var(--faint); font-size: 10.5px; line-height: 1.4; }
108
+ .eyebrow {
109
+ display: inline-block;
110
+ color: var(--accent-2); font-size: 10.5px; font-weight: 700;
111
+ letter-spacing: .14em; text-transform: uppercase;
112
+ }
113
+ .wizard-head { margin-top: 24px; }
114
+ .wizard-head h1 { margin: 6px 0 4px; font-size: 23px; line-height: 1.2; letter-spacing: -.015em; }
115
+ .wizard-head p { margin: 0; color: var(--muted); font-size: 12.5px; }
116
+
117
+ .stepper {
118
+ display: flex; align-items: center; gap: 7px;
119
+ margin: 22px 0 23px; padding: 0; list-style: none;
120
+ }
121
+ .step {
122
+ display: flex; align-items: center; gap: 7px;
123
+ flex: 1 1 0; min-width: 0;
124
+ color: var(--faint); font-size: 11.5px; white-space: nowrap;
125
+ }
126
+ .step i {
127
+ width: 24px; height: 24px; flex: 0 0 auto;
128
+ display: grid; place-items: center;
129
+ border: 1px solid var(--line-strong); border-radius: 50%;
130
+ font-style: normal; font-size: 10.5px;
131
+ transition: background .18s, border-color .18s, color .18s;
132
+ }
133
+ .step.on { color: var(--text); }
134
+ .step.on i { background: var(--accent); border-color: var(--accent); color: #fff; }
135
+ .step.done { color: var(--muted); }
136
+ .step.done i { color: var(--good); border-color: rgba(94,211,146,.52); background: var(--good-soft); }
137
+ .step:not(:last-child):after { content: ""; height: 1px; flex: 1 1 auto; min-width: 8px; background: var(--line); }
138
+
139
+ .wizard-body { position: relative; }
140
+ .step-panel { display: flex; flex-direction: column; min-height: 212px; }
141
+ .step-panel.step-next { animation: step-next .28s cubic-bezier(.16,1,.3,1) both; }
142
+ .step-panel.step-back { animation: step-back .28s cubic-bezier(.16,1,.3,1) both; }
143
+ .step-panel h2 { margin: 0; font-size: 16px; font-weight: 650; }
144
+ .step-panel .hint { margin: 5px 0 0; color: var(--muted); font-size: 12px; }
145
+ .step-panel .spacer { flex: 1 1 auto; min-height: 16px; }
146
+ .actions { display: flex; align-items: center; gap: 9px; margin-top: 20px; }
147
+ .actions .grow { flex: 1 1 auto; }
148
+ .error { min-height: 19px; margin-top: 8px; color: #ffaaaa; font-size: 12px; }
149
+ .notice {
150
+ margin-top: 15px; padding: 9px 11px;
151
+ border: 1px solid rgba(240,182,74,.22); border-radius: 9px;
152
+ background: rgba(240,182,74,.07); color: #e7c37d; font-size: 11.5px;
153
+ }
154
+
155
+ .code-input { letter-spacing: .14em; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 16px; text-transform: uppercase; }
156
+ .input-wrap { position: relative; }
157
+ .input-wrap input { padding-right: 56px; }
158
+ .input-wrap .reveal {
159
+ position: absolute; right: 5px; top: 50%; transform: translateY(-50%);
160
+ border: 0; background: transparent; color: var(--muted);
161
+ font-size: 11px; padding: 5px 6px; border-radius: 6px;
162
+ }
163
+ .input-wrap .reveal:hover { color: var(--text); background: rgba(255,255,255,.05); }
164
+
165
+ .requirements { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
166
+ .req { color: var(--faint); font-size: 10.5px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px; }
167
+ .req.ok { color: #9ce8bc; border-color: rgba(94,211,146,.3); background: var(--good-soft); }
168
+
169
+ .quota-grid { display: grid; grid-template-columns: 1fr 150px; gap: 10px; align-items: end; }
170
+ .quota-grid .unit { display: flex; align-items: center; gap: 7px; color: var(--faint); font-size: 12px; }
171
+ .quota-grid .unit input { width: 100%; }
172
+ .token-panel {
173
+ margin-top: 16px; padding: 13px;
174
+ border: 1px solid rgba(94,211,146,.25); border-radius: 11px;
175
+ background: rgba(94,211,146,.055);
176
+ }
177
+ .token-panel .token-title { color: #9ce8bc; font-size: 12px; font-weight: 600; }
178
+ .token-panel .token-note { margin: 3px 0 9px; color: var(--faint); font-size: 10.5px; }
179
+ .token-value {
180
+ padding: 10px; border: 1px solid var(--line); border-radius: 8px;
181
+ background: var(--bg); color: #c7dbff;
182
+ font-size: 11.5px; line-height: 1.55; word-break: break-all;
183
+ }
184
+ .token-actions { display: flex; gap: 7px; margin-top: 9px; }
185
+
186
+ .done-panel { align-items: flex-start; justify-content: center; }
187
+ .success-mark {
188
+ width: 46px; height: 46px; border-radius: 50%;
189
+ display: grid; place-items: center;
190
+ color: var(--good); background: var(--good-soft); border: 1px solid rgba(94,211,146,.3);
191
+ font-size: 21px; margin-bottom: 14px;
192
+ animation: pop-in .36s cubic-bezier(.16,1,.3,1) both;
193
+ }
194
+ .done-panel h2 { font-size: 22px; }
195
+ .setup-foot { margin: 17px 0 0; text-align: center; color: var(--faint); font-size: 11px; }
196
+
197
+ .toast {
198
+ position: fixed; left: 50%; bottom: 24px; z-index: 80;
199
+ max-width: min(520px, calc(100vw - 32px));
200
+ padding: 9px 14px; border: 1px solid var(--line-strong); border-radius: 10px;
201
+ background: rgba(30,35,44,.97); color: var(--text);
202
+ box-shadow: 0 16px 40px rgba(0,0,0,.4);
203
+ font-size: 12px; opacity: 0; pointer-events: none;
204
+ transform: translate(-50%, 8px);
205
+ transition: opacity .18s, transform .18s;
206
+ }
207
+ .toast.on { opacity: 1; transform: translate(-50%, 0); }
208
+ .toast.bad { border-color: rgba(239,119,119,.4); color: #ffb0b0; }
209
+
210
+ @keyframes wizard-in { from { opacity: 0; transform: translateY(-12px) scale(.985); } to { opacity: 1; transform: none; } }
211
+ @keyframes step-next { from { opacity: 0; transform: translateX(10px); } to { opacity: 1; transform: none; } }
212
+ @keyframes step-back { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: none; } }
213
+ @keyframes pop-in { from { opacity: 0; transform: scale(.75); } to { opacity: 1; transform: none; } }
214
+ @media (prefers-reduced-motion: reduce) {
215
+ *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
216
+ }
217
+ @media (max-width: 560px) {
218
+ .setup-page { padding: 16px 12px; }
219
+ .wizard { padding: 21px 18px 19px; border-radius: 16px; }
220
+ .wizard-head h1 { font-size: 20px; }
221
+ .step { font-size: 10px; gap: 4px; }
222
+ .step i { width: 21px; height: 21px; font-size: 9.5px; }
223
+ .step:not(:last-child):after { min-width: 4px; }
224
+ .quota-grid { grid-template-columns: 1fr; }
225
+ .actions { flex-wrap: wrap; }
226
+ }
227
+ </style>
228
+ </head>
229
+ <body>
230
+ <div class="setup-page">
231
+ <main class="wizard-wrap">
232
+ <section class="wizard" aria-labelledby="setupTitle">
233
+ <div class="brandline">
234
+ <div class="brand">
235
+ <img src="/logo.svg" alt="">
236
+ <div><b>dsh-session-cloudsync</b><span>Self-hosted setup</span></div>
237
+ </div>
238
+ <span class="eyebrow">E2E encrypted</span>
239
+ </div>
240
+
241
+ <div class="wizard-head">
242
+ <h1 id="setupTitle">初始化你的同步服务</h1>
243
+ <p>设置管理员、存储配额,并可选地创建第一台设备。</p>
244
+ </div>
245
+
246
+ <ol id="stepper" class="stepper" aria-label="初始化进度">
247
+ <li class="step on" data-step="1"><i>1</i><span>密钥</span></li>
248
+ <li class="step" data-step="2"><i>2</i><span>管理员</span></li>
249
+ <li class="step" data-step="3"><i>3</i><span>配额</span></li>
250
+ <li class="step" data-step="4"><i>4</i><span>设备</span></li>
251
+ </ol>
252
+
253
+ <div class="wizard-body">
254
+ <section class="step-panel step-next" data-panel="1">
255
+ <h2>输入初始化密钥</h2>
256
+ <p class="hint">密钥打印在启动服务的控制台里,形如 <code>ABCD-EFGH</code>,10 分钟内有效。</p>
257
+ <div id="resumeHint" class="notice" hidden>服务端已保存部分初始化进度;验证密钥后继续。</div>
258
+ <label for="code">初始化密钥</label>
259
+ <input id="code" class="code-input" autocomplete="off" placeholder="ABCD-EFGH" maxlength="9" spellcheck="false" autofocus>
260
+ <div id="error1" class="error" role="alert"></div>
261
+ <div class="spacer"></div>
262
+ <div class="actions">
263
+ <button id="verify" class="btn wide" type="button">继续</button>
264
+ </div>
265
+ </section>
266
+
267
+ <section class="step-panel" data-panel="2" hidden>
268
+ <h2>设置管理密码</h2>
269
+ <p class="hint">至少 6 位,且字母、数字、特殊字符中至少占两类。</p>
270
+ <label for="password">密码</label>
271
+ <div class="input-wrap">
272
+ <input id="password" type="password" autocomplete="new-password">
273
+ <button class="reveal" type="button" data-reveal="password">显示</button>
274
+ </div>
275
+ <label for="confirm">再输一次</label>
276
+ <input id="confirm" type="password" autocomplete="new-password">
277
+ <div class="requirements" aria-live="polite">
278
+ <span id="reqLength" class="req">至少 6 位</span>
279
+ <span id="reqMix" class="req">两类字符</span>
280
+ </div>
281
+ <div id="error2" class="error" role="alert"></div>
282
+ <div class="spacer"></div>
283
+ <div class="actions">
284
+ <button id="back2" class="btn ghost" type="button">上一步</button>
285
+ <span class="grow"></span>
286
+ <button id="savePassword" class="btn" type="button">继续</button>
287
+ </div>
288
+ </section>
289
+
290
+ <section class="step-panel" data-panel="3" hidden>
291
+ <h2>设置存储配额</h2>
292
+ <p class="hint">按已上传密文的唯一字节计算;之后可以在管理台修改。</p>
293
+ <label for="quotaPreset">预设</label>
294
+ <select id="quotaPreset">
295
+ <option value="1">1 GiB</option>
296
+ <option value="5">5 GiB</option>
297
+ <option value="10" selected>10 GiB(默认)</option>
298
+ <option value="50">50 GiB</option>
299
+ <option value="custom">自定义</option>
300
+ </select>
301
+ <div id="customQuota" class="quota-grid" hidden>
302
+ <div>
303
+ <label for="quotaCustom">自定义配额</label>
304
+ <input id="quotaCustom" type="number" min="1" step="1" placeholder="例如 20">
305
+ </div>
306
+ <div class="unit">GiB</div>
307
+ </div>
308
+ <div id="error3" class="error" role="alert"></div>
309
+ <div class="spacer"></div>
310
+ <div class="actions">
311
+ <button id="back3" class="btn ghost" type="button">上一步</button>
312
+ <span class="grow"></span>
313
+ <button id="saveQuota" class="btn" type="button">继续</button>
314
+ </div>
315
+ </section>
316
+
317
+ <section class="step-panel" data-panel="4" hidden>
318
+ <h2>第一台设备</h2>
319
+ <p class="hint">给要同步的设备起个名字,生成一个 token;token 只显示一次。也可以先跳过。</p>
320
+ <label for="deviceLabel">设备标签</label>
321
+ <input id="deviceLabel" placeholder="例如 PC-1" autocomplete="off">
322
+ <div id="error4" class="error" role="alert"></div>
323
+ <div id="tokenPanel" class="token-panel" hidden>
324
+ <div class="token-title">设备已创建</div>
325
+ <div class="token-note">关闭页面后无法再次查看,请现在保存。</div>
326
+ <div id="tokenValue" class="token-value" tabindex="0"></div>
327
+ <div class="token-actions">
328
+ <button id="copyToken" class="btn secondary small" type="button">复制 token</button>
329
+ </div>
330
+ </div>
331
+ <div class="spacer"></div>
332
+ <div class="actions">
333
+ <button id="back4" class="btn ghost" type="button">上一步</button>
334
+ <span class="grow"></span>
335
+ <button id="createDevice" class="btn secondary" type="button">创建设备</button>
336
+ <button id="skipDevice" class="btn secondary" type="button">跳过</button>
337
+ <button id="finish" class="btn" type="button" hidden>完成初始化</button>
338
+ </div>
339
+ </section>
340
+
341
+ <section class="step-panel done-panel" data-panel="5" hidden>
342
+ <div class="success-mark" aria-hidden="true">✓</div>
343
+ <h2>初始化完成</h2>
344
+ <p class="hint">管理台已经可以使用。之后可以在管理台查看设备、会话和存储。</p>
345
+ <div class="spacer"></div>
346
+ <div class="actions">
347
+ <button id="goAdmin" class="btn" type="button">进入管理台</button>
348
+ </div>
349
+ </section>
350
+ </div>
351
+ </section>
352
+ <p class="setup-foot">dsh-session-cloudsync · 端到端加密 · 自部署</p>
353
+ </main>
354
+ </div>
355
+
356
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
357
+
358
+ <script>
359
+ const $ = (id) => document.getElementById(id)
360
+ let activeStep = 1
361
+ let toastTimer = null
362
+
363
+ function showToast(text, bad) {
364
+ const node = $('toast')
365
+ node.textContent = text
366
+ node.classList.toggle('bad', bad === true)
367
+ node.classList.add('on')
368
+ clearTimeout(toastTimer)
369
+ toastTimer = setTimeout(() => node.classList.remove('on'), 2800)
370
+ }
371
+
372
+ async function copyText(text) {
373
+ try {
374
+ await navigator.clipboard.writeText(text)
375
+ } catch (error) {
376
+ const area = document.createElement('textarea')
377
+ area.value = text
378
+ area.style.position = 'fixed'
379
+ area.style.opacity = '0'
380
+ document.body.appendChild(area)
381
+ area.select()
382
+ document.execCommand('copy')
383
+ area.remove()
384
+ }
385
+ showToast('已复制')
386
+ }
387
+
388
+ async function post(path, body) {
389
+ const response = await fetch(path, {
390
+ method: 'POST',
391
+ headers: { 'content-type': 'application/json' },
392
+ body: JSON.stringify(body || {}),
393
+ })
394
+ const payload = await response.json().catch(() => ({}))
395
+ if (!response.ok) throw new Error((payload && payload.error && payload.error.message) || '请求失败')
396
+ return payload.value
397
+ }
398
+
399
+ function markSteps(index) {
400
+ for (const item of document.querySelectorAll('.step')) {
401
+ const number = Number(item.dataset.step)
402
+ const done = number < index
403
+ item.classList.toggle('on', number === index)
404
+ item.classList.toggle('done', done)
405
+ const icon = item.querySelector('i')
406
+ if (icon !== null) icon.textContent = done ? '✓' : String(number)
407
+ }
408
+ }
409
+
410
+ function showStep(index, direction) {
411
+ activeStep = index
412
+ for (const panel of document.querySelectorAll('.step-panel')) {
413
+ panel.hidden = Number(panel.dataset.panel) !== index
414
+ }
415
+ const panel = document.querySelector('.step-panel[data-panel="' + index + '"]')
416
+ if (panel !== null) {
417
+ panel.classList.remove('step-next', 'step-back')
418
+ void panel.offsetWidth
419
+ panel.classList.add(direction === 'back' ? 'step-back' : 'step-next')
420
+ const focusable = panel.querySelector('input:not([type="hidden"]), button')
421
+ if (focusable !== null) setTimeout(() => focusable.focus(), 60)
422
+ }
423
+ markSteps(index)
424
+ }
425
+
426
+ function setBusy(button, busy, label) {
427
+ button.disabled = busy
428
+ if (busy) {
429
+ button.dataset.label = button.textContent
430
+ button.textContent = label
431
+ } else {
432
+ button.textContent = button.dataset.label || button.textContent
433
+ delete button.dataset.label
434
+ }
435
+ }
436
+
437
+ function passwordProblem(value) {
438
+ if (value.length < 6) return '密码至少 6 位'
439
+ const kinds = [/[A-Za-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((pattern) => pattern.test(value)).length
440
+ if (kinds < 2) return '密码需要字母、数字、特殊字符中至少两类'
441
+ return null
442
+ }
443
+
444
+ function updateRequirements() {
445
+ const value = $('password').value
446
+ const kinds = [/[A-Za-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((pattern) => pattern.test(value)).length
447
+ $('reqLength').classList.toggle('ok', value.length >= 6)
448
+ $('reqMix').classList.toggle('ok', kinds >= 2)
449
+ }
450
+
451
+ $('code').addEventListener('input', () => {
452
+ const caret = $('code').selectionStart
453
+ $('code').value = $('code').value.toUpperCase()
454
+ if (caret !== null) $('code').setSelectionRange(caret, caret)
455
+ })
456
+
457
+ $('verify').onclick = async () => {
458
+ const button = $('verify')
459
+ $('error1').textContent = ''
460
+ if ($('code').value.trim() === '') {
461
+ $('error1').textContent = '请输入初始化密钥'
462
+ $('code').focus()
463
+ return
464
+ }
465
+ setBusy(button, true, '验证中…')
466
+ try {
467
+ await post('/setup/verify', { code: $('code').value.trim() })
468
+ showStep(2, 'next')
469
+ } catch (error) {
470
+ $('error1').textContent = error.message
471
+ } finally {
472
+ setBusy(button, false)
473
+ }
474
+ }
475
+ $('password').addEventListener('input', updateRequirements)
476
+ $('confirm').addEventListener('keydown', (event) => { if (event.key === 'Enter') $('savePassword').click() })
477
+ $('savePassword').onclick = async () => {
478
+ const button = $('savePassword')
479
+ $('error2').textContent = ''
480
+ const problem = passwordProblem($('password').value)
481
+ if (problem !== null) {
482
+ $('error2').textContent = problem
483
+ $('password').focus()
484
+ return
485
+ }
486
+ if ($('password').value !== $('confirm').value) {
487
+ $('error2').textContent = '两次输入不一致'
488
+ $('confirm').focus()
489
+ return
490
+ }
491
+ setBusy(button, true, '保存中…')
492
+ try {
493
+ await post('/setup/password', { password: $('password').value })
494
+ showStep(3, 'next')
495
+ } catch (error) {
496
+ $('error2').textContent = error.message
497
+ } finally {
498
+ setBusy(button, false)
499
+ }
500
+ }
501
+ $('back2').onclick = () => showStep(1, 'back')
502
+ $('back3').onclick = () => showStep(2, 'back')
503
+ $('back4').onclick = () => showStep(3, 'back')
504
+
505
+ $('quotaPreset').onchange = () => {
506
+ const custom = $('quotaPreset').value === 'custom'
507
+ $('customQuota').hidden = !custom
508
+ if (custom) setTimeout(() => $('quotaCustom').focus(), 30)
509
+ }
510
+ $('saveQuota').onclick = async () => {
511
+ const button = $('saveQuota')
512
+ const preset = $('quotaPreset').value
513
+ const gib = preset === 'custom' ? Number($('quotaCustom').value) : Number(preset)
514
+ $('error3').textContent = ''
515
+ if (!Number.isFinite(gib) || gib < 1) {
516
+ $('error3').textContent = '配额至少 1 GiB'
517
+ return
518
+ }
519
+ setBusy(button, true, '保存中…')
520
+ try {
521
+ await post('/setup/quota', { maxBytes: Math.round(gib * 1024 * 1024 * 1024) })
522
+ showStep(4, 'next')
523
+ } catch (error) {
524
+ $('error3').textContent = error.message
525
+ } finally {
526
+ setBusy(button, false)
527
+ }
528
+ }
529
+
530
+ function prepareFinish() {
531
+ $('createDevice').hidden = true
532
+ $('skipDevice').hidden = true
533
+ $('finish').hidden = false
534
+ }
535
+ $('createDevice').onclick = async () => {
536
+ const button = $('createDevice')
537
+ $('error4').textContent = ''
538
+ setBusy(button, true, '创建中…')
539
+ try {
540
+ const created = await post('/setup/device', { label: $('deviceLabel').value.trim() || 'device' })
541
+ $('tokenValue').textContent = created.token
542
+ $('tokenPanel').hidden = false
543
+ $('copyToken').onclick = () => copyText(created.token)
544
+ prepareFinish()
545
+ } catch (error) {
546
+ $('error4').textContent = error.message
547
+ } finally {
548
+ setBusy(button, false)
549
+ }
550
+ }
551
+ $('skipDevice').onclick = () => {
552
+ $('tokenPanel').hidden = true
553
+ prepareFinish()
554
+ }
555
+ $('finish').onclick = async () => {
556
+ const button = $('finish')
557
+ $('error4').textContent = ''
558
+ setBusy(button, true, '完成中…')
559
+ try {
560
+ await post('/setup/complete')
561
+ showStep(5, 'next')
562
+ for (const item of document.querySelectorAll('.step')) item.classList.add('done')
563
+ $('setupTitle').textContent = '初始化完成'
564
+ } catch (error) {
565
+ $('error4').textContent = error.message
566
+ } finally {
567
+ setBusy(button, false)
568
+ }
569
+ }
570
+ $('goAdmin').onclick = () => { location.href = '/admin' }
571
+
572
+ for (const button of document.querySelectorAll('[data-reveal]')) {
573
+ button.onclick = () => {
574
+ const input = $(button.dataset.reveal)
575
+ const reveal = input.type === 'password'
576
+ input.type = reveal ? 'text' : 'password'
577
+ button.textContent = reveal ? '隐藏' : '显示'
578
+ }
579
+ }
580
+
581
+ fetch('/setup/state').then((response) => response.json()).then((payload) => {
582
+ const value = payload && payload.value
583
+ if (!value) return
584
+ if (value.initialized === true) { location.href = '/admin'; return }
585
+ if (value.hasPassword === true || value.hasQuota === true || value.devices > 0) $('resumeHint').hidden = false
586
+ }).catch(() => {})
587
+
588
+ showStep(1, 'next')
589
+ </script>
590
+ </body>
591
+ </html>