mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2025-07-02 17:29:23 +00:00
1622 lines
51 KiB
Vue
1622 lines
51 KiB
Vue
<template>
|
||
<v-app-bar class="no-select">
|
||
<template #prepend>
|
||
<v-app-bar-nav-icon icon="mdi-home" />
|
||
</template>
|
||
|
||
<v-app-bar-title>
|
||
{{ state.classNumber }} - {{ titleText }}
|
||
</v-app-bar-title>
|
||
|
||
<v-spacer />
|
||
|
||
<template #append>
|
||
<v-btn icon="mdi-format-font-size-decrease" variant="text" @click="zoom('out')" />
|
||
<v-btn icon="mdi-format-font-size-increase" variant="text" @click="zoom('up')" />
|
||
<v-menu v-model="state.datePickerDialog" :close-on-content-click="false">
|
||
<template #activator="{ props }">
|
||
<v-btn icon="mdi-calendar" variant="text" v-bind="props" />
|
||
</template>
|
||
<v-card border>
|
||
<v-date-picker v-model="state.selectedDateObj" :model-value="state.selectedDateObj" color="primary"
|
||
@update:model-value="handleDateSelect" />
|
||
</v-card>
|
||
</v-menu>
|
||
<v-btn icon="mdi-refresh" variant="text" :loading="loading.download" @click="downloadData" /> <v-btn
|
||
icon="mdi-bell" variant="text" :badge="unreadCount || undefined"
|
||
:badge-color="unreadCount ? 'error' : undefined" @click="$refs.messageLog.drawer = true" />
|
||
<v-btn icon="mdi-cog" variant="text" @click="$router.push('/settings')" />
|
||
|
||
</template>
|
||
</v-app-bar>
|
||
<div class="d-flex">
|
||
<!-- 主要内容区域 -->
|
||
<v-container class="main-window flex-grow-1 no-select" fluid>
|
||
<!-- 有内容的科目卡片 -->
|
||
<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,
|
||
}">
|
||
<v-card border height="100%" class="glow-track" @click="!isEditingDisabled && openDialog(item.key)"
|
||
@mousemove="handleMouseMove" @touchmove="handleTouchMove">
|
||
<v-card-title>{{ item.name }}</v-card-title>
|
||
<v-card-text :style="state.contentStyle">
|
||
<v-list>
|
||
<v-list-item v-for="text in splitPoint(item.content)" :key="text">
|
||
{{ text }}
|
||
</v-list-item>
|
||
</v-list>
|
||
</v-card-text>
|
||
</v-card>
|
||
</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="isEditingDisabled"
|
||
@click="openDialog(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">
|
||
<v-card v-for="subject in unusedSubjects" :key="subject.key" border class="empty-subject-card"
|
||
:disabled="isEditingDisabled" @click="openDialog(subject.key)">
|
||
<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>
|
||
</TransitionGroup>
|
||
</div>
|
||
</div>
|
||
<v-btn v-if="!state.synced" color="error" size="large" :loading="loading.upload" class="ml-2"
|
||
@click="manualUpload">
|
||
上传
|
||
</v-btn>
|
||
<v-btn v-else color="success" size="large" @click="showSyncMessage">
|
||
同步完成 </v-btn><v-btn v-if="showRandomButton" color="yellow" prepend-icon="mdi-account-question"
|
||
append-icon="mdi-dice-multiple" size="large" class="ml-2" href="classisland://plugins/IslandCaller/Run">
|
||
随机点名
|
||
</v-btn>
|
||
<v-btn v-if="showFullscreenButton" :color="state.isFullscreen ? 'blue-grey' : 'blue'"
|
||
:prepend-icon="state.isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'" size="large" class="ml-2"
|
||
@click="toggleFullscreen">
|
||
{{ state.isFullscreen ? '退出全屏' : '全屏显示' }}
|
||
</v-btn><!-- 修改防烧屏提示卡片,使用 tonal 样式减少信息密度 -->
|
||
<v-card v-if="showAntiScreenBurnCard" border class="mt-4 anti-burn-card" color="primary" variant="tonal">
|
||
<v-card-title class="text-subtitle-1">
|
||
<v-icon start icon="mdi-shield-check" size="small" />
|
||
屏幕保护技术已启用
|
||
</v-card-title>
|
||
<v-card-text class="text-body-2">
|
||
<p>本应用采用独立自研的动态像素偏移技术(DPO™),有效防止LCD屏幕烧屏现象。</p>
|
||
<p class="text-caption text-grey">*研究显示动态像素偏移技术可以修复屏幕坏点,起到保护屏幕的作用,数据来自实验室。<a
|
||
href="https://patentscope.wipo.int/search/zh/detail.jsf?docId=CN232281523&_cid=P20-M8L0YX-67061-1"
|
||
target="_blank">专利号CN108648692 </a></p>
|
||
<p class="text-caption text-grey">*技术已自动适配您的设备,无需手动调整</p>
|
||
|
||
</v-card-text>
|
||
</v-card>
|
||
</v-container>
|
||
|
||
<!-- 出勤统计区域 -->
|
||
<v-col v-if="state.studentList && state.studentList.length" class="attendance-area no-select" cols="1"
|
||
@click="setAttendanceArea()">
|
||
<h1>出勤</h1>
|
||
<h2>
|
||
<snap style="white-space: nowrap"> 应到 </snap>:
|
||
<snap style="white-space: nowrap">
|
||
{{
|
||
state.studentList.length -
|
||
state.boardData.attendance.exclude.length
|
||
}}人
|
||
</snap>
|
||
</h2>
|
||
<h2>
|
||
<snap style="white-space: nowrap"> 实到 </snap>:
|
||
<snap style="white-space: nowrap">
|
||
{{
|
||
state.studentList.length -
|
||
state.boardData.attendance.absent.length -
|
||
state.boardData.attendance.late.length -
|
||
state.boardData.attendance.exclude.length
|
||
}}人
|
||
</snap>
|
||
</h2>
|
||
<h2>
|
||
<snap style="white-space: nowrap"> 请假 </snap>:
|
||
<snap style="white-space: nowrap">
|
||
{{ state.boardData.attendance.absent.length }}人
|
||
</snap>
|
||
</h2>
|
||
<h3 class="gray-text" v-for="(name, index) in state.boardData.attendance.absent" :key="'absent-' + index">
|
||
<span v-if="useDisplay().lgAndUp.value">{{ `${index + 1}. ` }}</span><span style="white-space: nowrap">{{ name
|
||
}}</span>
|
||
</h3>
|
||
<h2>
|
||
<snap style="white-space: nowrap">迟到</snap>:
|
||
<snap style="white-space: nowrap">
|
||
{{ state.boardData.attendance.late.length }}人
|
||
</snap>
|
||
</h2>
|
||
<h3 class="gray-text" v-for="(name, index) in state.boardData.attendance.late" :key="'late-' + index">
|
||
<span v-if="useDisplay().lgAndUp.value">{{ `${index + 1}. ` }}</span><span style="white-space: nowrap">{{ name
|
||
}}</span>
|
||
</h3>
|
||
<h2>
|
||
<snap style="white-space: nowrap">不参与</snap>:
|
||
<snap style="white-space: nowrap">
|
||
{{ state.boardData.attendance.exclude.length }}人
|
||
</snap>
|
||
</h2>
|
||
<h3 class="gray-text" v-for="(name, index) in state.boardData.attendance.exclude" :key="'exclude-' + index">
|
||
<span v-if="useDisplay().lgAndUp.value">{{ `${index + 1}. ` }}</span><span style="white-space: nowrap">{{ name
|
||
}}</span>
|
||
</h3>
|
||
</v-col>
|
||
</div>
|
||
|
||
<v-dialog v-model="state.dialogVisible" width="500" @click:outside="handleClose">
|
||
<v-card border>
|
||
<v-card-title>{{ state.dialogTitle }}</v-card-title>
|
||
<v-card-subtitle>
|
||
{{ autoSave ? "喵?喵呜!" : "写完后点击上传谢谢喵" }}
|
||
</v-card-subtitle>
|
||
<v-card-text>
|
||
<v-textarea ref="inputRef" v-model="state.textarea" auto-grow placeholder="使用换行表示分条" rows="5" />
|
||
</v-card-text>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<v-snackbar v-model="state.snackbar" :timeout="2000">
|
||
{{ state.snackbarText }}
|
||
</v-snackbar>
|
||
|
||
<v-dialog v-model="state.attendanceDialog" max-width="900" fullscreen-breakpoint="sm" @update:model-value="handleAttendanceDialogClose">
|
||
<v-card>
|
||
<v-card-title class="d-flex align-center">
|
||
<v-icon icon="mdi-account-group" class="mr-2" />
|
||
出勤状态管理
|
||
<v-spacer />
|
||
<v-chip color="primary" size="small" class="ml-2">
|
||
{{ state.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="attendanceSearch"
|
||
prepend-inner-icon="mdi-magnify"
|
||
label="搜索学生"
|
||
hide-details
|
||
variant="outlined"
|
||
density="compact"
|
||
clearable
|
||
@update:model-value="handleSearchChange"
|
||
/>
|
||
<div class="text-caption mt-1">支持筛选姓氏,如输入"张"可筛选所有姓张的学生</div>
|
||
|
||
<!-- 智能姓氏筛选 -->
|
||
<div class="d-flex flex-wrap mt-2 gap-1">
|
||
<v-btn
|
||
v-for="surname in extractedSurnames"
|
||
:key="surname.name"
|
||
size="small"
|
||
:variant="attendanceSearch === surname.name ? 'elevated' : 'text'"
|
||
:color="attendanceSearch === surname.name ? 'primary' : ''"
|
||
@click="attendanceSearch = attendanceSearch === surname.name ? '' : surname.name"
|
||
class="surname-btn"
|
||
>
|
||
{{ surname.name }}
|
||
<span class="text-caption ml-1">({{ surname.count }})</span>
|
||
</v-btn>
|
||
</div>
|
||
|
||
<!-- 搜索建议 -->
|
||
<div v-if="searchSuggestions.length > 0" class="mt-2">
|
||
<div class="text-caption text-grey">搜索建议:</div>
|
||
<div class="d-flex flex-wrap gap-1 mt-1">
|
||
<v-btn
|
||
v-for="suggestion in searchSuggestions"
|
||
:key="suggestion"
|
||
size="x-small"
|
||
variant="text"
|
||
color="secondary"
|
||
@click="attendanceSearch = suggestion"
|
||
class="suggestion-btn"
|
||
density="compact"
|
||
>
|
||
{{ suggestion }}
|
||
</v-btn>
|
||
</div>
|
||
</div>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<!-- 过滤器 -->
|
||
<div class="d-flex flex-wrap mb-4 gap-2">
|
||
<div>
|
||
<v-chip
|
||
value="present"
|
||
:color="attendanceFilter.includes('present') ? 'success' : ''"
|
||
:variant="attendanceFilter.includes('present') ? 'elevated' : 'tonal'"
|
||
class="px-2 filter-chip"
|
||
@click="toggleFilter('present')"
|
||
prepend-icon="mdi-account-check"
|
||
:append-icon="attendanceFilter.includes('present') ? 'mdi-check' : ''"
|
||
>
|
||
到课
|
||
</v-chip>
|
||
|
||
<v-chip
|
||
value="absent"
|
||
:color="attendanceFilter.includes('absent') ? 'error' : ''"
|
||
:variant="attendanceFilter.includes('absent') ? 'elevated' : 'tonal'"
|
||
class="px-2 filter-chip"
|
||
@click="toggleFilter('absent')"
|
||
prepend-icon="mdi-account-off"
|
||
:append-icon="attendanceFilter.includes('absent') ? 'mdi-check' : ''"
|
||
>
|
||
请假
|
||
</v-chip>
|
||
<v-chip
|
||
value="late"
|
||
:color="attendanceFilter.includes('late') ? 'warning' : ''"
|
||
:variant="attendanceFilter.includes('late') ? 'elevated' : 'tonal'"
|
||
class="px-2 filter-chip"
|
||
@click="toggleFilter('late')"
|
||
prepend-icon="mdi-clock-alert"
|
||
:append-icon="attendanceFilter.includes('late') ? 'mdi-check' : ''"
|
||
>
|
||
迟到
|
||
</v-chip>
|
||
<v-chip
|
||
value="exclude"
|
||
:color="attendanceFilter.includes('exclude') ? 'grey' : ''"
|
||
:variant="attendanceFilter.includes('exclude') ? 'elevated' : 'tonal'"
|
||
class="px-2 filter-chip"
|
||
@click="toggleFilter('exclude')"
|
||
prepend-icon="mdi-account-cancel"
|
||
:append-icon="attendanceFilter.includes('exclude') ? 'mdi-check' : ''"
|
||
>
|
||
不参与
|
||
</v-chip>
|
||
</div>
|
||
</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(state.studentList.indexOf(student))" size="24" class="mr-2">
|
||
<v-icon size="small">{{ getStudentStatusIcon(state.studentList.indexOf(student)) }}</v-icon>
|
||
</v-avatar>
|
||
<div class="text-subtitle-1">{{ student }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="attendance-actions">
|
||
<v-btn
|
||
:color="isPresent(state.studentList.indexOf(student)) ? 'success' : ''"
|
||
icon="mdi-account-check"
|
||
size="small"
|
||
variant="text"
|
||
@click="setPresent(state.studentList.indexOf(student))"
|
||
:title="'设为到课'"
|
||
/>
|
||
<v-btn
|
||
:color="isAbsent(state.studentList.indexOf(student)) ? 'error' : ''"
|
||
icon="mdi-account-off"
|
||
size="small"
|
||
variant="text"
|
||
@click="setAbsent(state.studentList.indexOf(student))"
|
||
:title="'设为请假'"
|
||
/>
|
||
<v-btn
|
||
:color="isLate(state.studentList.indexOf(student)) ? 'warning' : ''"
|
||
icon="mdi-clock-alert"
|
||
size="small"
|
||
variant="text"
|
||
@click="setLate(state.studentList.indexOf(student))"
|
||
:title="'设为迟到'"
|
||
/>
|
||
<v-btn
|
||
:color="isExclude(state.studentList.indexOf(student)) ? 'grey' : ''"
|
||
icon="mdi-account-cancel"
|
||
size="small"
|
||
variant="text"
|
||
@click="setExclude(state.studentList.indexOf(student))"
|
||
:title="'设为不参与'"
|
||
/>
|
||
</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="grey" variant="text" @click="state.attendanceDialog = false">
|
||
取消
|
||
</v-btn>
|
||
<v-btn color="primary" @click="saveAttendance">
|
||
<v-icon start>mdi-content-save</v-icon>
|
||
保存
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<message-log ref="messageLog" />
|
||
|
||
<!-- 添加确认对话框 -->
|
||
<v-dialog v-model="confirmDialog.show" max-width="400">
|
||
<v-card>
|
||
<v-card-title class="text-h6"> 确认保存 </v-card-title>
|
||
<v-card-text>
|
||
您正在修改 {{ state.dateString }} 的数据,确定要保存吗?
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn color="grey" variant="text" @click="confirmDialog.reject">
|
||
取消
|
||
</v-btn>
|
||
<v-btn color="primary" @click="confirmDialog.resolve"> 确认保存 </v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
|
||
</template>
|
||
|
||
<script>
|
||
import MessageLog from "@/components/MessageLog.vue";
|
||
import dataProvider from "@/utils/dataProvider";
|
||
import { getSetting, watchSettings, setSetting } from "@/utils/settings";
|
||
import { useDisplay } from "vuetify";
|
||
import "../styles/index.scss";
|
||
import "../styles/transitions.scss"; // 添加新的样式导入
|
||
import { debounce, throttle } from "@/utils/debounce";
|
||
import '../styles/global.scss';
|
||
|
||
export default {
|
||
name: "Classworks 作业板",
|
||
components: {
|
||
MessageLog,
|
||
},
|
||
data() {
|
||
return {
|
||
dataKey: "",
|
||
provider: "",
|
||
useDisplay: useDisplay,
|
||
state: {
|
||
classNumber: "",
|
||
studentList: [],
|
||
boardData: {
|
||
homework: {},
|
||
attendance: {
|
||
absent: [],
|
||
late: [],
|
||
exclude: [],
|
||
},
|
||
},
|
||
dialogVisible: false,
|
||
dialogTitle: "",
|
||
textarea: "",
|
||
dateString: "", // 从 state 内统一管理日期
|
||
synced: false,
|
||
attendDialogVisible: false,
|
||
contentStyle: { "font-size": `${getSetting("font.size")}px` },
|
||
uploadLoading: false,
|
||
downloadLoading: false,
|
||
snackbar: false,
|
||
snackbarText: "",
|
||
fontSize: getSetting("font.size"),
|
||
datePickerDialog: false,
|
||
selectedDate: new Date().toISOString().split("T")[0],
|
||
selectedDateObj: new Date(this.selectedDate),
|
||
refreshInterval: null,
|
||
subjectOrder: [
|
||
"语文",
|
||
"数学",
|
||
"英语",
|
||
"物理",
|
||
"化学",
|
||
"生物",
|
||
"政治",
|
||
"历史",
|
||
"地理",
|
||
"其他",
|
||
],
|
||
showNoDataMessage: false,
|
||
noDataMessage: "",
|
||
isToday: false,
|
||
attendanceDialog: false,
|
||
availableSubjects: [
|
||
{ key: "语文", name: "语文" },
|
||
{ key: "数学", name: "数学" },
|
||
{ key: "英语", name: "英语" },
|
||
{ key: "物理", name: "物理" },
|
||
{ key: "化学", name: "化学" },
|
||
{ key: "生物", name: "生物" },
|
||
{ key: "政治", name: "政治" },
|
||
{ key: "历史", name: "历史" },
|
||
{ key: "地理", name: "地理" },
|
||
{ key: "其他", name: "其他" },
|
||
],
|
||
isFullscreen: false,
|
||
},
|
||
loading: {
|
||
download: false,
|
||
upload: false,
|
||
students: false,
|
||
},
|
||
debouncedUpload: null,
|
||
throttledReflow: null,
|
||
sortedItemsCache: {
|
||
key: "",
|
||
value: [],
|
||
},
|
||
confirmDialog: {
|
||
show: false,
|
||
resolve: null,
|
||
reject: null,
|
||
},
|
||
attendanceSearch: "",
|
||
attendanceFilter: [],
|
||
searchSuggestions: [],
|
||
};
|
||
},
|
||
|
||
computed: {
|
||
isMobile() {
|
||
return useDisplay().mobile.value;
|
||
},
|
||
titleText() {
|
||
const today = this.getToday();
|
||
const yesterday = new Date(today);
|
||
yesterday.setDate(yesterday.getDate() - 1);
|
||
|
||
const currentDateStr = this.state.dateString;
|
||
const todayStr = this.formatDate(today);
|
||
const yesterdayStr = this.formatDate(yesterday);
|
||
|
||
if (currentDateStr === todayStr) {
|
||
return "今天的作业";
|
||
} else if (currentDateStr === yesterdayStr) {
|
||
return "昨天的作业";
|
||
} else {
|
||
return `${currentDateStr}的作业`;
|
||
}
|
||
},
|
||
sortedItems() {
|
||
const key = `${JSON.stringify(
|
||
this.state.boardData.homework
|
||
)}_${this.state.subjectOrder.join()}_${this.dynamicSort}`;
|
||
if (this.sortedItemsCache.key === key) {
|
||
return this.sortedItemsCache.value;
|
||
}
|
||
|
||
const items = Object.entries(this.state.boardData.homework)
|
||
.filter(([, value]) => value.content?.trim())
|
||
.map(([key, value]) => ({
|
||
key,
|
||
name:
|
||
this.state.availableSubjects.find((s) => s.key === key)?.name ||
|
||
key,
|
||
content: value.content,
|
||
order: this.state.subjectOrder.indexOf(key),
|
||
rowSpan: Math.ceil(
|
||
(value.content.split("\n").filter((line) => line.trim()).length +
|
||
1) *
|
||
0.8
|
||
),
|
||
}));
|
||
|
||
const result = this.dynamicSort
|
||
? this.optimizeGridLayout(items)
|
||
: items.sort((a, b) => a.order - b.order);
|
||
|
||
this.updateSortedItemsCache(key, result);
|
||
|
||
return result;
|
||
},
|
||
unusedSubjects() {
|
||
const usedKeys = Object.keys(this.state.boardData.homework).filter(
|
||
key => this.state.boardData.homework[key].content?.trim()
|
||
);
|
||
return this.state.availableSubjects.filter(
|
||
(subject) => !usedKeys.includes(subject.key)
|
||
);
|
||
},
|
||
emptySubjects() {
|
||
if (this.emptySubjectDisplay !== "button") return [];
|
||
return this.unusedSubjects;
|
||
},
|
||
autoSave() {
|
||
return getSetting("edit.autoSave");
|
||
},
|
||
blockNonTodayAutoSave() {
|
||
return getSetting("edit.blockNonTodayAutoSave");
|
||
},
|
||
isToday() {
|
||
const today = new Date().toISOString().split("T")[0];
|
||
return this.state.dateString === today;
|
||
},
|
||
canAutoSave() {
|
||
// 只考虑自动保存开关和非当天限制
|
||
return this.autoSave && (!this.blockNonTodayAutoSave || this.isToday);
|
||
},
|
||
needConfirmSave() {
|
||
// 只在非今天且开启了确认选项时需要确认
|
||
return !this.isToday && this.confirmNonTodaySave;
|
||
},
|
||
shouldShowBlockedMessage() {
|
||
// 只在非今天且开启了自动保存和禁止非当天自动保存时提示
|
||
return !this.isToday && this.autoSave && this.blockNonTodayAutoSave;
|
||
},
|
||
refreshBeforeEdit() {
|
||
return getSetting("edit.refreshBeforeEdit");
|
||
},
|
||
emptySubjectDisplay() {
|
||
return getSetting("display.emptySubjectDisplay");
|
||
},
|
||
dynamicSort() {
|
||
return getSetting("display.dynamicSort");
|
||
},
|
||
isEditingDisabled() {
|
||
return this.state.uploadLoading || this.state.downloadLoading;
|
||
},
|
||
unreadCount() {
|
||
return this.$refs.messageLog?.unreadCount || 0;
|
||
},
|
||
showRandomButton() {
|
||
return getSetting("display.showRandomButton");
|
||
},
|
||
confirmNonTodaySave() {
|
||
return getSetting("edit.confirmNonTodaySave");
|
||
},
|
||
shouldShowSaveConfirm() {
|
||
return !this.isToday && this.confirmNonTodaySave;
|
||
},
|
||
shouldBlockAutoSave() {
|
||
return !this.isToday && this.autoSave && this.blockNonTodayAutoSave;
|
||
},
|
||
showFullscreenButton() {
|
||
return getSetting("display.showFullscreenButton");
|
||
},
|
||
showAntiScreenBurnCard() {
|
||
return getSetting("display.showAntiScreenBurnCard");
|
||
},
|
||
filteredStudents() {
|
||
let students = [...this.state.studentList];
|
||
|
||
// 应用搜索过滤
|
||
if (this.attendanceSearch) {
|
||
const searchTerm = this.attendanceSearch.toLowerCase();
|
||
students = students.filter(student =>
|
||
student.toLowerCase().includes(searchTerm)
|
||
);
|
||
}
|
||
|
||
// 应用状态过滤
|
||
if (this.attendanceFilter && this.attendanceFilter.length > 0) {
|
||
students = students.filter(student => {
|
||
const index = this.state.studentList.indexOf(student);
|
||
if (this.attendanceFilter.includes('present') && this.isPresent(index)) return true;
|
||
if (this.attendanceFilter.includes('absent') && this.isAbsent(index)) return true;
|
||
if (this.attendanceFilter.includes('late') && this.isLate(index)) return true;
|
||
if (this.attendanceFilter.includes('exclude') && this.isExclude(index)) return true;
|
||
return false;
|
||
});
|
||
}
|
||
|
||
return students;
|
||
},
|
||
extractedSurnames() {
|
||
// 从学生名单中提取姓氏并统计数量
|
||
if (!this.state.studentList || this.state.studentList.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const surnameMap = new Map();
|
||
|
||
this.state.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) => b.count - a.count);
|
||
},
|
||
},
|
||
|
||
watch: {
|
||
homeworkData: {
|
||
handler() {
|
||
this.$nextTick(() => {
|
||
if (this.$refs.waterfall) {
|
||
this.$refs.waterfall.reflow();
|
||
}
|
||
});
|
||
},
|
||
deep: true,
|
||
},
|
||
"$vuetify.display.width": {
|
||
handler() {
|
||
this.throttledReflow();
|
||
},
|
||
deep: true,
|
||
},
|
||
},
|
||
|
||
created() {
|
||
// 创建防抖的上传函数
|
||
this.debouncedUpload = debounce(this.uploadData, 2000);
|
||
// 创建节流的重排函数
|
||
this.throttledReflow = throttle(() => {
|
||
if (this.$refs.gridContainer) {
|
||
this.optimizeGridLayout(this.sortedItems);
|
||
}
|
||
}, 200);
|
||
},
|
||
|
||
async mounted() {
|
||
try {
|
||
this.updateBackendUrl();
|
||
await this.initializeData();
|
||
this.setupAutoRefresh();
|
||
this.unwatchSettings = watchSettings(() => {
|
||
this.updateSettings();
|
||
});
|
||
|
||
// 监听全屏变化事件
|
||
document.addEventListener('fullscreenchange', this.fullscreenChangeHandler);
|
||
document.addEventListener('webkitfullscreenchange', this.fullscreenChangeHandler);
|
||
document.addEventListener('mozfullscreenchange', this.fullscreenChangeHandler);
|
||
document.addEventListener('MSFullscreenChange', this.fullscreenChangeHandler);
|
||
} catch (err) {
|
||
console.error("初始化失败:", err);
|
||
this.showError("初始化失败,请刷新页面重试");
|
||
}
|
||
},
|
||
|
||
beforeUnmount() {
|
||
if (this.unwatchSettings) {
|
||
this.unwatchSettings();
|
||
}
|
||
if (this.state.refreshInterval) {
|
||
// 注意刷新间隔存放在 state 内
|
||
clearInterval(this.state.refreshInterval);
|
||
}
|
||
|
||
// 移除全屏变化事件监听
|
||
document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler);
|
||
document.removeEventListener('webkitfullscreenchange', this.fullscreenChangeHandler);
|
||
document.removeEventListener('mozfullscreenchange', this.fullscreenChangeHandler);
|
||
document.removeEventListener('MSFullscreenChange', this.fullscreenChangeHandler);
|
||
},
|
||
|
||
methods: {
|
||
// 添加新的日期辅助方法
|
||
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(); // 如果无法解析,返回当前日期
|
||
},
|
||
|
||
formatDate(dateInput) {
|
||
const date = this.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}`;
|
||
},
|
||
|
||
getToday() {
|
||
return new Date();
|
||
},
|
||
|
||
async initializeData() {
|
||
this.provider = getSetting("server.provider");
|
||
const domain = getSetting("server.domain");
|
||
const classNum = getSetting("server.classNumber");
|
||
|
||
this.dataKey =
|
||
this.provider === "server" ? `${domain}/${classNum}` : classNum;
|
||
this.state.classNumber = classNum;
|
||
|
||
// 从 URL 获取日期,如果没有则使用今天的日期
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const dateFromUrl = urlParams.get("date");
|
||
const today = this.getToday();
|
||
|
||
// 确保日期格式正确
|
||
const currentDate = dateFromUrl ? new Date(dateFromUrl) : today;
|
||
this.state.dateString = this.formatDate(currentDate);
|
||
this.state.selectedDate = this.state.dateString;
|
||
this.state.isToday =
|
||
this.formatDate(currentDate) === this.formatDate(today);
|
||
|
||
await Promise.all([this.downloadData(), this.loadConfig()]);
|
||
},
|
||
|
||
async downloadData() {
|
||
if (this.loading.download) return;
|
||
|
||
try {
|
||
this.loading.download = true;
|
||
const response = await dataProvider.loadData(
|
||
this.provider,
|
||
this.dataKey,
|
||
this.state.dateString
|
||
);
|
||
|
||
if (!response.success) {
|
||
if (response.error.code === "NOT_FOUND") {
|
||
this.state.showNoDataMessage = true;
|
||
this.state.noDataMessage = response.error.message;
|
||
// 确保数据结构完整
|
||
this.state.boardData = {
|
||
homework: {},
|
||
attendance: { absent: [], late: [], exclude: [] },
|
||
};
|
||
} else {
|
||
throw new Error(response.error.message);
|
||
}
|
||
} else {
|
||
// 确保数据结构完整
|
||
this.state.boardData = {
|
||
homework: response.data.homework || {},
|
||
attendance: {
|
||
absent: response.data.attendance?.absent || [],
|
||
late: response.data.attendance?.late || [],
|
||
exclude: response.data.attendance?.exclude || [],
|
||
},
|
||
};
|
||
this.state.synced = true;
|
||
this.state.showNoDataMessage = false;
|
||
this.$message.success("下载成功", "数据已更新");
|
||
}
|
||
} catch (error) {
|
||
// 发生错误时也要确保数据结构完整
|
||
this.state.boardData = {
|
||
homework: {},
|
||
attendance: { absent: [], late: [], exclude: [] },
|
||
};
|
||
this.$message.error("下载失败", error.message);
|
||
} finally {
|
||
this.loading.download = false;
|
||
}
|
||
},
|
||
|
||
async trySave(isAutoSave = false) {
|
||
// 如果是自动保存但不满足自动保存条件
|
||
if (isAutoSave && !this.canAutoSave) {
|
||
if (this.shouldShowBlockedMessage) {
|
||
this.showMessage(
|
||
"需要手动保存",
|
||
"已禁止自动保存非当天数据",
|
||
"warning"
|
||
);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// 如果需要确认且不是自动保存
|
||
if (!isAutoSave && this.needConfirmSave) {
|
||
try {
|
||
await this.showConfirmDialog();
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 尝试保存
|
||
try {
|
||
await this.uploadData();
|
||
return true;
|
||
} catch (error) {
|
||
this.$message.error("保存失败", error.message || "请重试");
|
||
return false;
|
||
}
|
||
},
|
||
|
||
async handleClose() {
|
||
if (!this.currentEditSubject) return;
|
||
|
||
const content = this.state.textarea.trim();
|
||
const originalContent =
|
||
this.state.boardData.homework[this.currentEditSubject]?.content || "";
|
||
|
||
// 如果内容发生变化(包括清空),就视为修改
|
||
if (content !== originalContent.trim()) {
|
||
// 无论内容是否为空,都保留科目结构
|
||
this.state.boardData.homework[this.currentEditSubject] = {
|
||
content: content,
|
||
};
|
||
|
||
this.state.synced = false;
|
||
|
||
// 处理自动保存
|
||
if (this.autoSave) {
|
||
await this.trySave(true);
|
||
}
|
||
}
|
||
|
||
this.state.dialogVisible = false;
|
||
},
|
||
|
||
async uploadData() {
|
||
if (this.loading.upload) return;
|
||
|
||
try {
|
||
this.loading.upload = true;
|
||
const response = await dataProvider.saveData(
|
||
this.provider,
|
||
this.dataKey,
|
||
this.state.boardData,
|
||
this.state.dateString // 添加dateString参数
|
||
);
|
||
|
||
if (!response.success) {
|
||
throw new Error(response.error.message);
|
||
}
|
||
|
||
this.state.synced = true;
|
||
this.$message.success(response.message || "保存成功");
|
||
} finally {
|
||
this.loading.upload = false;
|
||
}
|
||
},
|
||
|
||
async loadConfig() {
|
||
try {
|
||
const response = await dataProvider.loadConfig(
|
||
this.provider,
|
||
this.dataKey
|
||
);
|
||
|
||
if (!response.success) {
|
||
throw new Error(response.error.message);
|
||
}
|
||
|
||
this.state.studentList = response.data.studentList || [];
|
||
} catch (error) {
|
||
console.error("加载配置失败:", error);
|
||
this.$message.error("加载配置失败", error.message);
|
||
}
|
||
},
|
||
|
||
showSyncMessage() {
|
||
this.$message.success("数据已同步", "数据已完成与服务器同步");
|
||
},
|
||
|
||
async openDialog(subject) {
|
||
if (this.refreshBeforeEdit) {
|
||
try {
|
||
await this.downloadData();
|
||
} catch (err) {
|
||
console.error("刷新数据失败:", err);
|
||
this.$message.error("刷新数据失败,可能显示的不是最新数据");
|
||
}
|
||
}
|
||
|
||
this.currentEditSubject = subject;
|
||
// 如果是新科目,需要创建对应的精简数据结构
|
||
if (!this.state.boardData.homework[subject]) {
|
||
this.state.boardData.homework[subject] = {
|
||
content: "",
|
||
};
|
||
}
|
||
this.state.dialogTitle =
|
||
this.state.availableSubjects.find((s) => s.key === subject)?.name ||
|
||
subject;
|
||
this.state.textarea = this.state.boardData.homework[subject].content;
|
||
this.state.dialogVisible = true;
|
||
this.$nextTick(() => {
|
||
if (this.$refs.inputRef) {
|
||
this.$refs.inputRef.focus();
|
||
}
|
||
});
|
||
},
|
||
|
||
splitPoint(content) {
|
||
return content.split("\n").filter((text) => text.trim());
|
||
},
|
||
|
||
setAttendanceArea() {
|
||
this.state.attendanceDialog = true;
|
||
},
|
||
|
||
toggleStudentStatus(index) {
|
||
const student = this.state.studentList[index];
|
||
if (this.state.boardData.attendance.absent.includes(student)) {
|
||
this.state.boardData.attendance.absent =
|
||
this.state.boardData.attendance.absent.filter(
|
||
(name) => name !== student
|
||
);
|
||
this.state.boardData.attendance.late.push(student);
|
||
} else if (this.state.boardData.attendance.late.includes(student)) {
|
||
this.state.boardData.attendance.late =
|
||
this.state.boardData.attendance.late.filter(
|
||
(name) => name !== student
|
||
);
|
||
this.state.boardData.attendance.exclude.push(student);
|
||
} else if (this.state.boardData.attendance.exclude.includes(student)) {
|
||
this.state.boardData.attendance.exclude =
|
||
this.state.boardData.attendance.exclude.filter(
|
||
(name) => name !== student
|
||
);
|
||
} else {
|
||
this.state.boardData.attendance.absent.push(student);
|
||
}
|
||
this.state.synced = false;
|
||
if (this.canAutoSave) {
|
||
this.uploadData();
|
||
}
|
||
},
|
||
|
||
cleanstudentslist() {
|
||
this.state.boardData.attendance.absent = [];
|
||
this.state.boardData.attendance.late = [];
|
||
this.state.boardData.attendance.exclude = [];
|
||
this.state.synced = false;
|
||
if (this.canAutoSave) {
|
||
this.uploadData();
|
||
}
|
||
},
|
||
|
||
zoom(direction) {
|
||
const step = 2;
|
||
if (direction === "up" && this.state.fontSize < 100) {
|
||
this.state.fontSize += step;
|
||
} else if (direction === "out" && this.state.fontSize > 16) {
|
||
this.state.fontSize -= step;
|
||
}
|
||
this.state.contentStyle = {
|
||
"font-size": `${this.state.fontSize}px`,
|
||
};
|
||
setSetting("font.size", this.state.fontSize);
|
||
},
|
||
|
||
updateBackendUrl() {
|
||
const provider = getSetting("server.provider");
|
||
const domain = getSetting("server.domain");
|
||
const classNum = getSetting("server.classNumber");
|
||
|
||
this.provider = provider;
|
||
this.dataKey = provider === "server" ? `${domain}/${classNum}` : classNum;
|
||
this.state.classNumber = classNum;
|
||
},
|
||
|
||
setupAutoRefresh() {
|
||
const autoRefresh = getSetting("refresh.auto");
|
||
const interval = getSetting("refresh.interval");
|
||
if (this.state.refreshInterval) {
|
||
clearInterval(this.state.refreshInterval);
|
||
}
|
||
if (autoRefresh) {
|
||
this.state.refreshInterval = setInterval(() => {
|
||
this.downloadData();
|
||
}, interval * 1000);
|
||
}
|
||
},
|
||
|
||
updateSettings() {
|
||
this.state.fontSize = getSetting("font.size");
|
||
this.state.contentStyle = { "font-size": `${this.state.fontSize}px` };
|
||
this.setupAutoRefresh();
|
||
this.updateBackendUrl();
|
||
},
|
||
|
||
handleDateSelect(newDate) {
|
||
if (!newDate) return;
|
||
|
||
try {
|
||
const selectedDate = this.ensureDate(newDate);
|
||
const formattedDate = this.formatDate(selectedDate);
|
||
|
||
// 只有当日期真正改变时才更新
|
||
if (this.state.dateString !== formattedDate) {
|
||
this.state.dateString = formattedDate;
|
||
this.state.selectedDate = formattedDate;
|
||
this.state.isToday =
|
||
formattedDate === this.formatDate(this.getToday());
|
||
|
||
// 使用 replace 而不是 push 来避免创建新的历史记录
|
||
this.$router
|
||
.replace({
|
||
query: { date: formattedDate },
|
||
})
|
||
.catch(() => { });
|
||
this.downloadData();
|
||
}
|
||
} catch (error) {
|
||
console.error("Date processing error:", error);
|
||
this.$message.error("日期处理错误", "请重新选择日期");
|
||
}
|
||
},
|
||
|
||
optimizeGridLayout(items) {
|
||
// 设置最大列数
|
||
const maxColumns = Math.min(3, Math.floor(window.innerWidth / 300));
|
||
if (maxColumns <= 1) return items;
|
||
|
||
// 使用贪心算法分配
|
||
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;
|
||
});
|
||
|
||
// 展平结果并添加顺序
|
||
return columns
|
||
.flatMap((col) => col.items)
|
||
.map((item, index) => ({
|
||
...item,
|
||
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,
|
||
}));
|
||
},
|
||
|
||
setAllPresent() {
|
||
this.state.boardData.attendance = {
|
||
absent: [],
|
||
late: [],
|
||
exclude: [],
|
||
};
|
||
this.state.synced = false;
|
||
},
|
||
|
||
setAllAbsent() {
|
||
this.state.boardData.attendance.absent = [...this.state.studentList];
|
||
this.state.boardData.attendance.late = [];
|
||
this.state.boardData.attendance.exclude = [];
|
||
this.state.synced = false;
|
||
},
|
||
|
||
setAllLate() {
|
||
this.state.boardData.attendance.absent = [];
|
||
this.state.boardData.attendance.late = [...this.state.studentList];
|
||
this.state.boardData.attendance.exclude = [];
|
||
this.state.synced = false;
|
||
},
|
||
setAllExclude() {
|
||
this.state.boardData.attendance.absent = [];
|
||
this.state.boardData.attendance.late = [];
|
||
this.state.boardData.attendance.exclude = [...this.state.studentList];
|
||
this.state.synced = false;
|
||
},
|
||
|
||
isPresent(index) {
|
||
const student = this.state.studentList[index];
|
||
const { absent, late, exclude } = this.state.boardData.attendance;
|
||
return (
|
||
!absent.includes(student) &&
|
||
!late.includes(student) &&
|
||
!exclude.includes(student)
|
||
);
|
||
},
|
||
|
||
isAbsent(index) {
|
||
return this.state.boardData.attendance.absent.includes(
|
||
this.state.studentList[index]
|
||
);
|
||
},
|
||
|
||
isLate(index) {
|
||
return this.state.boardData.attendance.late.includes(
|
||
this.state.studentList[index]
|
||
);
|
||
},
|
||
|
||
isExclude(index) {
|
||
return this.state.boardData.attendance.exclude.includes(
|
||
this.state.studentList[index]
|
||
);
|
||
},
|
||
|
||
setPresent(index) {
|
||
const student = this.state.studentList[index];
|
||
// 从所有状态列表中移除该学生
|
||
this.state.boardData.attendance.absent = this.state.boardData.attendance.absent.filter(
|
||
(name) => name !== student
|
||
);
|
||
this.state.boardData.attendance.late = this.state.boardData.attendance.late.filter(
|
||
(name) => name !== student
|
||
);
|
||
this.state.boardData.attendance.exclude = this.state.boardData.attendance.exclude.filter(
|
||
(name) => name !== student
|
||
);
|
||
this.state.synced = false;
|
||
},
|
||
|
||
setAbsent(index) {
|
||
const student = this.state.studentList[index];
|
||
// 先从所有状态列表中移除该学生
|
||
this.setPresent(index);
|
||
// 然后添加到请假列表
|
||
this.state.boardData.attendance.absent.push(student);
|
||
this.state.synced = false;
|
||
},
|
||
|
||
setLate(index) {
|
||
const student = this.state.studentList[index];
|
||
// 先从所有状态列表中移除该学生
|
||
this.setPresent(index);
|
||
// 然后添加到迟到列表
|
||
this.state.boardData.attendance.late.push(student);
|
||
this.state.synced = false;
|
||
},
|
||
|
||
setExclude(index) {
|
||
const student = this.state.studentList[index];
|
||
// 先从所有状态列表中移除该学生
|
||
this.setPresent(index);
|
||
// 然后添加到不参与列表
|
||
this.state.boardData.attendance.exclude.push(student);
|
||
this.state.synced = false;
|
||
},
|
||
|
||
async saveAttendance() {
|
||
try {
|
||
await this.trySave(true);
|
||
this.state.attendanceDialog = false;
|
||
} catch (error) {
|
||
console.error("保存出勤状态失败:", error);
|
||
this.$message.error("保存失败", "请重试");
|
||
}
|
||
},
|
||
|
||
showMessage(title, content = "", type = "success") {
|
||
this.$message[type](title, content);
|
||
},
|
||
|
||
updateSortedItemsCache(key, value) {
|
||
this._sortedItemsCache = {
|
||
key,
|
||
value,
|
||
};
|
||
},
|
||
|
||
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}%`);
|
||
}
|
||
},
|
||
|
||
showConfirmDialog() {
|
||
return new Promise((resolve, reject) => {
|
||
this.confirmDialog = {
|
||
show: true,
|
||
resolve: () => {
|
||
this.confirmDialog.show = false;
|
||
resolve();
|
||
},
|
||
reject: () => {
|
||
this.confirmDialog.show = false;
|
||
reject(new Error("用户取消保存"));
|
||
},
|
||
};
|
||
});
|
||
},
|
||
|
||
confirmSave() {
|
||
this.confirmDialog.show = false;
|
||
if (this.confirmDialog.resolve) {
|
||
this.confirmDialog.resolve(true);
|
||
}
|
||
},
|
||
|
||
cancelSave() {
|
||
this.confirmDialog.show = false;
|
||
if (this.confirmDialog.reject) {
|
||
this.confirmDialog.reject(new Error("用户取消保存"));
|
||
}
|
||
},
|
||
|
||
// 点击上传按钮时调用
|
||
async manualUpload() {
|
||
return this.trySave(false);
|
||
},
|
||
|
||
async handleAttendanceDialogClose(newValue) {
|
||
if (!newValue && !this.state.synced) {
|
||
// 对话框关闭且数据未同步时尝试保存
|
||
await this.trySave(true);
|
||
}
|
||
},
|
||
|
||
// 全屏相关方法
|
||
toggleFullscreen() {
|
||
if (!this.state.isFullscreen) {
|
||
this.enterFullscreen();
|
||
} else {
|
||
this.exitFullscreen();
|
||
}
|
||
},
|
||
|
||
enterFullscreen() {
|
||
const docElm = document.documentElement;
|
||
|
||
if (docElm.requestFullscreen) {
|
||
docElm.requestFullscreen();
|
||
} else if (docElm.webkitRequestFullScreen) {
|
||
docElm.webkitRequestFullScreen();
|
||
} else if (docElm.mozRequestFullScreen) {
|
||
docElm.mozRequestFullScreen();
|
||
} else if (docElm.msRequestFullscreen) {
|
||
docElm.msRequestFullscreen();
|
||
}
|
||
},
|
||
|
||
exitFullscreen() {
|
||
if (document.exitFullscreen) {
|
||
document.exitFullscreen();
|
||
} else if (document.webkitExitFullscreen) {
|
||
document.webkitExitFullscreen();
|
||
} else if (document.mozCancelFullScreen) {
|
||
document.mozCancelFullScreen();
|
||
} else if (document.msExitFullscreen) {
|
||
document.msExitFullscreen();
|
||
}
|
||
},
|
||
|
||
fullscreenChangeHandler() {
|
||
this.state.isFullscreen = !!(
|
||
document.fullscreenElement ||
|
||
document.webkitFullscreenElement ||
|
||
document.mozFullScreenElement ||
|
||
document.msFullscreenElement
|
||
);
|
||
},
|
||
|
||
getStudentStatusColor(index) {
|
||
const studentName = this.state.studentList[index];
|
||
if (this.state.boardData.attendance.absent.includes(studentName)) return 'error';
|
||
if (this.state.boardData.attendance.late.includes(studentName)) return 'warning';
|
||
if (this.state.boardData.attendance.exclude.includes(studentName)) return 'grey';
|
||
return 'success';
|
||
},
|
||
|
||
getStudentStatusVariant(index) {
|
||
const studentName = this.state.studentList[index];
|
||
if (this.state.boardData.attendance.absent.includes(studentName) ||
|
||
this.state.boardData.attendance.late.includes(studentName) ||
|
||
this.state.boardData.attendance.exclude.includes(studentName)) {
|
||
return 'tonal';
|
||
}
|
||
return 'outlined';
|
||
},
|
||
|
||
getStudentStatusIcon(index) {
|
||
const studentName = this.state.studentList[index];
|
||
if (this.state.boardData.attendance.absent.includes(studentName)) return 'mdi-account-off';
|
||
if (this.state.boardData.attendance.late.includes(studentName)) return 'mdi-clock-alert';
|
||
if (this.state.boardData.attendance.exclude.includes(studentName)) return 'mdi-account-cancel';
|
||
return 'mdi-account-check';
|
||
},
|
||
|
||
getStudentStatusText(index) {
|
||
const studentName = this.state.studentList[index];
|
||
if (this.state.boardData.attendance.absent.includes(studentName)) return '请假';
|
||
if (this.state.boardData.attendance.late.includes(studentName)) return '迟到';
|
||
if (this.state.boardData.attendance.exclude.includes(studentName)) return '不参与';
|
||
return '到课';
|
||
},
|
||
|
||
handleSearchChange(value) {
|
||
// 生成搜索建议
|
||
this.generateSearchSuggestions(value);
|
||
},
|
||
|
||
generateSearchSuggestions(searchTerm) {
|
||
if (!searchTerm || searchTerm.length < 1) {
|
||
this.searchSuggestions = [];
|
||
return;
|
||
}
|
||
|
||
// 查找匹配的学生名字
|
||
const matchingStudents = this.state.studentList.filter(student =>
|
||
student.toLowerCase().includes(searchTerm.toLowerCase())
|
||
);
|
||
|
||
// 如果有精确匹配,不显示建议
|
||
if (matchingStudents.some(student => student === searchTerm)) {
|
||
this.searchSuggestions = [];
|
||
return;
|
||
}
|
||
|
||
// 提取匹配的部分作为建议
|
||
const suggestions = new Set();
|
||
|
||
// 如果是单个字符,可能是在搜索姓氏
|
||
if (searchTerm.length === 1) {
|
||
// 添加所有以该字符开头的学生的完整姓名作为建议
|
||
matchingStudents.forEach(student => {
|
||
if (student.startsWith(searchTerm)) {
|
||
suggestions.add(student);
|
||
}
|
||
});
|
||
} else {
|
||
// 对于多字符搜索,添加包含该字符串的学生名字
|
||
matchingStudents.forEach(student => {
|
||
suggestions.add(student);
|
||
});
|
||
}
|
||
|
||
// 限制建议数量
|
||
this.searchSuggestions = Array.from(suggestions).slice(0, 5);
|
||
},
|
||
|
||
toggleFilter(filter) {
|
||
const index = this.attendanceFilter.indexOf(filter);
|
||
if (index === -1) {
|
||
this.attendanceFilter.push(filter);
|
||
} else {
|
||
this.attendanceFilter.splice(index, 1);
|
||
}
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
<style lang="scss">
|
||
// 添加卡片发光效果
|
||
.glow-track {
|
||
position: relative;
|
||
overflow: hidden;
|
||
transition: all 0.3s ease;
|
||
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: radial-gradient(circle at var(--x, 50%) var(--y, 50%),
|
||
rgba(255, 255, 255, 0.15) 0%,
|
||
rgba(255, 255, 255, 0) 70%);
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
}
|
||
|
||
&:hover::before {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
// 添加卡片悬浮效果
|
||
.grid-item .v-card {
|
||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||
|
||
&:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15) !important;
|
||
}
|
||
|
||
&:active {
|
||
transform: translateY(-2px);
|
||
}
|
||
}
|
||
|
||
// 添加空科目卡片样式
|
||
.empty-subject-card {
|
||
transition: all 0.3s ease;
|
||
opacity: 0.8;
|
||
|
||
&:hover {
|
||
opacity: 1;
|
||
transform: translateY(-4px);
|
||
}
|
||
}
|
||
|
||
// 修改防烧屏提示卡片,使用 tonal 样式减少信息密度
|
||
.anti-burn-card {
|
||
animation: subtle-glow 4s infinite alternate;
|
||
transition: all 0.3s ease;
|
||
|
||
&:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||
}
|
||
}
|
||
|
||
@keyframes subtle-glow {
|
||
0% {
|
||
box-shadow: 0 0 5px rgba(33, 150, 243, 0.1);
|
||
}
|
||
|
||
100% {
|
||
box-shadow: 0 0 15px rgba(33, 150, 243, 0.3);
|
||
}
|
||
}
|
||
|
||
// 出勤管理对话框样式
|
||
.attendance-stat {
|
||
height: 100%;
|
||
}
|
||
|
||
// 姓氏筛选按钮
|
||
.surname-btn {
|
||
margin: 2px;
|
||
min-width: 0;
|
||
padding: 0 8px;
|
||
|
||
&:active {
|
||
transform: scale(0.95);
|
||
}
|
||
}
|
||
|
||
// 搜索建议按钮
|
||
.suggestion-btn {
|
||
margin: 2px;
|
||
min-width: 0;
|
||
padding: 0 6px;
|
||
|
||
&:active {
|
||
transform: scale(0.95);
|
||
}
|
||
}
|
||
|
||
// 适配触摸屏
|
||
@media (hover: none) {
|
||
.student-card .attendance-actions {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
// 小屏幕适配
|
||
@media (max-width: 600px) {
|
||
.student-card {
|
||
.attendance-actions .v-btn {
|
||
margin: 0 1px;
|
||
min-width: 28px;
|
||
width: 28px;
|
||
height: 28px;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 过滤器芯片
|
||
.filter-chip {
|
||
cursor: pointer;
|
||
margin: 2px;
|
||
|
||
&:active {
|
||
transform: scale(0.95);
|
||
}
|
||
}
|
||
</style>
|