mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2025-07-05 02:59:23 +00:00
重构首页组件,整合状态管理,优化数据加载与保存逻辑,更新UI交互,提升用户体验
This commit is contained in:
parent
02c8fccb30
commit
05e9e73b5b
439
src/components/AttendanceManager.vue
Normal file
439
src/components/AttendanceManager.vue
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-dialog v-model="dialogVisible" max-width="900" fullscreen-breakpoint="sm" @update:model-value="handleDialogClose">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex align-center">
|
||||||
|
<v-icon icon="mdi-account-group" class="mr-2" />
|
||||||
|
出勤状态管理
|
||||||
|
<v-spacer />
|
||||||
|
<v-chip color="primary" size="small" class="ml-2">
|
||||||
|
{{ dateString }}
|
||||||
|
</v-chip>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text>
|
||||||
|
<!-- 批量操作和搜索 -->
|
||||||
|
<v-row class="mb-4">
|
||||||
|
<v-col cols="12" md="12">
|
||||||
|
<v-text-field v-model="search" 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"
|
||||||
|
:variant="search === surname.name ? 'elevated' : 'text'"
|
||||||
|
:color="search === surname.name ? 'primary' : ''"
|
||||||
|
@click="search = search === surname.name ? '' : surname.name">
|
||||||
|
{{ surname.name }}
|
||||||
|
({{ surname.count }})
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- 过滤器 -->
|
||||||
|
<div class="d-flex flex-wrap mb-4 gap-2">
|
||||||
|
<div>
|
||||||
|
<v-chip value="present" :color="filter.includes('present') ? 'success' : ''"
|
||||||
|
:variant="filter.includes('present') ? 'elevated' : 'tonal'" class="px-2 filter-chip"
|
||||||
|
@click="toggleFilter('present')" prepend-icon="mdi-account-check"
|
||||||
|
:append-icon="filter.includes('present') ? 'mdi-check' : ''">
|
||||||
|
到课
|
||||||
|
</v-chip>
|
||||||
|
|
||||||
|
<v-chip value="absent" :color="filter.includes('absent') ? 'error' : ''"
|
||||||
|
:variant="filter.includes('absent') ? 'elevated' : 'tonal'" class="px-2 filter-chip"
|
||||||
|
@click="toggleFilter('absent')" prepend-icon="mdi-account-off"
|
||||||
|
:append-icon="filter.includes('absent') ? 'mdi-check' : ''">
|
||||||
|
请假
|
||||||
|
</v-chip>
|
||||||
|
<v-chip value="late" :color="filter.includes('late') ? 'warning' : ''"
|
||||||
|
:variant="filter.includes('late') ? 'elevated' : 'tonal'" class="px-2 filter-chip"
|
||||||
|
@click="toggleFilter('late')" prepend-icon="mdi-clock-alert"
|
||||||
|
:append-icon="filter.includes('late') ? 'mdi-check' : ''">
|
||||||
|
迟到
|
||||||
|
</v-chip>
|
||||||
|
<v-chip value="exclude" :color="filter.includes('exclude') ? 'grey' : ''"
|
||||||
|
:variant="filter.includes('exclude') ? 'elevated' : 'tonal'" class="px-2 filter-chip"
|
||||||
|
@click="toggleFilter('exclude')" prepend-icon="mdi-account-cancel"
|
||||||
|
:append-icon="filter.includes('exclude') ? 'mdi-check' : ''">
|
||||||
|
不参与
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 学生列表 -->
|
||||||
|
<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-card-text class="d-flex align-center pa-2">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar :color="getStudentStatusColor(studentList.indexOf(student))" size="24" class="mr-2">
|
||||||
|
<v-icon size="small">{{ getStudentStatusIcon(studentList.indexOf(student)) }}</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
<div class="text-subtitle-1">{{ student }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="attendance-actions">
|
||||||
|
<v-btn :color="isPresent(studentList.indexOf(student)) ? 'success' : ''"
|
||||||
|
icon="mdi-account-check" size="small" variant="text"
|
||||||
|
@click="setPresent(studentList.indexOf(student))" :title="'设为到课'" />
|
||||||
|
<v-btn :color="isAbsent(studentList.indexOf(student)) ? 'error' : ''" icon="mdi-account-off"
|
||||||
|
size="small" variant="text" @click="setAbsent(studentList.indexOf(student))"
|
||||||
|
:title="'设为请假'" />
|
||||||
|
<v-btn :color="isLate(studentList.indexOf(student)) ? 'warning' : ''" icon="mdi-clock-alert"
|
||||||
|
size="small" variant="text" @click="setLate(studentList.indexOf(student))" :title="'设为迟到'" />
|
||||||
|
<v-btn :color="isExclude(studentList.indexOf(student)) ? 'grey' : ''" icon="mdi-account-cancel"
|
||||||
|
size="small" variant="text" @click="setExclude(studentList.indexOf(student))"
|
||||||
|
:title="'设为不参与'" />
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="12">
|
||||||
|
<v-card variant="tonal" color="primary" class="mb-4">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="text-subtitle-2 mb-2">批量操作</div>
|
||||||
|
<v-btn-group>
|
||||||
|
<v-btn color="success" prepend-icon="mdi-account-check" @click="setAllPresent">
|
||||||
|
全部到齐
|
||||||
|
</v-btn>
|
||||||
|
<v-btn color="error" prepend-icon="mdi-account-off" @click="setAllAbsent">
|
||||||
|
全部请假
|
||||||
|
</v-btn>
|
||||||
|
</v-btn-group>
|
||||||
|
<v-btn-group>
|
||||||
|
<v-btn color="warning" prepend-icon="mdi-clock-alert" @click="setAllLate">
|
||||||
|
全部迟到
|
||||||
|
</v-btn>
|
||||||
|
<v-btn color="grey" prepend-icon="mdi-account-cancel" @click="setAllExclude">
|
||||||
|
全部不参与
|
||||||
|
</v-btn>
|
||||||
|
</v-btn-group>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" @click="saveAttendance">
|
||||||
|
<v-icon start>mdi-content-save</v-icon>
|
||||||
|
保存
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- 出勤统计区域 -->
|
||||||
|
<v-col v-if="studentList && studentList.length" class="attendance-area no-select" cols="1" @click="openDialog">
|
||||||
|
<h1>出勤</h1>
|
||||||
|
<h2>
|
||||||
|
<snap style="white-space: nowrap"> 应到 </snap>:
|
||||||
|
<snap style="white-space: nowrap">
|
||||||
|
{{ studentList.length - localAttendance.exclude.length }}人
|
||||||
|
</snap>
|
||||||
|
</h2>
|
||||||
|
<h2>
|
||||||
|
<snap style="white-space: nowrap"> 实到 </snap>:
|
||||||
|
<snap style="white-space: nowrap">
|
||||||
|
{{ studentList.length - localAttendance.absent.length - localAttendance.late.length - localAttendance.exclude.length }}人
|
||||||
|
</snap>
|
||||||
|
</h2>
|
||||||
|
<h2>
|
||||||
|
<snap style="white-space: nowrap"> 请假 </snap>:
|
||||||
|
<snap style="white-space: nowrap">
|
||||||
|
{{ localAttendance.absent.length }}人
|
||||||
|
</snap>
|
||||||
|
</h2>
|
||||||
|
<h3 class="gray-text" v-for="(name, index) in localAttendance.absent" :key="'absent-' + index">
|
||||||
|
<span v-if="lgAndUp">{{ `${index + 1}. ` }}</span><span style="white-space: nowrap">{{ name }}</span>
|
||||||
|
</h3>
|
||||||
|
<h2>
|
||||||
|
<snap style="white-space: nowrap">迟到</snap>:
|
||||||
|
<snap style="white-space: nowrap">
|
||||||
|
{{ localAttendance.late.length }}人
|
||||||
|
</snap>
|
||||||
|
</h2>
|
||||||
|
<h3 class="gray-text" v-for="(name, index) in localAttendance.late" :key="'late-' + index">
|
||||||
|
<span v-if="lgAndUp">{{ `${index + 1}. ` }}</span><span style="white-space: nowrap">{{ name }}</span>
|
||||||
|
</h3>
|
||||||
|
<h2>
|
||||||
|
<snap style="white-space: nowrap">不参与</snap>:
|
||||||
|
<snap style="white-space: nowrap">
|
||||||
|
{{ localAttendance.exclude.length }}人
|
||||||
|
</snap>
|
||||||
|
</h2>
|
||||||
|
<h3 class="gray-text" v-for="(name, index) in localAttendance.exclude" :key="'exclude-' + index">
|
||||||
|
<span v-if="lgAndUp">{{ `${index + 1}. ` }}</span><span style="white-space: nowrap">{{ name }}</span>
|
||||||
|
</h3>
|
||||||
|
</v-col>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { useDisplay } from "vuetify";
|
||||||
|
import { pinyin } from "pinyin-pro";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AttendanceManager',
|
||||||
|
props: {
|
||||||
|
studentList: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
attendance: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
absent: [],
|
||||||
|
late: [],
|
||||||
|
exclude: []
|
||||||
|
})
|
||||||
|
},
|
||||||
|
dateString: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
dialogVisible: false,
|
||||||
|
search: '',
|
||||||
|
filter: [],
|
||||||
|
localAttendance: {
|
||||||
|
absent: [],
|
||||||
|
late: [],
|
||||||
|
exclude: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
lgAndUp() {
|
||||||
|
return useDisplay().lgAndUp.value;
|
||||||
|
},
|
||||||
|
|
||||||
|
filteredStudents() {
|
||||||
|
let students = [...this.studentList];
|
||||||
|
|
||||||
|
// 应用搜索过滤
|
||||||
|
if (this.search) {
|
||||||
|
const searchTerm = this.search.toLowerCase();
|
||||||
|
students = students.filter(student =>
|
||||||
|
student.toLowerCase().includes(searchTerm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用状态过滤
|
||||||
|
if (this.filter && this.filter.length > 0) {
|
||||||
|
students = students.filter(student => {
|
||||||
|
const index = this.studentList.indexOf(student);
|
||||||
|
if (this.filter.includes('present') && this.isPresent(index)) return true;
|
||||||
|
if (this.filter.includes('absent') && this.isAbsent(index)) return true;
|
||||||
|
if (this.filter.includes('late') && this.isLate(index)) return true;
|
||||||
|
if (this.filter.includes('exclude') && this.isExclude(index)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return students;
|
||||||
|
},
|
||||||
|
|
||||||
|
extractedSurnames() {
|
||||||
|
if (!this.studentList || this.studentList.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const surnameMap = new Map();
|
||||||
|
|
||||||
|
this.studentList.forEach(student => {
|
||||||
|
if (student && student.length > 0) {
|
||||||
|
// 中文姓名通常姓在前,取第一个字作为姓氏
|
||||||
|
const surname = student.charAt(0);
|
||||||
|
if (surnameMap.has(surname)) {
|
||||||
|
surnameMap.set(surname, surnameMap.get(surname) + 1);
|
||||||
|
} else {
|
||||||
|
surnameMap.set(surname, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换为数组并按拼音排序
|
||||||
|
return Array.from(surnameMap.entries())
|
||||||
|
.map(([name, count]) => ({ name, 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
attendance: {
|
||||||
|
handler(newValue) {
|
||||||
|
// Deep copy the attendance object to local state
|
||||||
|
this.localAttendance = {
|
||||||
|
absent: [...newValue.absent || []],
|
||||||
|
late: [...newValue.late || []],
|
||||||
|
exclude: [...newValue.exclude || []]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
openDialog() {
|
||||||
|
this.dialogVisible = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDialogClose(isOpen) {
|
||||||
|
if (!isOpen) {
|
||||||
|
this.$emit('update', this.localAttendance);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getStudentStatusColor(index) {
|
||||||
|
const studentName = this.studentList[index];
|
||||||
|
if (this.localAttendance.absent.includes(studentName)) return 'error';
|
||||||
|
if (this.localAttendance.late.includes(studentName)) return 'warning';
|
||||||
|
if (this.localAttendance.exclude.includes(studentName)) return 'grey';
|
||||||
|
return 'success';
|
||||||
|
},
|
||||||
|
|
||||||
|
getStudentStatusIcon(index) {
|
||||||
|
const studentName = this.studentList[index];
|
||||||
|
if (this.localAttendance.absent.includes(studentName)) return 'mdi-account-off';
|
||||||
|
if (this.localAttendance.late.includes(studentName)) return 'mdi-clock-alert';
|
||||||
|
if (this.localAttendance.exclude.includes(studentName)) return 'mdi-account-cancel';
|
||||||
|
return 'mdi-account-check';
|
||||||
|
},
|
||||||
|
|
||||||
|
isPresent(index) {
|
||||||
|
// Check if the student is neither absent, late, nor excluded
|
||||||
|
return !this.isAbsent(index) && !this.isLate(index) && !this.isExclude(index);
|
||||||
|
},
|
||||||
|
|
||||||
|
isAbsent(index) {
|
||||||
|
return this.localAttendance.absent.includes(this.studentList[index]);
|
||||||
|
},
|
||||||
|
|
||||||
|
isLate(index) {
|
||||||
|
return this.localAttendance.late.includes(this.studentList[index]);
|
||||||
|
},
|
||||||
|
|
||||||
|
isExclude(index) {
|
||||||
|
return this.localAttendance.exclude.includes(this.studentList[index]);
|
||||||
|
},
|
||||||
|
|
||||||
|
setPresent(index) {
|
||||||
|
const student = this.studentList[index];
|
||||||
|
this.localAttendance.absent = this.localAttendance.absent.filter(name => name !== student);
|
||||||
|
this.localAttendance.late = this.localAttendance.late.filter(name => name !== student);
|
||||||
|
this.localAttendance.exclude = this.localAttendance.exclude.filter(name => name !== student);
|
||||||
|
this.$emit('change');
|
||||||
|
},
|
||||||
|
|
||||||
|
setAbsent(index) {
|
||||||
|
const student = this.studentList[index];
|
||||||
|
this.setPresent(index);
|
||||||
|
this.localAttendance.absent.push(student);
|
||||||
|
this.$emit('change');
|
||||||
|
},
|
||||||
|
|
||||||
|
setLate(index) {
|
||||||
|
const student = this.studentList[index];
|
||||||
|
this.setPresent(index);
|
||||||
|
this.localAttendance.late.push(student);
|
||||||
|
this.$emit('change');
|
||||||
|
},
|
||||||
|
|
||||||
|
setExclude(index) {
|
||||||
|
const student = this.studentList[index];
|
||||||
|
this.setPresent(index);
|
||||||
|
this.localAttendance.exclude.push(student);
|
||||||
|
this.$emit('change');
|
||||||
|
},
|
||||||
|
|
||||||
|
setAllPresent() {
|
||||||
|
this.localAttendance.absent = [];
|
||||||
|
this.localAttendance.late = [];
|
||||||
|
this.localAttendance.exclude = [];
|
||||||
|
this.$emit('change');
|
||||||
|
},
|
||||||
|
|
||||||
|
setAllAbsent() {
|
||||||
|
this.localAttendance.absent = [...this.studentList];
|
||||||
|
this.localAttendance.late = [];
|
||||||
|
this.localAttendance.exclude = [];
|
||||||
|
this.$emit('change');
|
||||||
|
},
|
||||||
|
|
||||||
|
setAllLate() {
|
||||||
|
this.localAttendance.absent = [];
|
||||||
|
this.localAttendance.late = [...this.studentList];
|
||||||
|
this.localAttendance.exclude = [];
|
||||||
|
this.$emit('change');
|
||||||
|
},
|
||||||
|
|
||||||
|
setAllExclude() {
|
||||||
|
this.localAttendance.absent = [];
|
||||||
|
this.localAttendance.late = [];
|
||||||
|
this.localAttendance.exclude = [...this.studentList];
|
||||||
|
this.$emit('change');
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleFilter(filter) {
|
||||||
|
const index = this.filter.indexOf(filter);
|
||||||
|
if (index === -1) {
|
||||||
|
this.filter.push(filter);
|
||||||
|
} else {
|
||||||
|
this.filter.splice(index, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSearchChange() {
|
||||||
|
// Just a placeholder for future functionality
|
||||||
|
},
|
||||||
|
|
||||||
|
saveAttendance() {
|
||||||
|
this.$emit('save', this.localAttendance);
|
||||||
|
this.dialogVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.attendance-area {
|
||||||
|
border-left: 1px solid var(--v-border-color);
|
||||||
|
padding: 1rem;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gray-text {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-card {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
27
src/components/EmptySubjectCard.vue
Normal file
27
src/components/EmptySubjectCard.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<v-card border class="empty-subject-card" :disabled="disabled" @click="$emit('click')">
|
||||||
|
<v-card-title class="text-subtitle-1">
|
||||||
|
{{ subject.name }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="text-center">
|
||||||
|
<v-icon size="small" color="grey"> mdi-plus </v-icon>
|
||||||
|
<div class="text-caption text-grey">点击添加作业</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'EmptySubjectCard',
|
||||||
|
props: {
|
||||||
|
subject: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
59
src/components/SubjectCard.vue
Normal file
59
src/components/SubjectCard.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<v-card border height="100%" class="glow-track" @click="!disabled && $emit('click')"
|
||||||
|
@mousemove="handleMouseMove" @touchmove="handleTouchMove">
|
||||||
|
<v-card-title>{{ subject.name }}</v-card-title>
|
||||||
|
<v-card-text :style="contentStyle">
|
||||||
|
<v-list>
|
||||||
|
<v-list-item v-for="text in splitPoints(subject.content)" :key="text">
|
||||||
|
{{ text }}
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'SubjectCard',
|
||||||
|
props: {
|
||||||
|
subject: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
contentStyle: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
splitPoints(content) {
|
||||||
|
return content?.split("\n").filter(text => text.trim()) || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
handleMouseMove(e) {
|
||||||
|
const card = e.currentTarget;
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||||
|
card.style.setProperty('--x', `${x}%`);
|
||||||
|
card.style.setProperty('--y', `${y}%`);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleTouchMove(e) {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const card = e.currentTarget;
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
const x = ((touch.clientX - rect.left) / rect.width) * 100;
|
||||||
|
const y = ((touch.clientY - rect.top) / rect.height) * 100;
|
||||||
|
card.style.setProperty('--x', `${x}%`);
|
||||||
|
card.style.setProperty('--y', `${y}%`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
70
src/components/SubjectDialog.vue
Normal file
70
src/components/SubjectDialog.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog v-model="dialogVisible" width="500" @click:outside="handleClose">
|
||||||
|
<v-card border>
|
||||||
|
<v-card-title>{{ subject.name }}</v-card-title>
|
||||||
|
<v-card-subtitle>
|
||||||
|
{{ autoSave ? "自动保存已启用" : "写完后点击保存" }}
|
||||||
|
</v-card-subtitle>
|
||||||
|
<v-card-text>
|
||||||
|
<v-textarea ref="inputRef" v-model="content" auto-grow placeholder="使用换行表示分条" rows="5" />
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions v-if="!autoSave">
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="primary" @click="handleSave">保存</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'SubjectDialog',
|
||||||
|
props: {
|
||||||
|
subject: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ name: '', content: '' })
|
||||||
|
},
|
||||||
|
autoSave: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
dialogVisible: false,
|
||||||
|
content: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
open() {
|
||||||
|
this.content = this.subject.content || '';
|
||||||
|
this.dialogVisible = true;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.inputRef) {
|
||||||
|
this.$refs.inputRef.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
handleClose() {
|
||||||
|
if (this.autoSave) {
|
||||||
|
this.saveContent();
|
||||||
|
}
|
||||||
|
this.dialogVisible = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSave() {
|
||||||
|
this.saveContent();
|
||||||
|
this.dialogVisible = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveContent() {
|
||||||
|
if (this.content !== this.subject.content) {
|
||||||
|
this.$emit('save', this.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
189
src/components/SubjectGrid.vue
Normal file
189
src/components/SubjectGrid.vue
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- 有内容的科目卡片 -->
|
||||||
|
<div ref="gridContainer" class="grid-masonry">
|
||||||
|
<TransitionGroup name="grid">
|
||||||
|
<div v-for="item in sortedItems" :key="item.key" class="grid-item"
|
||||||
|
:style="{
|
||||||
|
'grid-row-end': `span ${item.rowSpan}`,
|
||||||
|
order: item.order,
|
||||||
|
}">
|
||||||
|
<subject-card
|
||||||
|
:subject="item"
|
||||||
|
:content-style="contentStyle"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="$emit('edit', item.key)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 单独显示空科目 -->
|
||||||
|
<div class="empty-subjects mt-4">
|
||||||
|
<template v-if="emptySubjectDisplay === 'button'">
|
||||||
|
<v-btn-group divided variant="outlined">
|
||||||
|
<v-btn v-for="subject in unusedSubjects" :key="subject.key" :disabled="disabled"
|
||||||
|
@click="$emit('edit', subject.key)">
|
||||||
|
<v-icon start>mdi-plus</v-icon>
|
||||||
|
{{ subject.name }}
|
||||||
|
</v-btn>
|
||||||
|
</v-btn-group>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty-subjects-grid">
|
||||||
|
<TransitionGroup name="v-list">
|
||||||
|
<empty-subject-card
|
||||||
|
v-for="subject in unusedSubjects"
|
||||||
|
:key="subject.key"
|
||||||
|
:subject="subject"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="$emit('edit', subject.key)"
|
||||||
|
/>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import SubjectCard from './SubjectCard.vue';
|
||||||
|
import EmptySubjectCard from './EmptySubjectCard.vue';
|
||||||
|
import { optimizeGridLayout } from '@/utils/gridUtils';
|
||||||
|
import { throttle } from "@/utils/debounce";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SubjectGrid',
|
||||||
|
components: {
|
||||||
|
SubjectCard,
|
||||||
|
EmptySubjectCard
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
homework: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
availableSubjects: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
contentStyle: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
emptySubjectDisplay: {
|
||||||
|
type: String,
|
||||||
|
default: 'grid'
|
||||||
|
},
|
||||||
|
dynamicSort: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
throttledReflow: null,
|
||||||
|
sortedItemsCache: {
|
||||||
|
key: "",
|
||||||
|
value: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
sortedItems() {
|
||||||
|
const key = `${JSON.stringify(this.homework)}_${this.dynamicSort}`;
|
||||||
|
if (this.sortedItemsCache.key === key) {
|
||||||
|
return this.sortedItemsCache.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Object.entries(this.homework)
|
||||||
|
.filter(([, value]) => value.content?.trim())
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
name: this.availableSubjects.find((s) => s.key === key)?.name || key,
|
||||||
|
content: value.content,
|
||||||
|
rowSpan: Math.ceil(
|
||||||
|
(value.content.split("\n").filter((line) => line.trim()).length + 1) * 0.8
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = this.dynamicSort
|
||||||
|
? optimizeGridLayout(items)
|
||||||
|
: items.sort((a, b) => this.availableSubjects.findIndex(s => s.key === a.key) -
|
||||||
|
this.availableSubjects.findIndex(s => s.key === b.key));
|
||||||
|
|
||||||
|
this.updateSortedItemsCache(key, result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
unusedSubjects() {
|
||||||
|
const usedKeys = Object.keys(this.homework).filter(
|
||||||
|
key => this.homework[key].content?.trim()
|
||||||
|
);
|
||||||
|
return this.availableSubjects.filter(
|
||||||
|
(subject) => !usedKeys.includes(subject.key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
'$vuetify.display.width': {
|
||||||
|
handler() {
|
||||||
|
this.throttledReflow();
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
// Create throttled reflow function
|
||||||
|
this.throttledReflow = throttle(() => {
|
||||||
|
if (this.$refs.gridContainer) {
|
||||||
|
// Trigger layout recalculation
|
||||||
|
this.updateSortedItemsCache('', []);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
updateSortedItemsCache(key, value) {
|
||||||
|
this.sortedItemsCache = { key, value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.grid-masonry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
grid-auto-rows: minmax(80px, auto);
|
||||||
|
grid-gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-item {
|
||||||
|
width: 100%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-subjects-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-subject-card {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-subject-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
1448
src/pages/index.vue
1448
src/pages/index.vue
File diff suppressed because it is too large
Load Diff
79
src/store/configStore.js
Normal file
79
src/store/configStore.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Configuration Store - Manages application settings and provides defaults
|
||||||
|
*/
|
||||||
|
import { reactive } from "vue";
|
||||||
|
import { getSetting, watchSettings } from "@/utils/settings";
|
||||||
|
|
||||||
|
const configStore = reactive({
|
||||||
|
// Server connection config
|
||||||
|
serverConfig: {
|
||||||
|
provider: getSetting("server.provider"),
|
||||||
|
domain: getSetting("server.domain"),
|
||||||
|
classNumber: getSetting("server.classNumber")
|
||||||
|
},
|
||||||
|
|
||||||
|
// Default subjects
|
||||||
|
defaultSubjects: [
|
||||||
|
{ key: "语文", name: "语文" },
|
||||||
|
{ key: "数学", name: "数学" },
|
||||||
|
{ key: "英语", name: "英语" },
|
||||||
|
{ key: "物理", name: "物理" },
|
||||||
|
{ key: "化学", name: "化学" },
|
||||||
|
{ key: "生物", name: "生物" },
|
||||||
|
{ key: "政治", name: "政治" },
|
||||||
|
{ key: "历史", name: "历史" },
|
||||||
|
{ key: "地理", name: "地理" },
|
||||||
|
{ key: "其他", name: "其他" }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Feature flags
|
||||||
|
featureFlags: {
|
||||||
|
get autoSave() { return getSetting("edit.autoSave"); },
|
||||||
|
get blockNonTodayAutoSave() { return getSetting("edit.blockNonTodayAutoSave"); },
|
||||||
|
get confirmNonTodaySave() { return getSetting("edit.confirmNonTodaySave"); },
|
||||||
|
get refreshBeforeEdit() { return getSetting("edit.refreshBeforeEdit"); },
|
||||||
|
get emptySubjectDisplay() { return getSetting("display.emptySubjectDisplay"); },
|
||||||
|
get dynamicSort() { return getSetting("display.dynamicSort"); },
|
||||||
|
get showRandomPickerButton() { return getSetting("randomPicker.enabled"); },
|
||||||
|
get showAntiScreenBurnCard() { return getSetting("display.showAntiScreenBurnCard"); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Data accessor
|
||||||
|
dataKey: "",
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
/**
|
||||||
|
* Initialize configuration from settings
|
||||||
|
*/
|
||||||
|
initialize() {
|
||||||
|
this.updateServerConfig();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update server configuration from settings
|
||||||
|
*/
|
||||||
|
updateServerConfig() {
|
||||||
|
this.serverConfig.provider = getSetting("server.provider");
|
||||||
|
this.serverConfig.domain = getSetting("server.domain");
|
||||||
|
this.serverConfig.classNumber = getSetting("server.classNumber");
|
||||||
|
|
||||||
|
// Update the data key
|
||||||
|
this.dataKey = this.serverConfig.provider === "server"
|
||||||
|
? `${this.serverConfig.domain}/${this.serverConfig.classNumber}`
|
||||||
|
: this.serverConfig.classNumber;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up settings watcher
|
||||||
|
* @param {Function} callback - Function to call when settings change
|
||||||
|
* @returns {Function} Unwatch function
|
||||||
|
*/
|
||||||
|
watchSettings(callback) {
|
||||||
|
return watchSettings(() => {
|
||||||
|
this.updateServerConfig();
|
||||||
|
if (callback) callback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default configStore;
|
179
src/store/dataStore.js
Normal file
179
src/store/dataStore.js
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Data store module - This handles all data that will be sent to and retrieved from the data provider
|
||||||
|
* It provides a clean separation between data management and UI state
|
||||||
|
*/
|
||||||
|
import dataProvider from "@/utils/dataProvider";
|
||||||
|
import { reactive } from "vue";
|
||||||
|
|
||||||
|
// Core data structure that mirrors the backend storage format
|
||||||
|
const initialData = {
|
||||||
|
homework: {},
|
||||||
|
attendance: {
|
||||||
|
absent: [],
|
||||||
|
late: [],
|
||||||
|
exclude: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a reactive data store
|
||||||
|
const dataStore = reactive({
|
||||||
|
// Main data that will be sent to the server
|
||||||
|
boardData: { ...initialData },
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
classNumber: "",
|
||||||
|
studentList: [],
|
||||||
|
dateString: "",
|
||||||
|
synced: false,
|
||||||
|
|
||||||
|
// Methods for data manipulation
|
||||||
|
/**
|
||||||
|
* Reset data store to initial empty state
|
||||||
|
*/
|
||||||
|
resetData() {
|
||||||
|
this.boardData = {
|
||||||
|
homework: {},
|
||||||
|
attendance: {
|
||||||
|
absent: [],
|
||||||
|
late: [],
|
||||||
|
exclude: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.synced = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set homework content for a subject
|
||||||
|
* @param {string} subjectKey - The subject key
|
||||||
|
* @param {string} content - The homework content
|
||||||
|
*/
|
||||||
|
setHomework(subjectKey, content) {
|
||||||
|
if (!this.boardData.homework[subjectKey]) {
|
||||||
|
this.boardData.homework[subjectKey] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.boardData.homework[subjectKey].content = content;
|
||||||
|
this.synced = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update attendance data
|
||||||
|
* @param {Object} attendance - The new attendance data
|
||||||
|
*/
|
||||||
|
updateAttendance(attendance) {
|
||||||
|
this.boardData.attendance = {
|
||||||
|
absent: [...attendance.absent || []],
|
||||||
|
late: [...attendance.late || []],
|
||||||
|
exclude: [...attendance.exclude || []]
|
||||||
|
};
|
||||||
|
this.synced = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subject data including content
|
||||||
|
* @param {string} subjectKey - The subject key
|
||||||
|
* @param {Array} availableSubjects - List of available subjects
|
||||||
|
* @returns {Object} Subject with name and content
|
||||||
|
*/
|
||||||
|
getSubject(subjectKey, availableSubjects) {
|
||||||
|
const name = availableSubjects.find(s => s.key === subjectKey)?.name || subjectKey;
|
||||||
|
const content = this.boardData.homework[subjectKey]?.content || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: subjectKey,
|
||||||
|
name,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Data loading and saving methods
|
||||||
|
/**
|
||||||
|
* Load data from server
|
||||||
|
* @param {string} provider - The data provider type
|
||||||
|
* @param {string} dataKey - The data key
|
||||||
|
* @param {string} dateString - The date string
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
*/
|
||||||
|
async loadData(provider, dataKey, dateString) {
|
||||||
|
try {
|
||||||
|
const response = await dataProvider.loadData(provider, dataKey, dateString);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
if (response.error.code === "NOT_FOUND") {
|
||||||
|
this.resetData();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the store with the retrieved data
|
||||||
|
this.boardData = {
|
||||||
|
homework: response.data.homework || {},
|
||||||
|
attendance: {
|
||||||
|
absent: response.data.attendance?.absent || [],
|
||||||
|
late: response.data.attendance?.late || [],
|
||||||
|
exclude: response.data.attendance?.exclude || []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.synced = true;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load data:", error);
|
||||||
|
this.resetData();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save data to server
|
||||||
|
* @param {string} provider - The data provider type
|
||||||
|
* @param {string} dataKey - The data key
|
||||||
|
* @param {string} dateString - The date string
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
*/
|
||||||
|
async saveData(provider, dataKey, dateString) {
|
||||||
|
try {
|
||||||
|
const response = await dataProvider.saveData(
|
||||||
|
provider,
|
||||||
|
dataKey,
|
||||||
|
this.boardData,
|
||||||
|
dateString
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.synced = true;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save data:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load student list and config
|
||||||
|
* @param {string} provider - The data provider type
|
||||||
|
* @param {string} dataKey - The data key
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
*/
|
||||||
|
async loadConfig(provider, dataKey) {
|
||||||
|
try {
|
||||||
|
const response = await dataProvider.loadConfig(provider, dataKey);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.studentList = response.data.studentList || [];
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load config:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default dataStore;
|
21
src/store/index.js
Normal file
21
src/store/index.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Store index - exports all application stores
|
||||||
|
* This barrel file simplifies imports in components
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dataStore from './dataStore';
|
||||||
|
import uiStore from './uiStateStore';
|
||||||
|
import configStore from './configStore';
|
||||||
|
|
||||||
|
export {
|
||||||
|
dataStore,
|
||||||
|
uiStore,
|
||||||
|
configStore
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default export for convenience
|
||||||
|
export default {
|
||||||
|
dataStore,
|
||||||
|
uiStore,
|
||||||
|
configStore
|
||||||
|
};
|
136
src/store/uiStateStore.js
Normal file
136
src/store/uiStateStore.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* UI State Store - Manages UI-specific state that doesn't need to be persisted to the server
|
||||||
|
*/
|
||||||
|
import { reactive } from "vue";
|
||||||
|
import { getSetting, setSetting } from "@/utils/settings";
|
||||||
|
import { formatDate } from "@/utils/dateUtils";
|
||||||
|
|
||||||
|
const uiStateStore = reactive({
|
||||||
|
// UI State
|
||||||
|
fontSize: getSetting("font.size"),
|
||||||
|
contentStyle: { "font-size": `${getSetting("font.size")}px` },
|
||||||
|
datePickerVisible: false,
|
||||||
|
selectedDateObj: new Date(),
|
||||||
|
confirmDialogVisible: false,
|
||||||
|
confirmDialogResolve: null,
|
||||||
|
confirmDialogReject: null,
|
||||||
|
currentSubjectKey: null,
|
||||||
|
loadingState: {
|
||||||
|
download: false,
|
||||||
|
upload: false
|
||||||
|
},
|
||||||
|
refreshInterval: null,
|
||||||
|
snackbar: {
|
||||||
|
visible: false,
|
||||||
|
text: "",
|
||||||
|
timeout: 2000
|
||||||
|
},
|
||||||
|
|
||||||
|
// UI State methods
|
||||||
|
/**
|
||||||
|
* Update font size
|
||||||
|
* @param {string} direction - "up" or "out"
|
||||||
|
*/
|
||||||
|
zoom(direction) {
|
||||||
|
const step = 2;
|
||||||
|
if (direction === "up" && this.fontSize < 100) {
|
||||||
|
this.fontSize += step;
|
||||||
|
} else if (direction === "out" && this.fontSize > 16) {
|
||||||
|
this.fontSize -= step;
|
||||||
|
}
|
||||||
|
this.contentStyle = {
|
||||||
|
"font-size": `${this.fontSize}px`
|
||||||
|
};
|
||||||
|
setSetting("font.size", this.fontSize);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a snackbar message
|
||||||
|
* @param {string} text - Message text
|
||||||
|
* @param {number} timeout - Message timeout in ms
|
||||||
|
*/
|
||||||
|
showSnackbar(text, timeout = 2000) {
|
||||||
|
this.snackbar.text = text;
|
||||||
|
this.snackbar.timeout = timeout;
|
||||||
|
this.snackbar.visible = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a confirmation dialog
|
||||||
|
* @returns {Promise} - Resolves when confirmed, rejects when cancelled
|
||||||
|
*/
|
||||||
|
showConfirmDialog() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.confirmDialogVisible = true;
|
||||||
|
this.confirmDialogResolve = () => {
|
||||||
|
this.confirmDialogVisible = false;
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
this.confirmDialogReject = () => {
|
||||||
|
this.confirmDialogVisible = false;
|
||||||
|
reject(new Error("User cancelled"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set current subject for editing
|
||||||
|
* @param {string} subjectKey - Subject key
|
||||||
|
*/
|
||||||
|
setCurrentSubject(subjectKey) {
|
||||||
|
this.currentSubjectKey = subjectKey;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle date selection
|
||||||
|
* @param {Date} newDate - The selected date
|
||||||
|
* @returns {string} Formatted date string
|
||||||
|
*/
|
||||||
|
selectDate(newDate) {
|
||||||
|
if (!newDate) return null;
|
||||||
|
|
||||||
|
this.selectedDateObj = new Date(newDate);
|
||||||
|
return formatDate(this.selectedDateObj);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up auto refresh
|
||||||
|
* @param {Function} refreshCallback - Function to call on refresh
|
||||||
|
*/
|
||||||
|
setupAutoRefresh(refreshCallback, shouldSkipRefreshFn) {
|
||||||
|
const autoRefresh = getSetting("refresh.auto");
|
||||||
|
const interval = getSetting("refresh.interval");
|
||||||
|
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoRefresh && refreshCallback) {
|
||||||
|
this.refreshInterval = setInterval(() => {
|
||||||
|
if (!shouldSkipRefreshFn()) {
|
||||||
|
refreshCallback();
|
||||||
|
}
|
||||||
|
}, interval * 1000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources
|
||||||
|
*/
|
||||||
|
cleanup() {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval);
|
||||||
|
this.refreshInterval = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update UI settings from app settings
|
||||||
|
*/
|
||||||
|
updateFromSettings() {
|
||||||
|
this.fontSize = getSetting("font.size");
|
||||||
|
this.contentStyle = { "font-size": `${this.fontSize}px` };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default uiStateStore;
|
80
src/utils/dateUtils.js
Normal file
80
src/utils/dateUtils.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Ensures the input is a valid Date object
|
||||||
|
* @param {Date|string} dateInput - A date object or string
|
||||||
|
* @returns {Date} A valid Date object
|
||||||
|
*/
|
||||||
|
export function ensureDate(dateInput) {
|
||||||
|
if (dateInput instanceof Date) {
|
||||||
|
return dateInput;
|
||||||
|
}
|
||||||
|
if (typeof dateInput === "string") {
|
||||||
|
const date = new Date(dateInput);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Date(); // If unable to parse, return current date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date to YYYY-MM-DD string
|
||||||
|
* @param {Date|string} dateInput - A date object or string
|
||||||
|
* @returns {string} Formatted date string
|
||||||
|
*/
|
||||||
|
export function formatDate(dateInput) {
|
||||||
|
const date = ensureDate(dateInput);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets today's date
|
||||||
|
* @returns {Date} Current date
|
||||||
|
*/
|
||||||
|
export function getToday() {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a date is today
|
||||||
|
* @param {Date|string} dateInput - Date to check
|
||||||
|
* @returns {boolean} True if the date is today
|
||||||
|
*/
|
||||||
|
export function isToday(dateInput) {
|
||||||
|
const today = getToday();
|
||||||
|
return formatDate(dateInput) === formatDate(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets yesterday's date
|
||||||
|
* @returns {Date} Yesterday's date
|
||||||
|
*/
|
||||||
|
export function getYesterday() {
|
||||||
|
const today = getToday();
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
return yesterday;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a formatted display text for a date relative to today
|
||||||
|
* @param {string} dateString - Date string in YYYY-MM-DD format
|
||||||
|
* @returns {string} Descriptive text
|
||||||
|
*/
|
||||||
|
export function getRelativeDateText(dateString) {
|
||||||
|
const today = getToday();
|
||||||
|
const yesterday = getYesterday();
|
||||||
|
|
||||||
|
const todayStr = formatDate(today);
|
||||||
|
const yesterdayStr = formatDate(yesterday);
|
||||||
|
|
||||||
|
if (dateString === todayStr) {
|
||||||
|
return "今天的作业";
|
||||||
|
} else if (dateString === yesterdayStr) {
|
||||||
|
return "昨天的作业";
|
||||||
|
} else {
|
||||||
|
return `${dateString}的作业`;
|
||||||
|
}
|
||||||
|
}
|
@ -1,27 +1,30 @@
|
|||||||
export function debounce(fn, delay) {
|
/**
|
||||||
let timer = null;
|
* Creates a debounced function that delays invoking func until after wait milliseconds
|
||||||
|
* @param {Function} func - The function to debounce
|
||||||
|
* @param {number} wait - The number of milliseconds to delay
|
||||||
|
* @returns {Function} - The debounced function
|
||||||
|
*/
|
||||||
|
export function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
return function (...args) {
|
return function (...args) {
|
||||||
if (timer) clearTimeout(timer);
|
const context = this;
|
||||||
timer = setTimeout(() => {
|
clearTimeout(timeout);
|
||||||
fn.apply(this, args);
|
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||||
}, delay);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function throttle(fn, delay) {
|
/**
|
||||||
let timer = null;
|
* Creates a throttled function that only invokes func at most once per every wait milliseconds
|
||||||
let last = 0;
|
* @param {Function} func - The function to throttle
|
||||||
|
* @param {number} wait - The number of milliseconds to throttle invocations to
|
||||||
|
* @returns {Function} - The throttled function
|
||||||
|
*/
|
||||||
|
export function throttle(func, wait) {
|
||||||
|
let lastCall = 0;
|
||||||
return function (...args) {
|
return function (...args) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - last < delay) {
|
if (now - lastCall < wait) return;
|
||||||
if (timer) clearTimeout(timer);
|
lastCall = now;
|
||||||
timer = setTimeout(() => {
|
return func.apply(this, args);
|
||||||
last = now;
|
|
||||||
fn.apply(this, args);
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
last = now;
|
|
||||||
fn.apply(this, args);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
82
src/utils/gridUtils.js
Normal file
82
src/utils/gridUtils.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Optimizes the layout of grid items using a greedy algorithm
|
||||||
|
* @param {Array} items - Array of items to layout
|
||||||
|
* @returns {Array} - Array of items with optimized order
|
||||||
|
*/
|
||||||
|
export function optimizeGridLayout(items) {
|
||||||
|
// Set maximum columns based on viewport width
|
||||||
|
const maxColumns = Math.min(3, Math.floor(window.innerWidth / 300));
|
||||||
|
if (maxColumns <= 1) return items;
|
||||||
|
|
||||||
|
// Use greedy algorithm to allocate
|
||||||
|
const columns = Array.from({ length: maxColumns }, () => ({
|
||||||
|
height: 0,
|
||||||
|
items: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const shortestColumn = columns.reduce(
|
||||||
|
(min, col, i) => (col.height < columns[min].height ? i : min),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
columns[shortestColumn].items.push(item);
|
||||||
|
columns[shortestColumn].height += item.rowSpan;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flatten result and add order
|
||||||
|
return columns
|
||||||
|
.flatMap((col) => col.items)
|
||||||
|
.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
order: index,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a fixed layout to grid items based on subject groups
|
||||||
|
* @param {Array} items - Array of items to layout
|
||||||
|
* @returns {Array} - Array of items with fixed order
|
||||||
|
*/
|
||||||
|
export function 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,
|
||||||
|
}));
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user