1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-12-07 21:13:11 +00:00
Classworks/src/pages/index.vue
copilot-swe-agent[bot] 056225b6b3 Fix notification deletion bug: save {} instead of [] when list is empty
When all notifications are deleted, the persistentNotifications array
becomes empty ([]). The backend doesn't accept an empty array as a
valid value, requiring an empty object ({}) instead. This fix modifies
both index.vue and UrgentTestDialog.vue to save {} when the
notification list is empty.

Co-authored-by: Sunwuyuan <88357633+Sunwuyuan@users.noreply.github.com>
2025-12-01 10:16:47 +00:00

2169 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<v-app-bar class="no-select">
<v-app-bar-title>
{{ titleText }}
</v-app-bar-title>
<v-spacer />
<template #append>
<!-- 只读 Token 警告 -->
<v-chip
v-if="tokenDisplayInfo.readonly"
class="mx-2"
color="warning"
prepend-icon="mdi-lock-alert"
variant="tonal"
>
只读
</v-chip>
<!-- 学生名称显示 chip始终蓝色 -->
<v-chip
v-if="tokenDisplayInfo.show"
:style="{ cursor: tokenDisplayInfo.disabled ? 'default' : 'pointer' }"
class="mx-2"
color="primary"
prepend-icon="mdi-account"
variant="tonal"
@click="handleTokenChipClick"
>
{{ tokenDisplayInfo.text }}
</v-chip>
<v-btn
v-if="shouldShowUrgentTestButton"
prepend-icon="mdi-chat"
variant="tonal"
@click="urgentTestDialog = true"
>
发送通知
</v-btn>
<v-btn
icon="mdi-chat"
variant="text"
@click="isChatOpen = true"
/>
<v-btn
:badge="unreadCount || undefined"
:badge-color="unreadCount ? 'error' : undefined"
icon="mdi-bell"
variant="text"
@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"
:preconfig="preconfigData"
@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 bloom-container"
fluid
>
<!-- 常驻通知区域 -->
<v-row
v-if="persistentNotifications.length > 0"
class="mb-4"
>
<v-col cols="12">
<v-card
v-for="notification in persistentNotifications"
:key="notification.id"
:color="notification.isUrgent ? 'error' : 'primary'"
class="mb-2 cursor-pointer"
variant="tonal"
@click="showNotificationDetail(notification)"
>
<v-card-text class="d-flex align-center py-3">
<span class="text-h6 text-truncate font-weight-bold">{{ notification.message }}</span>
<v-spacer />
<v-btn
icon="mdi-chevron-right"
variant="text"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 通知详情对话框 -->
<v-dialog
v-model="notificationDetailDialog"
max-width="700"
scrollable
>
<v-card
v-if="currentNotification"
class="rounded-xl"
>
<v-card-title class="d-flex align-center pa-4 text-h5">
<span
:class="currentNotification.isUrgent ? 'text-error' : ''"
class="font-weight-bold"
>
{{ currentNotification.isUrgent ? '强调通知' : '通知详情' }}
</span>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
@click="notificationDetailDialog = false"
/>
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<div
class="text-h4 font-weight-medium mb-4"
style="line-height: 1.5;"
>
{{ currentNotification.message }}
</div>
<div class="text-subtitle-1 text-grey">
发布时间{{ formatTime(currentNotification.timestamp) }}
</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-btn
color="error"
prepend-icon="mdi-delete"
size="x-large"
variant="tonal"
class="px-6"
@click="removePersistentNotification(currentNotification.id)"
>
删除通知
</v-btn>
<v-spacer />
<v-btn
color="primary"
size="x-large"
variant="elevated"
class="px-8"
@click="notificationDetailDialog = false"
>
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<homework-grid
:sorted-items="sortedItems"
:unused-subjects="unusedSubjects"
:empty-subject-display="emptySubjectDisplay"
:is-editing-disabled="isEditingDisabled"
:content-style="state.contentStyle"
:highlighted-cards="highlightedCards"
@open-dialog="openDialog"
@open-attendance="setAttendanceArea"
@disabled-click="handleDisabledClick"
/>
<home-actions
:synced="state.synced"
:loading-upload="loading.upload"
:show-random-picker-button="showRandomPickerButton"
:show-exam-schedule-button="showExamScheduleButton"
:show-list-card-button="showListCardButton"
:show-fullscreen-button="showFullscreenButton"
:is-fullscreen="state.isFullscreen"
:show-anti-screen-burn-card="showAntiScreenBurnCard"
:show-test-card-button="showTestCardButton"
@upload="manualUpload"
@show-sync-message="showSyncMessage"
@open-random-picker="openRandomPicker"
@toggle-fullscreen="toggleFullscreen"
@add-test-card="addTestCard"
/>
</v-container>
<!-- 出勤统计区域 -->
<attendance-sidebar
v-if="!mobile"
:student-list="state.studentList"
:attendance="state.boardData.attendance"
:is-editing-disabled="isEditingDisabled"
@click="setAttendanceArea"
@disabled-click="handleDisabledClick"
/>
</div>
<homework-edit-dialog
v-model="state.dialogVisible"
:auto-save="autoSave"
:initial-content="state.textarea"
:title="state.dialogTitle"
:is-editing-past-data="isEditingPastData"
:current-date-string="state.dateString"
@save="handleHomeworkSave"
/>
<v-snackbar
v-model="state.snackbar"
:timeout="2000"
>
{{ state.snackbarText }}
</v-snackbar>
<attendance-management-dialog
v-model="state.attendanceDialog"
:student-list="state.studentList"
:attendance="state.boardData.attendance"
:date-string="state.dateString"
@save="saveAttendance"
@change="handleAttendanceChange"
/>
<message-log ref="messageLog" />
<!-- 添加悬浮工具栏 -->
<floating-toolbar
:is-today="isToday"
:loading="loading.download"
:copy-to-today-loading="loading.copyToToday"
:selected-date="state.selectedDateObj"
:unread-count="unreadCount"
@refresh="downloadData"
@zoom="zoom"
@open-messages="$refs.messageLog.drawer = true"
@open-settings="$router.push('/settings')"
@date-select="handleDateSelect"
@prev-day="navigateDay(-1)"
@next-day="navigateDay(1)"
@copy-to-today="copyHomeworkToToday"
/>
<!-- 添加ICP备案悬浮组件 -->
<FloatingICP />
<!-- 设备聊天室右下角浮窗 -->
<ChatWidget
v-model="isChatOpen"
:show-button="false"
/>
<!-- 紧急通知测试对话框 -->
<UrgentTestDialog v-model="urgentTestDialog" />
<!-- 添加确认对话框 -->
<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"
:attendance="state.boardData.attendance"
:student-list="state.studentList"
/>
<!-- 添加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"
class="mr-2"
size="small"
/>
</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
class="mx-1"
icon="mdi-arrow-right"
size="small"
/>
<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>
<!-- 通知详情对话框 -->
<v-dialog
v-model="notificationDetailDialog"
max-width="600"
>
<v-card v-if="currentNotification">
<v-card-title
class="headline"
:class="currentNotification.isUrgent ? 'text-error' : 'text-primary'"
>
{{ currentNotification.isUrgent ? '强调通知' : '通知详情' }}
</v-card-title>
<v-card-text class="text-h5 py-4">
{{ currentNotification.message }}
</v-card-text>
<v-card-actions>
<v-btn
color="error"
variant="text"
@click="removePersistentNotification(currentNotification.id)"
>
删除
</v-btn>
<v-spacer />
<v-btn
color="primary"
@click="notificationDetailDialog = false"
>
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<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 UrgentTestDialog from "@/components/UrgentTestDialog.vue";
import AttendanceSidebar from "@/components/attendance/AttendanceSidebar.vue";
import AttendanceManagementDialog from "@/components/attendance/AttendanceManagementDialog.vue";
import HomeworkGrid from "@/components/home/HomeworkGrid.vue";
import HomeActions from "@/components/home/HomeActions.vue";
import dataProvider from "@/utils/dataProvider";
import {
getSetting,
watchSettings,
setSetting,
settingsDefinitions,
} from "@/utils/settings";
import { kvServerProvider } from "@/utils/providers/kvServerProvider";
import { useDisplay } from "vuetify";
import "../styles/index.scss";
import "../styles/transitions.scss";
import "../styles/global.scss";
import { debounce, throttle } from "@/utils/debounce";
import { Base64 } from "js-base64";
import {
getSocket,
on as socketOn,
joinToken,
leaveAll,
onConnect as onSocketConnect,
} from "@/utils/socketClient";
import { createDeviceEventHandler } from "@/utils/deviceEvents";
import axios from "@/axios/axios";
export default {
name: "Classworks 作业板",
components: {
MessageLog,
RandomPicker,
FloatingToolbar,
FloatingICP,
HomeworkEditDialog,
InitServiceChooser,
ChatWidget,
StudentNameManager,
UrgentTestDialog,
AttendanceSidebar,
AttendanceManagementDialog,
HomeworkGrid,
HomeActions,
},
setup() {
const { mobile } = useDisplay();
return { mobile };
},
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: "",
// 当前命名空间/设备信息(从云端加载)
namespaceInfo: null,
deviceName: "",
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,
copyToToday: false,
},
debouncedUpload: null,
debouncedAttendanceSave: null,
throttledReflow: null,
sortedItemsCache: {
key: "",
value: [],
},
confirmDialog: {
show: false,
resolve: null,
reject: null,
},
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,
// 预配数据
preconfigData: {
namespace: null,
authCode: null,
autoOpen: false,
autoExecute: false,
},
// 紧急通知测试对话框
urgentTestDialog: false,
// 令牌信息
tokenInfo: null,
// 常驻通知
persistentNotifications: [],
notificationDetailDialog: false,
currentNotification: null,
};
},
computed: {
isMobile() {
return this.mobile;
},
titleText() {
// 优先展示当前设备名称(如果已从云端获取)
const deviceName =
this.state.namespaceInfo?.device?.name ||
this.state.classNumber ||
"高三八班";
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 deviceName + " - 今天的作业";
} else if (currentDateStr === yesterdayStr) {
return deviceName + " - 昨天的作业";
} else {
return `${deviceName} - ${currentDateStr}的作业`;
}
},
sortedItems() {
const items = [];
// 如果是移动端,添加出勤卡片
if (this.mobile) {
items.push({
key: 'attendance-card',
name: '出勤统计',
type: 'attendance',
data: {
total: this.state.studentList.length,
absent: this.state.boardData.attendance.absent,
late: this.state.boardData.attendance.late,
exclude: this.state.boardData.attendance.exclude
}
});
}
// 添加作业卡片
for (const subject of this.state.availableSubjects) {
const subjectKey = subject.name;
const subjectData = this.state.boardData.homework[subjectKey];
if (subjectData && subjectData.content) {
items.push({
key: subjectKey,
name: subjectKey,
type: 'homework',
content: subjectData.content,
order: subject.order,
rowSpan: Math.ceil((subjectData.content.split("\n").filter((line) => line.trim()).length + 1) * 0.8),
});
}
}
// 添加自定义卡片
for (const key in this.state.boardData.homework) {
if (key.startsWith('custom-')) {
const card = this.state.boardData.homework[key];
items.push({
key: key,
name: card.name,
type: 'custom',
content: card.content,
order: 9999, // Put at the end
rowSpan: Math.ceil((card.content.split("\n").filter((line) => line.trim()).length + 1) * 0.8),
});
}
}
// 按照顺序排序
items.sort((a, b) => a.order - b.order);
return items;
},
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() {
// 检查是否禁用编辑:加载中、没有编辑权限、或被配置禁止编辑过往数据
if (this.state.uploadLoading || this.state.downloadLoading) return true;
// 检查是否是只读 token
const manager = this.$refs.studentNameManager;
if (manager?.isReadOnly) return true;
// 检查是否禁止编辑过往数据
if (!this.canEditCurrentDate) return true;
return false;
},
unreadCount() {
return this.$refs.messageLog?.unreadCount || 0;
},
showRandomPickerButton() {
return getSetting("randomPicker.enabled");
},
showListCardButton() {
return getSetting("display.showListCard");
},
confirmNonTodaySave() {
return getSetting("edit.confirmNonTodaySave");
},
blockPastDataEdit() {
return getSetting("edit.blockPastDataEdit");
},
shouldShowSaveConfirm() {
return !this.isToday && this.confirmNonTodaySave;
},
shouldBlockAutoSave() {
return !this.isToday && this.autoSave && this.blockNonTodayAutoSave;
},
canEditCurrentDate() {
// 检查是否可以编辑当前日期的数据
if (this.isToday) return true;
if (this.blockPastDataEdit) return false;
return true;
},
isEditingPastData() {
// 是否正在编辑过往数据(非今日数据)
return !this.isToday;
},
showFullscreenButton() {
return getSetting("display.showFullscreenButton");
},
showExamScheduleButton() {
return getSetting("display.showExamScheduleButton");
},
showAntiScreenBurnCard() {
return getSetting("display.showAntiScreenBurnCard");
},
showTestCardButton() {
return getSetting("developer.enabled");
},
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 === "");
},
// 是否显示紧急通知测试按钮(仅教师和课堂令牌)
shouldShowUrgentTestButton() {
// 检查是否使用 KV 服务器
const provider = getSetting("server.provider");
const isKv = provider === "kv-server" || provider === "classworkscloud";
if (!isKv) return false;
// 检查是否有令牌
const kvToken = getSetting("server.kvToken");
if (!kvToken) return false;
// 检查令牌信息是否已加载
if (!this.tokenInfo) return false;
// 只有 teacher 或 classroom 类型的令牌才显示
return (
this.tokenInfo.deviceType === "teacher" ||
this.tokenInfo.deviceType === "classroom"
);
},
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,
},
"state.attendanceDialog": {
handler(newValue) {
this.handleAttendanceDialogClose(newValue);
},
},
},
created() {
this.debouncedUpload = debounce(this.uploadData, 2000);
this.debouncedAttendanceSave = debounce(async () => {
if (this.autoSave) {
await this.trySave(true);
}
}, 2000);
this.throttledReflow = throttle(() => {
if (this.$refs.gridContainer) {
this.optimizeGridLayout(this.sortedItems);
}
}, 200);
},
async mounted() {
try {
this.updateBackendUrl();
await this.initializeData();
// 拉取设备/命名空间信息用于标题显示
await this.loadDeviceInfo();
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();
});
// 获取令牌信息
await this.loadTokenInfo();
// 加载常驻通知
this.loadPersistentNotifications();
} 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 && typeof this.$offKvChanged === "function") {
this.$offKvChanged();
this.$offKvChanged = null;
}
if (this.$offDeviceEvent && typeof this.$offDeviceEvent === "function") {
this.$offDeviceEvent();
this.$offDeviceEvent = null;
}
if (this.$offConnect && typeof this.$offConnect === "function") {
this.$offConnect();
this.$offConnect = null;
}
leaveAll();
} catch (e) {
console.warn("主页面事件清理失败:", e);
}
},
methods: {
// 加载设备/命名空间信息(仅云端模式)
async loadDeviceInfo() {
try {
const provider = getSetting("server.provider");
const useServer =
provider === "kv-server" || provider === "classworkscloud";
if (!useServer) return;
const res = await kvServerProvider.loadNamespaceInfo();
if (res && res.success === false) return; // 忽略错误
this.state.namespaceInfo = res || null;
// 兜底填充设备名,避免重复解析
this.state.deviceName = res?.account?.deviceName || "";
} catch (e) {
console.warn("加载设备信息失败:", e);
}
},
// 获取令牌信息
async loadTokenInfo() {
try {
const provider = getSetting("server.provider");
const isKv = provider === "kv-server" || provider === "classworkscloud";
if (!isKv) return;
const kvToken = getSetting("server.kvToken");
if (!kvToken) return;
const serverUrl = getSetting("server.domain");
if (!serverUrl) return;
// 获取 Token 信息
const tokenResponse = await axios.get(`${serverUrl}/kv/_token`, {
headers: {
Authorization: `Bearer ${kvToken}`,
},
});
this.tokenInfo = tokenResponse.data;
console.log("Token info loaded:", this.tokenInfo);
} catch (error) {
console.warn("Failed to load token info:", error);
this.tokenInfo = null;
}
},
// 更新 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}`;
},
formatTime(timestamp) {
if (!timestamp) return '';
return new Date(timestamp).toLocaleString();
},
getToday() {
return new Date();
},
async initializeData() {
// 解析预配数据
this.parsePreconfigData();
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(forceClear = false) {
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 (
forceClear ||
!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 (
forceClear ||
!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()) {
// 如果是自定义卡片,保留其他属性
if (this.state.boardData.homework[this.currentEditSubject].type === 'custom') {
this.state.boardData.homework[this.currentEditSubject].content = content;
} else {
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.isEditingDisabled) {
const manager = this.$refs.studentNameManager;
if (manager?.isReadOnly) {
this.$message.warning("无法编辑", "当前使用的是只读令牌");
} else if (!this.canEditCurrentDate) {
this.$message.warning("无法编辑", "已禁止编辑过往数据");
} else {
this.$message.warning("无法编辑", "数据加载中,请稍候");
}
return;
}
// 如果是自定义卡片
if (subject.startsWith('custom-')) {
this.currentEditSubject = subject;
this.state.dialogTitle = this.state.boardData.homework[subject].name;
this.state.textarea = this.state.boardData.homework[subject].content;
this.state.dialogVisible = true;
return;
}
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;
// 如果是自定义卡片,保留其他属性
if (this.state.boardData.homework[this.currentEditSubject].type === 'custom') {
this.state.boardData.homework[this.currentEditSubject].content = content;
} else {
this.state.boardData.homework[this.currentEditSubject] = {
content: content,
};
}
this.state.synced = false;
if (this.autoSave) {
await this.trySave(true);
}
},
setAttendanceArea() {
// 检查编辑权限
if (this.isEditingDisabled) {
this.handleDisabledClick();
return;
}
this.state.attendanceDialog = true;
},
handleDisabledClick() {
// 处理点击禁用卡片/区域的情况
const manager = this.$refs.studentNameManager;
if (manager?.isReadOnly) {
this.$message.warning("无法编辑", "当前使用的是只读令牌");
} else if (!this.canEditCurrentDate) {
this.$message.warning("无法编辑", "已禁止编辑过往数据");
} else {
this.$message.warning("无法编辑", "数据加载中,请稍候");
}
},
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();
this.loadPersistentNotifications();
}
}, 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();
// 设置更新时尝试刷新设备名称(例如 Token 或域名变更)
this.loadDeviceInfo();
// 重新加载令牌信息Token 可能已变更)
this.loadTokenInfo();
// 触发依赖刷新(例如 shouldShowInit
this.settingsTick++;
},
async handleDateSelect(newDate) {
if (!newDate) return;
try {
const selectedDate = this.ensureDate(newDate);
const dateStr = this.formatDate(selectedDate);
if (dateStr === this.state.dateString) return;
this.state.dateString = dateStr;
this.state.selectedDate = dateStr;
this.state.selectedDateObj = selectedDate;
this.state.isToday =
dateStr === this.formatDate(this.getToday());
// Load both data and subjects in parallel, force clear data when switching dates
await Promise.all([this.downloadData(true), this.loadSubjects()]);
} catch (error) {
console.error("Date processing error:", error);
this.$message.error("日期处理错误", "请重新选择日期");
}
},
// 实时频道:加入设备房间并监听键变化
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;
// 检查是否是通知列表更新
if (msg.key === 'notification-list') {
this.loadPersistentNotifications();
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);
};
// 监听 KV 变化事件(支持新旧格式)
const kvHandler = (eventData) => {
let msg = eventData;
// 新格式:直接事件数据
if (eventData.content && eventData.timestamp) {
msg = {
uuid: eventData.senderId || "realtime",
key: eventData.content.key,
action: eventData.content.action,
created: eventData.content.created,
updatedAt: eventData.content.updatedAt || eventData.timestamp,
deletedAt: eventData.content.deletedAt,
batch: eventData.content.batch,
};
}
handler(msg);
};
this.$offKvChanged = socketOn("kv-key-changed", kvHandler);
// 保留设备事件监听(为未来扩展)
this.deviceEventHandler = createDeviceEventHandler({
onKvChanged: handler,
enableLegacySupport: true,
});
this.$offDeviceEvent = socketOn(
"device-event",
this.deviceEventHandler
);
} catch (e) {
console.warn("实时频道初始化失败", e);
}
},
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,
};
},
addTestCard() {
const id = Date.now().toString();
this.state.boardData.homework[`custom-${id}`] = {
name: "测试卡片",
content: "这是一个测试卡片\n可以用来测试布局",
type: "custom",
};
this.state.synced = false;
},
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);
},
handleAttendanceChange() {
this.state.synced = false;
this.debouncedAttendanceSave();
},
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
);
},
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);
},
async copyHomeworkToToday() {
if (this.loading.copyToToday) return;
try {
this.loading.copyToToday = true;
// 1. 保存当前选中日期的作业数据
const sourceDate = this.state.dateString;
const sourceHomework = JSON.parse(JSON.stringify(this.state.boardData.homework));
// 2. 切换到今天并加载今天的数据(主要是为了获取考勤等其他数据)
const today = this.getToday();
const todayString = this.formatDate(today);
// 临时切换到今天以加载数据
this.state.dateString = todayString;
await this.downloadData();
// 3. 直接替换今天的作业数据(删除原有作业,使用源日期的作业)
// 深拷贝源日期的作业数据
const newHomework = {};
for (const key in sourceHomework) {
if (sourceHomework[key] && sourceHomework[key].content) {
// 如果是自定义卡片,保留完整结构
if (sourceHomework[key].type === 'custom') {
newHomework[key] = JSON.parse(JSON.stringify(sourceHomework[key]));
} else {
// 普通作业,只复制内容
newHomework[key] = {
content: sourceHomework[key].content
};
}
}
}
// 直接替换作业数据
this.state.boardData.homework = newHomework;
this.state.synced = false;
// 4. 保存到今天
await this.uploadData();
// 5. 更新视图状态为今天
this.state.selectedDate = todayString;
this.state.selectedDateObj = today;
this.state.isToday = true;
// 6. 更新URL
const url = new URL(window.location);
url.searchParams.delete('date');
window.history.pushState({}, '', url);
this.$message.success("复制成功", `已将 ${sourceDate} 的作业内容复制到今天(已替换原有作业)`);
} catch (error) {
console.error("复制作业失败:", error);
this.$message.error("复制失败", error.message || "请重试");
} finally {
this.loading.copyToToday = false;
}
},
// 解析预配数据
parsePreconfigData() {
try {
const urlParams = new URLSearchParams(window.location.search);
const namespace = urlParams.get("namespace");
const authCode =
urlParams.get("authCode") || urlParams.get("auth_code");
const autoExecute =
urlParams.get("autoExecute") || urlParams.get("auto_execute");
if (namespace) {
this.preconfigData.namespace = namespace;
this.preconfigData.authCode = authCode;
this.preconfigData.autoOpen = true;
// 解析自动执行参数,支持 true/false、1/0、yes/no
this.preconfigData.autoExecute = this.parseBoolean(autoExecute);
console.log("检测到预配数据:", {
namespace: this.preconfigData.namespace,
hasAuthCode: !!this.preconfigData.authCode,
autoExecute: this.preconfigData.autoExecute,
});
// 清理URL参数避免重复处理
this.cleanupUrlParams([
"namespace",
"authCode",
"auth_code",
"autoExecute",
"auto_execute",
]);
}
} catch (error) {
console.error("解析预配数据失败:", error);
}
},
// 解析布尔值参数
parseBoolean(value) {
if (!value) return false;
const lowerValue = value.toLowerCase();
return (
lowerValue === "true" || lowerValue === "1" || lowerValue === "yes"
);
},
// 清理URL参数
cleanupUrlParams(params) {
try {
const url = new URL(window.location);
let hasChanged = false;
params.forEach((param) => {
if (url.searchParams.has(param)) {
url.searchParams.delete(param);
hasChanged = true;
}
});
if (hasChanged) {
// 使用 replaceState 避免创建新的历史记录
window.history.replaceState({}, document.title, url.toString());
}
} catch (error) {
console.error("清理URL参数失败:", error);
}
},
async loadPersistentNotifications() {
try {
const res = await dataProvider.loadData('notification-list');
if (res && Array.isArray(res)) {
this.persistentNotifications = res;
} else if (res && res.success !== false && Array.isArray(res.data)) {
this.persistentNotifications = res.data;
} else {
this.persistentNotifications = [];
}
} catch (e) {
console.error('加载常驻通知失败', e);
}
},
showNotificationDetail(notification) {
this.currentNotification = notification;
this.notificationDetailDialog = true;
},
async removePersistentNotification(id) {
this.persistentNotifications = this.persistentNotifications.filter(n => n.id !== id);
// 当通知列表为空时,保存空对象 {} 而不是空数组 [],因为后端不接受空数组
const dataToSave = this.persistentNotifications.length > 0 ? this.persistentNotifications : {};
await dataProvider.saveData('notification-list', dataToSave);
this.notificationDetailDialog = false;
},
},
};
</script>