1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-07-04 18:39:22 +00:00
This commit is contained in:
SunWuyuan 2025-03-02 14:03:30 +08:00
parent 367954cfa6
commit c6b68ed3a0
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
3 changed files with 965 additions and 399 deletions

View File

@ -53,114 +53,199 @@
/> />
</template> </template>
</v-app-bar> </v-app-bar>
<v-container <div class="d-flex">
class="main-window" <!-- 主要内容区域 -->
fluid <v-container
> class="main-window flex-grow-1"
<v-row> fluid
<v-col :cols="attendanceVisible ? 11 : 12"> >
<div class="grid-masonry" ref="gridContainer"> <div v-if="showNoDataMessage && !isToday" class="no-data-message">
<div <v-card class="text-center pa-4" variant="outlined">
v-for="item in sortedItems" <v-card-title class="text-h6">{{ noDataMessage }}</v-card-title>
:key="item.key" <v-card-text>
class="grid-item" <div class="text-body-1">该日期未上传作业数据</div>
:class="{ </v-card-text>
'empty-card': !item.content, </v-card>
[`grid-row-${item.rowSpan}`]: true </div>
}"
:style="{ <template v-else>
'grid-row-end': `span ${item.rowSpan}`, <div v-if="showNoDataMessage && isToday" class="no-data-message">
order: item.order <v-card class="text-center pa-4" variant="outlined">
}" <v-card-title class="text-h6">开始今天的作业</v-card-title>
@click="openDialog(item.key)" <v-card-text>
> <div class="text-body-1 mb-4">今天还没有上传作业哦</div>
<v-card border height="100%"> <v-btn
<v-card-title :class="{ 'text-subtitle-1': !item.content }"> color="primary"
{{ item.name }} @click="initializeAndOpenDialog"
>
开始添加作业
</v-btn>
</v-card-text>
</v-card>
</div>
<template v-else>
<div class="grid-masonry" ref="gridContainer">
<div
v-for="item in sortedItems"
:key="item.key"
class="grid-item"
:class="{
'empty-card': !item.content,
[`grid-row-${item.rowSpan}`]: true
}"
:style="{
'grid-row-end': `span ${item.rowSpan}`,
order: item.order,
cursor: uploadLoading || downloadLoading ? 'not-allowed' : 'pointer',
opacity: uploadLoading || downloadLoading ? '0.7' : '1'
}"
@click="!uploadLoading && !downloadLoading && openDialog(item.key)"
>
<v-card border height="100%">
<v-card-title :class="{ 'text-subtitle-1': !item.content }">
{{ item.name }}
</v-card-title>
<v-card-text :style="item.content ? contentStyle : null">
<template v-if="item.content">
<v-list>
<v-list-item
v-for="text in splitPoint(item.content)"
:key="text"
>
{{ text }}
</v-list-item>
</v-list>
</template>
<template v-else>
<div class="text-center pa-2">
<v-icon size="small" color="grey">mdi-plus</v-icon>
<div class="text-caption text-grey">点击添加作业</div>
</div>
</template>
</v-card-text>
</v-card>
</div>
</div>
<!-- 空科目按钮组 -->
<div v-if="emptySubjectDisplay === 'button'" class="empty-subjects-container">
<v-btn
v-for="subject in emptySubjects"
:key="subject.key"
variant="outlined"
color="primary"
class="empty-subject-btn"
@click="!uploadLoading && !downloadLoading && openDialog(subject.key)"
:disabled="uploadLoading || downloadLoading"
>
<v-icon start>mdi-plus</v-icon>
{{ subject.name }}
</v-btn>
</div>
</template>
</template>
</v-container>
<!-- 出勤统计区域 -->
<v-navigation-drawer
v-if="studentList.length > 1"
location="right"
permanent
width="300"
class="attendance-drawer"
>
<v-card
class="h-100"
flat
>
<v-card-item>
<v-card-title class="text-h6">
<v-icon icon="mdi-account-multiple" class="mr-2" />
出勤统计
</v-card-title>
</v-card-item>
<v-card-text>
<div class="attendance-stats mb-4">
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-subtitle-1">应到人数</span>
<span class="text-h6">{{ studentList.length }}</span>
</div>
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-subtitle-1">实到人数</span>
<span class="text-h6 text-success">
{{ studentList.length - selectedSet.size - lateSet.size }}
</span>
</div>
<v-divider class="my-2" />
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-subtitle-1">请假人数</span>
<span class="text-h6 text-error">{{ selectedSet.size }}</span>
</div>
<div class="d-flex justify-space-between align-center">
<span class="text-subtitle-1">迟到人数</span>
<span class="text-h6 text-warning">{{ lateSet.size }}</span>
</div>
</div>
<template v-if="selectedSet.size > 0">
<v-card
variant="outlined"
class="mb-4"
>
<v-card-title class="text-subtitle-1">
<v-icon icon="mdi-account-off" class="mr-2" color="error" />
请假名单
</v-card-title> </v-card-title>
<v-card-text :style="item.content ? contentStyle : null"> <v-card-text>
<template v-if="item.content"> <v-list density="compact" nav>
<v-list> <v-list-item
<v-list-item v-for="i in Array.from(selectedSet)"
v-for="text in splitPoint(item.content)" :key="'absent-' + i"
:key="text" :title="`${i + 1}. ${studentList[i]}`"
> prepend-icon="mdi-account"
{{ text }} />
</v-list-item> </v-list>
</v-list>
</template>
<template v-else>
<div class="text-center pa-2">
<v-icon size="small" color="grey">mdi-plus</v-icon>
<div class="text-caption text-grey">点击添加作业</div>
</div>
</template>
</v-card-text> </v-card-text>
</v-card> </v-card>
</div> </template>
</div>
<!-- 空科目按钮组 --> <template v-if="lateSet.size > 0">
<div v-if="emptySubjectDisplay === 'button'" class="empty-subjects-container"> <v-card
variant="outlined"
>
<v-card-title class="text-subtitle-1">
<v-icon icon="mdi-clock-alert" class="mr-2" color="warning" />
迟到名单
</v-card-title>
<v-card-text>
<v-list density="compact" nav>
<v-list-item
v-for="i in Array.from(lateSet)"
:key="'late-' + i"
:title="`${i + 1}. ${studentList[i]}`"
prepend-icon="mdi-account-clock"
/>
</v-list>
</v-card-text>
</v-card>
</template>
</v-card-text>
<v-card-actions class="pa-4">
<v-btn <v-btn
v-for="subject in emptySubjects"
:key="subject.key"
variant="outlined"
color="primary"
class="empty-subject-btn"
@click="openDialog(subject.key)"
>
<v-icon start>mdi-plus</v-icon>
{{ subject.name }}
</v-btn>
</div>
</v-col>
<v-col
v-if="studentList.length"
class="attendance-area"
:cols="1"
@click="setAttendanceArea"
>
<h1>出勤</h1>
<h2>应到:{{ studentList.length }}</h2>
<h2>
实到:{{ studentList.length - selectedSet.size }}
</h2>
<h2>请假:{{ selectedSet.size }} </h2>
<h3
v-for="(i, index) in selectedSet"
:key="'absent-' + index"
>
{{ `${index + 1}. ${studentList[i]}` }}
</h3>
<h2>迟到:{{ lateSet.size }} </h2>
<h3
v-for="(i, index) in lateSet"
:key="'late-' + index"
>
{{ `${index + 1}. ${studentList[i]}` }}
</h3>
<!-- 空科目按钮显示区域 -->
<template v-if="showEmptySubjects && emptySubjectDisplay === 'button'">
<v-divider class="my-4" />
<h2>未填写作业</h2>
<v-btn
v-for="subject in emptySubjects"
:key="subject.key"
block block
variant="outlined" color="primary"
class="mb-2" prepend-icon="mdi-pencil"
@click.stop="openDialog(subject.key)" @click="setAttendanceArea"
> >
{{ subject.name }} 编辑出勤状态
</v-btn> </v-btn>
</template> </v-card-actions>
</v-col> </v-card>
</v-row> </v-navigation-drawer>
</v-container> </div>
<v-container fluid> <v-container fluid>
<v-btn <v-btn
v-if="!synced" v-if="!synced"
@ -331,11 +416,65 @@
.main-window::-webkit-scrollbar-thumb:hover { .main-window::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.3); background-color: rgba(0, 0, 0, 0.3);
} }
.no-data-message {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
margin: 20px 0;
}
.attendance-drawer {
border-left: 1px solid rgba(0, 0, 0, 0.12);
}
.attendance-drawer :deep(.v-navigation-drawer__content) {
overflow-y: auto;
}
/* 优化滚动条样式 */
.attendance-drawer :deep(.v-navigation-drawer__content::-webkit-scrollbar) {
width: 8px;
}
.attendance-drawer :deep(.v-navigation-drawer__content::-webkit-scrollbar-track) {
background: transparent;
}
.attendance-drawer :deep(.v-navigation-drawer__content::-webkit-scrollbar-thumb) {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.attendance-drawer :deep(.v-navigation-drawer__content::-webkit-scrollbar-thumb:hover) {
background-color: rgba(0, 0, 0, 0.3);
}
/* 响应式调整 */
@media (max-width: 960px) {
.attendance-drawer {
display: none;
}
}
.text-success {
color: rgb(var(--v-theme-success));
}
.text-error {
color: rgb(var(--v-theme-error));
}
.text-warning {
color: rgb(var(--v-theme-warning));
}
</style> </style>
<script> <script>
import axios from "axios"; import axios from "axios";
import { useDisplay } from "vuetify"; import { useDisplay } from "vuetify";
import { getSetting, watchSettings } from '@/utils/settings';
export default { export default {
name: "HomeworkBoard", name: "HomeworkBoard",
@ -353,25 +492,24 @@ export default {
dateString: "", dateString: "",
synced: false, synced: false,
attendDialogVisible: false, attendDialogVisible: false,
contentStyle: { "font-size": "28px" }, contentStyle: { "font-size": `${getSetting('font.size')}px` },
uploadLoading: false, uploadLoading: false,
downloadLoading: false, downloadLoading: false,
homeworkData: {}, homeworkData: {},
homeworkArrange: [[], []], homeworkArrange: [[], []],
snackbar: false, snackbar: false,
snackbarText: "", snackbarText: "",
fontSize: parseInt(localStorage.getItem('fontSize')) || 28, fontSize: getSetting('font.size'),
datePickerDialog: false, datePickerDialog: false,
selectedDate: null, selectedDate: null,
refreshInterval: null, refreshInterval: null,
autoSave: false,
refreshBeforeEdit: false,
showEmptySubjects: localStorage.getItem('showEmptySubjects') === 'true',
emptySubjectDisplay: localStorage.getItem('emptySubjectDisplay') || 'card',
subjectOrder: [ subjectOrder: [
"语文", "数学", "英语", "物理", "化学", "语文", "数学", "英语", "物理", "化学",
"生物", "政治", "历史", "地理", "其他" "生物", "政治", "历史", "地理", "其他"
], ],
showNoDataMessage: false,
noDataMessage: '',
isToday: false,
}; };
}, },
@ -405,7 +543,6 @@ export default {
name: value.name, name: value.name,
content: value.content, content: value.content,
order: this.subjectOrder.indexOf(key), order: this.subjectOrder.indexOf(key),
//
rowSpan: value.content ? rowSpan: value.content ?
Math.ceil((value.content.split('\n').filter(line => line.trim()).length + 1) * 0.8) : 1 Math.ceil((value.content.split('\n').filter(line => line.trim()).length + 1) * 0.8) : 1
})) }))
@ -416,8 +553,12 @@ export default {
return true; return true;
}); });
// //
return this.optimizeGridLayout(items); if (this.dynamicSort) {
return this.optimizeGridLayout(items);
} else {
return this.fixedGridLayout(items);
}
}, },
attendanceVisible() { attendanceVisible() {
return this.studentList.length > 0; return this.studentList.length > 0;
@ -435,6 +576,18 @@ export default {
.filter(subject => !subject.content) .filter(subject => !subject.content)
.sort((a, b) => a.order - b.order); .sort((a, b) => a.order - b.order);
}, },
autoSave() {
return getSetting('edit.autoSave');
},
refreshBeforeEdit() {
return getSetting('edit.refreshBeforeEdit');
},
emptySubjectDisplay() {
return getSetting('display.emptySubjectDisplay');
},
dynamicSort() {
return getSetting('display.dynamicSort');
},
}, },
async mounted() { async mounted() {
@ -442,14 +595,26 @@ export default {
this.updateBackendUrl(); this.updateBackendUrl();
await this.initializeData(); await this.initializeData();
this.setupAutoRefresh(); this.setupAutoRefresh();
this.autoSave = localStorage.getItem('autoSave') === 'true';
this.refreshBeforeEdit = localStorage.getItem('refreshBeforeEdit') === 'true'; //
this.unwatchSettings = watchSettings(() => {
this.updateSettings();
});
} catch (err) { } catch (err) {
console.error("初始化失败:", err); console.error("初始化失败:", err);
this.showError("初始化失败,请刷新页面重试"); this.showError("初始化失败,请刷新页面重试");
} }
}, },
beforeUnmount() {
if (this.unwatchSettings) {
this.unwatchSettings();
}
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
},
methods: { methods: {
async initializeData() { async initializeData() {
const res = await axios.get(`${this.backurl}/config`); const res = await axios.get(`${this.backurl}/config`);
@ -593,7 +758,7 @@ export default {
this.contentStyle = { this.contentStyle = {
"font-size": `${this.fontSize}px`, "font-size": `${this.fontSize}px`,
}; };
localStorage.setItem('fontSize', this.fontSize.toString()); setSetting('font.size', this.fontSize);
}, },
async uploadData() { async uploadData() {
@ -630,64 +795,42 @@ export default {
}, },
async downloadDataDirectly() { async downloadDataDirectly() {
const formattedDate = new Date(this.dateString).toISOString().split('T')[0]; try {
const res = await axios.get( const formattedDate = new Date(this.dateString).toISOString().split('T')[0];
`${this.backurl}/homework?date=${formattedDate}` const res = await axios.get(
); `${this.backurl}/homework?date=${formattedDate}`
this.homeworkData = res.data.data || this.homeworkData; );
const subjectOrder = [ //
"语文", const today = new Date();
"数学", const isToday = today.toISOString().split('T')[0] === formattedDate;
"英语", this.isToday = isToday;
"物理",
"化学",
"生物",
"政治",
"历史",
"地理",
"其他",
];
// 1. 便
let sortedSubjects = Object.keys(this.homeworkData).map((key) => ({
key,
...this.homeworkData[key],
}));
// 2. if (!res.data.status) {
sortedSubjects.sort((a, b) => { this.showNoDataMessage = true;
const indexA = subjectOrder.indexOf(a.key); this.noDataMessage = res.data.msg || '未找到数据';
const indexB = subjectOrder.indexOf(b.key); //
this.homeworkData = {};
// this.selectedSet.clear();
if (indexA !== indexB) { this.lateSet.clear();
return indexA - indexB; return;
} }
return 0; //
}); this.showNoDataMessage = false;
this.homeworkData = res.data.data || {};
// 3. content this.selectedSet = new Set(res.data.attendance || []);
sortedSubjects = [ this.lateSet = new Set(res.data.late || []);
// content this.synced = true;
...sortedSubjects.filter((item) => item.content != ""), } catch (error) {
// content console.error('下载数据失败:', error);
...sortedSubjects.filter((item) => item.content == ""), this.showError('下载数据失败,请重试');
]; }
// 4.
this.homeworkData = sortedSubjects.reduce((acc, curr) => {
acc[curr.key] = { name: curr.name, content: curr.content };
return acc;
}, {});
this.selectedSet = new Set(res.data.attendance || []);
this.lateSet = new Set(res.data.late || []); // Initialize late set
this.synced = true;
}, },
updateBackendUrl() { updateBackendUrl() {
const domain = localStorage.getItem('backendServerDomain'); const domain = getSetting('server.domain');
const classNum = localStorage.getItem('classNumber'); const classNum = getSetting('server.classNumber');
if (domain && classNum) { if (domain && classNum) {
this.backurl = `${domain}/${classNum}`; this.backurl = `${domain}/${classNum}`;
@ -696,8 +839,12 @@ export default {
}, },
setupAutoRefresh() { setupAutoRefresh() {
const autoRefresh = localStorage.getItem('autoRefresh') === 'true'; const autoRefresh = getSetting('refresh.auto');
const interval = parseInt(localStorage.getItem('refreshInterval')) || 300; const interval = getSetting('refresh.interval');
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
if (autoRefresh) { if (autoRefresh) {
this.refreshInterval = setInterval(() => { this.refreshInterval = setInterval(() => {
@ -706,6 +853,18 @@ export default {
} }
}, },
updateSettings() {
//
this.fontSize = getSetting('font.size');
this.contentStyle = { "font-size": `${this.fontSize}px` };
//
this.setupAutoRefresh();
//
this.updateBackendUrl();
},
handleDateSelect(newDate) { handleDateSelect(newDate) {
if (newDate) { if (newDate) {
// 使 // 使
@ -721,12 +880,6 @@ export default {
} }
}, },
beforeDestroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
},
optimizeGridLayout(items) { optimizeGridLayout(items) {
// //
const sortedItems = items.sort((a, b) => { const sortedItems = items.sort((a, b) => {
@ -760,7 +913,73 @@ export default {
...item, ...item,
order: index order: index
})); }));
} },
fixedGridLayout(items) {
//
const rowSubjects = [
["语文", "数学", "英语"],
["物理", "化学", "生物"],
["政治", "历史", "地理", "其他"]
];
//
return items.sort((a, b) => {
//
const getRowIndex = (subject) => {
for (let i = 0; i < rowSubjects.length; i++) {
if (rowSubjects[i].includes(subject)) {
return i;
}
}
return rowSubjects.length; //
};
//
const getColumnIndex = (subject) => {
for (const row of rowSubjects) {
const index = row.indexOf(subject);
if (index !== -1) return index;
}
return 999; //
};
const rowA = getRowIndex(a.key);
const rowB = getRowIndex(b.key);
if (rowA !== rowB) {
return rowA - rowB;
}
//
const colA = getColumnIndex(a.key);
const colB = getColumnIndex(b.key);
return colA - colB;
}).map((item, index) => ({
...item,
order: index,
//
rowSpan: item.content ? 2 : 1
}));
},
initializeAndOpenDialog() {
//
this.initializeHomeworkData();
this.showNoDataMessage = false;
this.synced = false; //
//
if (this.autoSave) {
this.uploadData();
}
//
const firstSubject = Object.keys(this.homeworkData)[0];
if (firstSubject) {
this.openDialog(firstSubject);
}
},
}, },
watch: { watch: {

View File

@ -24,7 +24,7 @@
<v-card-text> <v-card-text>
<v-text-field <v-text-field
v-model="serverDomain" v-model="settings.server.domain"
label="服务器域名" label="服务器域名"
placeholder="例如: http://example.com" placeholder="例如: http://example.com"
prepend-inner-icon="mdi-web" prepend-inner-icon="mdi-web"
@ -34,7 +34,7 @@
required required
/> />
<v-text-field <v-text-field
v-model="classNumber" v-model="settings.server.classNumber"
label="班号" label="班号"
placeholder="例如: 1 或 A" placeholder="例如: 1 或 A"
prepend-inner-icon="mdi-account-group" prepend-inner-icon="mdi-account-group"
@ -50,7 +50,7 @@
color="primary" color="primary"
prepend-icon="mdi-content-save" prepend-icon="mdi-content-save"
block block
@click="saveServerSettings" @click="saveSettings('server')"
> >
保存设置 保存设置
</v-btn> </v-btn>
@ -71,18 +71,18 @@
<v-card-text> <v-card-text>
<v-switch <v-switch
v-model="autoRefresh" v-model="settings.refresh.auto"
label="启用自动刷新" label="启用自动刷新"
color="primary" color="primary"
hide-details hide-details
class="mb-4" class="mb-4"
/> />
<v-text-field <v-text-field
v-model="refreshInterval" v-model="settings.refresh.interval"
type="number" type="number"
label="刷新间隔" label="刷新间隔"
suffix="秒" suffix="秒"
:disabled="!autoRefresh" :disabled="!settings.refresh.auto"
variant="outlined" variant="outlined"
density="comfortable" density="comfortable"
:rules="[ :rules="[
@ -97,7 +97,7 @@
color="primary" color="primary"
prepend-icon="mdi-content-save" prepend-icon="mdi-content-save"
block block
@click="saveRefreshSettings" @click="saveSettings('refresh')"
> >
保存设置 保存设置
</v-btn> </v-btn>
@ -116,7 +116,7 @@
<v-card-text> <v-card-text>
<v-text-field <v-text-field
v-model="fontSize" v-model="settings.font.size"
type="number" type="number"
label="字体大小" label="字体大小"
suffix="px" suffix="px"
@ -144,7 +144,7 @@
color="primary" color="primary"
prepend-icon="mdi-content-save" prepend-icon="mdi-content-save"
class="flex-grow-1" class="flex-grow-1"
@click="saveFontSize" @click="saveSettings('font')"
> >
保存设置 保存设置
</v-btn> </v-btn>
@ -163,7 +163,7 @@
<v-card-text> <v-card-text>
<v-switch <v-switch
v-model="autoSave" v-model="settings.edit.autoSave"
label="启用自动保存" label="启用自动保存"
color="primary" color="primary"
hide-details hide-details
@ -185,7 +185,7 @@
</v-switch> </v-switch>
<v-switch <v-switch
v-model="refreshBeforeEdit" v-model="settings.edit.refreshBeforeEdit"
label="编辑前自动刷新" label="编辑前自动刷新"
color="primary" color="primary"
hide-details hide-details
@ -211,13 +211,113 @@
color="primary" color="primary"
prepend-icon="mdi-content-save" prepend-icon="mdi-content-save"
block block
@click="saveEditSettings" @click="saveSettings('edit')"
> >
保存设置 保存设置
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-col> </v-col> <v-col cols="12" md="6">
<v-card elevation="2" class="rounded-lg">
<v-card-item>
<template v-slot:prepend>
<v-icon icon="mdi-card-outline" size="large" class="mr-2" />
</template>
<v-card-title class="text-h6">显示设置</v-card-title>
</v-card-item>
<v-card-text>
<v-switch
v-model="settings.display.dynamicSort"
label="启用动态排序"
hint="动态排序会根据内容长度自动调整卡片位置以优化显示效果"
persistent-hint
class="mb-4"
@change="saveSettings('display')"
>
<template v-slot:append>
<v-tooltip location="right">
<template v-slot:activator="{ props }">
<v-icon
v-bind="props"
icon="mdi-help-circle-outline"
size="small"
class="ml-2"
/>
</template>
<div>
<p>启用根据内容长度动态调整位置</p>
<p>关闭按语数英/物化生/政史地固定排列</p>
</div>
</v-tooltip>
</template>
</v-switch>
<v-divider class="my-4" />
<v-radio-group
v-model="settings.display.emptySubjectDisplay"
label="空作业显示方式"
class="mt-4"
@change="saveSettings('display')"
>
<v-radio
value="card"
label="显示为空卡片"
>
<template v-slot:label>
<div class="d-flex align-center">
显示为空卡片
<v-tooltip location="right">
<template v-slot:activator="{ props }">
<v-icon
v-bind="props"
icon="mdi-help-circle-outline"
size="small"
class="ml-2"
/>
</template>
在主界面中显示为可点击的空白卡片
</v-tooltip>
</div>
</template>
</v-radio>
<v-radio
value="button"
label="显示为按钮组"
>
<template v-slot:label>
<div class="d-flex align-center">
显示为按钮组
<v-tooltip location="right">
<template v-slot:activator="{ props }">
<v-icon
v-bind="props"
icon="mdi-help-circle-outline"
size="small"
class="ml-2"
/>
</template>
在主界面底部显示为一组添加按钮
</v-tooltip>
</div>
</template>
</v-radio>
</v-radio-group>
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-btn
color="primary"
prepend-icon="mdi-content-save"
block
@click="saveSettings('display')"
>
保存设置
</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12"> <v-col cols="12">
<v-card elevation="2" class="rounded-lg"> <v-card elevation="2" class="rounded-lg">
<v-card-item> <v-card-item>
@ -238,6 +338,23 @@
</v-card-item> </v-card-item>
<v-card-text> <v-card-text>
<v-progress-linear
v-if="studentsLoading"
indeterminate
color="primary"
class="mb-4"
/>
<v-alert
v-if="studentsError"
type="error"
variant="tonal"
closable
class="mb-4"
>
{{ studentsError }}
</v-alert>
<v-expand-transition> <v-expand-transition>
<div v-if="!showAdvancedEdit"> <div v-if="!showAdvancedEdit">
<v-row class="mb-6"> <v-row class="mb-6">
@ -402,6 +519,8 @@
color="primary" color="primary"
prepend-icon="mdi-content-save" prepend-icon="mdi-content-save"
size="large" size="large"
:loading="studentsLoading"
:disabled="studentsLoading"
@click="saveStudents" @click="saveStudents"
> >
保存学生列表 保存学生列表
@ -411,6 +530,8 @@
variant="outlined" variant="outlined"
prepend-icon="mdi-refresh" prepend-icon="mdi-refresh"
size="large" size="large"
:loading="studentsLoading"
:disabled="studentsLoading"
@click="reloadStudentList" @click="reloadStudentList"
> >
重置列表 重置列表
@ -484,86 +605,6 @@
</v-card> </v-card>
</v-col> </v-col>
</v-row> </v-row>
<v-switch
v-model="showEmptySubjects"
label="显示空作业科目"
hint="是否在主界面显示没有作业内容的科目"
persistent-hint
@change="saveSettings"
/>
<v-col cols="12" md="6">
<v-card elevation="2" class="rounded-lg">
<v-card-item>
<template v-slot:prepend>
<v-icon icon="mdi-card-outline" size="large" class="mr-2" />
</template>
<v-card-title class="text-h6">空作业显示设置</v-card-title>
</v-card-item>
<v-card-text>
<v-radio-group
v-model="emptySubjectDisplay"
label="空作业显示方式"
>
<v-radio
value="card"
label="显示为空卡片"
>
<template v-slot:label>
<div class="d-flex align-center">
显示为空卡片
<v-tooltip location="right">
<template v-slot:activator="{ props }">
<v-icon
v-bind="props"
icon="mdi-help-circle-outline"
size="small"
class="ml-2"
/>
</template>
在主界面中显示为可点击的空白卡片
</v-tooltip>
</div>
</template>
</v-radio>
<v-radio
value="button"
label="显示为按钮组"
>
<template v-slot:label>
<div class="d-flex align-center">
显示为按钮组
<v-tooltip location="right">
<template v-slot:activator="{ props }">
<v-icon
v-bind="props"
icon="mdi-help-circle-outline"
size="small"
class="ml-2"
/>
</template>
在主界面底部显示为一组添加按钮
</v-tooltip>
</div>
</template>
</v-radio>
</v-radio-group>
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-btn
color="primary"
prepend-icon="mdi-content-save"
block
@click="saveEmptySubjectSettings"
>
保存设置
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-container> </v-container>
<v-snackbar v-model="snackbar"> <v-snackbar v-model="snackbar">
@ -624,6 +665,12 @@
<script> <script>
import axios from 'axios'; import axios from 'axios';
import { useDisplay } from 'vuetify'; import { useDisplay } from 'vuetify';
import {
getSetting,
setSetting,
resetSetting,
watchSettings
} from '@/utils/settings';
export default { export default {
setup() { setup() {
@ -633,17 +680,31 @@ export default {
data() { data() {
return { return {
serverDomain: '', settings: {
classNumber: '', server: {
domain: getSetting('server.domain'),
classNumber: getSetting('server.classNumber'),
},
refresh: {
auto: getSetting('refresh.auto'),
interval: getSetting('refresh.interval'),
},
font: {
size: getSetting('font.size'),
},
edit: {
autoSave: getSetting('edit.autoSave'),
refreshBeforeEdit: getSetting('edit.refreshBeforeEdit'),
},
display: {
emptySubjectDisplay: getSetting('display.emptySubjectDisplay'),
dynamicSort: getSetting('display.dynamicSort'),
}
},
students: '', students: '',
studentsList: [], studentsList: [],
snackbar: false, snackbar: false,
snackbarText: '', snackbarText: '',
autoRefresh: false,
refreshInterval: 300,
fontSize: '28',
autoSave: false,
refreshBeforeEdit: false,
showAdvancedEdit: false, showAdvancedEdit: false,
newStudent: '', newStudent: '',
editingIndex: -1, editingIndex: -1,
@ -655,16 +716,58 @@ export default {
studentToMove: null, studentToMove: null,
touchStartTime: 0, touchStartTime: 0,
touchTimeout: null, touchTimeout: null,
showEmptySubjects: localStorage.getItem('showEmptySubjects') === 'true', studentsLoading: false,
emptySubjectDisplay: localStorage.getItem('emptySubjectDisplay') || 'card', studentsError: null,
}; };
}, },
mounted() { mounted() {
this.loadSettings(); this.loadAllSettings();
this.unwatchSettings = watchSettings(() => {
this.loadAllSettings();
});
this.loadStudentList();
},
beforeUnmount() {
if (this.unwatchSettings) {
this.unwatchSettings();
}
}, },
watch: { watch: {
'settings.server': {
handler(newVal, oldVal) {
if (newVal.domain !== oldVal?.domain || newVal.classNumber !== oldVal?.classNumber) {
this.loadStudentList();
}
},
deep: true
},
'settings.refresh': {
handler() {
this.saveSettings('refresh');
},
deep: true
},
'settings.font': {
handler() {
this.saveSettings('font');
},
deep: true
},
'settings.edit': {
handler() {
this.saveSettings('edit');
},
deep: true
},
'settings.display': {
handler() {
this.saveSettings('display');
},
deep: true
},
students: { students: {
handler(newVal) { handler(newVal) {
this.studentsList = newVal.split('\n').filter(s => s.trim()); this.studentsList = newVal.split('\n').filter(s => s.trim());
@ -680,64 +783,58 @@ export default {
}, },
methods: { methods: {
loadSettings() { loadAllSettings() {
const savedDomain = localStorage.getItem('backendServerDomain'); Object.keys(this.settings).forEach(section => {
const savedClass = localStorage.getItem('classNumber'); Object.keys(this.settings[section]).forEach(key => {
this.settings[section][key] = getSetting(`${section}.${key}`);
if (savedDomain) { });
this.serverDomain = savedDomain; });
}
if (savedClass) {
this.classNumber = savedClass;
}
if (localStorage.getItem('studentList')) {
this.students = localStorage.getItem('studentList').replace(/,/g, '\n');
this.studentsList = this.students.split('\n').filter(s => s.trim());
}
this.autoRefresh = localStorage.getItem('autoRefresh') === 'true';
this.refreshInterval = parseInt(localStorage.getItem('refreshInterval')) || 300;
const savedFontSize = localStorage.getItem('fontSize');
if (savedFontSize) {
this.fontSize = savedFontSize;
}
this.autoSave = localStorage.getItem('autoSave') === 'true';
this.refreshBeforeEdit = localStorage.getItem('refreshBeforeEdit') === 'true';
}, },
saveServerSettings() { saveSettings(section) {
try { try {
if (this.serverDomain === '') { Object.keys(this.settings[section]).forEach(key => {
localStorage.removeItem('backendServerDomain'); setSetting(`${section}.${key}`, this.settings[section][key]);
localStorage.removeItem('classNumber'); });
this.showMessage('删除成功'); this.showMessage('设置已保存');
return;
}
new URL(this.serverDomain);
const cleanDomain = this.serverDomain.replace(/\/+$/, '');
if (!this.classNumber || !/^[A-Za-z0-9]+$/.test(this.classNumber)) {
throw new Error('Invalid class number');
}
localStorage.setItem('backendServerDomain', cleanDomain);
localStorage.setItem('classNumber', this.classNumber);
this.showMessage('保存成功');
} catch (error) { } catch (error) {
console.error(error); console.error('保存设置失败:', error);
this.showMessage('保存失败,请检查服务器域名和班号'); this.showMessage('保存设置失败,请检查输入');
}
},
async loadStudentList() {
try {
this.studentsLoading = true;
this.studentsError = null;
const domain = getSetting('server.domain');
const classNum = getSetting('server.classNumber');
if (!domain || !classNum) {
throw new Error('请先设置服务器域名和班号');
}
const res = await axios.get(`${domain}/${classNum}/config`);
if (res.data && Array.isArray(res.data.studentList)) {
this.studentsList = res.data.studentList;
this.students = this.studentsList.join('\n');
} else {
throw new Error('获取学生列表失败');
}
} catch (error) {
console.error('加载学生列表失败:', error);
this.studentsError = error.message || '加载失败,请检查服务器设置';
this.showMessage(this.studentsError);
} finally {
this.studentsLoading = false;
} }
}, },
async saveStudents() { async saveStudents() {
try { try {
const domain = localStorage.getItem('backendServerDomain'); const domain = getSetting('server.domain');
const classNum = localStorage.getItem('classNumber'); const classNum = getSetting('server.classNumber');
if (!domain || !classNum) { if (!domain || !classNum) {
throw new Error('请先设置服务器域名和班号'); throw new Error('请先设置服务器域名和班号');
@ -751,74 +848,17 @@ export default {
localStorage.setItem('studentList', this.studentsList.join(',')); localStorage.setItem('studentList', this.studentsList.join(','));
this.showMessage('保存成功'); this.showMessage('保存成功');
} catch (error) { } catch (error) {
console.error(error); console.error('保存学生列表失败:', error);
this.showMessage(error.message || '保存失败,请检查服务器设置和学生列表'); this.showMessage(error.message || '保存失败,请检查服务器设置和学生列表');
} }
}, },
saveRefreshSettings() { async reloadStudentList() {
localStorage.setItem('autoRefresh', this.autoRefresh);
localStorage.setItem('refreshInterval', this.refreshInterval);
this.showMessage('保存成功');
},
saveFontSize() {
try { try {
const size = parseInt(this.fontSize); await this.loadStudentList();
if (size >= 16 && size <= 100) { this.showMessage('已重新加载学生列表');
localStorage.setItem('fontSize', size.toString());
this.showMessage('字体大小保存成功');
} else {
throw new Error('Invalid font size');
}
} catch (error) { } catch (error) {
this.showMessage('保存失败字体大小必须在16-100之间'); this.showMessage('重新加载失败');
}
},
resetFontSize() {
localStorage.removeItem('fontSize');
this.fontSize = '28';
this.showMessage('字体大小已重置为默认值');
},
saveEditSettings() {
localStorage.setItem('autoSave', this.autoSave);
localStorage.setItem('refreshBeforeEdit', this.refreshBeforeEdit);
this.showMessage(
this.autoSave
? '已启用自动保存'
: this.refreshBeforeEdit
? '已启用编辑前刷新'
: '已更新编辑设置'
);
},
addStudent() {
const student = this.newStudent.trim();
if (student && !this.studentsList.includes(student)) {
this.studentsList.push(student);
this.newStudent = '';
}
},
removeStudent(index) {
if (index !== undefined) {
this.studentsList.splice(index, 1);
this.deleteDialog = false;
this.studentToDelete = null;
this.synced = false;
if (this.autoSave) this.saveStudents();
}
},
reloadStudentList() {
const savedList = localStorage.getItem('studentList');
if (savedList) {
this.students = savedList.replace(/,/g, '\n');
} else {
this.students = '';
this.studentsList = [];
} }
}, },
@ -841,7 +881,7 @@ export default {
const newName = this.editingName.trim(); const newName = this.editingName.trim();
if (newName && newName !== this.studentsList[this.editingIndex]) { if (newName && newName !== this.studentsList[this.editingIndex]) {
this.studentsList[this.editingIndex] = newName; this.studentsList[this.editingIndex] = newName;
if (this.autoSave) { if (this.settings.edit.autoSave) {
this.saveStudents(); this.saveStudents();
} }
} }
@ -864,7 +904,7 @@ export default {
[this.studentsList[index], this.studentsList[newIndex]] = [this.studentsList[index], this.studentsList[newIndex]] =
[this.studentsList[newIndex], this.studentsList[index]]; [this.studentsList[newIndex], this.studentsList[index]];
if (this.autoSave) { if (this.settings.edit.autoSave) {
this.saveStudents(); this.saveStudents();
} }
} }
@ -887,8 +927,7 @@ export default {
const student = this.studentsList[this.studentToMove]; const student = this.studentsList[this.studentToMove];
this.studentsList.splice(this.studentToMove, 1); this.studentsList.splice(this.studentToMove, 1);
this.studentsList.splice(newPos, 0, student); this.studentsList.splice(newPos, 0, student);
this.synced = false; if (this.settings.edit.autoSave) this.saveStudents();
if (this.autoSave) this.saveStudents();
} }
this.numberDialog = false; this.numberDialog = false;
this.studentToMove = null; this.studentToMove = null;
@ -901,21 +940,37 @@ export default {
this.studentsList.splice(index, 1); this.studentsList.splice(index, 1);
this.studentsList.unshift(student); this.studentsList.unshift(student);
if (this.autoSave) { if (this.settings.edit.autoSave) {
this.saveStudents(); this.saveStudents();
} }
} }
}, },
saveSettings() { addStudent() {
localStorage.setItem('showEmptySubjects', this.showEmptySubjects.toString()); const student = this.newStudent.trim();
localStorage.setItem('emptySubjectDisplay', this.emptySubjectDisplay); if (student && !this.studentsList.includes(student)) {
this.studentsList.push(student);
this.newStudent = '';
if (this.settings.edit.autoSave) {
this.saveStudents();
}
}
}, },
saveEmptySubjectSettings() { removeStudent(index) {
this.saveSettings(); if (index !== undefined) {
this.showMessage('保存成功'); this.studentsList.splice(index, 1);
} this.deleteDialog = false;
this.studentToDelete = null;
if (this.settings.edit.autoSave) this.saveStudents();
}
},
resetFontSize() {
resetSetting('font.size');
this.settings.font.size = getSetting('font.size');
this.showMessage('字体大小已重置为默认值');
},
}, },
}; };
</script> </script>

292
src/utils/settings.js Normal file
View File

@ -0,0 +1,292 @@
/**
* 配置项定义
* @typedef {Object} SettingDefinition
* @property {string} type - 配置项类型 ('boolean' | 'number' | 'string')
* @property {any} default - 默认值
* @property {Function} [validate] - 可选的验证函数
* @property {string} [description] - 配置项描述
* @property {string} [legacyKey] - 旧版本localStorage键名(用于迁移)
*/
// 存储所有设置的localStorage键名
const SETTINGS_STORAGE_KEY = 'homeworkpage_settings';
/**
* 所有配置项的定义
* @type {Object.<string, SettingDefinition>}
*/
const settingsDefinitions = {
// 显示设置
// 'display.showEmptySubjects': {
// type: 'boolean',
// default: true,
// description: '是否在主界面显示没有作业内容的科目'
// },
'display.emptySubjectDisplay': {
type: 'string',
default: 'card',
validate: value => ['card', 'button'].includes(value),
description: '空科目的显示方式:卡片或按钮'
},
'display.dynamicSort': {
type: 'boolean',
default: true,
description: '是否启用动态排序以优化显示效果'
},
// 服务器设置
'server.domain': {
type: 'string',
default: '',
validate: value => !value || /^https?:\/\//.test(value),
description: '后端服务器域名'
},
'server.classNumber': {
type: 'string',
default: '',
validate: value => /^[A-Za-z0-9]*$/.test(value),
description: '班级编号'
},
// 刷新设置
'refresh.auto': {
type: 'boolean',
default: false,
description: '是否启用自动刷新'
},
'refresh.interval': {
type: 'number',
default: 300,
validate: value => value >= 10 && value <= 3600,
description: '自动刷新间隔(秒)'
},
// 字体设置
'font.size': {
type: 'number',
default: 28,
validate: value => value >= 16 && value <= 100,
description: '字体大小(像素)'
},
// 编辑设置
'edit.autoSave': {
type: 'boolean',
default: false,
description: '是否启用自动保存'
},
'edit.refreshBeforeEdit': {
type: 'boolean',
default: false,
description: '编辑前是否自动刷新'
}
};
// 内存中缓存的设置值
let settingsCache = null;
/**
* 从localStorage加载所有设置
* @returns {Object} 所有设置的值
*/
function loadSettings() {
try {
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
if (stored) {
settingsCache = JSON.parse(stored);
} else {
// 首次使用或迁移旧数据
settingsCache = migrateFromLegacy();
}
} catch (error) {
console.error('加载设置失败:', error);
settingsCache = {};
}
// 确保所有设置项都有值(使用默认值填充)
for (const [key, definition] of Object.entries(settingsDefinitions)) {
if (!(key in settingsCache)) {
settingsCache[key] = definition.default;
}
}
return settingsCache;
}
/**
* 从旧版本的localStorage迁移数据
*/
function migrateFromLegacy() {
const settings = {};
const legacyKeyMap = {
'server.domain': 'backendServerDomain',
'server.classNumber': 'classNumber',
'refresh.auto': 'autoRefresh',
'refresh.interval': 'refreshInterval',
'font.size': 'fontSize',
'edit.autoSave': 'autoSave',
'edit.refreshBeforeEdit': 'refreshBeforeEdit',
'display.emptySubjectDisplay': 'emptySubjectDisplay',
'display.dynamicSort': 'dynamicSort'
};
// 迁移旧数据
for (const [newKey, oldKey] of Object.entries(legacyKeyMap)) {
const oldValue = localStorage.getItem(oldKey);
if (oldValue !== null) {
const definition = settingsDefinitions[newKey];
switch (definition.type) {
case 'boolean':
settings[newKey] = oldValue === 'true';
break;
case 'number':
settings[newKey] = Number(oldValue);
break;
default:
settings[newKey] = oldValue;
}
// 可选删除旧的localStorage项
// localStorage.removeItem(oldKey);
}
}
// 保存迁移后的数据
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
return settings;
}
/**
* 保存所有设置到localStorage
*/
function saveSettings() {
try {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settingsCache));
} catch (error) {
console.error('保存设置失败:', error);
}
}
/**
* 获取设置项的值
* @param {string} key - 设置项键名
* @returns {any} 设置项的值
*/
function getSetting(key) {
if (!settingsCache) {
loadSettings();
}
const definition = settingsDefinitions[key];
if (!definition) {
console.warn(`未定义的设置项: ${key}`);
return null;
}
const value = settingsCache[key];
return value !== undefined ? value : definition.default;
}
/**
* 设置配置项的值
* @param {string} key - 设置项键名
* @param {any} value - 要设置的值
* @returns {boolean} 是否设置成功
*/
function setSetting(key, value) {
const definition = settingsDefinitions[key];
if (!definition) {
console.warn(`未定义的设置项: ${key}`);
return false;
}
try {
// 类型转换
if (typeof value !== definition.type) {
value = definition.type === 'boolean' ? Boolean(value) :
definition.type === 'number' ? Number(value) : String(value);
}
// 验证
if (definition.validate && !definition.validate(value)) {
console.warn(`设置项 ${key} 的值无效`);
return false;
}
if (!settingsCache) {
loadSettings();
}
settingsCache[key] = value;
saveSettings();
// 为了保持向后兼容同时更新旧的localStorage键
const legacyKey = definition.legacyKey;
if (legacyKey) {
localStorage.setItem(legacyKey, value.toString());
}
return true;
} catch (error) {
console.error(`设置配置项 ${key} 失败:`, error);
return false;
}
}
/**
* 重置指定设置项到默认值
* @param {string} key - 设置项键名
*/
function resetSetting(key) {
const definition = settingsDefinitions[key];
if (!definition) {
console.warn(`未定义的设置项: ${key}`);
return;
}
if (!settingsCache) {
loadSettings();
}
settingsCache[key] = definition.default;
saveSettings();
}
/**
* 重置所有设置项到默认值
*/
function resetAllSettings() {
settingsCache = {};
for (const [key, definition] of Object.entries(settingsDefinitions)) {
settingsCache[key] = definition.default;
}
saveSettings();
}
/**
* 监听设置变化
* @param {Function} callback - 当设置改变时调用的回调函数
* @returns {Function} 取消监听的函数
*/
function watchSettings(callback) {
const handler = (event) => {
if (event.key === SETTINGS_STORAGE_KEY) {
settingsCache = JSON.parse(event.newValue);
callback(settingsCache);
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}
// 初始化设置
loadSettings();
export {
settingsDefinitions,
getSetting,
setSetting,
resetSetting,
resetAllSettings,
watchSettings
};