mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2025-12-07 21:13:11 +00:00
2238 lines
68 KiB
Vue
2238 lines
68 KiB
Vue
<template>
|
||
<v-app-bar class="no-select">
|
||
<v-app-bar-title>
|
||
{{ state.classNumber }} - {{ titleText }}
|
||
</v-app-bar-title>
|
||
|
||
<v-spacer />
|
||
|
||
<template #append>
|
||
<!-- 只读 Token 警告 -->
|
||
<v-chip
|
||
v-if="tokenDisplayInfo.readonly"
|
||
color="warning"
|
||
variant="tonal"
|
||
class="mx-2"
|
||
prepend-icon="mdi-lock-alert"
|
||
>
|
||
只读
|
||
</v-chip>
|
||
|
||
<!-- 学生名称显示 chip(始终蓝色) -->
|
||
<v-chip
|
||
v-if="tokenDisplayInfo.show"
|
||
color="primary"
|
||
variant="tonal"
|
||
class="mx-2"
|
||
prepend-icon="mdi-account"
|
||
:style="{ cursor: tokenDisplayInfo.disabled ? 'default' : 'pointer' }"
|
||
@click="handleTokenChipClick"
|
||
>
|
||
{{ tokenDisplayInfo.text }}
|
||
</v-chip>
|
||
|
||
<v-btn icon="mdi-chat" variant="text" @click="isChatOpen = true" />
|
||
<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>
|
||
<!-- 初始化选择卡片,仅在首页且需要授权时显示;不影响顶栏 -->
|
||
<init-service-chooser v-if="shouldShowInit" @done="settingsTick++" />
|
||
|
||
<!-- 学生姓名管理组件 -->
|
||
<StudentNameManager
|
||
v-if="!shouldShowInit"
|
||
ref="studentNameManager"
|
||
@token-info-updated="updateTokenDisplayInfo"
|
||
/>
|
||
|
||
<div v-if="!shouldShowInit" 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"
|
||
:class="{ 'glow-highlight': highlightedCards[item.key] }"
|
||
@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="tonal">
|
||
<v-btn
|
||
v-for="subject in unusedSubjects"
|
||
:key="subject.name"
|
||
:disabled="isEditingDisabled"
|
||
@click="openDialog(subject.name)"
|
||
>
|
||
<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.name"
|
||
border
|
||
class="empty-subject-card"
|
||
:disabled="isEditingDisabled"
|
||
@click="openDialog(subject.name)"
|
||
>
|
||
<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="showRandomPickerButton"
|
||
color="amber"
|
||
prepend-icon="mdi-account-question"
|
||
append-icon="mdi-dice-multiple"
|
||
size="large"
|
||
class="ml-2"
|
||
@click="openRandomPicker"
|
||
>
|
||
随机点名
|
||
</v-btn>
|
||
<v-btn
|
||
v-if="showExamScheduleButton"
|
||
color="green"
|
||
prepend-icon="mdi-calendar-check"
|
||
size="large"
|
||
class="ml-2"
|
||
@click="$router.push('/examschedule')"
|
||
>
|
||
考试看板
|
||
</v-btn>
|
||
<v-btn
|
||
v-if="showListCardButton"
|
||
color="primary-darken-1"
|
||
prepend-icon="mdi-list-box"
|
||
size="large"
|
||
class="ml-2"
|
||
@click="$router.push('/list')"
|
||
>
|
||
列表
|
||
</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 ? "退出全屏" : "全屏显示"
|
||
}}<!-- 修改防烧屏提示卡片,使用 tonal 样式减少信息密度 -->
|
||
</v-btn>
|
||
<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-ripple="{ class: `text-${['primary','secondary','info','success','warning','error'][Math.floor(Math.random()*6)]}` }"
|
||
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>
|
||
|
||
<homework-edit-dialog
|
||
v-model="state.dialogVisible"
|
||
:title="state.dialogTitle"
|
||
:initial-content="state.textarea"
|
||
:auto-save="autoSave"
|
||
@save="handleHomeworkSave"
|
||
/>
|
||
|
||
<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="搜索学生"
|
||
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="
|
||
attendanceSearch === surname.name ? 'elevated' : 'text'
|
||
"
|
||
:color="attendanceSearch === surname.name ? 'primary' : ''"
|
||
@click="
|
||
attendanceSearch =
|
||
attendanceSearch === 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="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="primary" @click="saveAttendance">
|
||
<v-icon start>mdi-content-save</v-icon>
|
||
保存
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<message-log ref="messageLog" />
|
||
|
||
<!-- 添加悬浮工具栏 -->
|
||
<floating-toolbar
|
||
:loading="loading.download"
|
||
:unread-count="unreadCount"
|
||
:selected-date="state.selectedDateObj"
|
||
:is-today="isToday"
|
||
@zoom="zoom"
|
||
@refresh="downloadData"
|
||
@open-messages="$refs.messageLog.drawer = true"
|
||
@open-settings="$router.push('/settings')"
|
||
@date-select="handleDateSelect"
|
||
@prev-day="navigateDay(-1)"
|
||
@next-day="navigateDay(1)"
|
||
/>
|
||
|
||
<!-- 添加ICP备案悬浮组件 -->
|
||
<FloatingICP />
|
||
|
||
<!-- 设备聊天室(右下角浮窗) -->
|
||
<ChatWidget v-model="isChatOpen" :show-button="false" />
|
||
|
||
<!-- 添加确认对话框 -->
|
||
<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>
|
||
|
||
<!-- 添加随机点名组件 -->
|
||
<random-picker
|
||
ref="randomPicker"
|
||
:student-list="state.studentList"
|
||
:attendance="state.boardData.attendance"
|
||
/>
|
||
|
||
<!-- 添加URL配置确认对话框 -->
|
||
<v-dialog v-model="urlConfigDialog.show" max-width="500">
|
||
<v-card>
|
||
<v-card-title class="text-h6"> 确认应用URL配置 </v-card-title>
|
||
<v-card-text>
|
||
<p>以下配置将应用于当前班级:</p>
|
||
<v-list density="compact">
|
||
<v-list-item
|
||
v-for="change in urlConfigDialog.changes"
|
||
:key="change.key"
|
||
>
|
||
<template #prepend>
|
||
<v-icon :icon="change.icon" size="small" class="mr-2" />
|
||
</template>
|
||
<v-list-item-title class="d-flex align-center">
|
||
<span class="text-subtitle-1">{{ change.name }}</span>
|
||
<v-tooltip activator="parent" location="top">{{
|
||
change.description || change.key
|
||
}}</v-tooltip>
|
||
</v-list-item-title>
|
||
<v-list-item-subtitle>
|
||
<span class="text-grey-darken-1">{{ change.oldValue }}</span>
|
||
<v-icon icon="mdi-arrow-right" size="small" class="mx-1" />
|
||
<span class="text-primary font-weight-medium">{{
|
||
change.newValue
|
||
}}</span>
|
||
</v-list-item-subtitle>
|
||
</v-list-item>
|
||
</v-list>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn
|
||
color="grey"
|
||
variant="text"
|
||
@click="urlConfigDialog.cancelHandler"
|
||
>
|
||
取消
|
||
</v-btn>
|
||
<v-btn color="primary" @click="urlConfigDialog.confirmHandler">
|
||
确认应用
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card> </v-dialog
|
||
><br /><br /><br /><br /><br /><br />
|
||
</template>
|
||
|
||
<script>
|
||
import MessageLog from "@/components/MessageLog.vue";
|
||
import RandomPicker from "@/components/RandomPicker.vue";
|
||
import FloatingToolbar from "@/components/FloatingToolbar.vue";
|
||
import FloatingICP from "@/components/FloatingICP.vue";
|
||
import ChatWidget from "@/components/ChatWidget.vue";
|
||
import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue";
|
||
import InitServiceChooser from "@/components/InitServiceChooser.vue";
|
||
import StudentNameManager from "@/components/StudentNameManager.vue";
|
||
import dataProvider from "@/utils/dataProvider";
|
||
import {
|
||
getSetting,
|
||
watchSettings,
|
||
setSetting,
|
||
settingsDefinitions,
|
||
} from "@/utils/settings";
|
||
import { useDisplay } from "vuetify";
|
||
import "../styles/index.scss";
|
||
import "../styles/transitions.scss";
|
||
import "../styles/global.scss";
|
||
import { pinyin } from "pinyin-pro";
|
||
import { debounce, throttle } from "@/utils/debounce";
|
||
import { Base64 } from "js-base64";
|
||
import {
|
||
getSocket,
|
||
on as socketOn,
|
||
off as socketOff,
|
||
joinToken,
|
||
leaveAll,
|
||
onConnect as onSocketConnect,
|
||
} from "@/utils/socketClient";
|
||
|
||
export default {
|
||
name: "Classworks 作业板",
|
||
components: {
|
||
MessageLog,
|
||
RandomPicker,
|
||
FloatingToolbar,
|
||
FloatingICP,
|
||
HomeworkEditDialog,
|
||
InitServiceChooser,
|
||
ChatWidget,
|
||
StudentNameManager,
|
||
},
|
||
data() {
|
||
const defaultSubjects = [
|
||
{ name: "语文", order: 0 },
|
||
{ name: "数学", order: 1 },
|
||
{ name: "英语", order: 2 },
|
||
{ name: "物理", order: 3 },
|
||
{ name: "化学", order: 4 },
|
||
{ name: "生物", order: 5 },
|
||
{ name: "政治", order: 6 },
|
||
{ name: "历史", order: 7 },
|
||
{ name: "地理", order: 8 },
|
||
{ name: "其他", order: 9 },
|
||
];
|
||
|
||
return {
|
||
dataKey: "",
|
||
provider: "",
|
||
useDisplay: useDisplay,
|
||
state: {
|
||
classNumber: "",
|
||
studentList: [],
|
||
boardData: {
|
||
homework: {},
|
||
attendance: {
|
||
absent: [],
|
||
late: [],
|
||
exclude: [],
|
||
},
|
||
},
|
||
dialogVisible: false,
|
||
dialogTitle: "",
|
||
textarea: "",
|
||
dateString: "",
|
||
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].replace(/-/g, ""),
|
||
selectedDateObj: new Date(),
|
||
refreshInterval: null,
|
||
showNoDataMessage: false,
|
||
noDataMessage: "",
|
||
isToday: false,
|
||
attendanceDialog: false,
|
||
availableSubjects: defaultSubjects,
|
||
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: [],
|
||
urlConfigDialog: {
|
||
show: false,
|
||
config: null,
|
||
changes: [],
|
||
validSettings: {},
|
||
confirmHandler: null,
|
||
cancelHandler: null,
|
||
icons: {},
|
||
},
|
||
settingsTick: 0,
|
||
isChatOpen: false,
|
||
highlightedCards: {}, // 记录哪些卡片需要高亮
|
||
// Token 显示信息(统一显示 token 信息和学生姓名)
|
||
tokenDisplayInfo: {
|
||
show: false,
|
||
readonly: false, // 是否是只读 token
|
||
text: '',
|
||
color: 'primary',
|
||
variant: 'tonal',
|
||
icon: 'mdi-account',
|
||
disabled: false
|
||
},
|
||
// 实时刷新信息
|
||
realtimeInfo: {
|
||
show: false,
|
||
time: "",
|
||
key: "",
|
||
},
|
||
$offKvChanged: null,
|
||
$offConnect: null,
|
||
debouncedRealtimeRefresh: null,
|
||
};
|
||
},
|
||
|
||
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.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.name === key)?.name ||
|
||
key,
|
||
content: value.content,
|
||
order: this.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.name))
|
||
.sort((a, b) => a.order - b.order);
|
||
},
|
||
emptySubjects() {
|
||
if (this.emptySubjectDisplay !== "button") return [];
|
||
return this.unusedSubjects;
|
||
},
|
||
autoSave() {
|
||
return getSetting("edit.autoSave");
|
||
},
|
||
blockNonTodayAutoSave() {
|
||
return getSetting("edit.blockNonTodayAutoSave");
|
||
},
|
||
isToday() {
|
||
const today = (() => {
|
||
const now = new Date();
|
||
const yyyy = now.getFullYear();
|
||
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
||
const dd = String(now.getDate()).padStart(2, "0");
|
||
return `${yyyy}${mm}${dd}`;
|
||
})();
|
||
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;
|
||
},
|
||
showRandomPickerButton() {
|
||
return getSetting("randomPicker.enabled");
|
||
},
|
||
showListCardButton() {
|
||
return getSetting("display.showListCard");
|
||
},
|
||
confirmNonTodaySave() {
|
||
return getSetting("edit.confirmNonTodaySave");
|
||
},
|
||
shouldShowSaveConfirm() {
|
||
return !this.isToday && this.confirmNonTodaySave;
|
||
},
|
||
shouldBlockAutoSave() {
|
||
return !this.isToday && this.autoSave && this.blockNonTodayAutoSave;
|
||
},
|
||
showFullscreenButton() {
|
||
return getSetting("display.showFullscreenButton");
|
||
},
|
||
showExamScheduleButton() {
|
||
return getSetting("display.showExamScheduleButton");
|
||
},
|
||
showAntiScreenBurnCard() {
|
||
return getSetting("display.showAntiScreenBurnCard");
|
||
},
|
||
shouldShowInit() {
|
||
const provider = getSetting("server.provider");
|
||
const isKv = provider === "kv-server" || provider === "classworkscloud";
|
||
const token = getSetting("server.kvToken");
|
||
// 仅首页
|
||
const onHome = this.$route?.path === "/";
|
||
// 依赖 settingsTick 使其在设置变更时重新计算
|
||
void this.settingsTick;
|
||
return onHome && isKv && (!token || token === "");
|
||
},
|
||
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) => {
|
||
const pinyinA = pinyin(a.name, { toneType: "none", mode: "surname" });
|
||
const pinyinB = pinyin(b.name, { toneType: "none", mode: "surname" });
|
||
return pinyinA.localeCompare(pinyinB);
|
||
});
|
||
},
|
||
subjectOrder() {
|
||
return [...this.state.availableSubjects]
|
||
.sort((a, b) => a.order - b.order)
|
||
.map((subject) => subject.name);
|
||
},
|
||
},
|
||
|
||
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();
|
||
});
|
||
|
||
// 连接学生姓名管理组件
|
||
this.$nextTick(() => {
|
||
const studentNameManager = this.$refs.studentNameManager;
|
||
if (studentNameManager) {
|
||
this.studentNameInfo.name = studentNameManager.currentStudentName;
|
||
this.studentNameInfo.isStudent = studentNameManager.isStudentToken;
|
||
this.studentNameInfo.openDialog = () => studentNameManager.openDialog();
|
||
|
||
// 监听学生姓名变化
|
||
this.$watch(() => studentNameManager.currentStudentName, (newName) => {
|
||
this.studentNameInfo.name = newName;
|
||
});
|
||
this.$watch(() => studentNameManager.isStudentToken, (isStudent) => {
|
||
this.studentNameInfo.isStudent = isStudent;
|
||
});
|
||
}
|
||
});
|
||
|
||
document.addEventListener(
|
||
"fullscreenchange",
|
||
this.fullscreenChangeHandler
|
||
);
|
||
document.addEventListener(
|
||
"webkitfullscreenchange",
|
||
this.fullscreenChangeHandler
|
||
);
|
||
document.addEventListener(
|
||
"mozfullscreenchange",
|
||
this.fullscreenChangeHandler
|
||
);
|
||
document.addEventListener(
|
||
"MSFullscreenChange",
|
||
this.fullscreenChangeHandler
|
||
);
|
||
|
||
this.checkHashForRandomPicker();
|
||
|
||
window.addEventListener("hashchange", this.checkHashForRandomPicker);
|
||
|
||
// 实时频道:加入设备房间并监听键变化
|
||
this.setupRealtimeChannel();
|
||
|
||
// 初始化 Token 显示信息
|
||
this.$nextTick(() => {
|
||
this.updateTokenDisplayInfo();
|
||
});
|
||
} catch (err) {
|
||
console.error("初始化失败:", err);
|
||
this.showError("初始化失败,请刷新页面重试");
|
||
}
|
||
},
|
||
|
||
beforeUnmount() {
|
||
if (this.unwatchSettings) {
|
||
this.unwatchSettings();
|
||
}
|
||
if (this.state.refreshInterval) {
|
||
clearInterval(this.state.refreshInterval);
|
||
}
|
||
|
||
document.removeEventListener(
|
||
"fullscreenchange",
|
||
this.fullscreenChangeHandler
|
||
);
|
||
document.removeEventListener(
|
||
"webkitfullscreenchange",
|
||
this.fullscreenChangeHandler
|
||
);
|
||
document.removeEventListener(
|
||
"mozfullscreenchange",
|
||
this.fullscreenChangeHandler
|
||
);
|
||
document.removeEventListener(
|
||
"MSFullscreenChange",
|
||
this.fullscreenChangeHandler
|
||
);
|
||
|
||
window.removeEventListener("hashchange", this.checkHashForRandomPicker);
|
||
|
||
// 退出设备房间并清理监听
|
||
try {
|
||
if (this.$offKvChanged) this.$offKvChanged();
|
||
if (this.$offConnect) this.$offConnect();
|
||
leaveAll();
|
||
} catch (e) {
|
||
void e;
|
||
}
|
||
},
|
||
|
||
methods: {
|
||
// 更新 Token 显示信息
|
||
updateTokenDisplayInfo() {
|
||
const manager = this.$refs.studentNameManager
|
||
if (!manager || !manager.hasToken) {
|
||
this.tokenDisplayInfo.show = false
|
||
this.tokenDisplayInfo.readonly = false
|
||
return
|
||
}
|
||
|
||
const displayName = manager.displayName
|
||
const isReadOnly = manager.isReadOnly
|
||
const isStudent = manager.isStudentToken
|
||
|
||
// 设置只读状态(对所有类型的 token 都显示)
|
||
this.tokenDisplayInfo.readonly = isReadOnly
|
||
|
||
// 只有学生类型的 token 才显示名称 chip
|
||
if (!isStudent) {
|
||
this.tokenDisplayInfo.show = false
|
||
return
|
||
}
|
||
|
||
// 设置学生名称显示(始终蓝色)
|
||
this.tokenDisplayInfo.text = displayName
|
||
this.tokenDisplayInfo.color = 'primary'
|
||
this.tokenDisplayInfo.icon = 'mdi-account'
|
||
this.tokenDisplayInfo.disabled = isReadOnly // 只读时不可点击
|
||
this.tokenDisplayInfo.show = true
|
||
},
|
||
|
||
// 处理 Token Chip 点击
|
||
handleTokenChipClick() {
|
||
console.log('Token chip clicked')
|
||
const manager = this.$refs.studentNameManager
|
||
console.log('Manager:', manager)
|
||
console.log('Is student token:', manager?.isStudentToken)
|
||
|
||
if (manager && manager.isStudentToken) {
|
||
console.log('Opening dialog...')
|
||
manager.openDialog()
|
||
} else {
|
||
console.log('Cannot open dialog - conditions not met')
|
||
}
|
||
},
|
||
|
||
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() {
|
||
const configApplied = await this.parseUrlConfig();
|
||
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const dateFromUrl = urlParams.get("date");
|
||
const today = this.getToday();
|
||
|
||
let currentDate = today;
|
||
if (dateFromUrl) {
|
||
if (/^\d{8}$/.test(dateFromUrl)) {
|
||
const year = dateFromUrl.substring(0, 4);
|
||
const month = dateFromUrl.substring(4, 6);
|
||
const day = dateFromUrl.substring(6, 8);
|
||
currentDate = new Date(`${year}-${month}-${day}`);
|
||
} else {
|
||
currentDate = new Date(dateFromUrl);
|
||
}
|
||
if (isNaN(currentDate.getTime())) {
|
||
currentDate = today;
|
||
}
|
||
}
|
||
|
||
this.state.dateString = this.formatDate(currentDate);
|
||
this.state.selectedDate = this.state.dateString;
|
||
this.state.selectedDateObj = currentDate;
|
||
this.state.isToday =
|
||
this.formatDate(currentDate) === this.formatDate(today);
|
||
if (!configApplied) {
|
||
this.provider = getSetting("server.provider");
|
||
const classNum = getSetting("server.classNumber");
|
||
|
||
this.state.classNumber = classNum;
|
||
}
|
||
await Promise.all([this.downloadData(), this.loadConfig()]);
|
||
},
|
||
|
||
async downloadData() {
|
||
if (this.loading.download) return;
|
||
|
||
try {
|
||
this.loading.download = true;
|
||
const response = await dataProvider.loadData(
|
||
"classworks-data-" + this.state.dateString
|
||
);
|
||
|
||
if (response.success == false) {
|
||
if (response.error.code === "NOT_FOUND") {
|
||
this.state.showNoDataMessage = true;
|
||
this.state.noDataMessage = response.error.message;
|
||
// 只有当前没有数据时才设置为空,避免覆盖已有的本地数据
|
||
if (!this.state.boardData || (!this.state.boardData.homework && !this.state.boardData.attendance)) {
|
||
this.state.boardData = {
|
||
homework: {},
|
||
attendance: { absent: [], late: [], exclude: [] },
|
||
};
|
||
}
|
||
} else {
|
||
throw new Error(response.error.message);
|
||
}
|
||
} else {
|
||
this.state.boardData = {
|
||
homework: response.homework || {},
|
||
attendance: {
|
||
absent: response.attendance?.absent || [],
|
||
late: response.attendance?.late || [],
|
||
exclude: response.attendance?.exclude || [],
|
||
},
|
||
};
|
||
this.state.synced = true;
|
||
this.state.showNoDataMessage = false;
|
||
this.$message.success("下载成功", "数据已更新");
|
||
}
|
||
} catch (error) {
|
||
// 数据加载失败时不覆盖现有数据,只显示错误信息
|
||
console.error("数据加载失败:", error);
|
||
this.$message.error("下载失败", error.message);
|
||
// 如果当前没有任何数据,才初始化为空数据
|
||
if (!this.state.boardData || (!this.state.boardData.homework && !this.state.boardData.attendance)) {
|
||
this.state.boardData = {
|
||
homework: {},
|
||
attendance: { absent: [], late: [], exclude: [] },
|
||
};
|
||
}
|
||
} 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(
|
||
"classworks-data-" + this.state.dateString,
|
||
this.state.boardData
|
||
);
|
||
if (response.success == false) {
|
||
throw new Error(response.error.message);
|
||
}
|
||
|
||
this.state.synced = true;
|
||
this.$message.success(response.message || "保存成功");
|
||
} finally {
|
||
this.loading.upload = false;
|
||
}
|
||
},
|
||
|
||
async loadConfig() {
|
||
try {
|
||
// 加载学生列表
|
||
try {
|
||
const response = await dataProvider.loadData("classworks-list-main");
|
||
|
||
if (response.success != false && Array.isArray(response)) {
|
||
this.state.studentList = response.map((student) => student.name);
|
||
}
|
||
} catch (error) {
|
||
console.warn(
|
||
"Failed to load student list from dedicated key, falling back to config",
|
||
error
|
||
);
|
||
}
|
||
|
||
await this.loadSubjects();
|
||
} catch (error) {
|
||
console.error("加载配置失败:", error);
|
||
this.$message.error("加载配置失败", error.message);
|
||
}
|
||
},
|
||
|
||
async loadSubjects() {
|
||
try {
|
||
const subjectsResponse = await dataProvider.loadData(
|
||
"classworks-config-subject"
|
||
);
|
||
if (subjectsResponse && Array.isArray(subjectsResponse)) {
|
||
// 更新科目列表
|
||
this.state.availableSubjects = subjectsResponse;
|
||
}
|
||
} catch (error) {
|
||
console.warn("Failed to load subject configuration:", error);
|
||
// 保持默认科目列表
|
||
}
|
||
},
|
||
|
||
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.name === subject)?.name ||
|
||
subject;
|
||
this.state.textarea = this.state.boardData.homework[subject].content;
|
||
this.state.dialogVisible = true;
|
||
},
|
||
|
||
async handleHomeworkSave(content) {
|
||
if (!this.currentEditSubject) return;
|
||
|
||
this.state.boardData.homework[this.currentEditSubject] = {
|
||
content: content,
|
||
};
|
||
|
||
this.state.synced = false;
|
||
|
||
if (this.autoSave) {
|
||
await this.trySave(true);
|
||
}
|
||
},
|
||
|
||
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 classNum = getSetting("server.classNumber");
|
||
|
||
this.provider = provider;
|
||
|
||
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(() => {
|
||
if (!this.shouldSkipRefresh()) {
|
||
this.downloadData();
|
||
}
|
||
}, interval * 1000);
|
||
}
|
||
},
|
||
|
||
shouldSkipRefresh() {
|
||
if (this.state.dialogVisible) return true;
|
||
|
||
if (this.state.attendanceDialog) return true;
|
||
|
||
if (this.confirmDialog.show) return true;
|
||
|
||
if (this.state.datePickerDialog) return true;
|
||
|
||
if (this.loading.upload || this.loading.download) return true;
|
||
|
||
if (!this.state.synced) return true;
|
||
|
||
return false;
|
||
},
|
||
|
||
updateSettings() {
|
||
this.state.fontSize = getSetting("font.size");
|
||
this.state.contentStyle = { "font-size": `${this.state.fontSize}px` };
|
||
this.setupAutoRefresh();
|
||
this.updateBackendUrl();
|
||
// 触发依赖刷新(例如 shouldShowInit)
|
||
this.settingsTick++;
|
||
},
|
||
|
||
async 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.selectedDateObj = selectedDate;
|
||
this.state.isToday =
|
||
formattedDate === this.formatDate(this.getToday());
|
||
|
||
this.$router
|
||
.replace({
|
||
query: { date: formattedDate },
|
||
})
|
||
.catch(() => {});
|
||
|
||
// Load both data and subjects in parallel
|
||
await Promise.all([this.downloadData(), this.loadSubjects()]);
|
||
}
|
||
} 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,
|
||
}));
|
||
},
|
||
|
||
// 实时频道:加入设备房间并监听键变化
|
||
setupRealtimeChannel() {
|
||
try {
|
||
const token = getSetting("server.kvToken");
|
||
if (!token) {
|
||
console.warn("未配置 KV Token,无法加入实时频道");
|
||
return;
|
||
}
|
||
|
||
// Ensure socket created
|
||
getSocket();
|
||
joinToken(token);
|
||
|
||
// Re-join on reconnect
|
||
this.$offConnect = onSocketConnect(() => joinToken(token));
|
||
|
||
// Debounce refresh to avoid storms
|
||
if (!this.debouncedRealtimeRefresh) {
|
||
this.debouncedRealtimeRefresh = debounce(async () => {
|
||
const oldHomework = JSON.parse(
|
||
JSON.stringify(this.state.boardData.homework)
|
||
);
|
||
await this.downloadData();
|
||
const now = new Date();
|
||
const hh = String(now.getHours()).padStart(2, "0");
|
||
const mm = String(now.getMinutes()).padStart(2, "0");
|
||
const ss = String(now.getSeconds()).padStart(2, "0");
|
||
|
||
// 使用消息记录工具发送通知
|
||
this.$message?.info(
|
||
"数据已更新",
|
||
`已于 ${hh}:${mm}:${ss} 自动刷新`
|
||
); // 检测哪些科目发生了变化
|
||
const changed = {};
|
||
for (const key in this.state.boardData.homework) {
|
||
const oldContent = oldHomework[key]?.content || "";
|
||
const newContent =
|
||
this.state.boardData.homework[key]?.content || "";
|
||
if (oldContent !== newContent) {
|
||
changed[key] = true;
|
||
}
|
||
}
|
||
// 删除的科目也算变化
|
||
for (const key in oldHomework) {
|
||
if (!this.state.boardData.homework[key]) {
|
||
changed[key] = true;
|
||
}
|
||
}
|
||
|
||
// 设置高亮
|
||
this.highlightedCards = changed;
|
||
// 3秒后移除高亮
|
||
setTimeout(() => {
|
||
this.highlightedCards = {};
|
||
}, 10000);
|
||
}, 800);
|
||
}
|
||
|
||
const handler = (msg) => {
|
||
// Expect msg = { uuid, key, action, created?, updatedAt?, deletedAt?, batch? }
|
||
if (!msg) return;
|
||
// We only care about current date key changes
|
||
const expectedKey = `classworks-data-${this.state.dateString}`;
|
||
if (msg.key !== expectedKey) return;
|
||
if (msg.action !== "upsert" && msg.action !== "delete") return;
|
||
// Trigger a debounced refresh
|
||
this.debouncedRealtimeRefresh?.(msg.key);
|
||
};
|
||
|
||
this.$offKvChanged = socketOn("kv-key-changed", handler);
|
||
} catch (e) {
|
||
console.warn("实时频道初始化失败", e);
|
||
}
|
||
},
|
||
|
||
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 "到课";
|
||
},
|
||
|
||
toggleFilter(filter) {
|
||
const index = this.attendanceFilter.indexOf(filter);
|
||
if (index === -1) {
|
||
this.attendanceFilter.push(filter);
|
||
} else {
|
||
this.attendanceFilter.splice(index, 1);
|
||
}
|
||
},
|
||
|
||
openRandomPicker() {
|
||
if (this.$refs.randomPicker) {
|
||
this.$refs.randomPicker.open();
|
||
}
|
||
},
|
||
|
||
checkHashForRandomPicker() {
|
||
if (window.location.hash === "#random-picker") {
|
||
this.$nextTick(() => {
|
||
console.log("打开随机点名");
|
||
window.location.hash = "";
|
||
this.openRandomPicker();
|
||
});
|
||
}
|
||
},
|
||
|
||
parseUrlConfig() {
|
||
try {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const configParam = urlParams.get("config");
|
||
|
||
if (!configParam) return false;
|
||
|
||
try {
|
||
const binaryString = atob(configParam);
|
||
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
|
||
const decodedString = new TextDecoder().decode(bytes);
|
||
const decodedConfig = JSON.parse(decodedString);
|
||
console.log("从URL读取配置:", decodedConfig);
|
||
|
||
const changes = [];
|
||
const validSettings = {};
|
||
const icons = {};
|
||
|
||
this.processSpecialSettings(decodedConfig, changes, validSettings);
|
||
|
||
this.processStandardSettings(
|
||
decodedConfig,
|
||
changes,
|
||
validSettings,
|
||
icons
|
||
);
|
||
|
||
if (Object.keys(validSettings).length === 0) {
|
||
console.log("URL配置与当前配置相同,无需应用");
|
||
return false;
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
this.urlConfigDialog = {
|
||
show: true,
|
||
config: decodedConfig,
|
||
changes: changes,
|
||
validSettings: validSettings,
|
||
icons: icons,
|
||
confirmHandler: () => {
|
||
this.urlConfigDialog.show = false;
|
||
this.applyUrlConfig(validSettings);
|
||
resolve(true);
|
||
},
|
||
cancelHandler: () => {
|
||
this.urlConfigDialog.show = false;
|
||
resolve(false);
|
||
},
|
||
};
|
||
});
|
||
} catch (e) {
|
||
console.error("解析URL配置错误:", e);
|
||
this.$message.error("URL配置错误", "无法解析配置数据");
|
||
return false;
|
||
}
|
||
} catch (e) {
|
||
console.error("处理URL配置错误:", e);
|
||
return false;
|
||
}
|
||
},
|
||
|
||
processSpecialSettings(decodedConfig, changes, validSettings) {
|
||
if (decodedConfig.classNumber !== undefined) {
|
||
const current = getSetting("server.classNumber");
|
||
if (decodedConfig.classNumber !== current) {
|
||
changes.push({
|
||
key: "server.classNumber",
|
||
name: "班级",
|
||
oldValue: current,
|
||
newValue: decodedConfig.classNumber,
|
||
description:
|
||
settingsDefinitions["server.classNumber"]?.description ||
|
||
"班级编号",
|
||
icon:
|
||
settingsDefinitions["server.classNumber"]?.icon ||
|
||
"mdi-account-group",
|
||
});
|
||
validSettings["server.classNumber"] = decodedConfig.classNumber;
|
||
}
|
||
}
|
||
|
||
if (decodedConfig.date !== undefined) {
|
||
if (decodedConfig.date !== this.state.dateString) {
|
||
changes.push({
|
||
key: "date",
|
||
name: "日期",
|
||
oldValue: this.state.dateString,
|
||
newValue: decodedConfig.date,
|
||
description: "查看的日期",
|
||
icon: "mdi-calendar",
|
||
});
|
||
validSettings.date = decodedConfig.date;
|
||
}
|
||
}
|
||
|
||
if (decodedConfig.subjects && Array.isArray(decodedConfig.subjects)) {
|
||
changes.push({
|
||
key: "subjects",
|
||
name: "科目列表",
|
||
oldValue: `${this.state.availableSubjects.length}个科目`,
|
||
newValue: `${decodedConfig.subjects.length}个科目`,
|
||
description: "可用科目列表",
|
||
icon: "mdi-notebook",
|
||
});
|
||
validSettings.subjects = decodedConfig.subjects;
|
||
}
|
||
},
|
||
|
||
processStandardSettings(decodedConfig, changes, validSettings, icons) {
|
||
Object.entries(decodedConfig).forEach(([key, value]) => {
|
||
if (["classNumber", "date", "subjects"].includes(key)) {
|
||
return;
|
||
}
|
||
|
||
let settingKey = key;
|
||
let definition = settingsDefinitions[key];
|
||
|
||
if (!definition && !key.includes(".")) {
|
||
const prefixes = [
|
||
"server.",
|
||
"display.",
|
||
"theme.",
|
||
"edit.",
|
||
"refresh.",
|
||
"font.",
|
||
"randomPicker.",
|
||
];
|
||
for (const prefix of prefixes) {
|
||
const prefixedKey = `${prefix}${key}`;
|
||
if (settingsDefinitions[prefixedKey]) {
|
||
settingKey = prefixedKey;
|
||
definition = settingsDefinitions[prefixedKey];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (definition) {
|
||
let typedValue = this.convertValueToCorrectType(
|
||
value,
|
||
definition.type
|
||
);
|
||
|
||
if (definition.validate && !definition.validate(typedValue)) {
|
||
console.warn(`URL配置项 ${settingKey} 的值无效: ${value}`);
|
||
return;
|
||
}
|
||
|
||
const currentValue = getSetting(settingKey);
|
||
if (typedValue !== currentValue) {
|
||
changes.push({
|
||
key: settingKey,
|
||
name: this.getSettingDisplayName(settingKey),
|
||
oldValue: this.formatSettingValue(currentValue),
|
||
newValue: this.formatSettingValue(typedValue),
|
||
description: definition.description || settingKey,
|
||
icon: definition.icon || "mdi-cog",
|
||
});
|
||
validSettings[settingKey] = typedValue;
|
||
icons[settingKey] = definition.icon || "mdi-cog";
|
||
}
|
||
} else {
|
||
changes.push({
|
||
key: key,
|
||
name: this.getSettingDisplayName(key),
|
||
oldValue: "未知",
|
||
newValue: this.formatSettingValue(value),
|
||
description: "自定义配置项",
|
||
icon: "mdi-cog-outline",
|
||
});
|
||
validSettings[key] = value;
|
||
icons[key] = "mdi-cog-outline";
|
||
}
|
||
});
|
||
},
|
||
|
||
convertValueToCorrectType(value, type) {
|
||
if (type === "boolean") {
|
||
return Boolean(value);
|
||
} else if (type === "number") {
|
||
return Number(value);
|
||
} else {
|
||
return String(value);
|
||
}
|
||
},
|
||
|
||
formatSettingValue(value) {
|
||
if (typeof value === "boolean") {
|
||
return value ? "开启" : "关闭";
|
||
} else if (value === "" || value === null || value === undefined) {
|
||
return "空";
|
||
}
|
||
return value.toString();
|
||
},
|
||
|
||
getSettingDisplayName(key) {
|
||
const parts = key.split(".");
|
||
const lastPart = parts[parts.length - 1];
|
||
|
||
const nameMap = {
|
||
provider: "数据提供方",
|
||
domain: "服务器域名",
|
||
classNumber: "班级编号",
|
||
|
||
emptySubjectDisplay: "空科目显示方式",
|
||
dynamicSort: "动态排序",
|
||
showRandomButton: "随机按钮",
|
||
showFullscreenButton: "全屏按钮",
|
||
cardHoverEffect: "卡片悬浮效果",
|
||
enhancedTouchMode: "增强触摸模式",
|
||
showAntiScreenBurnCard: "防烧屏卡片",
|
||
|
||
mode: "主题模式",
|
||
|
||
size: "字体大小",
|
||
|
||
autoSave: "自动保存",
|
||
blockNonTodayAutoSave: "禁止自动保存非当日",
|
||
refreshBeforeEdit: "编辑前刷新",
|
||
confirmNonTodaySave: "非当日保存确认",
|
||
|
||
auto: "自动刷新",
|
||
interval: "刷新间隔",
|
||
};
|
||
|
||
return nameMap[lastPart] || lastPart;
|
||
},
|
||
|
||
safeBase64Decode(base64String) {
|
||
try {
|
||
return Base64.decode(base64String);
|
||
} catch (e) {
|
||
console.error("Base64解码错误:", e);
|
||
throw new Error("无法解码配置数据");
|
||
}
|
||
},
|
||
|
||
applyUrlConfig(validSettings) {
|
||
for (const [key, value] of Object.entries(validSettings)) {
|
||
if (key === "date") {
|
||
this.handleDateSelect(value);
|
||
continue;
|
||
}
|
||
|
||
if (key === "subjects") {
|
||
this.state.availableSubjects = value;
|
||
continue;
|
||
}
|
||
|
||
setSetting(key, value);
|
||
|
||
if (key === "server.classNumber") {
|
||
this.state.classNumber = value;
|
||
}
|
||
}
|
||
|
||
this.updateBackendUrl();
|
||
this.$message.success("URL配置已应用", "已从URL加载配置");
|
||
return true;
|
||
},
|
||
|
||
navigateDay(offset) {
|
||
const currentDate = new Date(this.state.selectedDateObj);
|
||
currentDate.setDate(currentDate.getDate() + offset);
|
||
this.handleDateSelect(currentDate);
|
||
},
|
||
},
|
||
};
|
||
</script>
|