@ash-qw/dsh-theme-prts 2.2.0 → 2.3.1
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 +22 -0
- package/README.en.md +10 -0
- package/README.md +10 -0
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/THIRD_PARTY_NOTICES.zh-CN.md +1 -1
- package/lib/client.js +733 -62
- package/package.json +2 -2
- package/src/client/background-layout.js +59 -0
- package/src/client/background-region-editor.js +103 -0
- package/src/client/conversation-control-adapter.js +2 -0
- package/src/client/cursor-adapter.js +1 -1
- package/src/client/custom-background.js +23 -6
- package/src/client/index.js +10 -0
- package/src/client/native-preferences.js +31 -0
- package/src/client/operations-shell-v2.js +2 -2
- package/src/client/particle-field-adapter.js +67 -14
- package/src/client/preferences.js +3 -1
- package/src/client/session-card-reveal.js +125 -0
- package/src/client/sidebar-control-adapter.js +40 -35
- package/src/client/theme-controller-v2.js +2 -1
- package/src/client/theme-settings-workbench.js +13 -5
- package/src/client/theme-transfer-panel.js +98 -0
- package/src/client/theme-transfer.js +170 -0
- package/src/server/backgrounds.js +10 -6
- package/src/shared/background-region.js +21 -0
- package/src/styles/cursor.css +8 -0
- package/src/styles/custom-background.css +83 -13
- package/src/styles/prts.css +2 -2
- package/src/styles/refinement.css +28 -0
- package/src/styles/theme-settings-workbench.css +87 -2
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Reveal the temporarily selected card before mounting the destination conversation.
|
|
2
|
+
// Keep the destination stable through unrelated reflow; retarget smoothly only when needed.
|
|
3
|
+
export function createSessionCardReveal({window, root, sessionId, onDone, onStart}) {
|
|
4
|
+
let row, scroller, frame, timer, started = false, disposed = false, geometry, destination, stableSince, lastPosition
|
|
5
|
+
const findRow = () => [...root.querySelectorAll('[data-row-key], [data-session-id]')].find(el =>
|
|
6
|
+
el.getAttribute('data-row-key') === `session:${sessionId}` || el.getAttribute('data-session-id') === sessionId)
|
|
7
|
+
const instant = top => {
|
|
8
|
+
try { scroller.scrollTo({top, behavior: 'instant'}) }
|
|
9
|
+
catch { scroller.scrollTop = top }
|
|
10
|
+
}
|
|
11
|
+
function cancel() {
|
|
12
|
+
if (disposed) return
|
|
13
|
+
disposed = true
|
|
14
|
+
if (scroller && destination !== undefined) instant(scroller.scrollTop)
|
|
15
|
+
destination = undefined
|
|
16
|
+
window.cancelAnimationFrame(frame)
|
|
17
|
+
window.clearTimeout(timer)
|
|
18
|
+
resize?.disconnect()
|
|
19
|
+
mutations.disconnect()
|
|
20
|
+
root.removeEventListener('transitionend', schedule)
|
|
21
|
+
root.removeEventListener('animationend', schedule)
|
|
22
|
+
scroller?.removeEventListener('scrollend', scrollEnded)
|
|
23
|
+
}
|
|
24
|
+
function scrollEnded() {
|
|
25
|
+
if (destination !== undefined && Math.abs(scroller.scrollTop - destination) <= 1) destination = undefined
|
|
26
|
+
schedule()
|
|
27
|
+
}
|
|
28
|
+
function schedule() {
|
|
29
|
+
if (!disposed && frame === undefined) frame = window.requestAnimationFrame(() => { frame = undefined; check() })
|
|
30
|
+
}
|
|
31
|
+
function check(final = false) {
|
|
32
|
+
if (disposed) return
|
|
33
|
+
const nextRow = findRow()
|
|
34
|
+
if (!nextRow || (nextRow.getAttribute('data-prts-selection-target') !== 'true' && nextRow.getAttribute('aria-selected') !== 'true')) {
|
|
35
|
+
stableSince = undefined
|
|
36
|
+
if (started && !final) schedule()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
const remounted = nextRow !== row
|
|
40
|
+
if (remounted) {
|
|
41
|
+
row = nextRow
|
|
42
|
+
const previousScroller = scroller
|
|
43
|
+
scroller?.removeEventListener('scrollend', scrollEnded)
|
|
44
|
+
resize?.disconnect()
|
|
45
|
+
scroller = undefined
|
|
46
|
+
for (let node = row.parentElement; node && node !== root.parentElement; node = node.parentElement) {
|
|
47
|
+
if (/^(auto|scroll)$/.test(window.getComputedStyle(node).overflowY) && node.clientHeight > 0) { scroller = node; break }
|
|
48
|
+
}
|
|
49
|
+
if (scroller !== previousScroller) destination = undefined
|
|
50
|
+
for (let node = row; node && node !== root.parentElement; node = node.parentElement) {
|
|
51
|
+
for (const animation of node.getAnimations?.() ?? []) {
|
|
52
|
+
const timing = animation.effect?.getComputedTiming?.()
|
|
53
|
+
if (animation.playState === 'running' && Number.isFinite(timing?.endTime)) animation.finished.then(schedule, schedule)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
resize?.observe(root)
|
|
57
|
+
if (scroller) { resize?.observe(scroller); scroller.addEventListener('scrollend', scrollEnded) }
|
|
58
|
+
}
|
|
59
|
+
let settled = true
|
|
60
|
+
const first = !started
|
|
61
|
+
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
62
|
+
|| root.ownerDocument.documentElement.getAttribute('data-prts-motion') === 'reduced'
|
|
63
|
+
if (scroller) {
|
|
64
|
+
// Read all geometry before writing scrollTop. Offset is independent of smooth-scroll progress.
|
|
65
|
+
const card = row.getBoundingClientRect(), box = scroller.getBoundingClientRect()
|
|
66
|
+
const current = scroller.scrollTop, height = scroller.clientHeight
|
|
67
|
+
const top = card.top - box.top - scroller.clientTop, bottom = card.bottom - box.top - scroller.clientTop
|
|
68
|
+
const next = [top + current, card.height, height, scroller.scrollHeight]
|
|
69
|
+
const changed = remounted || !geometry || next.some((v, i) => Math.abs(v - geometry[i]) > 1)
|
|
70
|
+
if (first || changed || final) {
|
|
71
|
+
// Evaluate visibility at the pending destination, not at an intermediate animation frame.
|
|
72
|
+
// Rows below the target may change scrollHeight without invalidating that destination.
|
|
73
|
+
const base = destination ?? current
|
|
74
|
+
const offset = next[0], bottomOffset = offset + card.height
|
|
75
|
+
const limit = Math.max(0, next[3] - height)
|
|
76
|
+
const nearest = position => Math.max(0, Math.min(limit,
|
|
77
|
+
offset < position ? offset : bottomOffset > position + height ? bottomOffset - height : position))
|
|
78
|
+
// A stale endpoint can lie on the other side of a card that is already visible.
|
|
79
|
+
// Preserve valid endpoints, but correct invalid ones from the actual viewport.
|
|
80
|
+
const target = Math.abs(nearest(base) - base) > 1 ? nearest(current) : base
|
|
81
|
+
if (final) {
|
|
82
|
+
// End any native animation at the nearest visible position before disconnecting.
|
|
83
|
+
instant(nearest(current))
|
|
84
|
+
destination = undefined
|
|
85
|
+
} else if (Math.abs(target - base) > 1) {
|
|
86
|
+
destination = target
|
|
87
|
+
if (!reduced && typeof scroller.scrollTo === 'function') scroller.scrollTo({top: target, behavior: 'smooth'})
|
|
88
|
+
else instant(target)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
settled = !changed && Math.abs(current - (lastPosition ?? current)) <= 1
|
|
92
|
+
&& (destination === undefined || Math.abs(scroller.scrollTop - destination) <= 1)
|
|
93
|
+
lastPosition = current
|
|
94
|
+
geometry = next
|
|
95
|
+
} else if (first || remounted || final) {
|
|
96
|
+
row.scrollIntoView?.({block:'nearest', inline:'nearest', behavior:'instant'})
|
|
97
|
+
}
|
|
98
|
+
for (let node = row; node && node !== root.parentElement; node = node.parentElement) {
|
|
99
|
+
if ((node.getAnimations?.() ?? []).some(animation => animation.playState === 'running'
|
|
100
|
+
&& Number.isFinite(animation.effect?.getComputedTiming?.().endTime))) settled = false
|
|
101
|
+
}
|
|
102
|
+
if (first) {
|
|
103
|
+
started = true
|
|
104
|
+
onStart?.()
|
|
105
|
+
// Bound the pre-navigation wait; finish any remaining scroll before opening.
|
|
106
|
+
timer = window.setTimeout(() => { check(true); cancel(); onDone() }, 1000)
|
|
107
|
+
}
|
|
108
|
+
if (!final) {
|
|
109
|
+
const now = window.performance.now()
|
|
110
|
+
if (!settled) stableSince = undefined
|
|
111
|
+
else stableSince ??= now
|
|
112
|
+
if (stableSince !== undefined && now - stableSince >= 80) {
|
|
113
|
+
cancel()
|
|
114
|
+
onDone()
|
|
115
|
+
} else schedule()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const resize = typeof window.ResizeObserver === 'function' ? new window.ResizeObserver(schedule) : undefined
|
|
119
|
+
const mutations = new window.MutationObserver(schedule)
|
|
120
|
+
mutations.observe(root, {childList:true, subtree:true, attributes:true, attributeFilter:['aria-selected','aria-expanded','data-prts-selection-target']})
|
|
121
|
+
root.addEventListener('transitionend', schedule)
|
|
122
|
+
root.addEventListener('animationend', schedule)
|
|
123
|
+
schedule()
|
|
124
|
+
return {cancel, interrupt: cancel}
|
|
125
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createSessionCardReveal } from './session-card-reveal.js'
|
|
1
2
|
import { createSessionSummaryPreview, scrollSummaryTitle } from './session-summary-preview.js'
|
|
2
3
|
import { ensureFacilityGraphic, ensureFacilityTextureDefs, renderFacilityGraphic } from './facility-vector.js'
|
|
3
4
|
import { createSessionLifelineAnimator } from './session-lifeline.js'
|
|
@@ -353,7 +354,11 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
|
|
|
353
354
|
queueScan()
|
|
354
355
|
}
|
|
355
356
|
function interruptSelectionTarget(event) {
|
|
356
|
-
if (event.target.closest?.('[data-prts-session-row]'))
|
|
357
|
+
if (event.target.closest?.('[data-prts-session-row]')) {
|
|
358
|
+
cancelPendingNavigation()
|
|
359
|
+
cancelReveal()
|
|
360
|
+
clearSelectionTarget()
|
|
361
|
+
}
|
|
357
362
|
}
|
|
358
363
|
function setSelectionTarget(id) {
|
|
359
364
|
clearSelectionTarget()
|
|
@@ -362,27 +367,41 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
|
|
|
362
367
|
document.addEventListener('pointerdown', interruptSelectionTarget, true)
|
|
363
368
|
}
|
|
364
369
|
|
|
365
|
-
let pendingReveal, revealTimer
|
|
370
|
+
let pendingReveal, revealTimer
|
|
366
371
|
let navigationFrame, navigationGeneration = 0
|
|
367
372
|
function cancelPendingNavigation() {
|
|
368
373
|
navigationGeneration++
|
|
369
374
|
window.cancelAnimationFrame?.(navigationFrame)
|
|
370
375
|
}
|
|
371
376
|
function cancelReveal() {
|
|
377
|
+
pendingReveal?.tracker?.cancel()
|
|
372
378
|
pendingReveal = undefined
|
|
373
379
|
window.clearTimeout(revealTimer)
|
|
374
|
-
|
|
375
|
-
window.cancelAnimationFrame?.(revealFrame)
|
|
380
|
+
for (const type of ['wheel','touchmove','pointerdown','keydown']) document.removeEventListener(type, interruptReveal, true)
|
|
376
381
|
}
|
|
377
|
-
function
|
|
382
|
+
function interruptReveal(event) {
|
|
383
|
+
if (!event.target?.closest?.('[data-prts-region="sessions"]')) return
|
|
384
|
+
if (event.type === 'keydown' && !['ArrowUp','ArrowDown','PageUp','PageDown','Home','End',' '].includes(event.key)) return
|
|
385
|
+
cancelPendingNavigation()
|
|
378
386
|
cancelReveal()
|
|
379
|
-
|
|
380
|
-
|
|
387
|
+
clearSelectionTarget()
|
|
388
|
+
}
|
|
389
|
+
function revealSessionCard(sessionId, onDone) {
|
|
390
|
+
pendingReveal?.tracker?.interrupt()
|
|
391
|
+
cancelReveal()
|
|
392
|
+
pendingReveal = {sessionId, onDone}
|
|
393
|
+
revealTimer = window.setTimeout(finishReveal, 4000)
|
|
394
|
+
for (const type of ['wheel','touchmove','pointerdown','keydown']) document.addEventListener(type, interruptReveal, {capture:true, passive:true})
|
|
381
395
|
const root = document?.querySelector?.('[data-prts-region="sessions"] [data-slot="sidebar.workspaces"]')
|
|
382
396
|
syncSessionReveal(root)
|
|
383
397
|
queueScan()
|
|
384
398
|
return Boolean(pendingReveal?.expanding)
|
|
385
399
|
}
|
|
400
|
+
function finishReveal() {
|
|
401
|
+
const onDone = pendingReveal?.onDone
|
|
402
|
+
cancelReveal()
|
|
403
|
+
onDone?.()
|
|
404
|
+
}
|
|
386
405
|
function syncSessionReveal(root) {
|
|
387
406
|
if (!pendingReveal || !root) return
|
|
388
407
|
const {sessionId} = pendingReveal
|
|
@@ -390,30 +409,14 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
|
|
|
390
409
|
const row = rows.find(el => el.getAttribute('data-row-key') === `session:${sessionId}`
|
|
391
410
|
|| el.getAttribute('data-session-id') === sessionId)
|
|
392
411
|
if (row) {
|
|
393
|
-
if (pendingReveal.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const animations = []
|
|
400
|
-
if (!reduced) for (let node = row; node && node !== root; node = node.parentElement) {
|
|
401
|
-
for (const animation of node.getAnimations?.() ?? []) {
|
|
402
|
-
const timing = animation.effect?.getComputedTiming?.()
|
|
403
|
-
if (animation.playState === 'running' && Number.isFinite(timing?.endTime)) animations.push(animation.finished)
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
if (animations.length) await Promise.race([
|
|
407
|
-
Promise.allSettled(animations),
|
|
408
|
-
new Promise(resolve => { revealSettleTimer = window.setTimeout(resolve, 300) }),
|
|
409
|
-
])
|
|
410
|
-
if (pendingReveal !== request || !row.isConnected) return
|
|
411
|
-
row.scrollIntoView?.({block:'nearest', inline:'nearest', behavior:reduced ? 'instant' : 'smooth'})
|
|
412
|
-
cancelReveal()
|
|
413
|
-
})
|
|
412
|
+
if (!pendingReveal.tracker) {
|
|
413
|
+
const request = pendingReveal
|
|
414
|
+
request.tracker = createSessionCardReveal({window, root, sessionId, onStart: () => window.clearTimeout(revealTimer), onDone: () => {
|
|
415
|
+
if (pendingReveal === request) finishReveal()
|
|
416
|
+
}})
|
|
417
|
+
}
|
|
414
418
|
return
|
|
415
419
|
}
|
|
416
|
-
if (++pendingReveal.attempts > 40) { cancelReveal(); return }
|
|
417
420
|
const items = snapshotOf(workspaces?.list)?.items ?? []
|
|
418
421
|
const owner = items.find(item => item.sessionIds?.includes(sessionId))
|
|
419
422
|
const key = owner?.workspaceId ?? ''
|
|
@@ -439,7 +442,6 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
|
|
|
439
442
|
closeNavigationPreview()
|
|
440
443
|
cancelPendingNavigation()
|
|
441
444
|
setSelectionTarget(sessionId)
|
|
442
|
-
revealSessionCard(sessionId)
|
|
443
445
|
const generation = navigationGeneration
|
|
444
446
|
const navigate = () => {
|
|
445
447
|
if (generation !== navigationGeneration || !adapterActive) return
|
|
@@ -462,11 +464,14 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
|
|
|
462
464
|
if (row) row.click?.()
|
|
463
465
|
else clearSelectionTarget()
|
|
464
466
|
}
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
navigationFrame = window.requestAnimationFrame(
|
|
467
|
+
// Finish sidebar motion first, then paint its final position before the host can block.
|
|
468
|
+
revealSessionCard(sessionId, () => {
|
|
469
|
+
if (generation !== navigationGeneration || !adapterActive) return
|
|
470
|
+
setSelectionTarget(sessionId)
|
|
471
|
+
navigationFrame = window.requestAnimationFrame(() => {
|
|
472
|
+
if (generation !== navigationGeneration) return
|
|
473
|
+
navigationFrame = window.requestAnimationFrame(navigate)
|
|
474
|
+
})
|
|
470
475
|
})
|
|
471
476
|
}
|
|
472
477
|
|
|
@@ -72,7 +72,7 @@ function resolveScheme(value) {
|
|
|
72
72
|
return identity.includes('dark') || identity.includes('night') ? 'dark' : 'light'
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export function createThemeController({ document, window, cssText, service, cursorAssets, onTransitionStateChange = () => {} }) {
|
|
75
|
+
export function createThemeController({ document, window, cssText, service, cursorAssets, onTransitionStateChange = () => {}, onSchemeChange = () => {} }) {
|
|
76
76
|
if (!document || !window) {
|
|
77
77
|
const gesture = { update() {}, finish() {}, cancel() {} }
|
|
78
78
|
return { apply() {}, sync() {}, refresh() {}, setTheme() {}, beginThemeTransition() { return gesture }, toggle() {}, dispose() {} }
|
|
@@ -153,6 +153,7 @@ export function createThemeController({ document, window, cssText, service, curs
|
|
|
153
153
|
if (disposed) return
|
|
154
154
|
const next = resolveScheme(value)
|
|
155
155
|
root.dataset.prtsScheme = next
|
|
156
|
+
onSchemeChange(next)
|
|
156
157
|
return next
|
|
157
158
|
}
|
|
158
159
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { themeTransferMarkup, mountThemeTransfer } from './theme-transfer-panel.js'
|
|
1
2
|
import { backgroundMarkup, mountBackgroundSettings } from './custom-background.js'
|
|
2
3
|
import { avatarMarkup, mountAvatarSettings } from './custom-avatar.js'
|
|
3
4
|
import { customEmblemMarkup, mountCustomEmblemEditor } from './custom-emblem.js'
|
|
@@ -38,7 +39,9 @@ function previewSetting(key, label, description) {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
function settingButtons(key, label, options) {
|
|
41
|
-
const note = key === '
|
|
42
|
+
const note = key === 'heroContrast'
|
|
43
|
+
? '<small data-prts-setting-note>为新会话徽记添加柔光衬底,使它在背景图片上更清晰。</small>'
|
|
44
|
+
: key === 'composerGlass'
|
|
42
45
|
? '<small data-prts-setting-note>增强输入框的背景模糊与轻微折射,关闭时使用原有材质</small>'
|
|
43
46
|
: key === 'bakerLoading'
|
|
44
47
|
? '<small data-prts-setting-note>每轮正文生成时显示三点动画,完成后显示全文;中断时保留已收到的内容</small>'
|
|
@@ -59,7 +62,7 @@ function settingButtons(key, label, options) {
|
|
|
59
62
|
: key === 'sessionFlowPalette'
|
|
60
63
|
? '<small data-prts-setting-note>同时适配日间与夜间模式</small>'
|
|
61
64
|
: ''
|
|
62
|
-
if (['composerGlass', 'bakerLoading', 'motion', 'cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem', 'sessionPreview', 'aiToolsEnabled', 'previewOngoing', 'previewTools', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning'].includes(key)) return `<div data-prts-setting-row="${key}">
|
|
65
|
+
if (['heroContrast', 'composerGlass', 'bakerLoading', 'motion', 'cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem', 'sessionPreview', 'aiToolsEnabled', 'previewOngoing', 'previewTools', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning'].includes(key)) return `<div data-prts-setting-row="${key}">
|
|
63
66
|
<span><strong>${label}</strong>${note}</span>
|
|
64
67
|
<button type="button" role="switch" aria-label="${label}" aria-checked="false" data-prts-setting-switch="${key}" ${key === 'cursor' ? 'data-prts-cursor-switch' : ''}><span aria-hidden="true"></span></button>
|
|
65
68
|
</div>`
|
|
@@ -134,9 +137,10 @@ export function themeSettingsMarkup() {
|
|
|
134
137
|
<span><strong>设置未保存</strong><small>未能保存到 DSH,可能存在连接中断、配置冲突或当前访问不支持持久保存。重试将重新提交本次修改。</small></span>
|
|
135
138
|
<button type="button" data-prts-retry-save>重试保存</button>
|
|
136
139
|
</div>
|
|
140
|
+
<button type="button" data-prts-transfer-open data-prts-footer-action aria-haspopup="dialog" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M6 14V3m-3 3 3-3 3 3M14 6v11m-3-3 3 3 3-3"/></svg><span>导入与导出</span></button>
|
|
137
141
|
<div data-prts-reset-zone>
|
|
138
142
|
|
|
139
|
-
<button type="button" data-prts-reset-visual title="恢复全部主题设置" aria-label="恢复全部主题设置">恢复默认</button>
|
|
143
|
+
<button type="button" data-prts-reset-visual data-prts-footer-action title="恢复全部主题设置" aria-label="恢复全部主题设置"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 7a6.5 6.5 0 1 1-.4 5M4 3v4h4"/></svg><span>恢复默认</span></button>
|
|
140
144
|
<div data-prts-reset-confirm hidden role="group" aria-label="确认恢复全部主题设置">
|
|
141
145
|
<span>重置全部分类的主题设置,保留主题开关、启动动画和徽记配置。</span>
|
|
142
146
|
<button type="button" data-prts-reset-confirm-action>确认恢复</button>
|
|
@@ -144,6 +148,7 @@ export function themeSettingsMarkup() {
|
|
|
144
148
|
</div>
|
|
145
149
|
</div>
|
|
146
150
|
</footer>
|
|
151
|
+
${themeTransferMarkup()}
|
|
147
152
|
</div>
|
|
148
153
|
<div data-prts-theme-settings-scroll>
|
|
149
154
|
<section data-prts-settings-section="appearance" role="tabpanel" id="prts-page-appearance" aria-labelledby="prts-tab-appearance" tabindex="0">
|
|
@@ -159,7 +164,7 @@ export function themeSettingsMarkup() {
|
|
|
159
164
|
</section>
|
|
160
165
|
</section>
|
|
161
166
|
<section data-prts-settings-section="background" role="tabpanel" id="prts-page-background" aria-labelledby="prts-tab-background" tabindex="0" hidden>
|
|
162
|
-
${backgroundMarkup()}
|
|
167
|
+
${backgroundMarkup(settingButtons('heroContrast', '新会话徽记增强', []))}
|
|
163
168
|
<div data-prts-composer-glass-setting>${settingButtons('composerGlass', '输入框增强毛玻璃', [])}</div>
|
|
164
169
|
<small data-prts-composer-glass-required hidden>自定义背景下,输入框增强毛玻璃始终开启。</small>
|
|
165
170
|
</section>
|
|
@@ -262,7 +267,7 @@ export function createThemeSettingsOverlay({
|
|
|
262
267
|
trigger,
|
|
263
268
|
panel,
|
|
264
269
|
backdrop,
|
|
265
|
-
customEmblemStore, avatarStore, backgroundStore,
|
|
270
|
+
customEmblemStore, avatarStore, backgroundStore, themeTransfer,
|
|
266
271
|
onOpen = () => {},
|
|
267
272
|
onPreferenceChange = () => {},
|
|
268
273
|
onPreferencePreview = () => {},
|
|
@@ -270,6 +275,7 @@ export function createThemeSettingsOverlay({
|
|
|
270
275
|
onRetrySave = () => {},
|
|
271
276
|
}) {
|
|
272
277
|
const window = document?.defaultView
|
|
278
|
+
const transferEditor = mountThemeTransfer({ panel, window, transfer: themeTransfer })
|
|
273
279
|
const backgroundEditor = mountBackgroundSettings({panel,window,store:backgroundStore,onChange:onPreferenceChange})
|
|
274
280
|
const avatarEditor = mountAvatarSettings({panel,window,store:avatarStore})
|
|
275
281
|
const customEditor = mountCustomEmblemEditor({ panel, window, store: customEmblemStore })
|
|
@@ -749,6 +755,7 @@ export function createThemeSettingsOverlay({
|
|
|
749
755
|
panel.querySelector('[data-prts-baker-options]').hidden = preferences?.conversationStyle !== 'deck-chat'
|
|
750
756
|
panel.querySelector('[data-prts-avatars]').hidden = preferences?.conversationStyle !== 'deck-chat'
|
|
751
757
|
const customBackground = preferences.backgroundMode !== 'off'
|
|
758
|
+
panel.querySelector('[data-prts-background-hero-setting]').hidden = !customBackground
|
|
752
759
|
panel.querySelector('[data-prts-composer-glass-setting]').hidden = customBackground
|
|
753
760
|
panel.querySelector('[data-prts-composer-glass-required]').hidden = !customBackground
|
|
754
761
|
backgroundEditor.update(preferences)
|
|
@@ -766,6 +773,7 @@ export function createThemeSettingsOverlay({
|
|
|
766
773
|
close: () => setOpen(false),
|
|
767
774
|
toggle,
|
|
768
775
|
dispose() {
|
|
776
|
+
transferEditor.dispose()
|
|
769
777
|
customEditor.dispose()
|
|
770
778
|
avatarEditor.dispose()
|
|
771
779
|
backgroundEditor.dispose()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { THEME_PACKAGE_LIMIT, validateThemePackage, describeThemePackage } from './theme-transfer.js'
|
|
2
|
+
|
|
3
|
+
export function themeTransferMarkup() {
|
|
4
|
+
return `<dialog data-prts-transfer-dialog aria-labelledby="prts-transfer-title">
|
|
5
|
+
<section data-prts-transfer>
|
|
6
|
+
<header data-prts-transfer-heading><div><small>P.R.T.S. / 主题文件</small><h2 id="prts-transfer-title">导入与导出</h2></div><button type="button" data-prts-transfer-close aria-label="关闭导入与导出">×</button></header>
|
|
7
|
+
<div data-prts-transfer-columns>
|
|
8
|
+
<section data-prts-transfer-card>
|
|
9
|
+
<h3>导出主题</h3><p>保存一份主题,备份或带到另一台设备。</p>
|
|
10
|
+
<label data-prts-transfer-option><input type="checkbox" data-prts-transfer-assets checked><span>包含自定义素材<small>背景、头像和徽记,保留编辑参数与排列。</small></span></label>
|
|
11
|
+
<small>取消勾选,仅导出主题设置。</small>
|
|
12
|
+
<button type="button" data-prts-transfer-export>导出主题文件 <span aria-hidden="true">↓</span></button>
|
|
13
|
+
</section>
|
|
14
|
+
<section data-prts-transfer-card>
|
|
15
|
+
<h3>导入主题</h3><p>选择已导出的文件,确认内容后应用。</p>
|
|
16
|
+
<div data-prts-transfer-file-hint><span aria-hidden="true">↑</span><small>.prts-theme.json · 最大 160 MB</small></div>
|
|
17
|
+
<button type="button" data-prts-transfer-choose>选择主题文件</button>
|
|
18
|
+
<input type="file" accept=".prts-theme.json,application/json" aria-label="导入主题文件" data-prts-transfer-file hidden>
|
|
19
|
+
</section>
|
|
20
|
+
</div>
|
|
21
|
+
<div data-prts-transfer-preview hidden>
|
|
22
|
+
<strong>确认导入</strong><p data-prts-transfer-summary></p>
|
|
23
|
+
<small>应用前会下载当前配置备份,请保留备份文件。</small>
|
|
24
|
+
<div data-prts-transfer-buttons><button type="button" data-prts-transfer-apply>确认导入</button><button type="button" data-prts-transfer-cancel>取消</button></div>
|
|
25
|
+
</div>
|
|
26
|
+
<div data-prts-transfer-recovery hidden><small>恢复将替换最近一次导入后所做的修改。</small><button type="button" data-prts-transfer-restore>恢复导入前配置</button></div>
|
|
27
|
+
<p data-prts-transfer-status role="status" aria-live="polite"></p>
|
|
28
|
+
</section>
|
|
29
|
+
</dialog>`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function mountThemeTransfer({ panel, window, transfer }) {
|
|
33
|
+
const section = panel.querySelector('[data-prts-transfer]')
|
|
34
|
+
if (!section || !transfer) return { dispose() {} }
|
|
35
|
+
const dialog = panel.querySelector('[data-prts-transfer-dialog]')
|
|
36
|
+
const trigger = panel.querySelector('[data-prts-transfer-open]')
|
|
37
|
+
const open = () => { dialog.showModal(); trigger?.setAttribute('aria-expanded', 'true'); get('close').focus() }
|
|
38
|
+
const close = () => { if (busy) return; dialog.close(); trigger?.setAttribute('aria-expanded', 'false'); trigger?.focus() }
|
|
39
|
+
const keydown = event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close() } }
|
|
40
|
+
const cancel = event => { if (event.target === dialog) { event.preventDefault(); event.stopPropagation(); close() } }
|
|
41
|
+
const get = name => section.querySelector(`[data-prts-transfer-${name}]`)
|
|
42
|
+
let pending, busy = false, disposed = false, generation = 0
|
|
43
|
+
const render = () => {
|
|
44
|
+
if (disposed) return
|
|
45
|
+
get('preview').hidden = !pending
|
|
46
|
+
get('recovery').hidden = !transfer.hasBackup
|
|
47
|
+
for (const control of section.querySelectorAll('button,input')) control.disabled = busy
|
|
48
|
+
// Prevent local editors from racing a multi-service import; other clients use revision checks.
|
|
49
|
+
for (const page of panel.querySelectorAll('[data-prts-settings-section]')) if (!page.contains(section)) page.inert = busy
|
|
50
|
+
}
|
|
51
|
+
async function run(task) {
|
|
52
|
+
if (busy) return
|
|
53
|
+
busy = true; get('status').textContent = '正在处理,请稍候…'; render()
|
|
54
|
+
try { get('status').textContent = await task() }
|
|
55
|
+
catch (error) { if (!disposed) get('status').textContent = error.message }
|
|
56
|
+
finally { busy = false; render() }
|
|
57
|
+
}
|
|
58
|
+
const change = event => {
|
|
59
|
+
if (event.target !== get('file')) return
|
|
60
|
+
const file = event.target.files?.[0], selected = ++generation
|
|
61
|
+
pending = undefined; render()
|
|
62
|
+
if (!file) return
|
|
63
|
+
void run(async () => {
|
|
64
|
+
if (file.size > THEME_PACKAGE_LIMIT) throw Error('主题文件不能超过 160 MB')
|
|
65
|
+
const text = await new Promise((resolve, reject) => {
|
|
66
|
+
const reader = new window.FileReader()
|
|
67
|
+
reader.onload = () => resolve(reader.result); reader.onerror = () => reject(Error('无法读取主题文件')); reader.readAsText(file)
|
|
68
|
+
})
|
|
69
|
+
let value
|
|
70
|
+
try { value = JSON.parse(text) } catch { throw Error('主题文件不是有效的 JSON') }
|
|
71
|
+
validateThemePackage(value)
|
|
72
|
+
if (disposed || selected !== generation) return ''
|
|
73
|
+
pending = value
|
|
74
|
+
get('summary').textContent = describeThemePackage(value)
|
|
75
|
+
return '已读取文件,请确认导入内容。'
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
const click = event => {
|
|
79
|
+
const button = event.target.closest?.('button')
|
|
80
|
+
if (button === get('close')) close()
|
|
81
|
+
if (button === get('choose')) get('file').click()
|
|
82
|
+
if (button === get('export')) void run(async () => {
|
|
83
|
+
const value = await transfer.export(get('assets').checked)
|
|
84
|
+
transfer.download(value)
|
|
85
|
+
return '已发起下载,请确认主题文件已保存。'
|
|
86
|
+
})
|
|
87
|
+
if (button === get('apply') && pending) void run(async () => {
|
|
88
|
+
await transfer.import(pending); pending = undefined; get('file').value = ''
|
|
89
|
+
return '主题已导入并保存。'
|
|
90
|
+
})
|
|
91
|
+
if (button === get('cancel')) { pending = undefined; get('file').value = ''; get('status').textContent = ''; render() }
|
|
92
|
+
if (button === get('restore')) void run(async () => { await transfer.restore(); return '已恢复导入前配置。' })
|
|
93
|
+
}
|
|
94
|
+
trigger?.addEventListener('click', open)
|
|
95
|
+
dialog.addEventListener('keydown', keydown); dialog.addEventListener('cancel', cancel)
|
|
96
|
+
section.addEventListener('change', change); section.addEventListener('click', click); render()
|
|
97
|
+
return { dispose() { disposed = true; generation++; if (dialog.open) dialog.close(); trigger?.removeEventListener('click', open); dialog.removeEventListener('keydown', keydown); dialog.removeEventListener('cancel', cancel); section.removeEventListener('change', change); section.removeEventListener('click', click) } }
|
|
98
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { validBackgroundRegion } from '../shared/background-region.js'
|
|
2
|
+
import { DEFAULT_PREFERENCES, normalizePreferences } from './preferences.js'
|
|
3
|
+
|
|
4
|
+
export const THEME_PACKAGE_LIMIT = 160 * 1024 * 1024
|
|
5
|
+
const transferSlots = ['full', 'sidebar', 'conversation']
|
|
6
|
+
const transferRoles = ['user', 'assistant']
|
|
7
|
+
const transferBuiltins = ['rhodes-island', 'lungmen', 'penguin-logistics', 'rhine-lab', 'reunion']
|
|
8
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
9
|
+
const encodedImage = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= Math.ceil(limit / 3) * 4 && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
|
|
10
|
+
|
|
11
|
+
export function validateThemePackage(value) {
|
|
12
|
+
if (!record(value) || value.format !== 'prts-theme' || value.version !== 1) throw Error('不支持的主题文件格式或版本')
|
|
13
|
+
if (!record(value.preferences) || !Object.keys(value.preferences).some(key => key !== 'version' && Object.hasOwn(DEFAULT_PREFERENCES, key))) throw Error('主题文件缺少设置')
|
|
14
|
+
if (!Number.isInteger(value.preferences.version) || value.preferences.version < 1 || value.preferences.version > DEFAULT_PREFERENCES.version) throw Error('不支持此设置版本,请更新主题后重试')
|
|
15
|
+
const normalized = normalizePreferences(value.preferences)
|
|
16
|
+
for (const [key, input] of Object.entries(value.preferences)) {
|
|
17
|
+
if (!Object.hasOwn(DEFAULT_PREFERENCES, key) || key === 'version') continue
|
|
18
|
+
if (typeof input !== typeof DEFAULT_PREFERENCES[key] || (typeof input === 'number' && !Number.isFinite(input))) throw Error(`设置 ${key} 的类型无效`)
|
|
19
|
+
// Current-format files must not silently turn malformed settings into defaults.
|
|
20
|
+
if (value.preferences.version === DEFAULT_PREFERENCES.version && normalized[key] !== input) throw Error(`设置 ${key} 的值无效`)
|
|
21
|
+
}
|
|
22
|
+
if (value.assets === undefined) return value
|
|
23
|
+
const a = value.assets
|
|
24
|
+
if (!record(a) || !record(a.backgrounds) || !record(a.avatars) || !record(a.emblems)) throw Error('主题素材数据不完整')
|
|
25
|
+
for (const slot of transferSlots) if (a.backgrounds[slot] !== null && !encodedImage(a.backgrounds[slot], 10 * 1024 * 1024)) throw Error('背景图片数据无效')
|
|
26
|
+
if (a.backgroundRegions !== undefined && (!record(a.backgroundRegions) || transferSlots.some(slot => !validBackgroundRegion(a.backgroundRegions[slot] ?? null)))) throw Error('背景保护区域无效')
|
|
27
|
+
for (const role of transferRoles) {
|
|
28
|
+
const avatar = a.avatars[role]
|
|
29
|
+
if (avatar === null) continue
|
|
30
|
+
const crop = avatar?.crop
|
|
31
|
+
if (!record(avatar) || !encodedImage(avatar.image, 5 * 1024 * 1024) || !record(crop)
|
|
32
|
+
|| !Number.isFinite(crop.zoom) || crop.zoom < 1 || crop.zoom > 4
|
|
33
|
+
|| !Number.isFinite(crop.x) || crop.x < 0 || crop.x > 1 || !Number.isFinite(crop.y) || crop.y < 0 || crop.y > 1) throw Error('头像数据或裁剪参数无效')
|
|
34
|
+
}
|
|
35
|
+
const { items, hero, conversation } = a.emblems
|
|
36
|
+
if (!Array.isArray(items) || items.length > 13 || !Array.isArray(conversation) || conversation.length < 1 || conversation.length > 12) throw Error('徽记数量无效')
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
if (!record(item) || item.format !== 'prts-emblem' || item.version !== 1 || typeof item.label !== 'string' || !item.label.trim() || item.label.length > 100 || !record(item.params) || !encodedImage(item.image, 8 * 1024 * 1024)) throw Error('徽记素材无效')
|
|
39
|
+
}
|
|
40
|
+
const validEntry = entry => record(entry) && (entry.kind === 'builtin' ? transferBuiltins.includes(entry.key) : entry.kind === 'custom' && Number.isInteger(entry.asset) && entry.asset >= 0 && entry.asset < items.length && typeof entry.label === 'string' && entry.label.length <= 100)
|
|
41
|
+
if ((hero !== null && (!validEntry(hero) || hero.kind !== 'custom')) || !conversation.every(validEntry)) throw Error('徽记引用无效')
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function mergeThemePreferences(current, imported) {
|
|
46
|
+
const normalized = normalizePreferences(imported)
|
|
47
|
+
const patch = Object.fromEntries(Object.keys(DEFAULT_PREFERENCES).filter(key => key !== 'version' && Object.hasOwn(imported, key)).map(key => [key, normalized[key]]))
|
|
48
|
+
return normalizePreferences({ ...current, ...patch, version: DEFAULT_PREFERENCES.version })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function describeThemePackage(value) {
|
|
52
|
+
const count = Object.keys(value.preferences).filter(key => key !== 'version' && Object.hasOwn(DEFAULT_PREFERENCES, key)).length
|
|
53
|
+
if (!value.assets) return `${count} 项主题设置;不包含素材,将保留当前背景、头像和徽记。`
|
|
54
|
+
const a = value.assets
|
|
55
|
+
return `${count} 项主题设置、${transferSlots.filter(k => a.backgrounds[k]).length} 张背景、${transferRoles.filter(k => a.avatars[k]).length} 个头像、${a.emblems.items.length} 个自定义徽记。将替换当前素材及徽记排列,空项恢复为默认。`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createThemeTransfer({ window, getPreferences, savePreferences, checkWritable, checkExport = checkWritable, themeVersion }) {
|
|
59
|
+
let busy = false, backup
|
|
60
|
+
const request = async (path, data) => {
|
|
61
|
+
const response = await window.fetch('/prts-theme-api/' + path, { credentials: 'same-origin',
|
|
62
|
+
...(data === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json', 'x-prts-request': '1' }, body: JSON.stringify(data) }) })
|
|
63
|
+
const result = await response.json()
|
|
64
|
+
if (!response.ok) throw Error(result.error || '无法连接主题素材服务')
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
const readImage = blob => new Promise((resolve, reject) => {
|
|
68
|
+
const reader = new window.FileReader()
|
|
69
|
+
reader.onload = () => resolve(String(reader.result).split(',')[1])
|
|
70
|
+
reader.onerror = () => reject(Error('无法读取背景图片'))
|
|
71
|
+
reader.readAsDataURL(blob)
|
|
72
|
+
})
|
|
73
|
+
async function capture(includeAssets) {
|
|
74
|
+
const value = { format: 'prts-theme', version: 1, themeVersion, exportedAt: new Date().toISOString(), preferences: { ...getPreferences() } }
|
|
75
|
+
const states = {}
|
|
76
|
+
if (includeAssets) {
|
|
77
|
+
const backgrounds = {}, backgroundRegions = {}, avatars = {}, items = [], ids = new Map()
|
|
78
|
+
states.backgrounds = await request('backgrounds/configuration')
|
|
79
|
+
for (const slot of transferSlots) {
|
|
80
|
+
const entry = states.backgrounds[slot]
|
|
81
|
+
backgroundRegions[slot] = entry?.region ?? null
|
|
82
|
+
backgrounds[slot] = null
|
|
83
|
+
if (entry) {
|
|
84
|
+
if (!/^[a-f0-9-]{36}$/.test(entry.id)) throw Error('背景引用无效')
|
|
85
|
+
const response = await window.fetch('/prts-theme-api/backgrounds/image/' + entry.id, { credentials: 'same-origin' })
|
|
86
|
+
if (!response.ok) throw Error('无法导出背景图片')
|
|
87
|
+
backgrounds[slot] = await readImage(await response.blob())
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
states.avatars = await request('avatars/configuration')
|
|
91
|
+
for (const role of transferRoles) {
|
|
92
|
+
const source = await request('avatars/edit/' + role)
|
|
93
|
+
if (source.revision !== states.avatars.revision) throw Error('头像已变化,请重新导出')
|
|
94
|
+
avatars[role] = source.image ? { image: source.image, crop: source.crop } : null
|
|
95
|
+
}
|
|
96
|
+
states.emblems = await request('emblems/configuration')
|
|
97
|
+
async function reference(entry) {
|
|
98
|
+
if (!entry) return null
|
|
99
|
+
if (entry.kind === 'builtin') return { kind: 'builtin', key: entry.key, label: entry.label }
|
|
100
|
+
if (!ids.has(entry.draftId)) {
|
|
101
|
+
ids.set(entry.draftId, items.length)
|
|
102
|
+
items.push(await request('emblems/draft/' + encodeURIComponent(entry.draftId) + '/export'))
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'custom', asset: ids.get(entry.draftId), label: entry.label }
|
|
105
|
+
}
|
|
106
|
+
const hero = await reference(states.emblems.configuration.hero), conversation = []
|
|
107
|
+
for (const entry of states.emblems.configuration.conversation) conversation.push(await reference(entry))
|
|
108
|
+
value.assets = { backgrounds, backgroundRegions, avatars, emblems: { items, hero, conversation } }
|
|
109
|
+
for (const kind of ['backgrounds', 'avatars', 'emblems']) {
|
|
110
|
+
if ((await request(kind + '/configuration')).revision !== states[kind].revision) throw Error('素材在导出期间发生变化,请重试')
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (JSON.stringify(value.preferences) !== JSON.stringify(getPreferences())) throw Error('设置在导出期间发生变化,请重试')
|
|
114
|
+
validateThemePackage(value)
|
|
115
|
+
return { value, states }
|
|
116
|
+
}
|
|
117
|
+
async function exclusive(fn) {
|
|
118
|
+
if (busy) throw Error('正在处理主题文件,请稍候')
|
|
119
|
+
busy = true
|
|
120
|
+
try { return await fn() } finally { busy = false }
|
|
121
|
+
}
|
|
122
|
+
function download(value, name = 'theme') {
|
|
123
|
+
const blob = new window.Blob([JSON.stringify(value)], { type: 'application/json' })
|
|
124
|
+
if (blob.size > THEME_PACKAGE_LIMIT) throw Error('主题文件超过 160 MB,请减少素材后重试')
|
|
125
|
+
const url = window.URL.createObjectURL(blob), link = window.document.createElement('a')
|
|
126
|
+
link.href = url; link.download = `prts-${name}-${new Date().toISOString().replace(/[:.]/g, '-')}.prts-theme.json`
|
|
127
|
+
window.document.body.append(link); link.click(); link.remove()
|
|
128
|
+
window.setTimeout(() => window.URL.revokeObjectURL(url), 1000)
|
|
129
|
+
}
|
|
130
|
+
async function apply(value) {
|
|
131
|
+
validateThemePackage(value)
|
|
132
|
+
const preferenceRevision = checkWritable()
|
|
133
|
+
const before = await capture(Boolean(value.assets))
|
|
134
|
+
backup = before.value
|
|
135
|
+
download(backup, 'before-import')
|
|
136
|
+
const undo = [], revisions = Object.fromEntries(Object.entries(before.states).map(([key, state]) => [key, state.revision]))
|
|
137
|
+
const write = async (kind, data, previous) => {
|
|
138
|
+
const result = await request(kind + '/configuration', { ...data, revision: revisions[kind] })
|
|
139
|
+
revisions[kind] = result.revision
|
|
140
|
+
undo.push(async () => {
|
|
141
|
+
const restored = await request(kind + '/configuration', { ...previous, revision: revisions[kind] })
|
|
142
|
+
revisions[kind] = restored.revision
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
if (value.assets) {
|
|
147
|
+
const a = value.assets, old = before.value.assets, drafts = []
|
|
148
|
+
// Generate every custom draft before changing the active configuration.
|
|
149
|
+
for (const item of a.emblems.items) drafts.push(await request('emblems/import', item))
|
|
150
|
+
const entry = (ref, index) => ref === null ? null : ref.kind === 'builtin' ? ref : { kind: 'custom', key: 'import-' + index + '-' + drafts[ref.asset].draftId, label: ref.label, draftId: drafts[ref.asset].draftId }
|
|
151
|
+
const configuration = { version: 2, hero: entry(a.emblems.hero, 'hero'), conversation: a.emblems.conversation.map(entry) }
|
|
152
|
+
for (const slot of transferSlots) await write('backgrounds', { slot, image: a.backgrounds[slot], region: a.backgroundRegions?.[slot] ?? null }, { slot, image: old.backgrounds[slot], region: old.backgroundRegions?.[slot] ?? null })
|
|
153
|
+
for (const role of transferRoles) await write('avatars', { role, image: a.avatars[role]?.image ?? null, crop: a.avatars[role]?.crop }, { role, image: old.avatars[role]?.image ?? null, crop: old.avatars[role]?.crop })
|
|
154
|
+
await write('emblems', { configuration }, { configuration: before.states.emblems.configuration })
|
|
155
|
+
}
|
|
156
|
+
await savePreferences(mergeThemePreferences(before.value.preferences, value.preferences), preferenceRevision)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
let failures = 0
|
|
159
|
+
for (const restore of undo.reverse()) { try { await restore() } catch { failures++ } }
|
|
160
|
+
throw Error(`${error.message}。${failures ? '部分素材未能自动恢复。' : '已回退已确认写入的素材。'}若连接中断,请检查实际状态;导入前备份已发起下载,可重新导入恢复。`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
export: includeAssets => exclusive(async () => { checkExport(); return (await capture(includeAssets)).value }),
|
|
165
|
+
import: value => exclusive(() => apply(value)),
|
|
166
|
+
restore: () => exclusive(() => { if (!backup) throw Error('没有可恢复的备份'); return apply(backup) }),
|
|
167
|
+
get hasBackup() { return Boolean(backup) },
|
|
168
|
+
download,
|
|
169
|
+
}
|
|
170
|
+
}
|