1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-07-01 16:49:22 +00:00

pwa,设置项,自动刷新

This commit is contained in:
SunWuyuan 2025-04-04 21:40:24 +08:00
parent 8e4d29afa3
commit 291c593178
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
32 changed files with 8794 additions and 965 deletions

1
dev-dist/registerSW.js Normal file
View File

@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

View File

92
dev-dist/sw.js Normal file
View File

@ -0,0 +1,92 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-86c9b217'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "suppress-warnings.js",
"revision": "d41d8cd98f00b204e9800998ecf8427e"
}, {
"url": "index.html",
"revision": "0.6m682f8mvag"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));

3391
dev-dist/workbox-86c9b217.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,10 @@
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Classworks 作业板</title>
<meta name="description" content="记录,查看并同步作业" />
<link rel="apple-touch-icon" href="/image/apple-touch-icon.png" sizes="180x180" />
<link rel="mask-icon" href="/image/mask-icon.svg" color="#212121" />
<meta name="theme-color" content="#212121" />
<script defer src="https://umami.wuyuan.dev/script.js" data-website-id="e3f8ed7a-4db4-4081-aaf4-45396b1f479c"></script>
</head>
<body>

View File

@ -23,6 +23,7 @@
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"@vite-pwa/assets-generator": "^1.0.0",
"@vitejs/plugin-vue": "^5.0.5",
"eslint": "^9.14.0",
"eslint-plugin-import": "^2.29.1",
@ -38,6 +39,7 @@
"unplugin-vue-components": "^0.27.2",
"unplugin-vue-router": "^0.10.0",
"vite": "^5.4.0",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "^2.0.3",
"vue-router": "^4.4.0"

3358
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

BIN
public/image/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

23
public/image/logo.svg Normal file
View File

@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256" viewBox="0 0 256 256" fill="none">
<g clip-path="url(#clip-path-74_1)">
<path fill="#FFFFFF" d="M0 256L256 256L256 0L0 0L0 256Z">
</path>
<rect x="0" y="0" width="256" height="128" fill="#D8C4A0" >
</rect>
<rect x="0" y="128" width="256" height="128" fill="#F5E0BB" >
</rect>
<path d="M28 228L128 128L228 128L128 228L28 228Z" fill-rule="evenodd" fill="#241A04" >
</path>
<path d="M28 128L128 28L228 28L128 128L28 128Z" fill-rule="evenodd" fill="#52452A" >
</path>
<g >
<path fill="#000000" d="M-3049.01 2467.94L-3043.48 2467.94L-3043.48 2466.99L-3045.92 2466.99C-3046.36 2466.99 -3046.9 2467.04 -3047.36 2467.08C-3045.29 2465.12 -3043.9 2463.33 -3043.9 2461.57C-3043.9 2460.01 -3044.9 2458.99 -3046.47 2458.99C-3047.58 2458.99 -3048.35 2459.49 -3049.06 2460.27L-3048.43 2460.9C-3047.93 2460.31 -3047.32 2459.88 -3046.6 2459.88C-3045.51 2459.88 -3044.98 2460.61 -3044.98 2461.62C-3044.98 2463.13 -3046.25 2464.88 -3049.01 2467.29L-3049.01 2467.94ZM-3039.27 2468.1C-3037.9 2468.1 -3036.74 2466.95 -3036.74 2465.24C-3036.74 2463.39 -3037.7 2462.48 -3039.19 2462.48C-3039.87 2462.48 -3040.64 2462.88 -3041.18 2463.54C-3041.13 2460.81 -3040.13 2459.89 -3038.91 2459.89C-3038.38 2459.89 -3037.85 2460.15 -3037.52 2460.56L-3036.89 2459.89C-3037.39 2459.36 -3038.04 2458.99 -3038.96 2458.99C-3040.66 2458.99 -3042.21 2460.3 -3042.21 2463.74C-3042.21 2466.65 -3040.95 2468.1 -3039.27 2468.1ZM-3041.15 2464.41C-3040.58 2463.6 -3039.91 2463.3 -3039.36 2463.3C-3038.3 2463.3 -3037.78 2464.05 -3037.78 2465.24C-3037.78 2466.44 -3038.43 2467.23 -3039.27 2467.23C-3040.37 2467.23 -3041.03 2466.24 -3041.15 2464.41ZM-3035.17 2467.94L-3030.34 2467.94L-3030.34 2467.03L-3032.1 2467.03L-3032.1 2459.15L-3032.95 2459.15C-3033.43 2459.42 -3033.99 2459.62 -3034.77 2459.77L-3034.77 2460.47L-3033.2 2460.47L-3033.2 2467.03L-3035.17 2467.03L-3035.17 2467.94ZM-3029.51 2467.94L-3028.4 2467.94L-3027.54 2465.25L-3024.33 2465.25L-3023.49 2467.94L-3022.31 2467.94L-3025.3 2459.15L-3026.54 2459.15L-3029.51 2467.94ZM-3027.27 2464.38L-3026.84 2463.02C-3026.52 2462.02 -3026.24 2461.08 -3025.96 2460.04L-3025.91 2460.04C-3025.62 2461.06 -3025.35 2462.02 -3025.02 2463.02L-3024.6 2464.38L-3027.27 2464.38ZM-3018.93 2468.1C-3017.26 2468.1 -3016.19 2466.58 -3016.19 2463.51C-3016.19 2460.47 -3017.26 2458.99 -3018.93 2458.99C-3020.61 2458.99 -3021.67 2460.47 -3021.67 2463.51C-3021.67 2466.58 -3020.61 2468.1 -3018.93 2468.1ZM-3018.93 2467.21C-3019.93 2467.21 -3020.61 2466.09 -3020.61 2463.51C-3020.61 2460.95 -3019.93 2459.85 -3018.93 2459.85C-3017.93 2459.85 -3017.25 2460.95 -3017.25 2463.51C-3017.25 2466.09 -3017.93 2467.21 -3018.93 2467.21ZM-3012.27 2468.1C-3010.6 2468.1 -3009.53 2466.58 -3009.53 2463.51C-3009.53 2460.47 -3010.6 2458.99 -3012.27 2458.99C-3013.95 2458.99 -3015.01 2460.47 -3015.01 2463.51C-3015.01 2466.58 -3013.95 2468.1 -3012.27 2468.1ZM-3012.27 2467.21C-3013.27 2467.21 -3013.95 2466.09 -3013.95 2463.51C-3013.95 2460.95 -3013.27 2459.85 -3012.27 2459.85C-3011.27 2459.85 -3010.59 2460.95 -3010.59 2463.51C-3010.59 2466.09 -3011.27 2467.21 -3012.27 2467.21Z">
</path>
</g>
</g>
<defs>
<clipPath id="clip-path-74_1">
<path d="M0 256L256 256L256 0L0 0L0 256Z" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/image/pwa-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

BIN
src/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

View File

@ -1,6 +1,23 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M261.126 140.65L164.624 307.732L256.001 466L377.028 256.5L498.001 47H315.192L261.126 140.65Z" fill="#1697F6"/>
<path d="M135.027 256.5L141.365 267.518L231.64 111.178L268.731 47H256H14L135.027 256.5Z" fill="#AEDDFF"/>
<path d="M315.191 47C360.935 197.446 256 466 256 466L164.624 307.732L315.191 47Z" fill="#1867C0"/>
<path d="M268.731 47C76.0026 47 141.366 267.518 141.366 267.518L268.731 47Z" fill="#7BC6FF"/>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256" viewBox="0 0 256 256" fill="none">
<g clip-path="url(#clip-path-74_1)">
<path fill="#FFFFFF" d="M0 256L256 256L256 0L0 0L0 256Z">
</path>
<rect x="0" y="0" width="256" height="128" fill="#D8C4A0" >
</rect>
<rect x="0" y="128" width="256" height="128" fill="#F5E0BB" >
</rect>
<path d="M28 228L128 128L228 128L128 228L28 228Z" fill-rule="evenodd" fill="#241A04" >
</path>
<path d="M28 128L128 28L228 28L128 128L28 128Z" fill-rule="evenodd" fill="#52452A" >
</path>
<g >
<path fill="#000000" d="M-3049.01 2467.94L-3043.48 2467.94L-3043.48 2466.99L-3045.92 2466.99C-3046.36 2466.99 -3046.9 2467.04 -3047.36 2467.08C-3045.29 2465.12 -3043.9 2463.33 -3043.9 2461.57C-3043.9 2460.01 -3044.9 2458.99 -3046.47 2458.99C-3047.58 2458.99 -3048.35 2459.49 -3049.06 2460.27L-3048.43 2460.9C-3047.93 2460.31 -3047.32 2459.88 -3046.6 2459.88C-3045.51 2459.88 -3044.98 2460.61 -3044.98 2461.62C-3044.98 2463.13 -3046.25 2464.88 -3049.01 2467.29L-3049.01 2467.94ZM-3039.27 2468.1C-3037.9 2468.1 -3036.74 2466.95 -3036.74 2465.24C-3036.74 2463.39 -3037.7 2462.48 -3039.19 2462.48C-3039.87 2462.48 -3040.64 2462.88 -3041.18 2463.54C-3041.13 2460.81 -3040.13 2459.89 -3038.91 2459.89C-3038.38 2459.89 -3037.85 2460.15 -3037.52 2460.56L-3036.89 2459.89C-3037.39 2459.36 -3038.04 2458.99 -3038.96 2458.99C-3040.66 2458.99 -3042.21 2460.3 -3042.21 2463.74C-3042.21 2466.65 -3040.95 2468.1 -3039.27 2468.1ZM-3041.15 2464.41C-3040.58 2463.6 -3039.91 2463.3 -3039.36 2463.3C-3038.3 2463.3 -3037.78 2464.05 -3037.78 2465.24C-3037.78 2466.44 -3038.43 2467.23 -3039.27 2467.23C-3040.37 2467.23 -3041.03 2466.24 -3041.15 2464.41ZM-3035.17 2467.94L-3030.34 2467.94L-3030.34 2467.03L-3032.1 2467.03L-3032.1 2459.15L-3032.95 2459.15C-3033.43 2459.42 -3033.99 2459.62 -3034.77 2459.77L-3034.77 2460.47L-3033.2 2460.47L-3033.2 2467.03L-3035.17 2467.03L-3035.17 2467.94ZM-3029.51 2467.94L-3028.4 2467.94L-3027.54 2465.25L-3024.33 2465.25L-3023.49 2467.94L-3022.31 2467.94L-3025.3 2459.15L-3026.54 2459.15L-3029.51 2467.94ZM-3027.27 2464.38L-3026.84 2463.02C-3026.52 2462.02 -3026.24 2461.08 -3025.96 2460.04L-3025.91 2460.04C-3025.62 2461.06 -3025.35 2462.02 -3025.02 2463.02L-3024.6 2464.38L-3027.27 2464.38ZM-3018.93 2468.1C-3017.26 2468.1 -3016.19 2466.58 -3016.19 2463.51C-3016.19 2460.47 -3017.26 2458.99 -3018.93 2458.99C-3020.61 2458.99 -3021.67 2460.47 -3021.67 2463.51C-3021.67 2466.58 -3020.61 2468.1 -3018.93 2468.1ZM-3018.93 2467.21C-3019.93 2467.21 -3020.61 2466.09 -3020.61 2463.51C-3020.61 2460.95 -3019.93 2459.85 -3018.93 2459.85C-3017.93 2459.85 -3017.25 2460.95 -3017.25 2463.51C-3017.25 2466.09 -3017.93 2467.21 -3018.93 2467.21ZM-3012.27 2468.1C-3010.6 2468.1 -3009.53 2466.58 -3009.53 2463.51C-3009.53 2460.47 -3010.6 2458.99 -3012.27 2458.99C-3013.95 2458.99 -3015.01 2460.47 -3015.01 2463.51C-3015.01 2466.58 -3013.95 2468.1 -3012.27 2468.1ZM-3012.27 2467.21C-3013.27 2467.21 -3013.95 2466.09 -3013.95 2463.51C-3013.95 2460.95 -3013.27 2459.85 -3012.27 2459.85C-3011.27 2459.85 -3010.59 2460.95 -3010.59 2463.51C-3010.59 2466.09 -3011.27 2467.21 -3012.27 2467.21Z">
</path>
</g>
</g>
<defs>
<clipPath id="clip-path-74_1">
<path d="M0 256L256 256L256 0L0 0L0 256Z" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 526 B

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,523 @@
<template>
<v-dialog v-model="dialog" max-width="600" fullscreen-breakpoint="sm">
<v-card class="random-picker-card">
<v-card-title class="text-h5 d-flex align-center">
<v-icon icon="mdi-account-question" class="mr-2" />
随机点名
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="dialog = false" />
</v-card-title>
<v-card-text v-if="!isPickingStarted" class="text-center py-6">
<div class="text-h6 mb-4">请选择抽取人数</div>
<div class="d-flex justify-center align-center counter-container">
<v-btn size="x-large" icon="mdi-minus" variant="tonal" color="primary" :disabled="count <= 1"
@click="decrementCount" class="counter-btn" />
<div class="count-display mx-8">
<span class="text-h2 font-weight-bold">{{ count }}</span>
<span class="text-subtitle-1 ml-2"></span>
</div>
<v-btn size="x-large" icon="mdi-plus" variant="tonal" color="primary" :disabled="count >= maxAllowedCount"
@click="incrementCount" class="counter-btn" />
</div>
<div class="mt-4">
<v-btn size="x-large" color="primary" prepend-icon="mdi-dice-multiple" @click="startPicking"
:disabled="filteredStudents.length === 0" class="start-btn">
开始抽取
</v-btn>
</div>
<div v-if="filteredStudents.length === 0" class="mt-4 text-error">
没有可抽取的学生请调整过滤选项
</div>
<div class="mt-4 text-caption">
当前可抽取学生: {{ filteredStudents.length }}
<v-tooltip location="bottom">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" icon="mdi-information-outline" size="small" class="ml-1" />
</template>
<div class="pa-2">
<div v-if="tempFilters.excludeAbsent"> 已排除请假学生 ({{ absentCount }})</div>
<div v-if="tempFilters.excludeLate"> 已排除迟到学生 ({{ lateCount }})</div>
<div v-if="tempFilters.excludeExcluded"> 已排除不参与学生 ({{ excludedCount }})</div>
</div>
</v-tooltip><!-- 添加临时过滤选项 -->
<div class="d-flex flex-wrap justify-center gap-2 mt-4">
<v-chip :color="tempFilters.excludeLate ? 'warning' : 'default'"
:variant="tempFilters.excludeLate ? 'elevated' : 'text'"
@click="tempFilters.excludeLate = !tempFilters.excludeLate" prepend-icon="mdi-clock-alert"
class="filter-chip">
{{ tempFilters.excludeLate ? '排除' : '包含' }}迟到学生
</v-chip>
<v-chip :color="tempFilters.excludeAbsent ? 'error' : 'default'"
:variant="tempFilters.excludeAbsent ? 'elevated' : 'text'"
@click="tempFilters.excludeAbsent = !tempFilters.excludeAbsent" prepend-icon="mdi-account-off"
class="filter-chip">
{{ tempFilters.excludeAbsent ? '排除' : '包含' }}请假学生
</v-chip>
<v-chip :color="tempFilters.excludeExcluded ? 'grey' : 'default'"
:variant="tempFilters.excludeExcluded ? 'elevated' : 'text'"
@click="tempFilters.excludeExcluded = !tempFilters.excludeExcluded" prepend-icon="mdi-account-cancel"
class="filter-chip">
{{ tempFilters.excludeExcluded ? '排除' : '包含' }}不参与学生
</v-chip>
</div>
</div>
</v-card-text>
<v-card-text v-else class="text-center py-6">
<div v-if="isAnimating" class="animation-container">
<div class="animation-wrapper">
<transition-group name="shuffle" tag="div" class="shuffle-container">
<div v-for="(student, index) in animationStudents" :key="student.id" class="student-item"
:class="{ 'highlighted': highlightedIndices.includes(index) }">
{{ student.name }}
</div>
</transition-group>
</div>
</div>
<div v-else class="result-container">
<div class="text-h6 mb-4">抽取结果</div>
<v-card v-for="(student, index) in pickedStudents" :key="index" variant="outlined" color="primary"
class="mb-2 result-card">
<v-card-text class="text-h4 text-center py-4 d-flex align-center justify-center">
{{ student }}
<v-btn icon="mdi-refresh" variant="text" size="small" class="ml-2 refresh-btn"
@click="refreshSingleStudent(index)" :disabled="remainingStudents.length === 0"
:title="remainingStudents.length === 0 ? '没有更多可用学生' : '重新抽取此学生'" />
</v-card-text>
</v-card>
<div class="mt-8 d-flex justify-center">
<v-btn color="primary" prepend-icon="mdi-refresh" @click="resetPicker" size="large" class="mx-2">
重新抽取
</v-btn>
<v-btn color="grey" variant="outlined" @click="dialog = false" size="large" class="mx-2">
关闭
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script>
import { getSetting } from '@/utils/settings';
export default {
name: 'RandomPicker',
props: {
studentList: {
type: Array,
required: true
},
attendance: {
type: Object,
required: true,
default: () => ({ absent: [], late: [], exclude: [] })
}
},
data() {
return {
dialog: false,
count: getSetting('randomPicker.defaultCount'),
isPickingStarted: false,
isAnimating: false,
pickedStudents: [],
animationStudents: [],
highlightedIndices: [],
animationTimer: null,
getSetting,
//
tempFilters: {
excludeAbsent: getSetting('randomPicker.excludeAbsent'),
excludeLate: getSetting('randomPicker.excludeLate'),
excludeExcluded: getSetting('randomPicker.excludeExcluded')
}
};
},
computed: {
//
absentCount() {
return this.attendance.absent ? this.attendance.absent.length : 0;
},
lateCount() {
return this.attendance.late ? this.attendance.late.length : 0;
},
excludedCount() {
return this.attendance.exclude ? this.attendance.exclude.length : 0;
},
// 使
filteredStudents() {
if (!this.studentList || !this.studentList.length) return [];
return this.studentList.filter(student => {
//
if (this.tempFilters.excludeAbsent && this.attendance.absent.includes(student)) {
return false;
}
if (this.tempFilters.excludeLate && this.attendance.late.includes(student)) {
return false;
}
if (this.tempFilters.excludeExcluded && this.attendance.exclude.includes(student)) {
return false;
}
return true;
});
},
// availableStudents 使
availableStudents() {
return this.filteredStudents;
},
maxAllowedCount() {
return Math.min(10, this.filteredStudents.length);
},
//
remainingStudents() {
return this.filteredStudents.filter(student => !this.pickedStudents.includes(student));
}
},
watch: {
dialog(newVal) {
if (newVal) {
//
this.count = getSetting('randomPicker.defaultCount');
this.isPickingStarted = false;
this.isAnimating = false;
this.pickedStudents = [];
//
this.tempFilters = {
excludeAbsent: getSetting('randomPicker.excludeAbsent'),
excludeLate: getSetting('randomPicker.excludeLate'),
excludeExcluded: getSetting('randomPicker.excludeExcluded')
};
} else {
//
if (this.animationTimer) {
clearTimeout(this.animationTimer);
this.animationTimer = null;
}
}
},
// count
'tempFilters': {
handler() {
if (this.count > this.maxAllowedCount) {
this.count = Math.max(1, this.maxAllowedCount);
}
},
deep: true
}
},
methods: {
open() {
this.dialog = true;
},
incrementCount() {
if (this.count < this.maxAllowedCount) {
this.count++;
}
},
decrementCount() {
if (this.count > 1) {
this.count--;
}
},
startPicking() {
if (this.filteredStudents.length === 0) return;
this.isPickingStarted = true;
if (getSetting('randomPicker.animation')) {
this.startAnimation();
} else {
this.finishPicking();
}
},
startAnimation() {
this.isAnimating = true;
// ID便
this.animationStudents = this.filteredStudents.map((name, index) => ({
id: `student-${index}`,
name
}));
//
this.animateHighlight();
},
animateHighlight() {
const totalSteps = 5; //
let currentStep = 0;
const intervalTime = 50; //
const animate = () => {
//
this.highlightedIndices = [];
//
const indices = [];
for (let i = 0; i < this.count; i++) {
let randomIndex;
do {
randomIndex = Math.floor(Math.random() * this.animationStudents.length);
} while (indices.includes(randomIndex));
indices.push(randomIndex);
}
this.highlightedIndices = indices;
currentStep++;
// 使
const nextInterval = intervalTime + (currentStep * 20);
if (currentStep < totalSteps) {
this.animationTimer = setTimeout(animate, nextInterval);
} else {
//
setTimeout(() => {
this.finishPicking();
}, 500);
}
};
//
animate();
},
finishPicking() {
this.isAnimating = false;
//
const shuffled = [...this.filteredStudents].sort(() => 0.5 - Math.random());
this.pickedStudents = shuffled.slice(0, this.count);
},
resetPicker() {
this.isPickingStarted = false;
this.isAnimating = false;
this.pickedStudents = [];
if (this.animationTimer) {
clearTimeout(this.animationTimer);
this.animationTimer = null;
}
},
//
refreshSingleStudent(index) {
if (this.remainingStudents.length === 0) return;
//
const randomIndex = Math.floor(Math.random() * this.remainingStudents.length);
const newStudent = this.remainingStudents[randomIndex];
//
this.pickedStudents[index] = newStudent;
//
const resultCards = document.querySelectorAll('.result-card');
if (resultCards[index]) {
resultCards[index].classList.add('refresh-animation');
setTimeout(() => {
resultCards[index].classList.remove('refresh-animation');
}, 500);
}
}
}
}
</script>
<style lang="scss" scoped>
.random-picker-card {
overflow: hidden;
}
.counter-container {
margin: 2rem 0;
}
.counter-btn {
width: 64px;
height: 64px;
border-radius: 50%;
}
.count-display {
min-width: 100px;
text-align: center;
}
.start-btn {
min-width: 200px;
height: 64px;
border-radius: 32px;
font-size: 1.2rem;
}
//
.filter-options-card {
max-width: 450px;
margin: 0 auto;
}
.filter-chip {
cursor: pointer;
transition: all 0.2s ease;
&:active {
transform: scale(0.95);
}
}
//
.student-list-tooltip {
max-height: 200px;
overflow-y: auto;
margin-top: 5px;
font-size: 0.9em;
}
.animation-container {
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.animation-wrapper {
width: 100%;
max-width: 400px;
margin: 0 auto;
}
.shuffle-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.student-item {
padding: 10px 15px;
background-color: rgba(var(--v-theme-surface-variant), 0.7);
border-radius: 8px;
transition: all 0.3s ease;
font-size: 1.2rem;
&.highlighted {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
transform: scale(1.1);
font-weight: bold;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
}
.result-container {
min-height: 300px;
}
.result-card {
max-width: 400px;
margin: 0 auto;
transition: transform 0.3s ease;
&:hover {
transform: translateY(-4px);
.refresh-btn {
opacity: 1;
}
}
}
.refresh-btn {
opacity: 0.7;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
}
//
@keyframes refresh-pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 rgba(var(--v-theme-primary), 0.5);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 15px rgba(var(--v-theme-primary), 0.7);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 rgba(var(--v-theme-primary), 0.5);
}
}
.refresh-animation {
animation: refresh-pulse 0.5s ease;
}
//
.shuffle-enter-active,
.shuffle-leave-active {
transition: all 0.5s ease;
}
.shuffle-enter-from,
.shuffle-leave-to {
opacity: 0;
transform: translateY(30px);
}
.shuffle-move {
transition: transform 0.5s ease;
}
//
@media (hover: none) {
.counter-btn,
.start-btn {
min-height: 72px;
}
.student-item {
padding: 12px 20px;
font-size: 1.4rem;
}
.refresh-btn {
opacity: 1;
min-width: 36px;
min-height: 36px;
}
.filter-chip {
min-height: 40px;
font-size: 1rem;
}
}
</style>

View File

@ -0,0 +1,80 @@
<template>
<v-card :border="border" class="setting-group">
<v-card-title v-if="title" class="d-flex align-center">
<v-icon v-if="icon" :icon="icon" class="mr-2" />
{{ title }}
</v-card-title>
<v-card-subtitle v-if="description">
{{ description }}
</v-card-subtitle>
<v-card-text>
<v-list>
<slot>
<!-- 默认插槽用于放置 SettingItem 组件 -->
</slot>
</v-list>
</v-card-text>
<v-card-actions v-if="$slots.actions">
<slot name="actions"></slot>
</v-card-actions>
</v-card>
</template>
<script>
export default {
name: 'SettingGroup',
props: {
/**
* 设置组的标题
*/
title: {
type: String,
default: null
},
/**
* 设置组的描述
*/
description: {
type: String,
default: null
},
/**
* 设置组的图标
*/
icon: {
type: String,
default: null
},
/**
* 是否显示边框
*/
border: {
type: Boolean,
default: false
}
},
methods: {
onSettingUpdate(key, value) {
this.$emit('update', key, value);
},
onSettingError(key) {
this.$emit('error', key);
}
}
};
</script>
<style scoped>
.setting-group {
margin-bottom: 16px;
}
</style>

View File

@ -0,0 +1,457 @@
<template>
<v-list-item class="setting-item" :disabled="disabled">
<template #prepend>
<v-icon :icon="settingIcon" />
</template>
<v-list-item-title class="text-wrap">
{{ displayTitle }}
</v-list-item-title>
<v-list-item-subtitle class="d-flex align-center text-wrap">
<span class="text-caption text-grey-darken-1">{{ settingKey }}</span>
</v-list-item-subtitle>
<template #append >
<div class="d-flex flex-column flex-sm-row align-center">
<div v-if="type !== 'string' || hasOptions" class="me-2">
<!-- 根据设置类型渲染不同的控件 -->
<v-switch v-if="type === 'boolean'" v-model="localValue" density="comfortable" hide-details :disabled="disabled"
@update:model-value="updateSetting" />
<v-select v-else-if="type === 'string' && hasOptions" v-model="localValue" :items="selectOptions"
density="compact" hide-details :disabled="disabled" class="setting-select" variant="outlined" bg-color="surface"
@update:model-value="updateSetting" item-title="title" item-value="value" />
<div v-else-if="type === 'number'" class="d-flex align-center">
<v-btn icon="mdi-minus" size="small" variant="text" :disabled="disabled || localValue <= minValue"
@click="adjustValue(-stepValue)" />
<v-text-field v-model.number="localValue" type="number" density="compact" hide-details :min="minValue"
:max="maxValue" :step="stepValue" :disabled="disabled" class="mx-2 setting-number-field" style="width: 80px"
variant="outlined" bg-color="surface" @update:model-value="updateSetting" />
<v-btn icon="mdi-plus" size="small" variant="text" :disabled="disabled || localValue >= maxValue"
@click="adjustValue(stepValue)" />
</div>
</div>
<v-menu location="bottom">
<template v-slot:activator="{ props }">
<v-btn icon="mdi-dots-vertical" size="small" variant="text" v-bind="props" class="ml-2"
:disabled="disabled" />
</template>
<v-list density="compact">
<v-list-item @click="copySettingId">
<template v-slot:prepend>
<v-icon icon="mdi-key" size="small" />
</template>
<v-list-item-title>复制设置ID</v-list-item-title>
</v-list-item>
<v-list-item @click="copySettingValue">
<template v-slot:prepend>
<v-icon icon="mdi-content-copy" size="small" />
</template>
<v-list-item-title>复制设置值</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item @click="resetToDefault" :disabled="isDefaultValue">
<template v-slot:prepend>
<v-icon icon="mdi-restore" size="small" />
</template>
<v-list-item-title>重置为默认值</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
</v-list-item>
<!-- 文本框显示在下方 -->
<div v-if="type === 'string' && !hasOptions" class="px-4 pb-2 pt-0">
<v-text-field v-model="localValue" density="compact" hide-details :disabled="disabled"
class="setting-text-field mt-1" variant="outlined" bg-color="surface" @update:model-value="updateSetting" />
</div>
<!-- 消息提示 -->
<v-snackbar v-model="showSnackbar" :timeout="2000" color="success" location="top">
{{ snackbarText }}
</v-snackbar>
</template>
<script>
import { getSetting, setSetting, getSettingDefinition, resetSetting } from '@/utils/settings';
export default {
name: 'SettingItem',
props: {
/**
* 设置项的键名例如 'display.cardHoverEffect'
*/
settingKey: {
type: String,
required: true
},
/**
* 可选的自定义图标如果不提供则使用默认图标
*/
icon: {
type: String,
default: null
},
/**
* 是否禁用此设置项
*/
disabled: {
type: Boolean,
default: false
},
/**
* 可选的自定义标题如果不提供则使用定义中的描述或键名
*/
title: {
type: String,
default: null
},
/**
* 可选的自定义描述如果不提供则不显示描述
*/
description: {
type: String,
default: null
}
},
data() {
return {
localValue: null,
definition: null,
type: null,
selectOptions: [],
hasOptions: false,
minValue: 0,
maxValue: 100,
stepValue: 1,
showSnackbar: false,
snackbarText: '',
fontFamilies: [
{ title: 'Arial', value: 'Arial, sans-serif' },
{ title: 'Calibri', value: 'Calibri, sans-serif' },
{ title: 'Cambria', value: 'Cambria, serif' },
{ title: 'Consolas', value: 'Consolas, monospace' },
{ title: 'Courier New', value: 'Courier New, monospace' },
{ title: 'Georgia', value: 'Georgia, serif' },
{ title: 'Helvetica', value: 'Helvetica, sans-serif' },
{ title: 'Segoe UI', value: 'Segoe UI, sans-serif' },
{ title: 'Times New Roman', value: 'Times New Roman, serif' },
{ title: 'Trebuchet MS', value: 'Trebuchet MS, sans-serif' },
{ title: 'Verdana', value: 'Verdana, sans-serif' },
{ title: 'Monospace', value: 'monospace' },
{ title: 'Sans-serif', value: 'sans-serif' },
{ title: 'Serif', value: 'serif' }
],
//
displayValueMappings: {
'display.emptySubjectDisplay': {
'card': '卡片',
'button': '按钮'
},
'theme.mode': {
'light': '浅色',
'dark': '深色'
},
'server.provider': {
'server': '远程服务器',
'indexedDB': '本地存储'
}
},
//
defaultIcons: {
'boolean': 'mdi-toggle-switch-outline',
'number': 'mdi-numeric',
'string': 'mdi-form-textbox'
}
};
},
computed: {
displayTitle() {
// 使
if (this.title) {
return this.title;
}
// 使
if (this.definition && this.definition.description) {
return this.definition.description;
}
// 使
const parts = this.settingKey.split('.');
return parts[parts.length - 1];
},
displayDescription() {
// 使
if (this.description) {
return this.description;
}
// 使
return this.settingKey;
},
//
isFontFamily() {
return this.settingKey.toLowerCase().includes('fontfamily') ||
this.settingKey.toLowerCase().includes('font.family');
},
//
isDefaultValue() {
if (!this.definition) return true;
//
if (typeof this.localValue === 'object' && this.localValue !== null) {
return JSON.stringify(this.localValue) === JSON.stringify(this.definition.default);
}
return this.localValue === this.definition.default;
},
//
settingIcon() {
// 使props
if (this.icon) {
return this.icon;
}
// 使
if (this.definition && this.definition.icon) {
return this.definition.icon;
}
// 使
return this.defaultIcons[this.type] || 'mdi-cog-outline';
}
},
created() {
this.loadSetting();
},
methods: {
loadSetting() {
//
this.definition = getSettingDefinition(this.settingKey);
if (!this.definition) {
console.error(`未找到设置项定义: ${this.settingKey}`);
return;
}
//
this.type = this.definition.type;
//
this.localValue = getSetting(this.settingKey);
//
if (this.type === 'string') {
//
if (this.isFontFamily) {
this.selectOptions = this.fontFamilies;
this.hasOptions = true;
}
//
else if (this.settingKey in this.displayValueMappings) {
const mapping = this.displayValueMappings[this.settingKey];
this.selectOptions = Object.entries(mapping).map(([value, title]) => ({
title,
value
}));
this.hasOptions = true;
}
//
else if (this.definition.validate) {
const validateStr = this.definition.validate.toString();
const match = validateStr.match(/\[(.*?)\]/);
if (match) {
const optionsStr = match[1];
const options = optionsStr.split(',').map(opt => {
const cleaned = opt.trim().replace(/['"]/g, '');
//
const displayValue = this.getDisplayValue(cleaned);
return {
title: displayValue || cleaned,
value: cleaned
};
});
if (options.length > 0) {
this.selectOptions = options;
this.hasOptions = true;
}
}
}
}
//
if (this.type === 'number' && this.definition.validate) {
const validateStr = this.definition.validate.toString();
//
const minMatch = validateStr.match(/value\s*>=\s*(\d+)/);
if (minMatch) {
this.minValue = Number(minMatch[1]);
}
//
const maxMatch = validateStr.match(/value\s*<=\s*(\d+)/);
if (maxMatch) {
this.maxValue = Number(maxMatch[1]);
}
//
const range = this.maxValue - this.minValue;
if (range > 100) {
this.stepValue = 10;
} else if (range > 20) {
this.stepValue = 5;
} else if (range > 10) {
this.stepValue = 2;
} else {
this.stepValue = 1;
}
}
},
//
getDisplayValue(value) {
if (this.settingKey in this.displayValueMappings) {
const mapping = this.displayValueMappings[this.settingKey];
return mapping[value] || value;
}
return value;
},
updateSetting(value) {
//
let typedValue = value;
if (this.type === 'boolean') {
typedValue = Boolean(value);
} else if (this.type === 'number') {
typedValue = Number(value);
//
if (typedValue < this.minValue) typedValue = this.minValue;
if (typedValue > this.maxValue) typedValue = this.maxValue;
}
//
const success = setSetting(this.settingKey, typedValue);
if (success) {
this.$emit('update', this.settingKey, typedValue);
} else {
//
this.localValue = getSetting(this.settingKey);
this.$emit('error', this.settingKey);
}
},
adjustValue(amount) {
if (this.type !== 'number') return;
const newValue = this.localValue + amount;
if (newValue >= this.minValue && newValue <= this.maxValue) {
this.localValue = newValue;
this.updateSetting(newValue);
}
},
// ID
copySettingId() {
navigator.clipboard.writeText(this.settingKey)
.then(() => {
this.showSnackbarMessage('设置ID已复制到剪贴板');
})
.catch(err => {
console.error('复制失败:', err);
});
},
//
copySettingValue() {
let valueText = '';
if (typeof this.localValue === 'object' && this.localValue !== null) {
valueText = JSON.stringify(this.localValue);
} else {
valueText = String(this.localValue);
}
navigator.clipboard.writeText(valueText)
.then(() => {
this.showSnackbarMessage('设置值已复制到剪贴板');
})
.catch(err => {
console.error('复制失败:', err);
});
},
//
resetToDefault() {
if (!this.definition) return;
resetSetting(this.settingKey);
this.localValue = getSetting(this.settingKey);
this.showSnackbarMessage('已重置为默认值');
this.$emit('update', this.settingKey, this.localValue);
},
//
showSnackbarMessage(message) {
this.snackbarText = message;
this.showSnackbar = true;
}
}
};
</script>
<style scoped>
.setting-item {
border-radius: 8px;
transition: background-color 0.2s;
}
.setting-text-field,
.setting-select,
.setting-number-field {
min-width: 180px;
border-radius: 6px;
}
.text-wrap {
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
}
@media (max-width: 600px) {
.setting-item {
flex-wrap: wrap;
}
}
</style>

View File

@ -0,0 +1,148 @@
<template>
<div class="settings-explorer">
<div >
<v-text-field v-model="searchQuery" label="搜索设置" prepend-inner-icon="mdi-magnify" clearable variant="outlined"
density="comfortable" class="mb-4" />
<v-list>
<div v-for="setting in allSettings" :key="setting.key">
<setting-item :key="setting.key" :setting-key="setting.key"
:disabled="setting.requireDeveloper && !isDeveloperMode" @update="onSettingUpdate" @error="onSettingError" />
<v-divider class="my-2" />
</div>
</v-list><v-card border>
<v-card-title class="text-subtitle-1">当前配置</v-card-title>
<v-card-text>
<pre class="settings-json">{{ formattedSettings }}</pre>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="copySettingsToClipboard">
复制到剪贴板
<v-icon right>mdi-content-copy</v-icon>
</v-btn>
</v-card-actions>
</v-card>
</div>
</div>
</template>
<script>
import { getSetting, settingsDefinitions, exportSettingsAsKeyValue, watchSettings } from '@/utils/settings';
import SettingItem from './SettingItem.vue';
export default {
name: 'SettingsExplorer',
components: {
SettingItem
},
data() {
return {
searchQuery: '',
currentSettings: {},
unwatchFunction: null,
};
},
computed: {
isDeveloperMode() {
return getSetting('developer.enabled');
},
allSettings() {
const settings = [];
for (const [key, definition] of Object.entries(settingsDefinitions)) {
//
if (this.searchQuery && !key.toLowerCase().includes(this.searchQuery.toLowerCase()) &&
!definition.description?.toLowerCase().includes(this.searchQuery.toLowerCase())) {
continue;
}
settings.push({
key,
...definition
});
}
return settings;
},
formattedSettings() {
return JSON.stringify(this.currentSettings, null, 2);
}
},
created() {
//
this.updateCurrentSettings();
//
this.unwatchFunction = watchSettings(() => {
this.updateCurrentSettings();
});
},
beforeUnmount() {
//
if (this.unwatchFunction) {
this.unwatchFunction();
}
},
methods: {
updateCurrentSettings() {
this.currentSettings = exportSettingsAsKeyValue();
},
onSettingUpdate(key, value) {
this.$emit('update', key, value);
//
this.updateCurrentSettings();
},
onSettingError(key) {
this.$emit('error', key);
},
copySettingsToClipboard() {
navigator.clipboard.writeText(JSON.stringify(this.currentSettings))
.then(() => {
//
this.$emit('message', { type: 'success', text: '设置已复制到剪贴板' });
})
.catch(err => {
console.error('复制到剪贴板失败:', err);
this.$emit('message', { type: 'error', text: '复制到剪贴板失败' });
});
}
}
};
</script>
<style scoped>
.settings-explorer {
padding: 8px 0;
}
.settings-json {
background-color: rgba(0, 0, 0, 0.05);
padding: 12px;
border-radius: 4px;
overflow-x: auto;
font-family: monospace;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.v-theme--dark .settings-json {
background-color: rgba(255, 255, 255, 0.05);
}
</style>

View File

@ -144,7 +144,7 @@ export default {
async clearIndexedDB() {
try {
const DBName = "HomeworkDB";
const DBName = "ClassworksDB";
//
await window.indexedDB.deleteDatabase(DBName);
this.$message.success("清除成功", "数据库缓存已清除");
@ -156,7 +156,7 @@ export default {
async exportData() {
try {
const DBName = "HomeworkDB";
const DBName = "ClassworksDB";
const data = { indexedDB: {} };
//

View File

@ -1,260 +1,46 @@
<template>
<settings-card
title="显示设置"
icon="mdi-monitor"
border
>
<v-form v-model="isValid" @submit.prevent="save">
<settings-card title="显示设置" icon="mdi-monitor" border>
<v-list>
<v-list-item>
<template #prepend>
<v-icon icon="mdi-card-outline" class="mr-3" />
</template>
<v-list-item-title>空科目显示方式</v-list-item-title>
<v-list-item-subtitle>选择空科目的显示方式</v-list-item-subtitle>
<template #append>
<v-select
v-model="emptySubjectDisplay"
:items="displayOptions"
density="compact"
hide-details
variant="outlined"
style="max-width: 150px"
/>
</template>
</v-list-item>
<setting-item :setting-key="'display.emptySubjectDisplay'" />
<v-divider class="my-2" />
<v-list-item>
<template #prepend>
<v-icon icon="mdi-sort-variant" class="mr-3" />
</template>
<v-list-item-title>动态排序</v-list-item-title>
<v-list-item-subtitle>优化卡片布局以提高显示效果</v-list-item-subtitle>
<template #append>
<v-switch
v-model="dynamicSort"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<setting-item :setting-key="'display.dynamicSort'" />
<v-divider class="my-2" />
<v-list-item>
<template #prepend>
<v-icon icon="mdi-dice-multiple" class="mr-3" />
</template>
<v-list-item-title>显示随机按钮</v-list-item-title>
<v-list-item-subtitle>在主页显示随机点名按钮</v-list-item-subtitle>
<template #append>
<v-switch
v-model="showRandomButton"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<setting-item :setting-key="'display.showRandomButton'" />
<v-divider class="my-2" />
<v-list-item>
<template #prepend>
<v-icon icon="mdi-fullscreen" class="mr-3" />
</template>
<v-list-item-title>显示全屏按钮</v-list-item-title>
<v-list-item-subtitle>在主页显示全屏切换按钮</v-list-item-subtitle>
<template #append>
<v-switch
v-model="showFullscreenButton"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<setting-item :setting-key="'display.showFullscreenButton'" />
<v-divider class="my-2" />
<v-list-item>
<template #prepend>
<v-icon icon="mdi-cards-outline" class="mr-3" />
</template>
<v-list-item-title>卡片悬浮效果</v-list-item-title>
<v-list-item-subtitle>启用卡片悬停时的动画效果</v-list-item-subtitle>
<template #append>
<v-switch
v-model="cardHoverEffect"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<setting-item :setting-key="'display.cardHoverEffect'" />
<v-divider class="my-2" />
<v-list-item>
<template #prepend>
<v-icon icon="mdi-gesture-tap" class="mr-3" />
</template>
<v-list-item-title>增强触摸模式</v-list-item-title>
<v-list-item-subtitle>优化触摸屏操作体验</v-list-item-subtitle>
<template #append>
<v-switch
v-model="enhancedTouchMode"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<setting-item :setting-key="'display.enhancedTouchMode'" />
<v-divider class="my-2" />
<setting-item :setting-key="'display.showAntiScreenBurnCard'" />
<v-list-item>
<template #prepend>
<v-icon icon="mdi-shield-check" class="mr-3" />
</template>
<v-list-item-title>显示防烧屏提示</v-list-item-title>
<v-list-item-subtitle>显示防烧屏技术提示卡片</v-list-item-subtitle>
<template #append>
<v-switch
v-model="showAntiScreenBurnCard"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<div class="d-flex gap-2 mt-4">
<v-btn
color="primary"
type="submit"
:disabled="!hasChanges || !isValid"
prepend-icon="mdi-content-save"
>
保存更改
</v-btn>
<v-btn
variant="outlined"
@click="reset"
:disabled="!hasChanges"
>
重置
</v-btn>
</div>
</v-list>
</v-form>
</settings-card>
</template>
<script>
import SettingsCard from '@/components/SettingsCard.vue';
import { getSetting, setSetting } from '@/utils/settings';
import SettingItem from '@/components/settings/SettingItem.vue';
export default {
name: 'DisplaySettingsCard',
components: { SettingsCard },
components: { SettingsCard, SettingItem },
data() {
const settings = {
emptySubjectDisplay: getSetting('display.emptySubjectDisplay'),
dynamicSort: getSetting('display.dynamicSort'),
showRandomButton: getSetting('display.showRandomButton'),
showFullscreenButton: getSetting('display.showFullscreenButton'),
cardHoverEffect: getSetting('display.cardHoverEffect'),
enhancedTouchMode: getSetting('display.enhancedTouchMode'),
showAntiScreenBurnCard: getSetting('display.showAntiScreenBurnCard')
};
return {
localSettings: { ...settings },
originalSettings: settings,
isValid: true,
displayOptions: [
{ title: '卡片', value: 'card' },
{ title: '按钮', value: 'button' }
]
};
},
computed: {
hasChanges() {
return JSON.stringify(this.localSettings) !== JSON.stringify(this.originalSettings);
},
emptySubjectDisplay: {
get() {
return this.localSettings.emptySubjectDisplay;
},
set(value) {
this.localSettings.emptySubjectDisplay = value;
this.$emit('saved');
}
},
dynamicSort: {
get() {
return this.localSettings.dynamicSort;
},
set(value) {
this.localSettings.dynamicSort = value;
this.$emit('saved');
}
},
showRandomButton: {
get() {
return this.localSettings.showRandomButton;
},
set(value) {
this.localSettings.showRandomButton = value;
this.$emit('saved');
}
},
showFullscreenButton: {
get() {
return this.localSettings.showFullscreenButton;
},
set(value) {
this.localSettings.showFullscreenButton = value;
this.$emit('saved');
}
},
cardHoverEffect: {
get() {
return this.localSettings.cardHoverEffect;
},
set(value) {
this.localSettings.cardHoverEffect = value;
this.$emit('saved');
}
},
enhancedTouchMode: {
get() {
return this.localSettings.enhancedTouchMode;
},
set(value) {
this.localSettings.enhancedTouchMode = value;
this.$emit('saved');
}
},
showAntiScreenBurnCard: {
get() {
return this.localSettings.showAntiScreenBurnCard;
},
set(value) {
this.localSettings.showAntiScreenBurnCard = value;
this.$emit('saved');
}
}
},
methods: {
save() {
Object.entries(this.localSettings).forEach(([key, value]) => {
setSetting(`display.${key}`, value);
});
this.originalSettings = { ...this.localSettings };
this.$emit('saved');
},
reset() {
this.localSettings = { ...this.originalSettings };
}
}
};
</script>

View File

@ -1,135 +1,27 @@
<template>
<settings-card
title="编辑设置"
icon="mdi-pencil-cog"
>
<v-form v-model="isValid" @submit.prevent="save">
<v-list>
<v-list-item>
<template #prepend>
<v-icon icon="mdi-content-save" class="mr-3" />
</template>
<v-list-item-title>自动保存</v-list-item-title>
<v-list-item-subtitle>在编辑完成后自动保存到服务器</v-list-item-subtitle>
<template #append>
<v-switch
v-model="localSettings.autoSave"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<settings-card title="编辑设置" icon="mdi-cog">
<v-list>
<setting-item :setting-key="'edit.autoSave'" />
<v-divider v-if="localSettings.autoSave" class="my-2" />
<v-list-item v-if="localSettings.autoSave">
<template #prepend>
<v-icon icon="mdi-calendar-lock" class="mr-3" />
</template>
<v-list-item-title>禁止自动保存非当天数据</v-list-item-title>
<v-list-item-subtitle>仅允许自动保存当天的数据避免误修改历史记录</v-list-item-subtitle>
<template #append>
<v-switch
v-model="localSettings.blockNonTodayAutoSave"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<v-divider class="my-2" />
<setting-item :setting-key="'edit.blockNonTodayAutoSave'" />
<v-divider class="my-2" />
<v-list-item>
<template #prepend>
<v-icon icon="mdi-calendar-alert" class="mr-3" />
</template>
<v-list-item-title>确认保存历史数据</v-list-item-title>
<v-list-item-subtitle>保存非当天数据时显示确认对话框</v-list-item-subtitle>
<template #append>
<v-switch
v-model="localSettings.confirmNonTodaySave"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<v-divider class="my-2" />
<setting-item :setting-key="'edit.confirmNonTodaySave'" />
<v-divider class="my-2" />
<v-list-item>
<template #prepend>
<v-icon icon="mdi-refresh" class="mr-3" />
</template>
<v-list-item-title>编辑前刷新</v-list-item-title>
<v-list-item-subtitle>在打开编辑框前从服务器获取最新数据</v-list-item-subtitle>
<template #append>
<v-switch
v-model="localSettings.refreshBeforeEdit"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<div class="d-flex gap-2 mt-4">
<v-btn
color="primary"
type="submit"
:disabled="!hasChanges || !isValid"
prepend-icon="mdi-content-save"
>
保存更改
</v-btn>
<v-btn
variant="outlined"
@click="reset"
:disabled="!hasChanges"
>
重置
</v-btn>
</div>
</v-list>
</v-form>
<v-divider class="my-2" />
<setting-item :setting-key="'edit.refreshBeforeEdit'" />
</v-list>
</settings-card>
</template>
<script>
import SettingsCard from '@/components/SettingsCard.vue';
import { getSetting, setSetting } from '@/utils/settings';
import SettingItem from '../SettingItem.vue';
export default {
name: 'EditSettingsCard',
components: { SettingsCard },
data() {
const settings = {
autoSave: getSetting('edit.autoSave'),
blockNonTodayAutoSave: getSetting('edit.blockNonTodayAutoSave'),
confirmNonTodaySave: getSetting('edit.confirmNonTodaySave'),
refreshBeforeEdit: getSetting('edit.refreshBeforeEdit')
};
return {
localSettings: { ...settings },
originalSettings: settings,
isValid: true
};
},
computed: {
hasChanges() {
return JSON.stringify(this.localSettings) !== JSON.stringify(this.originalSettings);
}
},
methods: {
save() {
Object.entries(this.localSettings).forEach(([key, value]) => {
setSetting(`edit.${key}`, value);
});
this.originalSettings = { ...this.localSettings };
this.$emit('saved');
},
reset() {
this.localSettings = { ...this.originalSettings };
}
}
};
</script>

View File

@ -0,0 +1,117 @@
<template>
<v-card border class="mb-4">
<v-card-title class="d-flex align-center">
<v-icon icon="mdi-account-question" class="mr-2" />
随机点名设置
</v-card-title>
<v-card-text>
<v-switch
v-model="randomPickerEnabled"
label="启用随机点名功能"
color="primary"
hide-details
class="mb-4"
/>
<v-switch
v-model="randomPickerAnimation"
label="启用随机点名动画效果"
color="primary"
hide-details
class="mb-4"
:disabled="!randomPickerEnabled"
/>
<v-slider
v-model="defaultCount"
label="默认抽取人数"
min="1"
max="10"
step="1"
thumb-label
:disabled="!randomPickerEnabled"
class="mb-4"
/>
<v-divider class="my-4" />
<div class="text-subtitle-1 mb-2">学生过滤设置</div>
<v-switch
v-model="excludeAbsent"
label="排除请假学生"
color="primary"
hide-details
class="mb-2"
:disabled="!randomPickerEnabled"
/>
<v-switch
v-model="excludeLate"
label="排除迟到学生"
color="primary"
hide-details
class="mb-2"
:disabled="!randomPickerEnabled"
/>
<v-switch
v-model="excludeExcluded"
label="排除不参与学生"
color="primary"
hide-details
class="mb-2"
:disabled="!randomPickerEnabled"
/>
<v-alert
v-if="randomPickerEnabled"
type="info"
variant="tonal"
class="mt-4"
density="compact"
>
随机点名功能将在主页显示一个按钮点击后可以随机抽取学生
</v-alert>
</v-card-text>
</v-card>
</template>
<script>
import { getSetting, setSetting } from '@/utils/settings';
export default {
name: 'RandomPickerSettingsCard',
data() {
return {
randomPickerEnabled: getSetting('randomPicker.enabled'),
randomPickerAnimation: getSetting('randomPicker.animation'),
defaultCount: getSetting('randomPicker.defaultCount'),
excludeAbsent: getSetting('randomPicker.excludeAbsent'),
excludeLate: getSetting('randomPicker.excludeLate'),
excludeExcluded: getSetting('randomPicker.excludeExcluded')
};
},
watch: {
randomPickerEnabled(newValue) {
setSetting('randomPicker.enabled', newValue);
},
randomPickerAnimation(newValue) {
setSetting('randomPicker.animation', newValue);
},
defaultCount(newValue) {
setSetting('randomPicker.defaultCount', newValue);
},
excludeAbsent(newValue) {
setSetting('randomPicker.excludeAbsent', newValue);
},
excludeLate(newValue) {
setSetting('randomPicker.excludeLate', newValue);
},
excludeExcluded(newValue) {
setSetting('randomPicker.excludeExcluded', newValue);
}
}
};
</script>

View File

@ -1,102 +1,28 @@
<template>
<settings-card
title="刷新设置"
icon="mdi-refresh-circle"
>
<v-form v-model="isValid" @submit.prevent="save">
<settings-card title="刷新设置" icon="mdi-refresh-circle">
<v-form>
<v-list>
<v-list-item>
<template #prepend>
<v-icon icon="mdi-refresh" class="mr-3" />
</template>
<v-list-item-title>自动刷新</v-list-item-title>
<v-list-item-subtitle>在后台自动刷新数据</v-list-item-subtitle>
<template #append>
<v-switch
v-model="localSettings.auto"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<setting-item setting-key="refresh.auto" title="自动刷新" /> <v-divider class="my-2" />
<v-divider class="my-2" />
<v-list-item>
<template #prepend>
<v-icon icon="mdi-timer" class="mr-3" />
</template>
<v-list-item-title>刷新间隔</v-list-item-title>
<v-list-item-subtitle>设置自动刷新的时间间隔分钟</v-list-item-subtitle>
<template #append>
<v-text-field
v-model="localSettings.interval"
type="number"
min="1"
max="60"
density="comfortable"
hide-details
/>
</template>
</v-list-item>
<div class="d-flex gap-2 mt-4">
<v-btn
color="primary"
type="submit"
:disabled="!hasChanges || !isValid"
prepend-icon="mdi-content-save"
>
保存更改
</v-btn>
<v-btn
variant="outlined"
@click="reset"
:disabled="!hasChanges"
>
重置
</v-btn>
</div>
<setting-item setting-key="refresh.interval" title="刷新间隔" />
</v-list>
</v-form>
</settings-card>
</template>
<script>
import SettingsCard from '@/components/SettingsCard.vue';
import { getSetting, setSetting } from '@/utils/settings';
import SettingItem from '@/components/settings/SettingItem.vue';
export default {
name: 'RefreshSettingsCard',
components: { SettingsCard },
data() {
const settings = {
auto: getSetting('refresh.auto'),
interval: getSetting('refresh.interval')
};
return {
localSettings: { ...settings },
originalSettings: settings,
isValid: true
};
},
computed: {
hasChanges() {
return JSON.stringify(this.localSettings) !== JSON.stringify(this.originalSettings);
}
},
methods: {
save() {
Object.entries(this.localSettings).forEach(([key, value]) => {
setSetting(`refresh.${key}`, value);
});
this.originalSettings = { ...this.localSettings };
this.$emit('saved');
},
reset() {
this.localSettings = { ...this.originalSettings };
}
}
};
</script>

View File

@ -1,52 +1,19 @@
<template>
<settings-card title="数据源设置" icon="mdi-database" :loading="loading">
<v-form v-model="isValid" @submit.prevent="save">
<v-select
v-model="localSettings.provider"
:items="dataProviders"
label="数据提供者"
class="mb-4"
/>
<v-expand-transition>
<div v-if="localSettings.provider === 'server'">
<v-text-field
v-model="localSettings.domain"
label="服务器域名"
placeholder="例如: http://example.com"
prepend-inner-icon="mdi-web"
/>
</div>
</v-expand-transition>
<v-form>
<setting-item setting-key="server.provider" title="数据提供者" />
<v-divider class="my-2" />
<setting-item setting-key="server.domain" title="服务器域名" /> <v-divider class="my-2" />
<v-text-field
v-model="localSettings.classNumber"
label="班号"
placeholder="例如: 1 或 A"
prepend-inner-icon="mdi-account-group"
persistent-hint
/>
<div class="d-flex gap-2 mt-4">
<v-btn
color="primary"
type="submit"
:disabled="!hasChanges || !isValid"
prepend-icon="mdi-content-save"
>
保存更改
</v-btn>
<v-btn variant="outlined" @click="reset" :disabled="!hasChanges">
重置
</v-btn>
</div>
<setting-item setting-key="server.classNumber" title="班号" />
</v-form>
</settings-card>
</template>
<script>
import SettingsCard from "@/components/SettingsCard.vue";
import { getSetting, setSetting } from "@/utils/settings";
import SettingItem from "@/components/settings/SettingItem.vue";
export default {
name: "ServerSettingsCard",
components: { SettingsCard },
@ -54,68 +21,12 @@ export default {
loading: Boolean,
},
data() {
const settings = {
provider: getSetting("server.provider"),
domain: getSetting("server.domain"),
classNumber: getSetting("server.classNumber"),
};
return {
localSettings: { ...settings },
originalSettings: settings,
isValid: true,
dataProviders: [
{ title: "服务器", value: "server" },
{ title: "本地数据库", value: "indexedDB" },
],
};
},
computed: {
hasChanges() {
return (
JSON.stringify(this.localSettings) !==
JSON.stringify(this.originalSettings)
);
},
},
methods: {
async save() {
try {
//
Object.entries(this.localSettings).forEach(([key, value]) => {
const success = setSetting(`server.${key}`, value);
if (!success) {
throw new Error(`保存设置 ${key} 失败`);
}
});
//
if (this.localSettings.provider === 'server' && this.localSettings.domain) {
const testUrl = `${this.localSettings.domain.replace(/\/$/, '')}/api/test`;
try {
const response = await fetch(testUrl);
if (!response.ok) {
throw new Error('服务器连接测试失败');
}
} catch (error) {
throw new Error('无法连接到服务器,请检查域名设置');
}
}
this.originalSettings = { ...this.localSettings };
this.$emit('saved');
//
setTimeout(() => {
//window.location.reload();
}, 1000);
} catch (error) {
this.$message?.error('保存失败', error.message);
}
},
reset() {
this.localSettings = { ...this.originalSettings };
},
},
};
</script>

View File

@ -85,14 +85,10 @@
上传
</v-btn>
<v-btn v-else color="success" size="large" @click="showSyncMessage">
同步完成 </v-btn><v-btn v-if="showRandomButton" color="yellow" prepend-icon="mdi-account-question"
append-icon="mdi-dice-multiple" size="large" class="ml-2" href="classisland://plugins/IslandCaller/Run">
同步完成 </v-btn>
<v-btn v-if="showRandomPickerButton" color="amber" prepend-icon="mdi-account-question"
append-icon="mdi-dice-multiple" size="large" class="ml-2" @click="openRandomPicker">
随机点名
</v-btn>
<v-btn v-if="showFullscreenButton" :color="state.isFullscreen ? 'blue-grey' : 'blue'"
:prepend-icon="state.isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'" size="large" class="ml-2"
@click="toggleFullscreen">
{{ state.isFullscreen ? '退出全屏' : '全屏显示' }}
</v-btn><!-- 修改防烧屏提示卡片使用 tonal 样式减少信息密度 -->
<v-card v-if="showAntiScreenBurnCard" border class="mt-4 anti-burn-card" color="primary" variant="tonal">
<v-card-title class="text-subtitle-1">
@ -142,7 +138,7 @@
</h2>
<h3 class="gray-text" v-for="(name, index) in state.boardData.attendance.absent" :key="'absent-' + index">
<span v-if="useDisplay().lgAndUp.value">{{ `${index + 1}. ` }}</span><span style="white-space: nowrap">{{ name
}}</span>
}}</span>
</h3>
<h2>
<snap style="white-space: nowrap">迟到</snap>:
@ -152,7 +148,7 @@
</h2>
<h3 class="gray-text" v-for="(name, index) in state.boardData.attendance.late" :key="'late-' + index">
<span v-if="useDisplay().lgAndUp.value">{{ `${index + 1}. ` }}</span><span style="white-space: nowrap">{{ name
}}</span>
}}</span>
</h3>
<h2>
<snap style="white-space: nowrap">不参与</snap>:
@ -162,7 +158,7 @@
</h2>
<h3 class="gray-text" v-for="(name, index) in state.boardData.attendance.exclude" :key="'exclude-' + index">
<span v-if="useDisplay().lgAndUp.value">{{ `${index + 1}. ` }}</span><span style="white-space: nowrap">{{ name
}}</span>
}}</span>
</h3>
</v-col>
</div>
@ -183,7 +179,8 @@
{{ state.snackbarText }}
</v-snackbar>
<v-dialog v-model="state.attendanceDialog" max-width="900" fullscreen-breakpoint="sm" @update:model-value="handleAttendanceDialogClose">
<v-dialog v-model="state.attendanceDialog" max-width="900" fullscreen-breakpoint="sm"
@update:model-value="handleAttendanceDialogClose">
<v-card>
<v-card-title class="d-flex align-center">
<v-icon icon="mdi-account-group" class="mr-2" />
@ -195,107 +192,55 @@
</v-card-title>
<v-card-text>
<!-- 批量操作和搜索 -->
<v-row class="mb-4">
<v-col cols="12" md="12">
<v-text-field
v-model="attendanceSearch"
prepend-inner-icon="mdi-magnify"
label="搜索学生"
hide-details
variant="outlined"
density="compact"
clearable
@update:model-value="handleSearchChange"
/>
<div class="text-caption mt-1">支持筛选姓氏如输入"张"可筛选所有姓张的学生</div>
<!-- 智能姓氏筛选 -->
<v-text-field v-model="attendanceSearch" prepend-inner-icon="mdi-magnify" label="搜索学生"
hint="支持筛选姓氏,如输入'孙'可筛选所有姓孙的学生" variant="outlined" clearable @update:model-value="handleSearchChange" />
<!-- 姓氏筛选 -->
<div class="d-flex flex-wrap mt-2 gap-1">
<v-btn
v-for="surname in extractedSurnames"
:key="surname.name"
size="small"
:variant="attendanceSearch === surname.name ? 'elevated' : 'text'"
:color="attendanceSearch === surname.name ? 'primary' : ''"
@click="attendanceSearch = attendanceSearch === surname.name ? '' : surname.name"
class="surname-btn"
>
<v-btn v-for="surname in extractedSurnames" :key="surname.name"
:variant="attendanceSearch === surname.name ? 'elevated' : 'text'"
:color="attendanceSearch === surname.name ? 'primary' : ''"
@click="attendanceSearch = attendanceSearch === surname.name ? '' : surname.name">
{{ surname.name }}
<span class="text-caption ml-1">({{ surname.count }})</span>
({{ surname.count }})
</v-btn>
</div>
<!-- 搜索建议 -->
<div v-if="searchSuggestions.length > 0" class="mt-2">
<div class="text-caption text-grey">搜索建议:</div>
<div class="d-flex flex-wrap gap-1 mt-1">
<v-btn
v-for="suggestion in searchSuggestions"
:key="suggestion"
size="x-small"
variant="text"
color="secondary"
@click="attendanceSearch = suggestion"
class="suggestion-btn"
density="compact"
>
{{ suggestion }}
</v-btn>
</div>
</div>
</v-col>
</v-row>
<!-- 过滤器 -->
<div class="d-flex flex-wrap mb-4 gap-2">
<div>
<v-chip
value="present"
:color="attendanceFilter.includes('present') ? 'success' : ''"
:variant="attendanceFilter.includes('present') ? 'elevated' : 'tonal'"
class="px-2 filter-chip"
@click="toggleFilter('present')"
prepend-icon="mdi-account-check"
:append-icon="attendanceFilter.includes('present') ? 'mdi-check' : ''"
>
<v-chip value="present" :color="attendanceFilter.includes('present') ? 'success' : ''"
:variant="attendanceFilter.includes('present') ? 'elevated' : 'tonal'" class="px-2 filter-chip"
@click="toggleFilter('present')" prepend-icon="mdi-account-check"
:append-icon="attendanceFilter.includes('present') ? 'mdi-check' : ''">
到课
</v-chip>
<v-chip
value="absent"
:color="attendanceFilter.includes('absent') ? 'error' : ''"
:variant="attendanceFilter.includes('absent') ? 'elevated' : 'tonal'"
class="px-2 filter-chip"
@click="toggleFilter('absent')"
prepend-icon="mdi-account-off"
:append-icon="attendanceFilter.includes('absent') ? 'mdi-check' : ''"
>
<v-chip value="absent" :color="attendanceFilter.includes('absent') ? 'error' : ''"
:variant="attendanceFilter.includes('absent') ? 'elevated' : 'tonal'" class="px-2 filter-chip"
@click="toggleFilter('absent')" prepend-icon="mdi-account-off"
:append-icon="attendanceFilter.includes('absent') ? 'mdi-check' : ''">
请假
</v-chip>
<v-chip
value="late"
:color="attendanceFilter.includes('late') ? 'warning' : ''"
:variant="attendanceFilter.includes('late') ? 'elevated' : 'tonal'"
class="px-2 filter-chip"
@click="toggleFilter('late')"
prepend-icon="mdi-clock-alert"
:append-icon="attendanceFilter.includes('late') ? 'mdi-check' : ''"
>
<v-chip value="late" :color="attendanceFilter.includes('late') ? 'warning' : ''"
:variant="attendanceFilter.includes('late') ? 'elevated' : 'tonal'" class="px-2 filter-chip"
@click="toggleFilter('late')" prepend-icon="mdi-clock-alert"
:append-icon="attendanceFilter.includes('late') ? 'mdi-check' : ''">
迟到
</v-chip>
<v-chip
value="exclude"
:color="attendanceFilter.includes('exclude') ? 'grey' : ''"
:variant="attendanceFilter.includes('exclude') ? 'elevated' : 'tonal'"
class="px-2 filter-chip"
@click="toggleFilter('exclude')"
prepend-icon="mdi-account-cancel"
:append-icon="attendanceFilter.includes('exclude') ? 'mdi-check' : ''"
>
<v-chip value="exclude" :color="attendanceFilter.includes('exclude') ? 'grey' : ''"
:variant="attendanceFilter.includes('exclude') ? 'elevated' : 'tonal'" class="px-2 filter-chip"
@click="toggleFilter('exclude')" prepend-icon="mdi-account-cancel"
:append-icon="attendanceFilter.includes('exclude') ? 'mdi-check' : ''">
不参与
</v-chip>
</div>
@ -303,15 +248,8 @@
<!-- 学生列表 -->
<v-row>
<v-col
v-for="student in filteredStudents"
:key="student"
cols="12" sm="6" md="6" lg="4"
>
<v-card
class="student-card"
border
>
<v-col v-for="student in filteredStudents" :key="student" cols="12" sm="6" md="6" lg="4">
<v-card class="student-card" border>
<v-card-text class="d-flex align-center pa-2">
<div class="flex-grow-1">
<div class="d-flex align-center">
@ -322,38 +260,17 @@
</div>
</div>
<div class="attendance-actions">
<v-btn
:color="isPresent(state.studentList.indexOf(student)) ? 'success' : ''"
icon="mdi-account-check"
size="small"
variant="text"
@click="setPresent(state.studentList.indexOf(student))"
:title="'设为到课'"
/>
<v-btn
:color="isAbsent(state.studentList.indexOf(student)) ? 'error' : ''"
icon="mdi-account-off"
size="small"
variant="text"
@click="setAbsent(state.studentList.indexOf(student))"
:title="'设为请假'"
/>
<v-btn
:color="isLate(state.studentList.indexOf(student)) ? 'warning' : ''"
icon="mdi-clock-alert"
size="small"
variant="text"
@click="setLate(state.studentList.indexOf(student))"
:title="'设为迟到'"
/>
<v-btn
:color="isExclude(state.studentList.indexOf(student)) ? 'grey' : ''"
icon="mdi-account-cancel"
size="small"
variant="text"
@click="setExclude(state.studentList.indexOf(student))"
:title="'设为不参与'"
/>
<v-btn :color="isPresent(state.studentList.indexOf(student)) ? 'success' : ''"
icon="mdi-account-check" size="small" variant="text"
@click="setPresent(state.studentList.indexOf(student))" :title="'设为到课'" />
<v-btn :color="isAbsent(state.studentList.indexOf(student)) ? 'error' : ''" icon="mdi-account-off"
size="small" variant="text" @click="setAbsent(state.studentList.indexOf(student))"
:title="'设为请假'" />
<v-btn :color="isLate(state.studentList.indexOf(student)) ? 'warning' : ''" icon="mdi-clock-alert"
size="small" variant="text" @click="setLate(state.studentList.indexOf(student))" :title="'设为迟到'" />
<v-btn :color="isExclude(state.studentList.indexOf(student)) ? 'grey' : ''" icon="mdi-account-cancel"
size="small" variant="text" @click="setExclude(state.studentList.indexOf(student))"
:title="'设为不参与'" />
</div>
</v-card-text>
</v-card>
@ -369,7 +286,7 @@
</v-btn>
<v-btn color="error" prepend-icon="mdi-account-off" @click="setAllAbsent">
全部请假
</v-btn> </v-btn-group> <v-btn-group>
</v-btn> </v-btn-group> <v-btn-group>
<v-btn color="warning" prepend-icon="mdi-clock-alert" @click="setAllLate">
全部迟到
@ -387,9 +304,7 @@
<v-card-actions>
<v-spacer />
<v-btn color="grey" variant="text" @click="state.attendanceDialog = false">
取消
</v-btn>
<v-btn color="primary" @click="saveAttendance">
<v-icon start>mdi-content-save</v-icon>
保存
@ -417,11 +332,14 @@
</v-card>
</v-dialog>
<!-- 添加随机点名组件 -->
<random-picker ref="randomPicker" :student-list="state.studentList" :attendance="state.boardData.attendance" />
</template>
<script>
import MessageLog from "@/components/MessageLog.vue";
import RandomPicker from "@/components/RandomPicker.vue"; //
import dataProvider from "@/utils/dataProvider";
import { getSetting, watchSettings, setSetting } from "@/utils/settings";
import { useDisplay } from "vuetify";
@ -429,11 +347,13 @@ import "../styles/index.scss";
import "../styles/transitions.scss"; //
import { debounce, throttle } from "@/utils/debounce";
import '../styles/global.scss';
import { pinyin } from "pinyin-pro";
export default {
name: "Classworks 作业板",
components: {
MessageLog,
RandomPicker, //
},
data() {
return {
@ -515,7 +435,6 @@ export default {
},
attendanceSearch: "",
attendanceFilter: [],
searchSuggestions: [],
};
},
@ -621,8 +540,8 @@ export default {
unreadCount() {
return this.$refs.messageLog?.unreadCount || 0;
},
showRandomButton() {
return getSetting("display.showRandomButton");
showRandomPickerButton() {
return getSetting("randomPicker.enabled");
},
confirmNonTodaySave() {
return getSetting("edit.confirmNonTodaySave");
@ -641,15 +560,15 @@ export default {
},
filteredStudents() {
let students = [...this.state.studentList];
//
if (this.attendanceSearch) {
const searchTerm = this.attendanceSearch.toLowerCase();
students = students.filter(student =>
students = students.filter(student =>
student.toLowerCase().includes(searchTerm)
);
}
//
if (this.attendanceFilter && this.attendanceFilter.length > 0) {
students = students.filter(student => {
@ -661,7 +580,7 @@ export default {
return false;
});
}
return students;
},
extractedSurnames() {
@ -669,9 +588,9 @@ export default {
if (!this.state.studentList || this.state.studentList.length === 0) {
return [];
}
const surnameMap = new Map();
this.state.studentList.forEach(student => {
if (student && student.length > 0) {
//
@ -683,11 +602,15 @@ export default {
}
}
});
//
//
return Array.from(surnameMap.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
.sort((a, b) => {
const pinyinA = pinyin(a.name, { toneType: "none", mode: 'surname' });
const pinyinB = pinyin(b.name, { toneType: "none", mode: 'surname' });
return pinyinA.localeCompare(pinyinB);
});
},
},
@ -735,6 +658,12 @@ export default {
document.addEventListener('webkitfullscreenchange', this.fullscreenChangeHandler);
document.addEventListener('mozfullscreenchange', this.fullscreenChangeHandler);
document.addEventListener('MSFullscreenChange', this.fullscreenChangeHandler);
// URL#pick
this.checkHashForRandomPicker();
//
window.addEventListener('hashchange', this.checkHashForRandomPicker);
} catch (err) {
console.error("初始化失败:", err);
this.showError("初始化失败,请刷新页面重试");
@ -755,6 +684,9 @@ export default {
document.removeEventListener('webkitfullscreenchange', this.fullscreenChangeHandler);
document.removeEventListener('mozfullscreenchange', this.fullscreenChangeHandler);
document.removeEventListener('MSFullscreenChange', this.fullscreenChangeHandler);
//
window.removeEventListener('hashchange', this.checkHashForRandomPicker);
},
methods: {
@ -1065,11 +997,38 @@ export default {
}
if (autoRefresh) {
this.state.refreshInterval = setInterval(() => {
this.downloadData();
//
if (!this.shouldSkipRefresh()) {
this.downloadData();
}
}, interval * 1000);
}
},
//
shouldSkipRefresh() {
//
if (this.state.dialogVisible) return true;
//
if (this.state.attendanceDialog) return true;
//
if (this.confirmDialog.show) return true;
//
if (this.state.datePickerDialog) return true;
//
if (this.loading.upload || this.loading.download) return true;
//
if (!this.state.synced) return true;
//
return false;
},
updateSettings() {
this.state.fontSize = getSetting("font.size");
this.state.contentStyle = { "font-size": `${this.state.fontSize}px` };
@ -1200,7 +1159,7 @@ export default {
this.state.boardData.attendance.late = [];
this.state.boardData.attendance.exclude = [...this.state.studentList];
this.state.synced = false;
},
},
isPresent(index) {
const student = this.state.studentList[index];
@ -1407,17 +1366,17 @@ export default {
if (this.state.boardData.attendance.exclude.includes(studentName)) return 'grey';
return 'success';
},
getStudentStatusVariant(index) {
const studentName = this.state.studentList[index];
if (this.state.boardData.attendance.absent.includes(studentName) ||
this.state.boardData.attendance.late.includes(studentName) ||
this.state.boardData.attendance.exclude.includes(studentName)) {
this.state.boardData.attendance.late.includes(studentName) ||
this.state.boardData.attendance.exclude.includes(studentName)) {
return 'tonal';
}
return 'outlined';
},
getStudentStatusIcon(index) {
const studentName = this.state.studentList[index];
if (this.state.boardData.attendance.absent.includes(studentName)) return 'mdi-account-off';
@ -1425,7 +1384,7 @@ export default {
if (this.state.boardData.attendance.exclude.includes(studentName)) return 'mdi-account-cancel';
return 'mdi-account-check';
},
getStudentStatusText(index) {
const studentName = this.state.studentList[index];
if (this.state.boardData.attendance.absent.includes(studentName)) return '请假';
@ -1434,49 +1393,6 @@ export default {
return '到课';
},
handleSearchChange(value) {
//
this.generateSearchSuggestions(value);
},
generateSearchSuggestions(searchTerm) {
if (!searchTerm || searchTerm.length < 1) {
this.searchSuggestions = [];
return;
}
//
const matchingStudents = this.state.studentList.filter(student =>
student.toLowerCase().includes(searchTerm.toLowerCase())
);
//
if (matchingStudents.some(student => student === searchTerm)) {
this.searchSuggestions = [];
return;
}
//
const suggestions = new Set();
//
if (searchTerm.length === 1) {
//
matchingStudents.forEach(student => {
if (student.startsWith(searchTerm)) {
suggestions.add(student);
}
});
} else {
//
matchingStudents.forEach(student => {
suggestions.add(student);
});
}
//
this.searchSuggestions = Array.from(suggestions).slice(0, 5);
},
toggleFilter(filter) {
const index = this.attendanceFilter.indexOf(filter);
@ -1486,136 +1402,22 @@ export default {
this.attendanceFilter.splice(index, 1);
}
},
openRandomPicker() {
if (this.$refs.randomPicker) {
this.$refs.randomPicker.open();
}
},
checkHashForRandomPicker() {
if (window.location.hash === '#random-picker') {
this.$nextTick(() => {
console.log("打开随机点名");
window.location.hash = '';
this.openRandomPicker();
});
}
},
},
};
</script>
<style lang="scss">
//
.glow-track {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at var(--x, 50%) var(--y, 50%),
rgba(255, 255, 255, 0.15) 0%,
rgba(255, 255, 255, 0) 70%);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
z-index: 1;
}
&:hover::before {
opacity: 1;
}
}
//
.grid-item .v-card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15) !important;
}
&:active {
transform: translateY(-2px);
}
}
//
.empty-subject-card {
transition: all 0.3s ease;
opacity: 0.8;
&:hover {
opacity: 1;
transform: translateY(-4px);
}
}
// 使 tonal
.anti-burn-card {
animation: subtle-glow 4s infinite alternate;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
}
@keyframes subtle-glow {
0% {
box-shadow: 0 0 5px rgba(33, 150, 243, 0.1);
}
100% {
box-shadow: 0 0 15px rgba(33, 150, 243, 0.3);
}
}
//
.attendance-stat {
height: 100%;
}
//
.surname-btn {
margin: 2px;
min-width: 0;
padding: 0 8px;
&:active {
transform: scale(0.95);
}
}
//
.suggestion-btn {
margin: 2px;
min-width: 0;
padding: 0 6px;
&:active {
transform: scale(0.95);
}
}
//
@media (hover: none) {
.student-card .attendance-actions {
opacity: 1;
}
}
//
@media (max-width: 600px) {
.student-card {
.attendance-actions .v-btn {
margin: 0 1px;
min-width: 28px;
width: 28px;
height: 28px;
}
}
}
//
.filter-chip {
cursor: pointer;
margin: 2px;
&:active {
transform: scale(0.95);
}
}
</style>

View File

@ -15,7 +15,7 @@
<v-row>
<v-col cols="12" md="6">
<server-settings-card
border
border
:loading="loading.server"
@saved="onSettingsSaved"
/>
@ -37,16 +37,14 @@
<display-settings-card @saved="onSettingsSaved" border/>
</v-col>
<!-- 添加主题设置卡片 -->
<v-col cols="12" md="6">
<theme-settings-card border />
</v-col>
<!-- 开发者选项卡片 -->
<v-col :cols="12" :md="settings.developer.enabled ? 12 : 6">
<settings-card
border
border
title="开发者选项"
icon="mdi-developer-board"
>
@ -85,8 +83,6 @@
</template>
</v-list-item>
<v-divider class="my-2" />
<v-expand-transition>
<div v-if="settings.developer.showDebugConfig">
<v-divider class="my-2" />
@ -138,12 +134,34 @@
<v-col cols="12">
<echo-chamber-card border />
</v-col>
<!-- 关于卡片 -->
<v-col cols="12">
<about-card />
</v-col>
<!-- 开发者模式下显示所有设置 -->
<v-col v-if="settings.developer.enabled" cols="12">
<v-card border>
<v-card-title class="d-flex align-center">
<v-icon icon="mdi-cog-outline" class="mr-2" />
所有设置
</v-card-title>
<v-card-subtitle>
浏览和修改所有可用设置
</v-card-subtitle>
<v-card-text>
<settings-explorer @update="onSettingUpdate" />
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<random-picker-settings-card />
</v-col>
</v-row>
</v-container>
<!-- 消息记录组件 -->
<message-log ref="messageLog" />
</div>
@ -170,6 +188,8 @@ import StudentListCard from '@/components/settings/StudentListCard.vue';
import AboutCard from '@/components/settings/AboutCard.vue';
import '../styles/settings.scss';
import dataProvider from '@/utils/dataProvider';
import SettingsExplorer from '@/components/settings/SettingsExplorer.vue';
import RandomPickerSettingsCard from '@/components/settings/cards/RandomPickerSettingsCard.vue';
export default {
name: 'Settings',
@ -184,7 +204,9 @@ export default {
AboutCard,
DataProviderSettingsCard,
ThemeSettingsCard,
EchoChamberCard
EchoChamberCard,
SettingsExplorer,
RandomPickerSettingsCard
},
setup() {
const { mobile } = useDisplay();
@ -565,6 +587,11 @@ export default {
onSettingsSaved() {
this.showMessage('设置已更新', '您的设置已成功保存');
//
},
onSettingUpdate(key, value) {
//
this.showMessage('设置已更新', `${key} 已保存为 ${value}`);
}
}
}

View File

@ -1,4 +1,124 @@
// 添加卡片发光效果
.glow-track {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at var(--x, 50%) var(--y, 50%),
rgba(255, 255, 255, 0.15) 0%,
rgba(255, 255, 255, 0) 70%);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
z-index: 1;
}
&:hover::before {
opacity: 1;
}
}
// 添加卡片悬浮效果
.grid-item .v-card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15) !important;
}
&:active {
transform: translateY(-2px);
}
}
// 添加空科目卡片样式
.empty-subject-card {
transition: all 0.3s ease;
opacity: 0.8;
&:hover {
opacity: 1;
transform: translateY(-4px);
}
}
// 修改防烧屏提示卡片使用 tonal 样式减少信息密度
.anti-burn-card {
animation: subtle-glow 4s infinite alternate;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
}
@keyframes subtle-glow {
0% {
box-shadow: 0 0 5px rgba(33, 150, 243, 0.1);
}
100% {
box-shadow: 0 0 15px rgba(33, 150, 243, 0.3);
}
}
// 出勤管理对话框样式
.attendance-stat {
height: 100%;
}
// 搜索建议按钮
.suggestion-btn {
margin: 2px;
min-width: 0;
padding: 0 6px;
&:active {
transform: scale(0.95);
}
}
// 适配触摸屏
@media (hover: none) {
.student-card .attendance-actions {
opacity: 1;
}
}
// 小屏幕适配
@media (max-width: 600px) {
.student-card {
.attendance-actions .v-btn {
margin: 0 1px;
min-width: 28px;
width: 28px;
height: 28px;
}
}
}
// 过滤器芯片
.filter-chip {
cursor: pointer;
margin: 2px;
&:active {
transform: scale(0.95);
}
}
.grid-masonry {
display: grid;
grid-template-columns: repeat(3, 1fr);
@ -173,4 +293,124 @@
.empty-subject-card:not(:disabled):hover {
opacity: 1;
transform: scale(1.02);
}
}
// 添加卡片发光效果
.glow-track {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at var(--x, 50%) var(--y, 50%),
rgba(255, 255, 255, 0.15) 0%,
rgba(255, 255, 255, 0) 70%);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
z-index: 1;
}
&:hover::before {
opacity: 1;
}
}
// 添加卡片悬浮效果
.grid-item .v-card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15) !important;
}
&:active {
transform: translateY(-2px);
}
}
// 添加空科目卡片样式
.empty-subject-card {
transition: all 0.3s ease;
opacity: 0.8;
&:hover {
opacity: 1;
transform: translateY(-4px);
}
}
// 修改防烧屏提示卡片使用 tonal 样式减少信息密度
.anti-burn-card {
animation: subtle-glow 4s infinite alternate;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
}
@keyframes subtle-glow {
0% {
box-shadow: 0 0 5px rgba(33, 150, 243, 0.1);
}
100% {
box-shadow: 0 0 15px rgba(33, 150, 243, 0.3);
}
}
// 出勤管理对话框样式
.attendance-stat {
height: 100%;
}
// 搜索建议按钮
.suggestion-btn {
margin: 2px;
min-width: 0;
padding: 0 6px;
&:active {
transform: scale(0.95);
}
}
// 适配触摸屏
@media (hover: none) {
.student-card .attendance-actions {
opacity: 1;
}
}
// 小屏幕适配
@media (max-width: 600px) {
.student-card {
.attendance-actions .v-btn {
margin: 0 1px;
min-width: 28px;
width: 28px;
height: 28px;
}
}
}
// 过滤器芯片
.filter-chip {
cursor: pointer;
margin: 2px;
&:active {
transform: scale(0.95);
}
}

View File

@ -2,7 +2,7 @@ import { openDB } from 'idb';
import { formatResponse, formatError } from '../dataProvider';
import { defaultConfig, defaultHomework } from '../defaults/defaultData';
const DB_NAME = "HomeworkDB";
const DB_NAME = "ClassworksDB";
const DB_VERSION = 1;
const initDB = async () => {

View File

@ -20,10 +20,6 @@ async function requestNotificationPermission() {
* @returns {Promise<boolean>} 是否成功启用持久性存储
*/
async function requestPersistentStorage() {
if (!getSetting("storage.persistentStorage")) {
return false;
}
try {
if (navigator.storage?.persist) {
return await navigator.storage.persist();
@ -58,6 +54,7 @@ window.addEventListener("load", initializeStorage);
* @property {string} [description] - 配置项描述
* @property {string} [legacyKey] - 旧版本localStorage键名(用于迁移)
* @property {boolean} [requireDeveloper] - 是否需要开发者选项启用
* @property {string} [icon] - 设置项的图标
*/
// 存储所有设置的localStorage键名
@ -69,15 +66,11 @@ const SETTINGS_STORAGE_KEY = "Classworks_settings";
*/
const settingsDefinitions = {
// 存储设置
"storage.persistentStorage": {
type: "boolean",
default: true,
description: "是否启用持久性存储",
},
"storage.persistOnLoad": {
type: "boolean",
default: true,
description: "是否在页面加载时自动请求持久性存储",
icon: "mdi-database-sync",
},
// 显示设置
@ -85,37 +78,48 @@ const settingsDefinitions = {
type: "string",
default: "card", // 修改默认值为 'button'
validate: (value) => ["card", "button"].includes(value),
description: "空科目的显示方式:卡片或按钮",
description: "空科目的显示方式",
icon: "mdi-card-outline",
},
"display.dynamicSort": {
type: "boolean",
default: true,
description: "是否启用动态排序以优化显示效果",
description: "是否启用动态排序",
icon: "mdi-sort-variant",
// 启用后会根据内容自动调整卡片顺序,提供更好的视觉体验
},
"display.showRandomButton": {
type: "boolean",
default: false,
description: "是否显示随机按钮",
description: "是否显示随机点人按钮",
icon: "mdi-shuffle-variant",
// 控制是否显示随机排序按钮,可用于随机调整卡片顺序
},
"display.showFullscreenButton": {
type: "boolean",
default: true,
description: "是否显示全屏按钮",
icon: "mdi-fullscreen",
// 控制是否显示进入全屏模式的按钮
},
"display.cardHoverEffect": {
type: "boolean",
default: true,
description: "是否启用卡片悬浮效果",
icon: "mdi-gesture-tap",
// 启用后鼠标悬停在卡片上时会显示视觉反馈效果
},
"display.enhancedTouchMode": {
type: "boolean",
default: true,
description: "是否启用增强触摸模式(更大的触摸目标)",
description: "是否启用增强触摸模式",
icon: "mdi-gesture-tap-button",
},
"display.showAntiScreenBurnCard": {
type: "boolean",
default: false,
description: "是否显示防烧屏提示卡片",
description: "是否显示防烧屏忽悠卡片",
icon: "mdi-monitor-shimmer",
},
// 服务器设置(合并了数据提供者设置)
@ -135,20 +139,25 @@ const settingsDefinitions = {
}
},
description: "后端服务器域名",
icon: "mdi-web",
// 设置后端服务器的域名,用于从远程服务器获取数据
},
"server.classNumber": {
type: "string",
default: "高三八班",
//validate: (value) => /^[A-Za-z0-9]*$/.test(value),
validate: (value) => /.*/.test(value),
description: "班级编号",
icon: "mdi-account-group",
// 设置班级标识,用于区分不同班级的数据
},
"server.provider": {
type: "string",
default: "indexedDB",
validate: (value) => ["server", "indexedDB"].includes(value),
description: "数据提供者,用于决定数据存储方式",
description: "数据提供者",
icon: "mdi-database",
// 选择数据存储方式使用本地IndexedDB或远程服务器
},
// 刷新设置
@ -156,12 +165,16 @@ const settingsDefinitions = {
type: "boolean",
default: false,
description: "是否启用自动刷新",
icon: "mdi-refresh-auto",
// 启用后将按设定的时间间隔自动刷新数据
},
"refresh.interval": {
type: "number",
default: 300,
validate: (value) => value >= 10 && value <= 3600,
description: "自动刷新间隔(秒)",
icon: "mdi-timer-outline",
// 设置自动刷新的时间间隔范围10-3600秒
},
// 字体设置
@ -169,7 +182,8 @@ const settingsDefinitions = {
type: "number",
default: 28,
validate: (value) => value >= 16 && value <= 100,
description: "字体大小(像素)",
description: "字体大小",
icon: "mdi-format-size",
},
// 编辑设置
@ -177,23 +191,30 @@ const settingsDefinitions = {
type: "boolean",
default: true,
description: "是否启用自动保存",
icon: "mdi-content-save-outline",
// 启用后编辑内容时会自动保存更改,无需手动点击保存按钮
},
"edit.blockNonTodayAutoSave": {
// 添加新选项
type: "boolean",
default: true,
description: "禁止自动保存非当天数据",
icon: "mdi-calendar-lock",
// 启用后只有当天的数据会自动保存,防止意外修改历史数据
},
"edit.refreshBeforeEdit": {
type: "boolean",
default: true,
description: "编辑前是否自动刷新",
icon: "mdi-refresh",
// 启用后在开始编辑前会自动刷新数据,确保编辑的是最新内容
},
"edit.confirmNonTodaySave": {
// 添加新选项
type: "boolean",
default: true,
description: "保存非当天数据时显示确认对话框,禁用则允许直接保存",
description: "保存非当天数据需确认",
icon: "mdi-calendar-alert",
},
// 开发者选项
@ -201,11 +222,15 @@ const settingsDefinitions = {
type: "boolean",
default: false,
description: "是否启用开发者选项",
icon: "mdi-developer-board",
// 启用后可以访问高级开发者功能和设置项
},
"developer.showDebugConfig": {
type: "boolean",
default: false,
description: "是否显示调试配置",
icon: "mdi-bug-outline",
// 启用后在控制台显示详细的配置信息和设置变更日志
},
"developer.disableMessageLog": {
// 添加新的设置项
@ -213,6 +238,8 @@ const settingsDefinitions = {
default: false,
description: "禁用消息日志记录",
requireDeveloper: true,
icon: "mdi-message-off-outline",
// 启用后将不再记录应用消息到日志,可减少内存占用
},
// 消息设置
@ -221,6 +248,8 @@ const settingsDefinitions = {
default: true,
description: "是否显示消息记录侧栏",
requireDeveloper: true, // 添加标记
icon: "mdi-message-text-outline",
// 控制是否显示消息历史记录侧栏,需要开发者模式
},
"message.maxActiveMessages": {
type: "number",
@ -228,6 +257,8 @@ const settingsDefinitions = {
validate: (value) => value >= 1 && value <= 10,
description: "同时显示的最大消息数量",
requireDeveloper: true,
icon: "mdi-message-badge-outline",
// 控制界面上同时显示的最大消息数量范围1-10条
},
"message.timeout": {
type: "number",
@ -235,12 +266,16 @@ const settingsDefinitions = {
validate: (value) => value >= 1000 && value <= 30000,
description: "消息自动关闭时间(毫秒)",
requireDeveloper: true,
icon: "mdi-timer-sand",
// 设置消息自动消失的时间范围1000-30000毫秒
},
"message.saveHistory": {
type: "boolean",
default: true,
description: "是否保存消息历史记录",
requireDeveloper: true,
icon: "mdi-history",
// 启用后将保存消息历史记录,可在侧栏中查看
},
// 主题设置
@ -249,6 +284,47 @@ const settingsDefinitions = {
default: "dark",
validate: (value) => ["light", "dark"].includes(value),
description: "主题模式",
icon: "mdi-theme-light-dark",
// 设置应用的主题模式,可选亮色或暗色主题
},
// 随机点名设置
"randomPicker.enabled": {
type: "boolean",
default: true,
description: "是否启用随机点名功能",
icon: "mdi-account-question",
},
"randomPicker.animation": {
type: "boolean",
default: true,
description: "是否启用随机点名动画效果",
icon: "mdi-animation-play",
},
"randomPicker.defaultCount": {
type: "number",
default: 1,
validate: (value) => value >= 1 && value <= 10,
description: "默认抽取人数",
icon: "mdi-counter",
},
"randomPicker.excludeAbsent": {
type: "boolean",
default: true,
description: "是否排除请假学生",
icon: "mdi-account-off",
},
"randomPicker.excludeLate": {
type: "boolean",
default: false,
description: "是否排除迟到学生",
icon: "mdi-clock-alert",
},
"randomPicker.excludeExcluded": {
type: "boolean",
default: true,
description: "是否排除不参与学生",
icon: "mdi-account-cancel",
},
};
@ -488,6 +564,36 @@ function watchSettings(callback) {
// 初始化设置
loadSettings();
/**
* 获取设置项的定义
* @param {string} key - 设置项键名
* @returns {SettingDefinition|null} 设置项的定义或null
*/
function getSettingDefinition(key) {
return settingsDefinitions[key] || null;
}
/**
* 将当前配置导出为简单的键值对对象
* @returns {Object} 包含所有设置的键值对对象
*/
function exportSettingsAsKeyValue() {
if (!settingsCache) {
loadSettings();
}
// 创建一个新对象,避免直接返回引用
const exportedSettings = {};
// 遍历所有设置项
for (const key in settingsDefinitions) {
// 获取当前值确保使用getSetting以应用所有规则如开发者选项依赖
exportedSettings[key] = getSetting(key);
}
return exportedSettings;
}
export {
settingsDefinitions,
getSetting,
@ -495,4 +601,6 @@ export {
resetSetting,
resetAllSettings,
watchSettings,
getSettingDefinition,
exportSettingsAsKeyValue
};

View File

@ -6,6 +6,7 @@ import Layouts from 'vite-plugin-vue-layouts'
import Vue from '@vitejs/plugin-vue'
import VueRouter from 'unplugin-vue-router/vite'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import { VitePWA } from 'vite-plugin-pwa'
// Utilities
import { defineConfig } from 'vite'
@ -18,6 +19,71 @@ export default defineConfig({
Layouts(),
Vue({
template: { transformAssetUrls }
}), VitePWA({
registerType: 'autoUpdate',
devOptions: {
navigateFallback: '/index.html', // 离线支持navigateFallback
enabled: true,
suppressWarnings: true, // 是否抑制 Workbox 的警告
},
lang: 'zh-CN',
manifest: {
name: 'Classworks作业板',
short_name: 'Classworks',
description: '记录,查看并同步作业',
theme_color: '#212121',
background_color: '#212121',
display: 'standalone',
start_url: '/',
edge_side_panel: {
default_path: '/',
},
workbox: {
globPatterns: ['**/*.{js,css,html,png,svg}'],
navigateFallback: '/index.html', // 离线支持navigateFallback
runtimeCaching: [
//所有资源都使用网络优先
{
urlPattern: /./,
handler: 'NetworkFirst',
},
],
},
icons: [
{
src: '/image/pwa-64x64.png',
sizes: '64x64',
type: 'image/png'
},
{
src: '/image/pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/image/pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: '/image/maskable-icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
],
shortcuts: [
{
name: '随机点名',
short_name: '随机点名',
url: '/#random-picker',
},
],
}
}),
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
Vuetify({