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
|
// Web Workers
|
||||||
Worker: 'readonly',
|
Worker: 'readonly',
|
||||||
SharedWorker: 'readonly',
|
SharedWorker: 'readonly',
|
||||||
|
// Fetch API
|
||||||
|
AbortController: 'readonly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,14 +6,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Classworks 作业板</title>
|
<title>Classworks 作业板</title>
|
||||||
|
|
||||||
<!-- SEO -->
|
|
||||||
<meta name="description" content="Classworks —— 适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
|
<meta name="description" content="Classworks —— 适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
|
||||||
<meta name="keywords" content="Classworks,作业板,班级大屏,作业管理,课表,作业同步,开源" />
|
<meta name="keywords" content="Classworks,作业板,班级大屏,作业管理,课表,作业同步,开源" />
|
||||||
<meta name="author" content="Sunwuyuan" />
|
<meta name="author" content="Sunwuyuan" />
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow" />
|
||||||
<link rel="canonical" href="https://cs.houlang.cloud/" />
|
<link rel="canonical" href="https://cs.houlang.cloud/" />
|
||||||
|
|
||||||
<!-- Open Graph -->
|
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="Classworks 作业板" />
|
<meta property="og:title" content="Classworks 作业板" />
|
||||||
<meta property="og:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
|
<meta property="og:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
|
||||||
@ -24,7 +22,6 @@
|
|||||||
<meta property="og:image:height" content="512" />
|
<meta property="og:image:height" content="512" />
|
||||||
<meta property="og:locale" content="zh_CN" />
|
<meta property="og:locale" content="zh_CN" />
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<meta name="twitter:title" content="Classworks 作业板" />
|
<meta name="twitter:title" content="Classworks 作业板" />
|
||||||
<meta name="twitter:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
|
<meta name="twitter:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
|
||||||
@ -39,6 +36,5 @@
|
|||||||
|
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
85
src/App.vue
85
src/App.vue
@ -1,5 +1,17 @@
|
|||||||
<template>
|
<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 }">
|
<router-view v-slot="{ Component, route }">
|
||||||
<transition
|
<transition
|
||||||
@ -18,24 +30,74 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from "vue";
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
import { useTheme } from "vuetify";
|
import { useTheme } from "vuetify";
|
||||||
import { getSetting } from "@/utils/settings";
|
import { getSetting, watchSettings } from "@/utils/settings";
|
||||||
import RateLimitModal from "@/components/RateLimitModal.vue";
|
import RateLimitModal from "@/components/RateLimitModal.vue";
|
||||||
|
|
||||||
const theme = useTheme();
|
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(() => {
|
onMounted(() => {
|
||||||
// 应用保存的主题设置
|
// 应用保存的主题设置
|
||||||
const savedTheme = getSetting("theme.mode");
|
const savedTheme = getSetting("theme.mode");
|
||||||
theme.global.name.value = savedTheme;
|
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) => {
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.deferredPwaPrompt = e;
|
window.deferredPwaPrompt = e;
|
||||||
window.dispatchEvent(new Event('pwa-prompt-ready'));
|
window.dispatchEvent(new Event('pwa-prompt-ready'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unwatchSettings) unwatchSettings();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
/* 全局样式(从 index.vue 迁移,确保全局可用且仅加载一次) */
|
/* 全局样式(从 index.vue 迁移,确保全局可用且仅加载一次) */
|
||||||
@ -58,4 +120,21 @@ onMounted(() => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-0.5vw);
|
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>
|
</style>
|
||||||
|
|||||||
@ -61,7 +61,8 @@ export default {
|
|||||||
refreshInterval: 60,
|
refreshInterval: 60,
|
||||||
kvConfig: {
|
kvConfig: {
|
||||||
sources: ['zhaoyu'],
|
sources: ['zhaoyu'],
|
||||||
sensitiveWords: []
|
sensitiveWords: [],
|
||||||
|
hitokotoCategories: []
|
||||||
},
|
},
|
||||||
sentence: '',
|
sentence: '',
|
||||||
author: '',
|
author: '',
|
||||||
@ -125,7 +126,8 @@ export default {
|
|||||||
this.kvConfig = {
|
this.kvConfig = {
|
||||||
sources: Array.isArray(data.sources) && data.sources.length > 0 ? data.sources : ['zhaoyu'],
|
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) : [],
|
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) {
|
} catch (e) {
|
||||||
@ -155,7 +157,13 @@ export default {
|
|||||||
let origin = ''
|
let origin = ''
|
||||||
|
|
||||||
if (source === 'hitokoto') {
|
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
|
data = res.data
|
||||||
content = data.hitokoto
|
content = data.hitokoto
|
||||||
author = data.from_who
|
author = data.from_who
|
||||||
|
|||||||
@ -64,6 +64,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-list-item>
|
</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-list-item v-if="kvConfig.sources.includes('jinrishici')">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="kvConfig.jinrishiciToken"
|
v-model="kvConfig.jinrishiciToken"
|
||||||
@ -402,8 +425,23 @@ export default {
|
|||||||
kvConfig: {
|
kvConfig: {
|
||||||
sources: ['zhaoyu'],
|
sources: ['zhaoyu'],
|
||||||
sensitiveWords: '',
|
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,
|
loading: false,
|
||||||
testLoading: false,
|
testLoading: false,
|
||||||
testMessage: '',
|
testMessage: '',
|
||||||
@ -430,7 +468,8 @@ export default {
|
|||||||
this.kvConfig = {
|
this.kvConfig = {
|
||||||
sources: Array.isArray(data.sources) ? data.sources : ['zhaoyu'],
|
sources: Array.isArray(data.sources) ? data.sources : ['zhaoyu'],
|
||||||
sensitiveWords: data.sensitiveWords || '',
|
sensitiveWords: data.sensitiveWords || '',
|
||||||
jinrishiciToken: data.jinrishiciToken
|
jinrishiciToken: data.jinrishiciToken,
|
||||||
|
hitokotoCategories: Array.isArray(data.hitokotoCategories) ? data.hitokotoCategories : []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -25,7 +25,11 @@
|
|||||||
{{ timeString }}<span
|
{{ timeString }}<span
|
||||||
class="seconds-text"
|
class="seconds-text"
|
||||||
:style="secondsStyle"
|
:style="secondsStyle"
|
||||||
>{{ secondsString }}</span>
|
>{{ secondsString }}</span><span
|
||||||
|
v-if="use12hClock"
|
||||||
|
class="ampm-text"
|
||||||
|
:style="secondsStyle"
|
||||||
|
> {{ amPmString }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="date-line mt-3"
|
class="date-line mt-3"
|
||||||
@ -305,7 +309,10 @@
|
|||||||
<v-tabs-window-item value="clock">
|
<v-tabs-window-item value="clock">
|
||||||
<div class="d-flex flex-column align-center justify-center">
|
<div class="d-flex flex-column align-center justify-center">
|
||||||
<div class="fullscreen-time-display">
|
<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>
|
||||||
<div class="fullscreen-date-line mt-6">
|
<div class="fullscreen-date-line mt-6">
|
||||||
{{ dateString }} {{ weekdayString }} {{ periodOfDay }}
|
{{ dateString }} {{ weekdayString }} {{ periodOfDay }}
|
||||||
@ -680,6 +687,24 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</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-list>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
@ -723,6 +748,7 @@ export default {
|
|||||||
showFullscreen: false,
|
showFullscreen: false,
|
||||||
showSettings: false,
|
showSettings: false,
|
||||||
timeCardEnabled: true,
|
timeCardEnabled: true,
|
||||||
|
use12hClock: false,
|
||||||
// 全屏模式切换
|
// 全屏模式切换
|
||||||
fullscreenMode: 'clock',
|
fullscreenMode: 'clock',
|
||||||
// 工具栏自动隐藏
|
// 工具栏自动隐藏
|
||||||
@ -788,9 +814,16 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
timeString() {
|
timeString() {
|
||||||
const h = String(this.now.getHours()).padStart(2, '0')
|
const hours = this.now.getHours()
|
||||||
const m = String(this.now.getMinutes()).padStart(2, '0')
|
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() {
|
secondsString() {
|
||||||
return `:${String(this.now.getSeconds()).padStart(2, '0')}`
|
return `:${String(this.now.getSeconds()).padStart(2, '0')}`
|
||||||
@ -997,12 +1030,17 @@ export default {
|
|||||||
loadSettings() {
|
loadSettings() {
|
||||||
this.fontSize = SettingsManager.getSetting('font.size')
|
this.fontSize = SettingsManager.getSetting('font.size')
|
||||||
this.timeCardEnabled = getSetting('timeCard.enabled')
|
this.timeCardEnabled = getSetting('timeCard.enabled')
|
||||||
|
this.use12hClock = getSetting('timeCard.use12h')
|
||||||
this.noiseEnabled = getSetting('noiseMonitor.enabled')
|
this.noiseEnabled = getSetting('noiseMonitor.enabled')
|
||||||
},
|
},
|
||||||
setTimeCardEnabled(val) {
|
setTimeCardEnabled(val) {
|
||||||
this.timeCardEnabled = val
|
this.timeCardEnabled = val
|
||||||
setSetting('timeCard.enabled', val)
|
setSetting('timeCard.enabled', val)
|
||||||
},
|
},
|
||||||
|
setUse12hClock(val) {
|
||||||
|
this.use12hClock = val
|
||||||
|
setSetting('timeCard.use12h', val)
|
||||||
|
},
|
||||||
startTimer() {
|
startTimer() {
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
this.now = new Date()
|
this.now = new Date()
|
||||||
|
|||||||
@ -11,9 +11,9 @@
|
|||||||
{{ displayTitle }}
|
{{ displayTitle }}
|
||||||
</v-list-item-title>
|
</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>
|
<span class="text-caption text-grey-darken-1">{{ settingKey }}</span>
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>-->
|
||||||
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<div class="d-flex flex-column flex-sm-row align-center">
|
<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 />
|
<homework-template-card border />
|
||||||
</v-tabs-window-item>
|
</v-tabs-window-item>
|
||||||
|
|
||||||
|
<v-tabs-window-item value="background">
|
||||||
|
<background-settings-card border />
|
||||||
|
</v-tabs-window-item>
|
||||||
|
|
||||||
|
|
||||||
<v-tabs-window-item value="developer">
|
<v-tabs-window-item value="developer">
|
||||||
<settings-card
|
<settings-card
|
||||||
border
|
border
|
||||||
@ -328,6 +333,7 @@ import KvDatabaseCard from "@/components/settings/cards/KvDatabaseCard.vue";
|
|||||||
import HitokotoSettings from "@/components/HitokotoSettings.vue";
|
import HitokotoSettings from "@/components/HitokotoSettings.vue";
|
||||||
import NotificationSoundSettings from "@/components/settings/NotificationSoundSettings.vue";
|
import NotificationSoundSettings from "@/components/settings/NotificationSoundSettings.vue";
|
||||||
import NoiseSettingsCard from "@/components/settings/cards/NoiseSettingsCard.vue";
|
import NoiseSettingsCard from "@/components/settings/cards/NoiseSettingsCard.vue";
|
||||||
|
import BackgroundSettingsCard from "@/components/settings/cards/BackgroundSettingsCard.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Settings",
|
name: "Settings",
|
||||||
@ -352,6 +358,7 @@ export default {
|
|||||||
HitokotoSettings,
|
HitokotoSettings,
|
||||||
NotificationSoundSettings,
|
NotificationSoundSettings,
|
||||||
NoiseSettingsCard,
|
NoiseSettingsCard,
|
||||||
|
BackgroundSettingsCard,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const {mobile} = useDisplay();
|
const {mobile} = useDisplay();
|
||||||
@ -490,6 +497,12 @@ export default {
|
|||||||
value: "randomPicker",
|
value: "randomPicker",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: "背景",
|
||||||
|
icon: "mdi-image",
|
||||||
|
value: "background",
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: "开发者",
|
title: "开发者",
|
||||||
icon: "mdi-developer-board",
|
icon: "mdi-developer-board",
|
||||||
|
|||||||
@ -1,24 +1,134 @@
|
|||||||
/**
|
/**
|
||||||
* Server rotation utility for Classworks Cloud provider
|
* 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";
|
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 = [
|
const CLASSWORKS_CLOUD_SERVERS = [
|
||||||
"https://kv-service.houlang.cloud",
|
"https://kv-service.houlang.cloud",
|
||||||
"https://kv-service.wuyuan.dev",
|
"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
|
* @param {string} provider - The provider type
|
||||||
* @returns {string[]} Array of server URLs to try
|
* @returns {string[]} Array of server URLs to try
|
||||||
*/
|
*/
|
||||||
export function getServerList(provider) {
|
export function getServerList(provider) {
|
||||||
if (provider === "classworkscloud") {
|
if (provider === "classworkscloud") {
|
||||||
return [...CLASSWORKS_CLOUD_SERVERS];
|
return getOrderedCloudServers();
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other providers, use the configured domain
|
// For other providers, use the configured domain
|
||||||
@ -27,31 +137,37 @@ export function getServerList(provider) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try an operation with server rotation fallback
|
* Try an operation with server rotation fallback.
|
||||||
* @param {Function} operation - Async function that takes a serverUrl and returns a promise
|
*
|
||||||
* @param {Object} options - Options
|
* Key behaviours:
|
||||||
* @param {string} options.provider - Provider type (optional, defaults to current setting)
|
* - HTTP 4xx responses (e.g. 404) are treated as valid server replies and are
|
||||||
* @param {Function} options.onServerTried - Callback called when a server is tried (optional)
|
* propagated immediately without trying the next server.
|
||||||
* Receives: { url, status, tried } where tried is a snapshot of attempts
|
* - 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
|
* @returns {Promise} Result from the first successful server, or throws the last error
|
||||||
*/
|
*/
|
||||||
export async function tryWithRotation(operation, options = {}) {
|
export async function tryWithRotation(operation, options = {}) {
|
||||||
const provider = options.provider || getSetting("server.provider");
|
const provider = options.provider || getSetting("server.provider");
|
||||||
const onServerTried = options.onServerTried;
|
const onServerTried = options.onServerTried;
|
||||||
const hasCallback = typeof onServerTried === 'function';
|
const hasCallback = typeof onServerTried === "function";
|
||||||
|
|
||||||
const servers = getServerList(provider);
|
const servers = getServerList(provider);
|
||||||
const triedServers = [];
|
const triedServers = [];
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
|
|
||||||
for (const serverUrl of servers) {
|
for (const serverUrl of servers) {
|
||||||
try {
|
triedServers.push({ url: serverUrl, status: "trying" });
|
||||||
triedServers.push({ url: serverUrl, status: "trying" });
|
if (hasCallback) {
|
||||||
if (hasCallback) {
|
onServerTried({ url: serverUrl, status: "trying", tried: [...triedServers] });
|
||||||
// Provide a snapshot to prevent callback from mutating internal state
|
}
|
||||||
onServerTried({ url: serverUrl, status: "trying", tried: [...triedServers] });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await operation(serverUrl);
|
const result = await operation(serverUrl);
|
||||||
|
|
||||||
triedServers[triedServers.length - 1].status = "success";
|
triedServers[triedServers.length - 1].status = "success";
|
||||||
@ -59,8 +175,22 @@ export async function tryWithRotation(operation, options = {}) {
|
|||||||
onServerTried({ url: serverUrl, status: "success", tried: [...triedServers] });
|
onServerTried({ url: serverUrl, status: "success", tried: [...triedServers] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember this server as the preferred one for future requests
|
||||||
|
if (provider === "classworkscloud") {
|
||||||
|
setCachedPreference(serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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;
|
lastError = error;
|
||||||
triedServers[triedServers.length - 1].status = "failed";
|
triedServers[triedServers.length - 1].status = "failed";
|
||||||
triedServers[triedServers.length - 1].error = error.message || String(error);
|
triedServers[triedServers.length - 1].error = error.message || String(error);
|
||||||
@ -68,12 +198,11 @@ export async function tryWithRotation(operation, options = {}) {
|
|||||||
onServerTried({ url: serverUrl, status: "failed", error, tried: [...triedServers] });
|
onServerTried({ url: serverUrl, status: "failed", error, tried: [...triedServers] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue to next server
|
console.warn(`Server ${serverUrl} failed, trying next:`, error.message);
|
||||||
console.warn(`Server ${serverUrl} failed:`, error.message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All servers failed
|
// All servers exhausted
|
||||||
console.error("All servers failed. Tried:", triedServers);
|
console.error("All servers failed. Tried:", triedServers);
|
||||||
const error = lastError || new Error("All servers failed");
|
const error = lastError || new Error("All servers failed");
|
||||||
error.triedServers = triedServers;
|
error.triedServers = triedServers;
|
||||||
@ -81,23 +210,23 @@ export async function tryWithRotation(operation, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the effective server URL for the current provider
|
* Get the effective server URL for the current provider.
|
||||||
* For classworkscloud, returns the first server in the list
|
* For classworkscloud, returns the cached preferred server (fastest known),
|
||||||
* For other providers, returns the configured domain
|
* falling back to the first server in the list.
|
||||||
* @returns {string} Server URL
|
* @returns {string} Server URL
|
||||||
*/
|
*/
|
||||||
export function getEffectiveServerUrl() {
|
export function getEffectiveServerUrl() {
|
||||||
const provider = getSetting("server.provider");
|
const provider = getSetting("server.provider");
|
||||||
|
|
||||||
if (provider === "classworkscloud") {
|
if (provider === "classworkscloud") {
|
||||||
return CLASSWORKS_CLOUD_SERVERS[0];
|
return serverPreference.preferred || CLASSWORKS_CLOUD_SERVERS[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return getSetting("server.domain") || "";
|
return getSetting("server.domain") || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if rotation is enabled for the current provider
|
* Check if rotation is enabled for the current provider.
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export function isRotationEnabled() {
|
export function isRotationEnabled() {
|
||||||
|
|||||||
@ -62,6 +62,9 @@ async function initializeStorage() {
|
|||||||
// 存储所有设置的localStorage键名
|
// 存储所有设置的localStorage键名
|
||||||
const SETTINGS_STORAGE_KEY = "Classworks_settings";
|
const SETTINGS_STORAGE_KEY = "Classworks_settings";
|
||||||
|
|
||||||
|
// 同标签页设置变化事件名
|
||||||
|
const SETTINGS_CHANGED_EVENT = "classworks:settings:changed";
|
||||||
|
|
||||||
|
|
||||||
// 新增: Classworks云端存储的默认设置
|
// 新增: Classworks云端存储的默认设置
|
||||||
const classworksCloudDefaults = {
|
const classworksCloudDefaults = {
|
||||||
@ -127,6 +130,12 @@ const settingsDefinitions = {
|
|||||||
description: "启用时间卡片",
|
description: "启用时间卡片",
|
||||||
icon: "mdi-clock-outline",
|
icon: "mdi-clock-outline",
|
||||||
},
|
},
|
||||||
|
"timeCard.use12h": {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
description: "使用 12 小时制显示时间",
|
||||||
|
icon: "mdi-clock-time-six-outline",
|
||||||
|
},
|
||||||
|
|
||||||
// 一言设置
|
// 一言设置
|
||||||
"hitokoto.enabled": {
|
"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": {
|
"notification.singleSound": {
|
||||||
type: "string",
|
type: "string",
|
||||||
@ -668,6 +711,13 @@ class SettingsManagerClass {
|
|||||||
this.saveSettings();
|
this.saveSettings();
|
||||||
this.logSettingsChange(key, oldValue, value);
|
this.logSettingsChange(key, oldValue, value);
|
||||||
|
|
||||||
|
// 触发同标签页内的设置变化事件
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent(SETTINGS_CHANGED_EVENT, {
|
||||||
|
detail: { key, value },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// 为了保持向后兼容,同时更新旧的localStorage键
|
// 为了保持向后兼容,同时更新旧的localStorage键
|
||||||
const legacyKey = definition.legacyKey;
|
const legacyKey = definition.legacyKey;
|
||||||
if (legacyKey && typeof localStorage !== "undefined") {
|
if (legacyKey && typeof localStorage !== "undefined") {
|
||||||
@ -715,6 +765,13 @@ class SettingsManagerClass {
|
|||||||
|
|
||||||
this.settingsCache[key] = definition.default;
|
this.settingsCache[key] = definition.default;
|
||||||
this.saveSettings();
|
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 () => {
|
if (typeof window === "undefined") return () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handler = (event) => {
|
const storageHandler = (event) => {
|
||||||
if (event.key === SETTINGS_STORAGE_KEY) {
|
if (event.key === SETTINGS_STORAGE_KEY) {
|
||||||
this.settingsCache = JSON.parse(event.newValue);
|
this.settingsCache = JSON.parse(event.newValue);
|
||||||
callback(this.settingsCache);
|
callback(this.settingsCache, null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("storage", handler);
|
const customHandler = (event) => {
|
||||||
return () => window.removeEventListener("storage", handler);
|
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