@ash-qw/dsh-theme-prts 2.3.1 → 2.5.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 +24 -0
- package/README.en.md +7 -3
- package/README.md +7 -3
- package/lib/client.js +509 -93
- package/package.json +1 -1
- package/src/client/avatar-crop.js +7 -2
- package/src/client/background-layout.js +13 -7
- package/src/client/composer-glass-surface.js +10 -1
- package/src/client/conversation-control-adapter.js +152 -3
- package/src/client/custom-background.js +55 -5
- package/src/client/custom-emblem.js +9 -3
- package/src/client/emblem-reorder.js +41 -10
- package/src/client/file-drop.js +48 -0
- package/src/client/host-geometry-adapter.js +6 -1
- package/src/client/native-preferences.js +7 -0
- package/src/client/opacity-dial.js +13 -8
- package/src/client/preferences.js +5 -1
- package/src/client/theme-controller-v2.js +2 -0
- package/src/client/theme-transfer-panel.js +66 -24
- package/src/client/theme-transfer.js +95 -26
- package/src/styles/custom-background.css +93 -13
- package/src/styles/prts.css +4 -2
- package/src/styles/refinement.css +41 -2
- package/src/styles/theme-settings-workbench.css +26 -8
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const extensions = {
|
|
2
|
+
'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'], 'image/webp': ['.webp'],
|
|
3
|
+
'image/avif': ['.avif'], 'image/gif': ['.gif'], 'application/json': ['.json'],
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// Feed the existing input/change flow so picker and drop share validation and saving.
|
|
7
|
+
export function mountFileDrop({ area, input, enabled = () => true, onError }) {
|
|
8
|
+
let depth = 0
|
|
9
|
+
const getInput = () => typeof input === 'function' ? input() : input
|
|
10
|
+
const clear = () => { depth = 0; area.removeAttribute('data-prts-drop-active') }
|
|
11
|
+
const available = () => enabled() && !getInput()?.disabled && !area.closest('[hidden], [inert]')
|
|
12
|
+
const isFile = event => Array.from(event.dataTransfer?.types ?? []).includes('Files')
|
|
13
|
+
|| Boolean(event.dataTransfer?.files?.length)
|
|
14
|
+
const enter = event => {
|
|
15
|
+
if (!isFile(event)) return
|
|
16
|
+
event.preventDefault(); event.stopPropagation()
|
|
17
|
+
depth++
|
|
18
|
+
if (available()) area.setAttribute('data-prts-drop-active', '')
|
|
19
|
+
}
|
|
20
|
+
const over = event => {
|
|
21
|
+
if (!isFile(event)) return
|
|
22
|
+
event.preventDefault(); event.stopPropagation()
|
|
23
|
+
event.dataTransfer.dropEffect = available() ? 'copy' : 'none'
|
|
24
|
+
if (!available()) clear()
|
|
25
|
+
}
|
|
26
|
+
const leave = event => {
|
|
27
|
+
if (!depth) return
|
|
28
|
+
event.stopPropagation()
|
|
29
|
+
if (--depth <= 0) clear()
|
|
30
|
+
}
|
|
31
|
+
const drop = event => {
|
|
32
|
+
if (!isFile(event)) return
|
|
33
|
+
event.preventDefault(); event.stopPropagation(); clear()
|
|
34
|
+
if (!available()) return
|
|
35
|
+
const files = event.dataTransfer.files, target = getInput()
|
|
36
|
+
if (files.length !== 1) { onError('一次只能添加一个文件'); return }
|
|
37
|
+
const file = files[0], name = file.name.toLowerCase()
|
|
38
|
+
const accepts = target.accept.toLowerCase().split(',').map(value => value.trim()).filter(Boolean)
|
|
39
|
+
const matches = accepts.some(type => type.startsWith('.') ? name.endsWith(type)
|
|
40
|
+
: file.type.toLowerCase() === type || (!file.type && extensions[type]?.some(ext => name.endsWith(ext))))
|
|
41
|
+
if (accepts.length && !matches) { onError('文件类型不支持,请选择此处支持的图片或文件'); return }
|
|
42
|
+
target.files = files
|
|
43
|
+
target.dispatchEvent(new area.ownerDocument.defaultView.Event('change', { bubbles: true }))
|
|
44
|
+
}
|
|
45
|
+
const handlers = { dragenter: enter, dragover: over, dragleave: leave, drop, dragend: clear }
|
|
46
|
+
for (const [name, handler] of Object.entries(handlers)) area.addEventListener(name, handler)
|
|
47
|
+
return { dispose() { clear(); for (const [name, handler] of Object.entries(handlers)) area.removeEventListener(name, handler) } }
|
|
48
|
+
}
|
|
@@ -78,7 +78,12 @@ export function createHostGeometryAdapter({ document, window }) {
|
|
|
78
78
|
shouldMeasure = true
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
// Keep shared wallpaper geometry in the same paint as the resized sidebar.
|
|
82
|
+
if (shouldMeasure || !entries.length) {
|
|
83
|
+
if (frameRequest !== undefined) window?.cancelAnimationFrame?.(frameRequest)
|
|
84
|
+
frameRequest = undefined
|
|
85
|
+
measure()
|
|
86
|
+
}
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
function stopObservation() {
|
|
@@ -28,6 +28,13 @@ export function createNativePreferences({ storage, onChange }) {
|
|
|
28
28
|
migrationAttempted = true
|
|
29
29
|
pending = { ...legacy, ...pending }
|
|
30
30
|
}
|
|
31
|
+
// Materialize the old shared sidebar values once, so later left-side edits
|
|
32
|
+
// cannot change a right-side preference that was never written by the Host.
|
|
33
|
+
if (s.value?.preferencesInitialized && writable(s)) {
|
|
34
|
+
for (const key of ['rightSidebarOpacity', 'rightSidebarBlur']) {
|
|
35
|
+
if (!Object.hasOwn(s.value, key) && !Object.hasOwn(pending, key) && !Object.hasOwn(inFlight, key) && !Object.hasOwn(failed, key)) pending[key] = host[key]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
31
38
|
current = normalizePreferences({ ...host, ...inFlight, ...pending })
|
|
32
39
|
if (s.value?.preferencesInitialized) {
|
|
33
40
|
legacy = null
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Ring geometry adapted from React Bits CometDial (David Haz).
|
|
2
2
|
// See src/vendor/react-bits/LICENSE.md and THIRD_PARTY_NOTICES.md.
|
|
3
3
|
export const BACKGROUND_OPACITY_CONTROLS = Object.freeze([
|
|
4
|
-
{ key: 'sidebarOpacity', label: '
|
|
4
|
+
{ key: 'sidebarOpacity', label: '左侧边栏', property: '--prts-sidebar-opacity', value: 94, blurKey: 'sidebarBlur', blurProperty: '--prts-sidebar-blur', blurDefault: 8 },
|
|
5
|
+
{ key: 'rightSidebarOpacity', label: '右侧边栏', property: '--prts-right-sidebar-opacity', value: 94, blurKey: 'rightSidebarBlur', blurProperty: '--prts-right-sidebar-blur', blurDefault: 8 },
|
|
5
6
|
{ key: 'railOpacity', label: '设置侧栏', property: '--prts-rail-opacity', value: 70, blurKey: 'railBlur', blurProperty: '--prts-rail-blur', blurDefault: 12 },
|
|
6
7
|
{ key: 'bakerOpacity', label: 'Baker 气泡', property: '--prts-baker-opacity', value: 50, blurKey: 'bakerBlur', blurProperty: '--prts-baker-blur', blurDefault: 8, theme: 'deck-chat' },
|
|
7
8
|
{ key: 'classicUserOpacity', label: '经典用户消息', property: '--prts-classic-user-opacity', value: 50, blurKey: 'classicUserBlur', blurProperty: '--prts-classic-user-blur', blurDefault: 8, theme: 'native' },
|
|
@@ -16,7 +17,8 @@ export function opacityDialMarkup() {
|
|
|
16
17
|
const controls = BACKGROUND_OPACITY_CONTROLS.map(({key,label,value,blurDefault}) => {
|
|
17
18
|
const sample = key === 'bakerOpacity' ? '<span>这是一条回复</span><i></i>'
|
|
18
19
|
: key === 'classicUserOpacity' ? '<span>这是我发送的消息</span><i></i>'
|
|
19
|
-
: key === 'railOpacity' ? '<b>◇</b><b>☼</b><b>⚙</b>'
|
|
20
|
+
: key === 'railOpacity' ? '<b>◇</b><b>☼</b><b>⚙</b>'
|
|
21
|
+
: key === 'rightSidebarOpacity' ? '<span>侧边栏</span><i></i><i></i>' : '<span>工作区</span><i></i><i></i>'
|
|
20
22
|
return `<div data-prts-opacity-control="${key}">
|
|
21
23
|
<div data-prts-opacity-heading><strong>${label}</strong><button type="button" data-prts-opacity-reset data-prts-opacity-reset-tooltip="恢复到 ${value}%" aria-label="恢复${label}透明度到 ${value}%"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 10a7.5 7.5 0 1 1 .7 5.2M4.5 4.5V10H10"/></svg></button></div>
|
|
22
24
|
<div data-prts-opacity-dial><svg viewBox="0 0 200 200" role="slider" tabindex="0" aria-label="${label}透明度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${value}" aria-valuetext="${value}%"><circle data-dial-focus cx="100" cy="100" r="97"/><path data-dial-track d="${dialArc(110,430)}"/><path data-dial-lit/><path data-dial-tail/><circle data-dial-head r="8"/></svg><label data-prts-opacity-number><input type="number" min="0" max="100" step="1" value="${value}" aria-label="${label}透明度百分比"><span>%</span></label></div>
|
|
@@ -24,8 +26,8 @@ export function opacityDialMarkup() {
|
|
|
24
26
|
<div data-prts-blur-control hidden><label><span>模糊</span><input type="range" min="0" max="32" step="1" value="${blurDefault}" aria-label="${label}模糊度"></label><output>${blurDefault}px</output><button type="button" data-prts-blur-reset title="恢复到 ${blurDefault}px" aria-label="恢复${label}模糊度到 ${blurDefault}px"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 10a7.5 7.5 0 1 1 .7 5.2M4.5 4.5V10H10"/></svg></button></div>
|
|
25
27
|
<small data-prts-opacity-note></small>
|
|
26
28
|
</div>`
|
|
27
|
-
})
|
|
28
|
-
return `<fieldset data-prts-opacity-settings hidden><legend>材质透明度与模糊度</legend><small>透明度数值越高越透明;模糊度以像素计。调整时预览,松手后保存。</small><div data-prts-opacity-grid>${controls}</div></fieldset>`
|
|
29
|
+
})
|
|
30
|
+
return `<fieldset data-prts-opacity-settings hidden><legend>材质透明度与模糊度</legend><small>透明度数值越高越透明;模糊度以像素计。调整时预览,松手后保存。</small><div data-prts-opacity-grid>${controls.join('')}</div></fieldset>`
|
|
29
31
|
}
|
|
30
32
|
export function mountOpacityDials({ section, window, onChange }) {
|
|
31
33
|
const root = section.ownerDocument.documentElement
|
|
@@ -108,14 +110,14 @@ export function mountOpacityDials({ section, window, onChange }) {
|
|
|
108
110
|
for(const [el,type,fn] of bindings)el.addEventListener(type,fn)
|
|
109
111
|
paint(value)
|
|
110
112
|
return {
|
|
111
|
-
update(preferences, active) {
|
|
113
|
+
update(preferences, active, inactiveNote) {
|
|
112
114
|
container.hidden = Boolean(definition.theme && preferences.conversationStyle !== definition.theme)
|
|
113
115
|
saved = 100 - Math.round((preferences[definition.key] ?? (100 - definition.value) / 100) * 100)
|
|
114
116
|
if (!grip && section.ownerDocument.activeElement !== input) paint(saved)
|
|
115
117
|
savedBlur = preferences[definition.blurKey] ?? definition.blurDefault
|
|
116
118
|
if (section.ownerDocument.activeElement !== blurInput) paintBlur(savedBlur)
|
|
117
119
|
blurControl.hidden = !active
|
|
118
|
-
container.querySelector('[data-prts-opacity-note]').textContent = active ? '' :
|
|
120
|
+
container.querySelector('[data-prts-opacity-note]').textContent = active ? '' : inactiveNote
|
|
119
121
|
},
|
|
120
122
|
dispose() { if(grip)finish({},true);window.clearTimeout(timer);for(const [el,type,fn] of bindings)el.removeEventListener(type,fn) },
|
|
121
123
|
}
|
|
@@ -127,10 +129,13 @@ export function mountOpacityDials({ section, window, onChange }) {
|
|
|
127
129
|
section.querySelector('[data-prts-opacity-settings]').hidden = preferences.backgroundMode === 'off'
|
|
128
130
|
const conversationImage = full || (split && Boolean(state.conversation))
|
|
129
131
|
const sidebarImage = full || (split && Boolean(state.sidebar))
|
|
130
|
-
const active = [sidebarImage, sidebarImage,
|
|
132
|
+
const active = [sidebarImage || conversationImage, conversationImage, sidebarImage,
|
|
131
133
|
conversationImage && preferences.conversationStyle === 'deck-chat',
|
|
132
134
|
conversationImage && preferences.conversationStyle === 'native']
|
|
133
|
-
|
|
135
|
+
const missingImage = full || !split ? '上传完整背景图片后生效' : null
|
|
136
|
+
const inactiveNotes = [missingImage || '上传左侧边栏或正文图片后生效', missingImage || '上传正文图片后生效',
|
|
137
|
+
missingImage || '上传左侧边栏图片后生效', missingImage || '上传正文图片后生效', missingImage || '上传正文图片后生效']
|
|
138
|
+
controls.forEach((control,i)=>control.update(preferences,active[i],inactiveNotes[i]))
|
|
134
139
|
},
|
|
135
140
|
dispose() {for(const control of controls)control.dispose()},
|
|
136
141
|
}
|
|
@@ -29,10 +29,12 @@ export const DEFAULT_PREFERENCES = Object.freeze({
|
|
|
29
29
|
backgroundDim: .45,
|
|
30
30
|
heroContrast: true,
|
|
31
31
|
sidebarOpacity: .06,
|
|
32
|
+
rightSidebarOpacity: .06,
|
|
32
33
|
railOpacity: .3,
|
|
33
34
|
bakerOpacity: .5,
|
|
34
35
|
classicUserOpacity: .5,
|
|
35
36
|
sidebarBlur: 8,
|
|
37
|
+
rightSidebarBlur: 8,
|
|
36
38
|
railBlur: 12,
|
|
37
39
|
bakerBlur: 8,
|
|
38
40
|
classicUserBlur: 8,
|
|
@@ -100,7 +102,7 @@ export const PARTICLE_DETAIL_LEVELS = Object.freeze({
|
|
|
100
102
|
precise: Object.freeze({ conversationParticleDensity: 'dense', heroParticleDensity: 'ultra' }),
|
|
101
103
|
})
|
|
102
104
|
export const PREFERENCE_GROUPS = Object.freeze({
|
|
103
|
-
background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'heroContrast', 'sidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
|
|
105
|
+
background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'heroContrast', 'sidebarOpacity', 'rightSidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'rightSidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
|
|
104
106
|
particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity', 'particleTraversalSpeed']),
|
|
105
107
|
material: Object.freeze(['glass', 'composerGlass']),
|
|
106
108
|
accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor']),
|
|
@@ -214,10 +216,12 @@ export function normalizePreferences(value) {
|
|
|
214
216
|
bakerLoading: input.bakerLoading === true,
|
|
215
217
|
backgroundMode: ['off', 'split', 'full'].includes(input.backgroundMode) ? input.backgroundMode : 'off',
|
|
216
218
|
sidebarOpacity: typeof input.sidebarOpacity === 'number' && Number.isFinite(input.sidebarOpacity) ? Math.min(1, Math.max(0, input.sidebarOpacity)) : .06,
|
|
219
|
+
rightSidebarOpacity: typeof input.rightSidebarOpacity === 'number' && Number.isFinite(input.rightSidebarOpacity) ? Math.min(1, Math.max(0, input.rightSidebarOpacity)) : typeof input.sidebarOpacity === 'number' && Number.isFinite(input.sidebarOpacity) ? Math.min(1, Math.max(0, input.sidebarOpacity)) : .06,
|
|
217
220
|
railOpacity: typeof input.railOpacity === 'number' && Number.isFinite(input.railOpacity) ? Math.min(1, Math.max(0, input.railOpacity)) : .3,
|
|
218
221
|
bakerOpacity: typeof input.bakerOpacity === 'number' && Number.isFinite(input.bakerOpacity) ? Math.min(1, Math.max(0, input.bakerOpacity)) : .5,
|
|
219
222
|
classicUserOpacity: typeof input.classicUserOpacity === 'number' && Number.isFinite(input.classicUserOpacity) ? Math.min(1, Math.max(0, input.classicUserOpacity)) : .5,
|
|
220
223
|
sidebarBlur: materialBlurOr(input.sidebarBlur, DEFAULT_PREFERENCES.sidebarBlur),
|
|
224
|
+
rightSidebarBlur: materialBlurOr(input.rightSidebarBlur, materialBlurOr(input.sidebarBlur, DEFAULT_PREFERENCES.sidebarBlur)),
|
|
221
225
|
railBlur: materialBlurOr(input.railBlur, DEFAULT_PREFERENCES.railBlur),
|
|
222
226
|
bakerBlur: materialBlurOr(input.bakerBlur, DEFAULT_PREFERENCES.bakerBlur),
|
|
223
227
|
classicUserBlur: materialBlurOr(input.classicUserBlur, DEFAULT_PREFERENCES.classicUserBlur),
|
|
@@ -771,8 +771,10 @@ export function createThemeController({ document, window, cssText, service, curs
|
|
|
771
771
|
root.dataset.prtsBackgroundMode = preferences.backgroundMode
|
|
772
772
|
root.style.setProperty('--prts-background-shade', `${preferences.backgroundDim * 100}%`)
|
|
773
773
|
for (const name of ['sidebar', 'rail', 'baker']) root.style.setProperty(`--prts-${name}-opacity`, `${preferences[`${name}Opacity`] * 100}%`)
|
|
774
|
+
root.style.setProperty('--prts-right-sidebar-opacity', `${preferences.rightSidebarOpacity * 100}%`)
|
|
774
775
|
root.style.setProperty('--prts-classic-user-opacity', `${preferences.classicUserOpacity * 100}%`)
|
|
775
776
|
for (const name of ['sidebar', 'rail', 'baker']) root.style.setProperty(`--prts-${name}-blur`, `${preferences[`${name}Blur`]}px`)
|
|
777
|
+
root.style.setProperty('--prts-right-sidebar-blur', `${preferences.rightSidebarBlur}px`)
|
|
776
778
|
root.style.setProperty('--prts-classic-user-blur', `${preferences.classicUserBlur}px`)
|
|
777
779
|
refresh()
|
|
778
780
|
},
|
|
@@ -1,29 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mountFileDrop } from './file-drop.js'
|
|
2
|
+
import { checkTransferCancelled, readTransferFile, THEME_PACKAGE_LIMIT, validateThemePackage, describeThemePackage, ignoredThemePackageFields } from './theme-transfer.js'
|
|
2
3
|
|
|
3
4
|
export function themeTransferMarkup() {
|
|
4
5
|
return `<dialog data-prts-transfer-dialog aria-labelledby="prts-transfer-title">
|
|
5
6
|
<section data-prts-transfer>
|
|
6
7
|
<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
8
|
<div data-prts-transfer-columns>
|
|
8
|
-
<section data-prts-transfer-card>
|
|
9
|
+
<section data-prts-transfer-card="export">
|
|
9
10
|
<h3>导出主题</h3><p>保存一份主题,备份或带到另一台设备。</p>
|
|
10
11
|
<label data-prts-transfer-option><input type="checkbox" data-prts-transfer-assets checked><span>包含自定义素材<small>背景、头像和徽记,保留编辑参数与排列。</small></span></label>
|
|
11
12
|
<small>取消勾选,仅导出主题设置。</small>
|
|
12
13
|
<button type="button" data-prts-transfer-export>导出主题文件 <span aria-hidden="true">↓</span></button>
|
|
13
14
|
</section>
|
|
14
|
-
<section data-prts-transfer-card>
|
|
15
|
-
<h3>导入主题</h3><p
|
|
15
|
+
<section data-prts-transfer-card="import" data-prts-transfer-drop>
|
|
16
|
+
<h3>导入主题</h3><p>选择或拖入已导出的文件,确认内容后应用。</p>
|
|
16
17
|
<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
|
|
18
|
+
<button type="button" data-prts-transfer-choose>选择主题文件 <span aria-hidden="true">↑</span></button>
|
|
18
19
|
<input type="file" accept=".prts-theme.json,application/json" aria-label="导入主题文件" data-prts-transfer-file hidden>
|
|
19
20
|
</section>
|
|
20
21
|
</div>
|
|
21
22
|
<div data-prts-transfer-preview hidden>
|
|
22
23
|
<strong>确认导入</strong><p data-prts-transfer-summary></p>
|
|
24
|
+
<p data-prts-transfer-compatibility hidden role="alert"></p>
|
|
23
25
|
<small>应用前会下载当前配置备份,请保留备份文件。</small>
|
|
24
26
|
<div data-prts-transfer-buttons><button type="button" data-prts-transfer-apply>确认导入</button><button type="button" data-prts-transfer-cancel>取消</button></div>
|
|
25
27
|
</div>
|
|
26
28
|
<div data-prts-transfer-recovery hidden><small>恢复将替换最近一次导入后所做的修改。</small><button type="button" data-prts-transfer-restore>恢复导入前配置</button></div>
|
|
29
|
+
<div data-prts-transfer-progress hidden><div><span data-prts-transfer-phase></span><span data-prts-transfer-percent></span></div><progress data-prts-transfer-bar max="1" aria-label="主题文件处理进度"></progress></div>
|
|
30
|
+
<button type="button" data-prts-transfer-stop hidden>取消操作</button>
|
|
27
31
|
<p data-prts-transfer-status role="status" aria-live="polite"></p>
|
|
28
32
|
</section>
|
|
29
33
|
</dialog>`
|
|
@@ -39,60 +43,98 @@ export function mountThemeTransfer({ panel, window, transfer }) {
|
|
|
39
43
|
const keydown = event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close() } }
|
|
40
44
|
const cancel = event => { if (event.target === dialog) { event.preventDefault(); event.stopPropagation(); close() } }
|
|
41
45
|
const get = name => section.querySelector(`[data-prts-transfer-${name}]`)
|
|
42
|
-
let pending, busy = false, disposed = false, generation = 0
|
|
46
|
+
let choosing = false, activeOperation, pending, busy = false, disposed = false, generation = 0, controller, cancellable = true
|
|
43
47
|
const render = () => {
|
|
44
48
|
if (disposed) return
|
|
49
|
+
for (const card of section.querySelectorAll('[data-prts-transfer-card]')) card.toggleAttribute('data-prts-transfer-active', card.dataset.prtsTransferCard === (busy ? activeOperation : pending || choosing ? 'import' : undefined))
|
|
45
50
|
get('preview').hidden = !pending
|
|
51
|
+
if (!pending) get('compatibility').hidden = true
|
|
46
52
|
get('recovery').hidden = !transfer.hasBackup
|
|
47
53
|
for (const control of section.querySelectorAll('button,input')) control.disabled = busy
|
|
54
|
+
get('stop').hidden = !busy
|
|
55
|
+
get('stop').disabled = !busy || !cancellable || controller?.signal.aborted
|
|
48
56
|
// Prevent local editors from racing a multi-service import; other clients use revision checks.
|
|
49
57
|
for (const page of panel.querySelectorAll('[data-prts-settings-section]')) if (!page.contains(section)) page.inert = busy
|
|
50
58
|
}
|
|
51
|
-
|
|
59
|
+
const progress = ({ value, label, cancellable: allowed }) => {
|
|
60
|
+
if (disposed) return
|
|
61
|
+
if (allowed === false) { cancellable = false; render() }
|
|
62
|
+
get('progress').hidden = false
|
|
63
|
+
get('phase').textContent = label
|
|
64
|
+
get('percent').textContent = value === null ? '' : `${Math.round(value * 100)}%`
|
|
65
|
+
if (value === null) get('bar').removeAttribute('value')
|
|
66
|
+
else get('bar').value = value
|
|
67
|
+
}
|
|
68
|
+
const paint = () => new Promise(resolve => window.setTimeout(resolve, 0))
|
|
69
|
+
async function run(kind, task) {
|
|
52
70
|
if (busy) return
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
71
|
+
controller = new window.AbortController(); cancellable = true
|
|
72
|
+
activeOperation = kind
|
|
73
|
+
busy = true; section.setAttribute('aria-busy', 'true'); get('status').textContent = ''; get('progress').hidden = true; render()
|
|
74
|
+
progress({ value: null, label: '正在准备…' })
|
|
75
|
+
try {
|
|
76
|
+
await paint()
|
|
77
|
+
checkTransferCancelled(controller.signal)
|
|
78
|
+
const message = await task(controller.signal)
|
|
79
|
+
if (!disposed) { progress({ value: 1, label: '处理完成' }); get('status').textContent = message }
|
|
80
|
+
}
|
|
81
|
+
catch (error) { if (!disposed) { get('progress').hidden = true; get('status').textContent = error.name === 'AbortError' ? '操作已取消。' : error.message } }
|
|
82
|
+
finally { busy = false; if (!pending && !disposed) get('file').value = ''; if (!disposed) section.setAttribute('aria-busy', 'false'); render() }
|
|
57
83
|
}
|
|
58
84
|
const change = event => {
|
|
59
85
|
if (event.target !== get('file')) return
|
|
86
|
+
choosing = false
|
|
60
87
|
const file = event.target.files?.[0], selected = ++generation
|
|
61
88
|
pending = undefined; render()
|
|
62
89
|
if (!file) return
|
|
63
|
-
void run(async
|
|
90
|
+
void run('import', async signal => {
|
|
64
91
|
if (file.size > THEME_PACKAGE_LIMIT) throw Error('主题文件不能超过 160 MB')
|
|
65
|
-
const text = await
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
92
|
+
const text = await readTransferFile(window, file, signal, event => progress({ value: event.lengthComputable && event.total ? event.loaded / event.total * 0.8 : null, label: '正在读取主题文件…' }))
|
|
93
|
+
progress({ value: 0.8, label: '正在解析并检查主题文件…' })
|
|
94
|
+
await paint()
|
|
95
|
+
checkTransferCancelled(signal)
|
|
69
96
|
let value
|
|
70
97
|
try { value = JSON.parse(text) } catch { throw Error('主题文件不是有效的 JSON') }
|
|
71
98
|
validateThemePackage(value)
|
|
72
99
|
if (disposed || selected !== generation) return ''
|
|
73
100
|
pending = value
|
|
74
101
|
get('summary').textContent = describeThemePackage(value)
|
|
102
|
+
const ignored = ignoredThemePackageFields(value)
|
|
103
|
+
const compatibility = get('compatibility')
|
|
104
|
+
compatibility.hidden = ignored.length === 0
|
|
105
|
+
compatibility.textContent = ignored.length
|
|
106
|
+
? `当前版本不支持以下配置,导入后将丢失:${ignored.join('、')}。请保留原文件。`
|
|
107
|
+
: ''
|
|
75
108
|
return '已读取文件,请确认导入内容。'
|
|
76
109
|
})
|
|
77
110
|
}
|
|
78
111
|
const click = event => {
|
|
79
112
|
const button = event.target.closest?.('button')
|
|
113
|
+
if (button === get('stop') && busy && cancellable) {
|
|
114
|
+
controller.abort(); progress({ value: null, label: '正在取消,请稍候…', cancellable: false }); return
|
|
115
|
+
}
|
|
80
116
|
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)
|
|
117
|
+
if (button === get('choose')) { choosing = true; render(); get('file').click() }
|
|
118
|
+
if (button === get('export')) void run('export', async signal => {
|
|
119
|
+
const value = await transfer.export(get('assets').checked, state => progress({ ...state, value: state.value * 0.9 }), signal)
|
|
120
|
+
progress({ value: 0.9, label: '正在生成下载文件…' })
|
|
121
|
+
await paint()
|
|
122
|
+
checkTransferCancelled(signal)
|
|
84
123
|
transfer.download(value)
|
|
85
124
|
return '已发起下载,请确认主题文件已保存。'
|
|
86
125
|
})
|
|
87
|
-
if (button === get('apply') && pending) void run(async
|
|
88
|
-
await transfer.import(pending); pending = undefined; get('file').value = ''
|
|
126
|
+
if (button === get('apply') && pending) void run('import', async signal => {
|
|
127
|
+
await transfer.import(pending, progress, signal); pending = undefined; get('file').value = ''
|
|
89
128
|
return '主题已导入并保存。'
|
|
90
129
|
})
|
|
91
|
-
if (button === get('cancel')) { pending = undefined; get('file').value = ''; get('status').textContent = ''; render() }
|
|
92
|
-
if (button === get('restore')) void run(async
|
|
130
|
+
if (button === get('cancel')) { pending = undefined; get('file').value = ''; get('status').textContent = ''; get('progress').hidden = true; render() }
|
|
131
|
+
if (button === get('restore')) void run('import', async signal => { await transfer.restore(progress, signal); return '已恢复导入前配置。' })
|
|
93
132
|
}
|
|
133
|
+
const drop = mountFileDrop({ area: get('drop'), input: get('file'), enabled: () => !busy && !disposed, onError: message => { get('status').textContent = message } })
|
|
134
|
+
const cancelChoice = () => { choosing = false; render() }
|
|
135
|
+
get('file').addEventListener('cancel', cancelChoice)
|
|
94
136
|
trigger?.addEventListener('click', open)
|
|
95
137
|
dialog.addEventListener('keydown', keydown); dialog.addEventListener('cancel', cancel)
|
|
96
138
|
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) } }
|
|
139
|
+
return { dispose() { get('file').removeEventListener('cancel', cancelChoice); drop.dispose(); disposed = true; controller?.abort(); 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
140
|
}
|
|
@@ -1,12 +1,58 @@
|
|
|
1
1
|
import { validBackgroundRegion } from '../shared/background-region.js'
|
|
2
2
|
import { DEFAULT_PREFERENCES, normalizePreferences } from './preferences.js'
|
|
3
3
|
|
|
4
|
+
export function checkTransferCancelled(signal) {
|
|
5
|
+
if (signal?.aborted) throw Object.assign(new Error('操作已取消'), { name: 'AbortError' })
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function readTransferFile(window, blob, signal, onProgress, asImage = false) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
checkTransferCancelled(signal)
|
|
11
|
+
const reader = new window.FileReader()
|
|
12
|
+
const cleanup = () => signal?.removeEventListener('abort', abort)
|
|
13
|
+
const abort = () => { reader.abort(); cleanup(); reject(Object.assign(new Error('操作已取消'), { name: 'AbortError' })) }
|
|
14
|
+
reader.onload = () => { cleanup(); resolve(asImage ? String(reader.result).split(',')[1] : reader.result) }
|
|
15
|
+
reader.onerror = () => { cleanup(); reject(Error('无法读取主题文件或图片')) }
|
|
16
|
+
reader.onprogress = onProgress
|
|
17
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
18
|
+
if (asImage) reader.readAsDataURL(blob)
|
|
19
|
+
else reader.readAsText(blob)
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
4
23
|
export const THEME_PACKAGE_LIMIT = 160 * 1024 * 1024
|
|
5
24
|
const transferSlots = ['full', 'sidebar', 'conversation']
|
|
6
25
|
const transferRoles = ['user', 'assistant']
|
|
7
26
|
const transferBuiltins = ['rhodes-island', 'lungmen', 'penguin-logistics', 'rhine-lab', 'reunion']
|
|
8
27
|
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
9
28
|
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)
|
|
29
|
+
const unknownFields = (value, known, prefix) => record(value)
|
|
30
|
+
? Object.keys(value).filter(key => !known.includes(key)).map(key => `${prefix}.${key}`)
|
|
31
|
+
: []
|
|
32
|
+
|
|
33
|
+
export function ignoredThemePackageFields(value) {
|
|
34
|
+
const ignored = unknownFields(value.preferences, Object.keys(DEFAULT_PREFERENCES), '设置')
|
|
35
|
+
const assets = value.assets
|
|
36
|
+
if (!assets) return ignored
|
|
37
|
+
ignored.push(...unknownFields(assets, ['backgrounds', 'backgroundRegions', 'avatars', 'emblems'], '素材'))
|
|
38
|
+
ignored.push(...unknownFields(assets.backgrounds, transferSlots, '背景'))
|
|
39
|
+
ignored.push(...unknownFields(assets.backgroundRegions, transferSlots, '背景保护区域'))
|
|
40
|
+
for (const slot of transferSlots) ignored.push(...unknownFields(assets.backgroundRegions?.[slot], ['x', 'y', 'width', 'height'], `背景保护区域.${slot}`))
|
|
41
|
+
ignored.push(...unknownFields(assets.avatars, transferRoles, '头像'))
|
|
42
|
+
for (const role of transferRoles) {
|
|
43
|
+
ignored.push(...unknownFields(assets.avatars?.[role], ['image', 'crop'], `头像.${role}`))
|
|
44
|
+
ignored.push(...unknownFields(assets.avatars?.[role]?.crop, ['zoom', 'x', 'y'], `头像.${role}.crop`))
|
|
45
|
+
}
|
|
46
|
+
const emblems = assets.emblems
|
|
47
|
+
ignored.push(...unknownFields(emblems, ['items', 'hero', 'conversation'], '徽记'))
|
|
48
|
+
for (const [index, item] of (emblems?.items ?? []).entries()) {
|
|
49
|
+
ignored.push(...unknownFields(item, ['format', 'version', 'label', 'params', 'image'], `徽记.items[${index}]`))
|
|
50
|
+
ignored.push(...unknownFields(item?.params, ['mode', 'lineSource', 'cleanup', 'threshold', 'zoom', 'x', 'y'], `徽记.items[${index}].params`))
|
|
51
|
+
}
|
|
52
|
+
ignored.push(...unknownFields(emblems?.hero, ['kind', 'key', 'label', 'asset'], '徽记.hero'))
|
|
53
|
+
for (const [index, entry] of (emblems?.conversation ?? []).entries()) ignored.push(...unknownFields(entry, ['kind', 'key', 'label', 'asset'], `徽记.conversation[${index}]`))
|
|
54
|
+
return ignored
|
|
55
|
+
}
|
|
10
56
|
|
|
11
57
|
export function validateThemePackage(value) {
|
|
12
58
|
if (!record(value) || value.format !== 'prts-theme' || value.version !== 1) throw Error('不支持的主题文件格式或版本')
|
|
@@ -57,61 +103,64 @@ export function describeThemePackage(value) {
|
|
|
57
103
|
|
|
58
104
|
export function createThemeTransfer({ window, getPreferences, savePreferences, checkWritable, checkExport = checkWritable, themeVersion }) {
|
|
59
105
|
let busy = false, backup
|
|
60
|
-
const request = async (path, data) => {
|
|
61
|
-
|
|
106
|
+
const request = async (path, data, signal) => {
|
|
107
|
+
checkTransferCancelled(signal)
|
|
108
|
+
const response = await window.fetch('/prts-theme-api/' + path, { credentials: 'same-origin', signal,
|
|
62
109
|
...(data === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json', 'x-prts-request': '1' }, body: JSON.stringify(data) }) })
|
|
63
110
|
const result = await response.json()
|
|
64
111
|
if (!response.ok) throw Error(result.error || '无法连接主题素材服务')
|
|
65
112
|
return result
|
|
66
113
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
reader.onerror = () => reject(Error('无法读取背景图片'))
|
|
71
|
-
reader.readAsDataURL(blob)
|
|
72
|
-
})
|
|
73
|
-
async function capture(includeAssets) {
|
|
114
|
+
async function capture(includeAssets, progress = () => {}, signal) {
|
|
115
|
+
checkTransferCancelled(signal)
|
|
116
|
+
progress({ value: 0, label: '正在读取主题设置…' })
|
|
74
117
|
const value = { format: 'prts-theme', version: 1, themeVersion, exportedAt: new Date().toISOString(), preferences: { ...getPreferences() } }
|
|
75
118
|
const states = {}
|
|
76
119
|
if (includeAssets) {
|
|
77
120
|
const backgrounds = {}, backgroundRegions = {}, avatars = {}, items = [], ids = new Map()
|
|
78
|
-
|
|
121
|
+
progress({ value: 0.1, label: '正在读取背景…' })
|
|
122
|
+
states.backgrounds = await request('backgrounds/configuration', undefined, signal)
|
|
79
123
|
for (const slot of transferSlots) {
|
|
80
124
|
const entry = states.backgrounds[slot]
|
|
81
125
|
backgroundRegions[slot] = entry?.region ?? null
|
|
82
126
|
backgrounds[slot] = null
|
|
83
127
|
if (entry) {
|
|
84
128
|
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' })
|
|
129
|
+
const response = await window.fetch('/prts-theme-api/backgrounds/image/' + entry.id, { credentials: 'same-origin', signal })
|
|
86
130
|
if (!response.ok) throw Error('无法导出背景图片')
|
|
87
|
-
backgrounds[slot] = await
|
|
131
|
+
backgrounds[slot] = await readTransferFile(window, await response.blob(), signal, undefined, true)
|
|
88
132
|
}
|
|
89
133
|
}
|
|
90
|
-
|
|
134
|
+
progress({ value: 0.35, label: '正在读取头像…' })
|
|
135
|
+
states.avatars = await request('avatars/configuration', undefined, signal)
|
|
91
136
|
for (const role of transferRoles) {
|
|
92
|
-
const source = await request('avatars/edit/' + role)
|
|
137
|
+
const source = await request('avatars/edit/' + role, undefined, signal)
|
|
93
138
|
if (source.revision !== states.avatars.revision) throw Error('头像已变化,请重新导出')
|
|
94
139
|
avatars[role] = source.image ? { image: source.image, crop: source.crop } : null
|
|
95
140
|
}
|
|
96
|
-
|
|
141
|
+
progress({ value: 0.5, label: '正在读取徽记…' })
|
|
142
|
+
states.emblems = await request('emblems/configuration', undefined, signal)
|
|
97
143
|
async function reference(entry) {
|
|
98
144
|
if (!entry) return null
|
|
99
145
|
if (entry.kind === 'builtin') return { kind: 'builtin', key: entry.key, label: entry.label }
|
|
100
146
|
if (!ids.has(entry.draftId)) {
|
|
101
147
|
ids.set(entry.draftId, items.length)
|
|
102
|
-
items.push(await request('emblems/draft/' + encodeURIComponent(entry.draftId) + '/export'))
|
|
148
|
+
items.push(await request('emblems/draft/' + encodeURIComponent(entry.draftId) + '/export', undefined, signal))
|
|
103
149
|
}
|
|
104
150
|
return { kind: 'custom', asset: ids.get(entry.draftId), label: entry.label }
|
|
105
151
|
}
|
|
106
152
|
const hero = await reference(states.emblems.configuration.hero), conversation = []
|
|
107
153
|
for (const entry of states.emblems.configuration.conversation) conversation.push(await reference(entry))
|
|
108
154
|
value.assets = { backgrounds, backgroundRegions, avatars, emblems: { items, hero, conversation } }
|
|
155
|
+
progress({ value: 0.85, label: '正在核对素材…' })
|
|
109
156
|
for (const kind of ['backgrounds', 'avatars', 'emblems']) {
|
|
110
|
-
if ((await request(kind + '/configuration')).revision !== states[kind].revision) throw Error('素材在导出期间发生变化,请重试')
|
|
157
|
+
if ((await request(kind + '/configuration', undefined, signal)).revision !== states[kind].revision) throw Error('素材在导出期间发生变化,请重试')
|
|
111
158
|
}
|
|
112
159
|
}
|
|
113
160
|
if (JSON.stringify(value.preferences) !== JSON.stringify(getPreferences())) throw Error('设置在导出期间发生变化,请重试')
|
|
161
|
+
checkTransferCancelled(signal)
|
|
114
162
|
validateThemePackage(value)
|
|
163
|
+
progress({ value: 1, label: '主题数据已就绪' })
|
|
115
164
|
return { value, states }
|
|
116
165
|
}
|
|
117
166
|
async function exclusive(fn) {
|
|
@@ -127,14 +176,23 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
|
|
|
127
176
|
window.document.body.append(link); link.click(); link.remove()
|
|
128
177
|
window.setTimeout(() => window.URL.revokeObjectURL(url), 1000)
|
|
129
178
|
}
|
|
130
|
-
async function apply(value) {
|
|
179
|
+
async function apply(value, progress = () => {}, signal) {
|
|
180
|
+
checkTransferCancelled(signal)
|
|
131
181
|
validateThemePackage(value)
|
|
132
182
|
const preferenceRevision = checkWritable()
|
|
133
|
-
const before = await capture(Boolean(value.assets))
|
|
183
|
+
const before = await capture(Boolean(value.assets), state => progress({ value: state.value * 0.3, label: '备份:' + state.label }), signal)
|
|
134
184
|
backup = before.value
|
|
185
|
+
progress({ value: 0.3, label: '正在生成导入前备份…' })
|
|
186
|
+
await new Promise(resolve => window.setTimeout(resolve, 0))
|
|
187
|
+
checkTransferCancelled(signal)
|
|
135
188
|
download(backup, 'before-import')
|
|
189
|
+
let completed = 0
|
|
190
|
+
const total = value.assets ? value.assets.emblems.items.length + 7 : 1
|
|
191
|
+
const step = label => progress({ value: 0.35 + 0.65 * completed / total, label })
|
|
136
192
|
const undo = [], revisions = Object.fromEntries(Object.entries(before.states).map(([key, state]) => [key, state.revision]))
|
|
137
193
|
const write = async (kind, data, previous) => {
|
|
194
|
+
checkTransferCancelled(signal)
|
|
195
|
+
// Await writes without aborting: their confirmed revision is needed for rollback.
|
|
138
196
|
const result = await request(kind + '/configuration', { ...data, revision: revisions[kind] })
|
|
139
197
|
revisions[kind] = result.revision
|
|
140
198
|
undo.push(async () => {
|
|
@@ -146,24 +204,35 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
|
|
|
146
204
|
if (value.assets) {
|
|
147
205
|
const a = value.assets, old = before.value.assets, drafts = []
|
|
148
206
|
// Generate every custom draft before changing the active configuration.
|
|
149
|
-
for (const item of a.emblems.items)
|
|
207
|
+
for (const item of a.emblems.items) {
|
|
208
|
+
step('正在导入徽记素材…')
|
|
209
|
+
checkTransferCancelled(signal)
|
|
210
|
+
drafts.push(await request('emblems/import', item)); completed++
|
|
211
|
+
}
|
|
150
212
|
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
213
|
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
|
-
|
|
214
|
+
for (const slot of transferSlots) { step('正在应用背景…'); await write('backgrounds', { slot, image: a.backgrounds[slot], region: a.backgroundRegions?.[slot] ?? null }, { slot, image: old.backgrounds[slot], region: old.backgroundRegions?.[slot] ?? null }); completed++ }
|
|
215
|
+
for (const role of transferRoles) { step('正在应用头像…'); 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 }); completed++ }
|
|
216
|
+
step('正在应用徽记排列…')
|
|
217
|
+
await write('emblems', { configuration }, { configuration: before.states.emblems.configuration }); completed++
|
|
155
218
|
}
|
|
219
|
+
step('正在保存主题设置…')
|
|
220
|
+
checkTransferCancelled(signal)
|
|
221
|
+
progress({ value: 0.99, label: '正在保存主题设置…', cancellable: false })
|
|
156
222
|
await savePreferences(mergeThemePreferences(before.value.preferences, value.preferences), preferenceRevision)
|
|
223
|
+
progress({ value: 1, label: '主题已保存' })
|
|
157
224
|
} catch (error) {
|
|
225
|
+
progress({ value: null, label: signal?.aborted ? '正在取消,回退素材…' : '导入失败,正在回退素材…', cancellable: false })
|
|
158
226
|
let failures = 0
|
|
159
227
|
for (const restore of undo.reverse()) { try { await restore() } catch { failures++ } }
|
|
228
|
+
if (signal?.aborted && !failures && error.name === 'AbortError') throw Error('操作已取消,已回退已写入的素材。')
|
|
160
229
|
throw Error(`${error.message}。${failures ? '部分素材未能自动恢复。' : '已回退已确认写入的素材。'}若连接中断,请检查实际状态;导入前备份已发起下载,可重新导入恢复。`)
|
|
161
230
|
}
|
|
162
231
|
}
|
|
163
232
|
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) }),
|
|
233
|
+
export: (includeAssets, progress, signal) => exclusive(async () => { checkExport(); return (await capture(includeAssets, progress, signal)).value }),
|
|
234
|
+
import: (value, progress, signal) => exclusive(() => apply(value, progress, signal)),
|
|
235
|
+
restore: (progress, signal) => exclusive(() => { if (!backup) throw Error('没有可恢复的备份'); return apply(backup, progress, signal) }),
|
|
167
236
|
get hasBackup() { return Boolean(backup) },
|
|
168
237
|
download,
|
|
169
238
|
}
|