mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2026-06-13 18:45:07 +00:00
feat: 添加离线缓冲层,支持断网编辑和数据持久化
- dataProvider.js: 添加 cache-aside 读取和 write-through 写入策略 - kvLocalProvider.js: IndexedDB 升级到 v3,新增 syncQueue store - index.vue: 集成 syncManager,处理离线/待同步状态提示
This commit is contained in:
commit
5d0b0bb175
@ -62,6 +62,8 @@ export default [
|
||||
// Web Workers
|
||||
Worker: 'readonly',
|
||||
SharedWorker: 'readonly',
|
||||
// Fetch API
|
||||
AbortController: 'readonly',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -6,14 +6,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Classworks 作业板</title>
|
||||
|
||||
<!-- SEO -->
|
||||
<meta name="description" content="Classworks —— 适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
|
||||
<meta name="keywords" content="Classworks,作业板,班级大屏,作业管理,课表,作业同步,开源" />
|
||||
<meta name="author" content="Sunwuyuan" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href="https://cs.houlang.cloud/" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Classworks 作业板" />
|
||||
<meta property="og:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
|
||||
@ -24,7 +22,6 @@
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Classworks 作业板" />
|
||||
<meta name="twitter:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
|
||||
@ -39,6 +36,5 @@
|
||||
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener" style="display: none;">xICP备x号-4</a>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
85
src/App.vue
85
src/App.vue
@ -1,5 +1,17 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-app :style="vAppStyle">
|
||||
<!-- 自定义背景层 -->
|
||||
<template v-if="bgEnabled">
|
||||
<div
|
||||
class="app-background-image"
|
||||
:style="bgImageStyle"
|
||||
/>
|
||||
<div
|
||||
class="app-background-overlay"
|
||||
:style="bgOverlayStyle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 正常路由 -->
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition
|
||||
@ -18,24 +30,74 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { useTheme } from "vuetify";
|
||||
import { getSetting } from "@/utils/settings";
|
||||
import { getSetting, watchSettings } from "@/utils/settings";
|
||||
import RateLimitModal from "@/components/RateLimitModal.vue";
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
// Background reactive refs
|
||||
const bgEnabled = ref(false);
|
||||
const bgSrc = ref("");
|
||||
const bgBlur = ref(10);
|
||||
const bgOpacity = ref(30);
|
||||
|
||||
function loadBgSettings() {
|
||||
bgEnabled.value = getSetting("background.enabled") || false;
|
||||
const imageData = getSetting("background.imageData") || "";
|
||||
const url = getSetting("background.url") || "";
|
||||
bgSrc.value = imageData || url;
|
||||
bgBlur.value = getSetting("background.blur") ?? 10;
|
||||
bgOpacity.value = getSetting("background.opacity") ?? 30;
|
||||
}
|
||||
|
||||
const vAppStyle = computed(() => {
|
||||
if (!bgEnabled.value || !bgSrc.value) return {};
|
||||
return { background: "transparent" };
|
||||
});
|
||||
|
||||
const bgImageStyle = computed(() => ({
|
||||
backgroundImage: `url(${bgSrc.value})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
filter: `blur(${bgBlur.value}px)`,
|
||||
// Scale slightly to hide blur edge artifacts
|
||||
transform: "scale(1.05)",
|
||||
}));
|
||||
|
||||
const bgOverlayStyle = computed(() => ({
|
||||
background: `rgba(0, 0, 0, ${bgOpacity.value / 100})`,
|
||||
}));
|
||||
|
||||
let unwatchSettings = null;
|
||||
|
||||
onMounted(() => {
|
||||
// 应用保存的主题设置
|
||||
const savedTheme = getSetting("theme.mode");
|
||||
theme.global.name.value = savedTheme;
|
||||
|
||||
loadBgSettings();
|
||||
|
||||
unwatchSettings = watchSettings((_, event) => {
|
||||
// If event detail is available (same-tab change), only reload on background keys
|
||||
const changedKey = event?.detail?.key;
|
||||
if (!changedKey || changedKey.startsWith("background.") || changedKey === "theme.mode") {
|
||||
loadBgSettings();
|
||||
theme.global.name.value = getSetting("theme.mode");
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
window.deferredPwaPrompt = e;
|
||||
window.dispatchEvent(new Event('pwa-prompt-ready'));
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unwatchSettings) unwatchSettings();
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
/* 全局样式(从 index.vue 迁移,确保全局可用且仅加载一次) */
|
||||
@ -58,4 +120,21 @@ onMounted(() => {
|
||||
opacity: 0;
|
||||
transform: translateX(-0.5vw);
|
||||
}
|
||||
|
||||
/* 自定义背景层 */
|
||||
.app-background-image,
|
||||
.app-background-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-background-image {
|
||||
transform-origin: center center;
|
||||
will-change: transform, filter;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -61,7 +61,8 @@ export default {
|
||||
refreshInterval: 60,
|
||||
kvConfig: {
|
||||
sources: ['zhaoyu'],
|
||||
sensitiveWords: []
|
||||
sensitiveWords: [],
|
||||
hitokotoCategories: []
|
||||
},
|
||||
sentence: '',
|
||||
author: '',
|
||||
@ -125,7 +126,8 @@ export default {
|
||||
this.kvConfig = {
|
||||
sources: Array.isArray(data.sources) && data.sources.length > 0 ? data.sources : ['zhaoyu'],
|
||||
sensitiveWords: data.sensitiveWords ? data.sensitiveWords.split(/[,,]/).map(w => w.trim()).filter(w => w) : [],
|
||||
jinrishiciToken: data.jinrishiciToken
|
||||
jinrishiciToken: data.jinrishiciToken,
|
||||
hitokotoCategories: Array.isArray(data.hitokotoCategories) ? data.hitokotoCategories : []
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@ -155,7 +157,13 @@ export default {
|
||||
let origin = ''
|
||||
|
||||
if (source === 'hitokoto') {
|
||||
const res = await axios.get('https://v1.hitokoto.cn/')
|
||||
const params = new URLSearchParams()
|
||||
const categories = this.kvConfig.hitokotoCategories
|
||||
if (Array.isArray(categories) && categories.length > 0) {
|
||||
categories.forEach(cat => params.append('c', cat))
|
||||
}
|
||||
const url = 'https://v1.hitokoto.cn/' + (params.toString() ? '?' + params.toString() : '')
|
||||
const res = await axios.get(url)
|
||||
data = res.data
|
||||
content = data.hitokoto
|
||||
author = data.from_who
|
||||
|
||||
@ -64,6 +64,29 @@
|
||||
</div>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="kvConfig.sources.includes('hitokoto')">
|
||||
<v-list-item-title class="mb-2">
|
||||
一言句子类型
|
||||
</v-list-item-title>
|
||||
<div class="text-caption text-grey mb-2">
|
||||
不选则返回所有类型;可多选。
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-checkbox
|
||||
v-for="cat in hitokotoCategories"
|
||||
:key="cat.value"
|
||||
v-model="kvConfig.hitokotoCategories"
|
||||
:label="cat.label"
|
||||
:value="cat.value"
|
||||
hide-details
|
||||
density="compact"
|
||||
class="mr-4"
|
||||
:disabled="loading"
|
||||
@update:model-value="saveKvSettings"
|
||||
/>
|
||||
</div>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="kvConfig.sources.includes('jinrishici')">
|
||||
<v-text-field
|
||||
v-model="kvConfig.jinrishiciToken"
|
||||
@ -402,8 +425,23 @@ export default {
|
||||
kvConfig: {
|
||||
sources: ['zhaoyu'],
|
||||
sensitiveWords: '',
|
||||
jinrishiciToken: null
|
||||
jinrishiciToken: null,
|
||||
hitokotoCategories: []
|
||||
},
|
||||
hitokotoCategories: [
|
||||
{ value: 'a', label: '动画' },
|
||||
{ value: 'b', label: '漫画' },
|
||||
{ value: 'c', label: '游戏' },
|
||||
{ value: 'd', label: '文学' },
|
||||
{ value: 'e', label: '原创' },
|
||||
{ value: 'f', label: '来自网络' },
|
||||
{ value: 'g', label: '其他' },
|
||||
{ value: 'h', label: '影视' },
|
||||
{ value: 'i', label: '诗词' },
|
||||
{ value: 'j', label: '网易云' },
|
||||
{ value: 'k', label: '哲学' },
|
||||
{ value: 'l', label: '抖机灵' }
|
||||
],
|
||||
loading: false,
|
||||
testLoading: false,
|
||||
testMessage: '',
|
||||
@ -430,7 +468,8 @@ export default {
|
||||
this.kvConfig = {
|
||||
sources: Array.isArray(data.sources) ? data.sources : ['zhaoyu'],
|
||||
sensitiveWords: data.sensitiveWords || '',
|
||||
jinrishiciToken: data.jinrishiciToken
|
||||
jinrishiciToken: data.jinrishiciToken,
|
||||
hitokotoCategories: Array.isArray(data.hitokotoCategories) ? data.hitokotoCategories : []
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@ -25,7 +25,11 @@
|
||||
{{ timeString }}<span
|
||||
class="seconds-text"
|
||||
:style="secondsStyle"
|
||||
>{{ secondsString }}</span>
|
||||
>{{ secondsString }}</span><span
|
||||
v-if="use12hClock"
|
||||
class="ampm-text"
|
||||
:style="secondsStyle"
|
||||
> {{ amPmString }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="date-line mt-3"
|
||||
@ -305,7 +309,10 @@
|
||||
<v-tabs-window-item value="clock">
|
||||
<div class="d-flex flex-column align-center justify-center">
|
||||
<div class="fullscreen-time-display">
|
||||
{{ timeString }}<span class="fullscreen-seconds">{{ secondsString }}</span>
|
||||
{{ timeString }}<span class="fullscreen-seconds">{{ secondsString }}</span><span
|
||||
v-if="use12hClock"
|
||||
class="fullscreen-seconds"
|
||||
> {{ amPmString }}</span>
|
||||
</div>
|
||||
<div class="fullscreen-date-line mt-6">
|
||||
{{ dateString }} {{ weekdayString }} {{ periodOfDay }}
|
||||
@ -680,6 +687,24 @@
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
class="mr-3"
|
||||
icon="mdi-clock-time-six-outline"
|
||||
/>
|
||||
</template>
|
||||
<v-list-item-title>12 小时制</v-list-item-title>
|
||||
<v-list-item-subtitle>以 12 小时制(AM/PM)显示时间。</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-switch
|
||||
:model-value="use12hClock"
|
||||
hide-details
|
||||
density="comfortable"
|
||||
@update:model-value="setUse12hClock"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
@ -723,6 +748,7 @@ export default {
|
||||
showFullscreen: false,
|
||||
showSettings: false,
|
||||
timeCardEnabled: true,
|
||||
use12hClock: false,
|
||||
// 全屏模式切换
|
||||
fullscreenMode: 'clock',
|
||||
// 工具栏自动隐藏
|
||||
@ -788,9 +814,16 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
timeString() {
|
||||
const h = String(this.now.getHours()).padStart(2, '0')
|
||||
const hours = this.now.getHours()
|
||||
const m = String(this.now.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
if (this.use12hClock) {
|
||||
const h12 = hours % 12 || 12
|
||||
return `${h12}:${m}`
|
||||
}
|
||||
return `${String(hours).padStart(2, '0')}:${m}`
|
||||
},
|
||||
amPmString() {
|
||||
return this.now.getHours() < 12 ? 'AM' : 'PM'
|
||||
},
|
||||
secondsString() {
|
||||
return `:${String(this.now.getSeconds()).padStart(2, '0')}`
|
||||
@ -997,12 +1030,17 @@ export default {
|
||||
loadSettings() {
|
||||
this.fontSize = SettingsManager.getSetting('font.size')
|
||||
this.timeCardEnabled = getSetting('timeCard.enabled')
|
||||
this.use12hClock = getSetting('timeCard.use12h')
|
||||
this.noiseEnabled = getSetting('noiseMonitor.enabled')
|
||||
},
|
||||
setTimeCardEnabled(val) {
|
||||
this.timeCardEnabled = val
|
||||
setSetting('timeCard.enabled', val)
|
||||
},
|
||||
setUse12hClock(val) {
|
||||
this.use12hClock = val
|
||||
setSetting('timeCard.use12h', val)
|
||||
},
|
||||
startTimer() {
|
||||
this.timer = setInterval(() => {
|
||||
this.now = new Date()
|
||||
|
||||
@ -11,9 +11,9 @@
|
||||
{{ displayTitle }}
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="d-flex align-center text-wrap">
|
||||
<!--<v-list-item-subtitle class="d-flex align-center text-wrap">
|
||||
<span class="text-caption text-grey-darken-1">{{ settingKey }}</span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-subtitle>-->
|
||||
|
||||
<template #append>
|
||||
<div class="d-flex flex-column flex-sm-row align-center">
|
||||
|
||||
513
src/components/settings/cards/BackgroundSettingsCard.vue
Normal file
513
src/components/settings/cards/BackgroundSettingsCard.vue
Normal file
@ -0,0 +1,513 @@
|
||||
<template>
|
||||
<settings-card
|
||||
border
|
||||
icon="mdi-image"
|
||||
title="背景设置"
|
||||
>
|
||||
<v-list>
|
||||
<setting-item
|
||||
:key="settingItemKey"
|
||||
:setting-key="'background.enabled'"
|
||||
/>
|
||||
</v-list>
|
||||
|
||||
<v-divider class="mb-4" />
|
||||
|
||||
<div class="px-4 pb-4">
|
||||
<!-- 预览区域 -->
|
||||
<div
|
||||
class="preview-area mb-6"
|
||||
:style="previewContainerStyle"
|
||||
>
|
||||
<div
|
||||
class="preview-bg"
|
||||
:style="previewBgStyle"
|
||||
/>
|
||||
<div
|
||||
class="preview-overlay"
|
||||
:style="previewOverlayStyle"
|
||||
/>
|
||||
<div class="preview-text">
|
||||
背景预览
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片来源 -->
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
>
|
||||
mdi-image-search
|
||||
</v-icon>
|
||||
<span class="text-subtitle-1 font-weight-bold">图片来源</span>
|
||||
</div>
|
||||
|
||||
<!-- 来源选择 -->
|
||||
<v-btn-toggle
|
||||
v-model="imageSource"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
class="mb-4"
|
||||
mandatory
|
||||
rounded="xl"
|
||||
>
|
||||
<v-btn
|
||||
value="url"
|
||||
prepend-icon="mdi-link-variant"
|
||||
>
|
||||
网络地址
|
||||
</v-btn>
|
||||
<v-btn
|
||||
value="upload"
|
||||
prepend-icon="mdi-upload"
|
||||
>
|
||||
本地上传
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<!-- URL 输入 -->
|
||||
<div
|
||||
v-if="imageSource === 'url'"
|
||||
class="mb-4"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="localUrl"
|
||||
label="图片地址"
|
||||
placeholder="https://example.com/background.jpg"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-link"
|
||||
clearable
|
||||
hide-details="auto"
|
||||
:rules="[validateUrl]"
|
||||
@update:model-value="onUrlChange"
|
||||
/>
|
||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||
<v-chip
|
||||
v-for="preset in urlPresets"
|
||||
:key="preset.label"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="cursor-pointer"
|
||||
@click="applyPreset(preset.url)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地上传 -->
|
||||
<div
|
||||
v-if="imageSource === 'upload'"
|
||||
class="mb-4"
|
||||
>
|
||||
<div
|
||||
class="upload-area rounded-xl pa-6 text-center mb-3"
|
||||
:class="{ 'upload-hover': isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<v-icon
|
||||
size="40"
|
||||
color="primary"
|
||||
class="mb-2"
|
||||
>
|
||||
mdi-image-plus
|
||||
</v-icon>
|
||||
<div class="text-body-2">
|
||||
点击或拖拽图片到此处上传
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
支持 JPG、PNG、WebP、GIF(建议小于 {{ maxImageSizeMB }}MB)
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="uploadWarning"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
icon="mdi-alert"
|
||||
>
|
||||
{{ uploadWarning }}
|
||||
</v-alert>
|
||||
|
||||
<div
|
||||
v-if="localImageData"
|
||||
class="d-flex align-center ga-2"
|
||||
>
|
||||
<v-chip
|
||||
color="success"
|
||||
prepend-icon="mdi-check-circle"
|
||||
size="small"
|
||||
>
|
||||
已上传本地图片
|
||||
</v-chip>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
prepend-icon="mdi-delete"
|
||||
@click="clearUploadedImage"
|
||||
>
|
||||
清除
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
|
||||
<!-- 毛玻璃效果设置 -->
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="blue"
|
||||
>
|
||||
mdi-blur
|
||||
</v-icon>
|
||||
<span class="text-subtitle-1 font-weight-bold">毛玻璃效果</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-space-between align-center mb-1">
|
||||
<span class="text-body-2 text-medium-emphasis">模糊幅度</span>
|
||||
<span class="text-body-2 font-weight-bold">{{ localBlur }}px</span>
|
||||
</div>
|
||||
<v-slider
|
||||
v-model="localBlur"
|
||||
:min="0"
|
||||
:max="50"
|
||||
:step="1"
|
||||
color="primary"
|
||||
track-color="grey-lighten-3"
|
||||
thumb-label
|
||||
hide-details
|
||||
@update:model-value="onBlurChange"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
size="small"
|
||||
color="grey"
|
||||
>
|
||||
mdi-blur-off
|
||||
</v-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<v-icon
|
||||
size="small"
|
||||
color="primary"
|
||||
>
|
||||
mdi-blur
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-slider>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-space-between align-center mb-1">
|
||||
<span class="text-body-2 text-medium-emphasis">遮罩暗色程度</span>
|
||||
<span class="text-body-2 font-weight-bold">{{ localOpacity }}%</span>
|
||||
</div>
|
||||
<v-slider
|
||||
v-model="localOpacity"
|
||||
:min="0"
|
||||
:max="80"
|
||||
:step="1"
|
||||
color="blue-grey"
|
||||
track-color="grey-lighten-3"
|
||||
thumb-label
|
||||
hide-details
|
||||
@update:model-value="onOpacityChange"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
size="small"
|
||||
color="grey"
|
||||
>
|
||||
mdi-brightness-7
|
||||
</v-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<v-icon
|
||||
size="small"
|
||||
color="blue-grey"
|
||||
>
|
||||
mdi-brightness-2
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-slider>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div class="d-flex justify-end ga-3">
|
||||
<v-btn
|
||||
variant="text"
|
||||
prepend-icon="mdi-restore"
|
||||
@click="resetAll"
|
||||
>
|
||||
重置
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
prepend-icon="mdi-content-save"
|
||||
:loading="saving"
|
||||
@click="saveAll"
|
||||
>
|
||||
保存设置
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</settings-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SettingsCard from '@/components/SettingsCard.vue';
|
||||
import SettingItem from '@/components/settings/SettingItem.vue';
|
||||
import { getSetting, setSetting, resetSetting } from '@/utils/settings';
|
||||
|
||||
const URL_PRESETS = [
|
||||
{ label: 'Bing 随机壁纸', url: 'https://bing.img.run/rand.php' },
|
||||
{ label: 'Bing 每日壁纸', url: 'https://bing.img.run/1920x1080.php' },
|
||||
{ label: '随机风景', url: 'https://picsum.photos/1920/1080?random=1' },
|
||||
{ label: '随机二次元', url: 'https://uapis.cn/api/v1/random/image?category=acg&type=pc' },
|
||||
];
|
||||
|
||||
const MAX_IMAGE_SIZE_MB = 10;
|
||||
|
||||
export default {
|
||||
name: 'BackgroundSettingsCard',
|
||||
components: { SettingsCard, SettingItem },
|
||||
|
||||
data() {
|
||||
const imageData = getSetting('background.imageData') || '';
|
||||
const url = getSetting('background.url') || '';
|
||||
|
||||
return {
|
||||
imageSource: imageData ? 'upload' : 'url',
|
||||
localUrl: url,
|
||||
localImageData: imageData,
|
||||
localBlur: getSetting('background.blur') ?? 10,
|
||||
localOpacity: getSetting('background.opacity') ?? 30,
|
||||
isDragging: false,
|
||||
saving: false,
|
||||
uploadWarning: '',
|
||||
urlPresets: URL_PRESETS,
|
||||
settingItemKey: 0,
|
||||
maxImageSizeMB: MAX_IMAGE_SIZE_MB,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
/** The active image src for preview */
|
||||
activeImageSrc() {
|
||||
if (this.imageSource === 'upload' && this.localImageData) {
|
||||
return this.localImageData;
|
||||
}
|
||||
if (this.imageSource === 'url' && this.localUrl) {
|
||||
return this.localUrl;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
previewContainerStyle() {
|
||||
return {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '160px',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(128,128,128,0.3)',
|
||||
};
|
||||
},
|
||||
|
||||
previewBgStyle() {
|
||||
if (!this.activeImageSrc) {
|
||||
return {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
filter: `blur(${this.localBlur}px)`,
|
||||
transform: 'scale(1.1)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
backgroundImage: `url(${this.activeImageSrc})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
filter: `blur(${this.localBlur}px)`,
|
||||
transform: 'scale(1.1)',
|
||||
};
|
||||
},
|
||||
|
||||
previewOverlayStyle() {
|
||||
return {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
background: `rgba(0, 0, 0, ${this.localOpacity / 100})`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
validateUrl(val) {
|
||||
if (!val) return true;
|
||||
try {
|
||||
new URL(val);
|
||||
return true;
|
||||
} catch {
|
||||
return '请输入有效的图片地址';
|
||||
}
|
||||
},
|
||||
|
||||
onUrlChange(val) {
|
||||
this.localUrl = val || '';
|
||||
},
|
||||
|
||||
onBlurChange(val) {
|
||||
this.localBlur = val;
|
||||
},
|
||||
|
||||
onOpacityChange(val) {
|
||||
this.localOpacity = val;
|
||||
},
|
||||
|
||||
applyPreset(url) {
|
||||
this.localUrl = url;
|
||||
this.imageSource = 'url';
|
||||
},
|
||||
|
||||
triggerFileInput() {
|
||||
this.$refs.fileInput.click();
|
||||
},
|
||||
|
||||
handleDrop(event) {
|
||||
this.isDragging = false;
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) this.processFile(file);
|
||||
},
|
||||
|
||||
handleFileChange(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) this.processFile(file);
|
||||
// Reset input so same file can be re-selected
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
processFile(file) {
|
||||
this.uploadWarning = '';
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this.uploadWarning = '请选择图片文件';
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeMB = file.size / 1024 / 1024;
|
||||
if (sizeMB > MAX_IMAGE_SIZE_MB) {
|
||||
this.uploadWarning = `图片大小为 ${sizeMB.toFixed(1)}MB,超过 ${MAX_IMAGE_SIZE_MB}MB 限制,请压缩后重试`;
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.localImageData = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
|
||||
clearUploadedImage() {
|
||||
this.localImageData = '';
|
||||
this.uploadWarning = '';
|
||||
},
|
||||
|
||||
async saveAll() {
|
||||
this.saving = true;
|
||||
try {
|
||||
// Determine which image source to persist
|
||||
if (this.imageSource === 'upload') {
|
||||
setSetting('background.imageData', this.localImageData || '');
|
||||
setSetting('background.url', '');
|
||||
} else {
|
||||
setSetting('background.url', this.localUrl || '');
|
||||
setSetting('background.imageData', '');
|
||||
}
|
||||
|
||||
setSetting('background.blur', this.localBlur);
|
||||
setSetting('background.opacity', this.localOpacity);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetAll() {
|
||||
resetSetting('background.enabled');
|
||||
resetSetting('background.url');
|
||||
resetSetting('background.imageData');
|
||||
resetSetting('background.blur');
|
||||
resetSetting('background.opacity');
|
||||
|
||||
this.localUrl = getSetting('background.url') || '';
|
||||
this.localImageData = getSetting('background.imageData') || '';
|
||||
this.localBlur = getSetting('background.blur') ?? 10;
|
||||
this.localOpacity = getSetting('background.opacity') ?? 30;
|
||||
this.imageSource = 'url';
|
||||
this.uploadWarning = '';
|
||||
// Force re-render of SettingItem to reflect reset enabled state
|
||||
this.settingItemKey++;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-area {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed rgba(128, 128, 128, 0.4);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: rgba(128, 128, 128, 0.05);
|
||||
}
|
||||
|
||||
.upload-area:hover,
|
||||
.upload-hover {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@ -226,6 +226,11 @@
|
||||
<homework-template-card border />
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item value="background">
|
||||
<background-settings-card border />
|
||||
</v-tabs-window-item>
|
||||
|
||||
|
||||
<v-tabs-window-item value="developer">
|
||||
<settings-card
|
||||
border
|
||||
@ -328,6 +333,7 @@ import KvDatabaseCard from "@/components/settings/cards/KvDatabaseCard.vue";
|
||||
import HitokotoSettings from "@/components/HitokotoSettings.vue";
|
||||
import NotificationSoundSettings from "@/components/settings/NotificationSoundSettings.vue";
|
||||
import NoiseSettingsCard from "@/components/settings/cards/NoiseSettingsCard.vue";
|
||||
import BackgroundSettingsCard from "@/components/settings/cards/BackgroundSettingsCard.vue";
|
||||
|
||||
export default {
|
||||
name: "Settings",
|
||||
@ -352,6 +358,7 @@ export default {
|
||||
HitokotoSettings,
|
||||
NotificationSoundSettings,
|
||||
NoiseSettingsCard,
|
||||
BackgroundSettingsCard,
|
||||
},
|
||||
setup() {
|
||||
const {mobile} = useDisplay();
|
||||
@ -490,6 +497,12 @@ export default {
|
||||
value: "randomPicker",
|
||||
},
|
||||
|
||||
{
|
||||
title: "背景",
|
||||
icon: "mdi-image",
|
||||
value: "background",
|
||||
},
|
||||
|
||||
{
|
||||
title: "开发者",
|
||||
icon: "mdi-developer-board",
|
||||
|
||||
@ -1,79 +1,208 @@
|
||||
/**
|
||||
* Server rotation utility for Classworks Cloud provider
|
||||
* Provides fallback mechanism across multiple server endpoints
|
||||
* Provides fallback mechanism across multiple server endpoints with
|
||||
* latency-based preference, background probing, and response caching.
|
||||
*/
|
||||
|
||||
import { getSetting } from "./settings";
|
||||
|
||||
// Server list for classworkscloud provider (in priority order)
|
||||
// Server list for classworkscloud provider (in default priority order)
|
||||
const CLASSWORKS_CLOUD_SERVERS = [
|
||||
"https://kv-service.houlang.cloud",
|
||||
"https://kv-service.wuyuan.dev",
|
||||
];
|
||||
|
||||
// Cache TTL for server preference (5 minutes)
|
||||
const SERVER_PREFERENCE_TTL = 5 * 60 * 1000;
|
||||
|
||||
// Probe timeout (3 seconds)
|
||||
const PROBE_TIMEOUT_MS = 3000;
|
||||
|
||||
// Server preference cache
|
||||
const serverPreference = {
|
||||
preferred: null, // URL of the fastest responding server
|
||||
cachedAt: 0, // Timestamp when the preference was last updated
|
||||
probing: false, // Whether a background probe is currently running
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the list of servers to try for the given provider
|
||||
* Update the preference cache to mark the given server URL as preferred.
|
||||
* @param {string} url
|
||||
*/
|
||||
function setCachedPreference(url) {
|
||||
serverPreference.preferred = url;
|
||||
serverPreference.cachedAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an error should trigger rotation to the next server.
|
||||
* Network errors and 5xx server errors warrant rotation.
|
||||
* HTTP 4xx responses mean the server is reachable and should NOT trigger rotation
|
||||
* (e.g. 404 simply means the key does not exist on a healthy server).
|
||||
* @param {Error} error
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldRotateOnError(error) {
|
||||
if (!error.response) {
|
||||
// Network / timeout error — server unreachable, rotate
|
||||
return true;
|
||||
}
|
||||
// Only rotate for server-side (5xx) errors
|
||||
return error.response.status >= 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a single server and return its round-trip latency in milliseconds.
|
||||
* Returns Infinity when the server cannot be reached within the timeout.
|
||||
* Any HTTP response (including 4xx) counts as reachable.
|
||||
* @param {string} serverUrl
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function probeServer(serverUrl) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
||||
const start = Date.now();
|
||||
try {
|
||||
await fetch(`${serverUrl}/`, { method: "HEAD", signal: controller.signal });
|
||||
return Date.now() - start;
|
||||
} catch {
|
||||
return Infinity;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe all cloud servers concurrently and update the preference cache
|
||||
* with the fastest reachable one. Runs silently in the background.
|
||||
*/
|
||||
async function updateServerPreference() {
|
||||
if (serverPreference.probing) return;
|
||||
serverPreference.probing = true;
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
CLASSWORKS_CLOUD_SERVERS.map(async (url) => ({
|
||||
url,
|
||||
latency: await probeServer(url),
|
||||
}))
|
||||
);
|
||||
const reachable = results
|
||||
.filter((r) => r.latency < Infinity)
|
||||
.sort((a, b) => a.latency - b.latency);
|
||||
if (reachable.length > 0) {
|
||||
setCachedPreference(reachable[0].url);
|
||||
}
|
||||
} catch {
|
||||
// Probe failure is non-fatal; keep existing preference
|
||||
} finally {
|
||||
serverPreference.probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the cloud server list ordered by cached preference (fastest first).
|
||||
* Triggers a background probe when the cache is stale without blocking.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getOrderedCloudServers() {
|
||||
const now = Date.now();
|
||||
const cacheStale = now - serverPreference.cachedAt > SERVER_PREFERENCE_TTL;
|
||||
|
||||
if (cacheStale && !serverPreference.probing) {
|
||||
// Non-blocking background probe to refresh the preference
|
||||
updateServerPreference().catch(() => {});
|
||||
}
|
||||
|
||||
const preferred = serverPreference.preferred;
|
||||
if (preferred && CLASSWORKS_CLOUD_SERVERS.includes(preferred)) {
|
||||
return [preferred, ...CLASSWORKS_CLOUD_SERVERS.filter((s) => s !== preferred)];
|
||||
}
|
||||
return [...CLASSWORKS_CLOUD_SERVERS];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of servers to try for the given provider.
|
||||
* For classworkscloud the list is ordered by latency preference (fastest first).
|
||||
* @param {string} provider - The provider type
|
||||
* @returns {string[]} Array of server URLs to try
|
||||
*/
|
||||
export function getServerList(provider) {
|
||||
if (provider === "classworkscloud") {
|
||||
return [...CLASSWORKS_CLOUD_SERVERS];
|
||||
return getOrderedCloudServers();
|
||||
}
|
||||
|
||||
|
||||
// For other providers, use the configured domain
|
||||
const domain = getSetting("server.domain");
|
||||
return domain ? [domain] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Try an operation with server rotation fallback
|
||||
* @param {Function} operation - Async function that takes a serverUrl and returns a promise
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.provider - Provider type (optional, defaults to current setting)
|
||||
* @param {Function} options.onServerTried - Callback called when a server is tried (optional)
|
||||
* Receives: { url, status, tried } where tried is a snapshot of attempts
|
||||
* Try an operation with server rotation fallback.
|
||||
*
|
||||
* Key behaviours:
|
||||
* - HTTP 4xx responses (e.g. 404) are treated as valid server replies and are
|
||||
* propagated immediately without trying the next server.
|
||||
* - Only network errors or HTTP 5xx responses cause rotation to the next server.
|
||||
* - On success, the responding server is remembered as the preferred server.
|
||||
*
|
||||
* @param {Function} operation - Async function that receives a serverUrl and returns a promise
|
||||
* @param {Object} options
|
||||
* @param {string} [options.provider] - Provider type (defaults to current setting)
|
||||
* @param {Function} [options.onServerTried] - Callback invoked on each attempt;
|
||||
* receives { url, status, tried } where tried is a snapshot of all attempts so far
|
||||
* @returns {Promise} Result from the first successful server, or throws the last error
|
||||
*/
|
||||
export async function tryWithRotation(operation, options = {}) {
|
||||
const provider = options.provider || getSetting("server.provider");
|
||||
const onServerTried = options.onServerTried;
|
||||
const hasCallback = typeof onServerTried === 'function';
|
||||
|
||||
const hasCallback = typeof onServerTried === "function";
|
||||
|
||||
const servers = getServerList(provider);
|
||||
const triedServers = [];
|
||||
let lastError = null;
|
||||
|
||||
for (const serverUrl of servers) {
|
||||
triedServers.push({ url: serverUrl, status: "trying" });
|
||||
if (hasCallback) {
|
||||
onServerTried({ url: serverUrl, status: "trying", tried: [...triedServers] });
|
||||
}
|
||||
|
||||
try {
|
||||
triedServers.push({ url: serverUrl, status: "trying" });
|
||||
if (hasCallback) {
|
||||
// Provide a snapshot to prevent callback from mutating internal state
|
||||
onServerTried({ url: serverUrl, status: "trying", tried: [...triedServers] });
|
||||
}
|
||||
|
||||
const result = await operation(serverUrl);
|
||||
|
||||
|
||||
triedServers[triedServers.length - 1].status = "success";
|
||||
if (hasCallback) {
|
||||
onServerTried({ url: serverUrl, status: "success", tried: [...triedServers] });
|
||||
}
|
||||
|
||||
|
||||
// Remember this server as the preferred one for future requests
|
||||
if (provider === "classworkscloud") {
|
||||
setCachedPreference(serverUrl);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// For HTTP 4xx errors the server is alive — propagate immediately without rotation
|
||||
if (!shouldRotateOnError(error)) {
|
||||
triedServers[triedServers.length - 1].status = "client-error";
|
||||
if (hasCallback) {
|
||||
onServerTried({ url: serverUrl, status: "client-error", error, tried: [...triedServers] });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
lastError = error;
|
||||
triedServers[triedServers.length - 1].status = "failed";
|
||||
triedServers[triedServers.length - 1].error = error.message || String(error);
|
||||
if (hasCallback) {
|
||||
onServerTried({ url: serverUrl, status: "failed", error, tried: [...triedServers] });
|
||||
}
|
||||
|
||||
// Continue to next server
|
||||
console.warn(`Server ${serverUrl} failed:`, error.message);
|
||||
|
||||
console.warn(`Server ${serverUrl} failed, trying next:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// All servers failed
|
||||
// All servers exhausted
|
||||
console.error("All servers failed. Tried:", triedServers);
|
||||
const error = lastError || new Error("All servers failed");
|
||||
error.triedServers = triedServers;
|
||||
@ -81,23 +210,23 @@ export async function tryWithRotation(operation, options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective server URL for the current provider
|
||||
* For classworkscloud, returns the first server in the list
|
||||
* For other providers, returns the configured domain
|
||||
* Get the effective server URL for the current provider.
|
||||
* For classworkscloud, returns the cached preferred server (fastest known),
|
||||
* falling back to the first server in the list.
|
||||
* @returns {string} Server URL
|
||||
*/
|
||||
export function getEffectiveServerUrl() {
|
||||
const provider = getSetting("server.provider");
|
||||
|
||||
|
||||
if (provider === "classworkscloud") {
|
||||
return CLASSWORKS_CLOUD_SERVERS[0];
|
||||
return serverPreference.preferred || CLASSWORKS_CLOUD_SERVERS[0];
|
||||
}
|
||||
|
||||
|
||||
return getSetting("server.domain") || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rotation is enabled for the current provider
|
||||
* Check if rotation is enabled for the current provider.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRotationEnabled() {
|
||||
|
||||
@ -62,6 +62,9 @@ async function initializeStorage() {
|
||||
// 存储所有设置的localStorage键名
|
||||
const SETTINGS_STORAGE_KEY = "Classworks_settings";
|
||||
|
||||
// 同标签页设置变化事件名
|
||||
const SETTINGS_CHANGED_EVENT = "classworks:settings:changed";
|
||||
|
||||
|
||||
// 新增: Classworks云端存储的默认设置
|
||||
const classworksCloudDefaults = {
|
||||
@ -127,6 +130,12 @@ const settingsDefinitions = {
|
||||
description: "启用时间卡片",
|
||||
icon: "mdi-clock-outline",
|
||||
},
|
||||
"timeCard.use12h": {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "使用 12 小时制显示时间",
|
||||
icon: "mdi-clock-time-six-outline",
|
||||
},
|
||||
|
||||
// 一言设置
|
||||
"hitokoto.enabled": {
|
||||
@ -435,6 +444,40 @@ const settingsDefinitions = {
|
||||
// 设置应用的主题模式,可选亮色或暗色主题
|
||||
},
|
||||
|
||||
// 背景设置
|
||||
"background.enabled": {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "启用自定义背景",
|
||||
icon: "mdi-image",
|
||||
},
|
||||
"background.url": {
|
||||
type: "string",
|
||||
default: "",
|
||||
description: "背景图片地址",
|
||||
icon: "mdi-link",
|
||||
},
|
||||
"background.imageData": {
|
||||
type: "string",
|
||||
default: "",
|
||||
description: "本地背景图片(Base64)",
|
||||
icon: "mdi-image-area",
|
||||
},
|
||||
"background.blur": {
|
||||
type: "number",
|
||||
default: 10,
|
||||
validate: (value) => value >= 0 && value <= 50,
|
||||
description: "毛玻璃模糊幅度(px)",
|
||||
icon: "mdi-blur",
|
||||
},
|
||||
"background.opacity": {
|
||||
type: "number",
|
||||
default: 30,
|
||||
validate: (value) => value >= 0 && value <= 80,
|
||||
description: "遮罩暗色程度(%)",
|
||||
icon: "mdi-circle-half-full",
|
||||
},
|
||||
|
||||
// 通知铃声设置
|
||||
"notification.singleSound": {
|
||||
type: "string",
|
||||
@ -668,6 +711,13 @@ class SettingsManagerClass {
|
||||
this.saveSettings();
|
||||
this.logSettingsChange(key, oldValue, value);
|
||||
|
||||
// 触发同标签页内的设置变化事件
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(SETTINGS_CHANGED_EVENT, {
|
||||
detail: { key, value },
|
||||
}));
|
||||
}
|
||||
|
||||
// 为了保持向后兼容,同时更新旧的localStorage键
|
||||
const legacyKey = definition.legacyKey;
|
||||
if (legacyKey && typeof localStorage !== "undefined") {
|
||||
@ -715,6 +765,13 @@ class SettingsManagerClass {
|
||||
|
||||
this.settingsCache[key] = definition.default;
|
||||
this.saveSettings();
|
||||
|
||||
// 触发同标签页内的设置变化事件
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(SETTINGS_CHANGED_EVENT, {
|
||||
detail: { key, value: definition.default },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -737,15 +794,23 @@ class SettingsManagerClass {
|
||||
if (typeof window === "undefined") return () => {
|
||||
};
|
||||
|
||||
const handler = (event) => {
|
||||
const storageHandler = (event) => {
|
||||
if (event.key === SETTINGS_STORAGE_KEY) {
|
||||
this.settingsCache = JSON.parse(event.newValue);
|
||||
callback(this.settingsCache);
|
||||
callback(this.settingsCache, null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handler);
|
||||
return () => window.removeEventListener("storage", handler);
|
||||
const customHandler = (event) => {
|
||||
callback(this.settingsCache, event);
|
||||
};
|
||||
|
||||
window.addEventListener("storage", storageHandler);
|
||||
window.addEventListener(SETTINGS_CHANGED_EVENT, customHandler);
|
||||
return () => {
|
||||
window.removeEventListener("storage", storageHandler);
|
||||
window.removeEventListener(SETTINGS_CHANGED_EVENT, customHandler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user