1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-05-13 19:35:07 +00:00

Merge pull request #51 from ZeroCatDev/copilot/add-custom-background-image

feat: custom background image with frosted glass effect
This commit is contained in:
孙悟元 2026-04-11 21:59:38 +08:00 committed by GitHub
commit dd68ef7423
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 578 additions and 7 deletions

View File

@ -1,5 +1,11 @@
<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 mode="out-in" name="md3">
@ -12,24 +18,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 迁移,确保全局可用且仅加载一次) */
@ -52,4 +108,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>

View File

@ -0,0 +1,427 @@
<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_uhd.php' },
{ label: '随机风景', url: 'https://picsum.photos/1920/1080?random=1' },
{ label: '随机自然', url: 'https://source.unsplash.com/1920x1080/?nature' },
];
const MAX_IMAGE_SIZE_MB = 2;
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

@ -195,6 +195,10 @@
<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 icon="mdi-developer-board" title="开发者选项">
@ -280,6 +284,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",
@ -304,6 +309,7 @@ export default {
HitokotoSettings,
NotificationSoundSettings,
NoiseSettingsCard,
BackgroundSettingsCard,
},
setup() {
const {mobile} = useDisplay();
@ -442,6 +448,12 @@ export default {
value: "randomPicker",
},
{
title: "背景",
icon: "mdi-image",
value: "background",
},
{
title: "开发者",
icon: "mdi-developer-board",

View File

@ -62,6 +62,9 @@ async function initializeStorage() {
// 存储所有设置的localStorage键名
const SETTINGS_STORAGE_KEY = "Classworks_settings";
// 同标签页设置变化事件名
const SETTINGS_CHANGED_EVENT = "classworks:settings:changed";
// 新增: Classworks云端存储的默认设置
const classworksCloudDefaults = {
@ -441,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",
@ -674,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") {
@ -721,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 },
}));
}
}
/**
@ -743,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);
};
}
/**