1
0
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:
Sunwuyuan 2026-05-23 19:24:03 +08:00
commit 5d0b0bb175
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
11 changed files with 936 additions and 54 deletions

View File

@ -62,6 +62,8 @@ export default [
// Web Workers // Web Workers
Worker: 'readonly', Worker: 'readonly',
SharedWorker: 'readonly', SharedWorker: 'readonly',
// Fetch API
AbortController: 'readonly',
}, },
}, },
} }

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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) {

View File

@ -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()

View File

@ -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">

View 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">
支持 JPGPNGWebPGIF建议小于 {{ 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>

View File

@ -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",

View File

@ -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() {

View File

@ -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);
};
} }
/** /**