pwa,设置项,自动刷新

This commit is contained in:
SunWuyuan
2025-04-04 21:40:24 +08:00
parent 8e4d29afa3
commit 291c593178
32 changed files with 8794 additions and 965 deletions
+523
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>
+80
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>
+457
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>
@@ -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>
@@ -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: {} };
// 打开数据库
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>