From 05e9e73b5bb50cae07203fae797c1413271c2793 Mon Sep 17 00:00:00 2001 From: SunWuyuan Date: Sat, 19 Apr 2025 17:18:27 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=A6=96=E9=A1=B5=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E6=95=B4=E5=90=88=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E4=B8=8E=E4=BF=9D=E5=AD=98=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0UI=E4=BA=A4=E4=BA=92=EF=BC=8C=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AttendanceManager.vue | 439 ++++++++ src/components/EmptySubjectCard.vue | 27 + src/components/SubjectCard.vue | 59 ++ src/components/SubjectDialog.vue | 70 ++ src/components/SubjectGrid.vue | 189 ++++ src/pages/index.vue | 1450 +++++--------------------- src/store/configStore.js | 79 ++ src/store/dataStore.js | 179 ++++ src/store/index.js | 21 + src/store/uiStateStore.js | 136 +++ src/utils/dateUtils.js | 80 ++ src/utils/debounce.js | 41 +- src/utils/gridUtils.js | 82 ++ 13 files changed, 1662 insertions(+), 1190 deletions(-) create mode 100644 src/components/AttendanceManager.vue create mode 100644 src/components/EmptySubjectCard.vue create mode 100644 src/components/SubjectCard.vue create mode 100644 src/components/SubjectDialog.vue create mode 100644 src/components/SubjectGrid.vue create mode 100644 src/store/configStore.js create mode 100644 src/store/dataStore.js create mode 100644 src/store/index.js create mode 100644 src/store/uiStateStore.js create mode 100644 src/utils/dateUtils.js create mode 100644 src/utils/gridUtils.js diff --git a/src/components/AttendanceManager.vue b/src/components/AttendanceManager.vue new file mode 100644 index 0000000..e511886 --- /dev/null +++ b/src/components/AttendanceManager.vue @@ -0,0 +1,439 @@ + + + + + \ No newline at end of file diff --git a/src/components/EmptySubjectCard.vue b/src/components/EmptySubjectCard.vue new file mode 100644 index 0000000..20159ac --- /dev/null +++ b/src/components/EmptySubjectCard.vue @@ -0,0 +1,27 @@ + + + \ No newline at end of file diff --git a/src/components/SubjectCard.vue b/src/components/SubjectCard.vue new file mode 100644 index 0000000..e562597 --- /dev/null +++ b/src/components/SubjectCard.vue @@ -0,0 +1,59 @@ + + + \ No newline at end of file diff --git a/src/components/SubjectDialog.vue b/src/components/SubjectDialog.vue new file mode 100644 index 0000000..00a4624 --- /dev/null +++ b/src/components/SubjectDialog.vue @@ -0,0 +1,70 @@ + + + \ No newline at end of file diff --git a/src/components/SubjectGrid.vue b/src/components/SubjectGrid.vue new file mode 100644 index 0000000..9e28c24 --- /dev/null +++ b/src/components/SubjectGrid.vue @@ -0,0 +1,189 @@ + + + + + \ No newline at end of file diff --git a/src/pages/index.vue b/src/pages/index.vue index 3d0bf0d..1830639 100644 --- a/src/pages/index.vue +++ b/src/pages/index.vue @@ -5,668 +5,289 @@ - {{ state.classNumber }} - {{ titleText }} + {{ configStore.serverConfig.classNumber }} - {{ titleText }} +
- - -
- -
- - {{ item.name }} - - - - {{ text }} - - - - -
-
-
+ + - -
- -
- - - - {{ subject.name }} - - - mdi-plus -
点击添加作业
-
-
-
-
-
- + 上传 - 同步完成 - + 同步完成 + + 随机点名 - - + + + + 屏幕保护技术已启用 -

本应用采用独立自研的动态像素偏移技术(DPO™),有效防止LCD屏幕烧屏现象。

-

*研究显示动态像素偏移技术可以修复屏幕坏点,起到保护屏幕的作用,数据来自实验室。 + 本应用采用独立自研的动态像素偏移技术(DPO™),有效防止LCD屏幕烧屏现象。 +

+

+ *研究显示动态像素偏移技术可以修复屏幕坏点,起到保护屏幕的作用,数据来自实验室。专利号CN108648692

-

*技术已自动适配您的设备,无需手动调整

- + target="_blank" + >专利号CN108648692 + +

+

+ *技术已自动适配您的设备,无需手动调整 +

- -

出勤

-

- 应到 : - - {{ - state.studentList.length - - state.boardData.attendance.exclude.length - }}人 - -

-

- 实到 : - - {{ - state.studentList.length - - state.boardData.attendance.absent.length - - state.boardData.attendance.late.length - - state.boardData.attendance.exclude.length - }}人 - -

-

- 请假 : - - {{ state.boardData.attendance.absent.length }}人 - -

-

- {{ `${index + 1}. ` }}{{ name - }} -

-

- 迟到: - - {{ state.boardData.attendance.late.length }}人 - -

-

- {{ `${index + 1}. ` }}{{ name - }} -

-

- 不参与: - - {{ state.boardData.attendance.exclude.length }}人 - -

-

- {{ `${index + 1}. ` }}{{ name - }} -

-
+
- - - {{ state.dialogTitle }} - - {{ autoSave ? "喵?喵呜!" : "写完后点击上传谢谢喵" }} - - - - - - + + - - {{ state.snackbarText }} + + {{ uiStore.snackbar.text }} - - - - - 出勤状态管理 - - - {{ state.dateString }} - - - - - - - - - - - - - -
- - {{ surname.name }} - ({{ surname.count }}) - -
- -
-
- - -
-
- - 到课 - - - - 请假 - - - 迟到 - - - 不参与 - -
-
- - - - - - -
-
- - {{ getStudentStatusIcon(state.studentList.indexOf(student)) }} - -
{{ student }}
-
-
-
- - - - -
-
-
-
-
- - - -
批量操作
- - - 全部到齐 - - - 全部请假 - - - - 全部迟到 - - - 全部不参与 - - -
-
-
-
- - - - - - - - mdi-content-save - 保存 - - -
-
- - + - 确认保存 + 确认保存 - 您正在修改 {{ state.dateString }} 的数据,确定要保存吗? + 您正在修改 {{ dataStore.dateString }} 的数据,确定要保存吗? - + 取消 - 确认保存 + 确认保存 - - + + + diff --git a/src/store/configStore.js b/src/store/configStore.js new file mode 100644 index 0000000..b948322 --- /dev/null +++ b/src/store/configStore.js @@ -0,0 +1,79 @@ +/** + * Configuration Store - Manages application settings and provides defaults + */ +import { reactive } from "vue"; +import { getSetting, watchSettings } from "@/utils/settings"; + +const configStore = reactive({ + // Server connection config + serverConfig: { + provider: getSetting("server.provider"), + domain: getSetting("server.domain"), + classNumber: getSetting("server.classNumber") + }, + + // Default subjects + defaultSubjects: [ + { key: "语文", name: "语文" }, + { key: "数学", name: "数学" }, + { key: "英语", name: "英语" }, + { key: "物理", name: "物理" }, + { key: "化学", name: "化学" }, + { key: "生物", name: "生物" }, + { key: "政治", name: "政治" }, + { key: "历史", name: "历史" }, + { key: "地理", name: "地理" }, + { key: "其他", name: "其他" } + ], + + // Feature flags + featureFlags: { + get autoSave() { return getSetting("edit.autoSave"); }, + get blockNonTodayAutoSave() { return getSetting("edit.blockNonTodayAutoSave"); }, + get confirmNonTodaySave() { return getSetting("edit.confirmNonTodaySave"); }, + get refreshBeforeEdit() { return getSetting("edit.refreshBeforeEdit"); }, + get emptySubjectDisplay() { return getSetting("display.emptySubjectDisplay"); }, + get dynamicSort() { return getSetting("display.dynamicSort"); }, + get showRandomPickerButton() { return getSetting("randomPicker.enabled"); }, + get showAntiScreenBurnCard() { return getSetting("display.showAntiScreenBurnCard"); } + }, + + // Data accessor + dataKey: "", + + // Methods + /** + * Initialize configuration from settings + */ + initialize() { + this.updateServerConfig(); + }, + + /** + * Update server configuration from settings + */ + updateServerConfig() { + this.serverConfig.provider = getSetting("server.provider"); + this.serverConfig.domain = getSetting("server.domain"); + this.serverConfig.classNumber = getSetting("server.classNumber"); + + // Update the data key + this.dataKey = this.serverConfig.provider === "server" + ? `${this.serverConfig.domain}/${this.serverConfig.classNumber}` + : this.serverConfig.classNumber; + }, + + /** + * Set up settings watcher + * @param {Function} callback - Function to call when settings change + * @returns {Function} Unwatch function + */ + watchSettings(callback) { + return watchSettings(() => { + this.updateServerConfig(); + if (callback) callback(); + }); + } +}); + +export default configStore; \ No newline at end of file diff --git a/src/store/dataStore.js b/src/store/dataStore.js new file mode 100644 index 0000000..0d3cd6b --- /dev/null +++ b/src/store/dataStore.js @@ -0,0 +1,179 @@ +/** + * Data store module - This handles all data that will be sent to and retrieved from the data provider + * It provides a clean separation between data management and UI state + */ +import dataProvider from "@/utils/dataProvider"; +import { reactive } from "vue"; + +// Core data structure that mirrors the backend storage format +const initialData = { + homework: {}, + attendance: { + absent: [], + late: [], + exclude: [] + } +}; + +// Create a reactive data store +const dataStore = reactive({ + // Main data that will be sent to the server + boardData: { ...initialData }, + + // Metadata + classNumber: "", + studentList: [], + dateString: "", + synced: false, + + // Methods for data manipulation + /** + * Reset data store to initial empty state + */ + resetData() { + this.boardData = { + homework: {}, + attendance: { + absent: [], + late: [], + exclude: [] + } + }; + this.synced = false; + }, + + /** + * Set homework content for a subject + * @param {string} subjectKey - The subject key + * @param {string} content - The homework content + */ + setHomework(subjectKey, content) { + if (!this.boardData.homework[subjectKey]) { + this.boardData.homework[subjectKey] = {}; + } + + this.boardData.homework[subjectKey].content = content; + this.synced = false; + }, + + /** + * Update attendance data + * @param {Object} attendance - The new attendance data + */ + updateAttendance(attendance) { + this.boardData.attendance = { + absent: [...attendance.absent || []], + late: [...attendance.late || []], + exclude: [...attendance.exclude || []] + }; + this.synced = false; + }, + + /** + * Get subject data including content + * @param {string} subjectKey - The subject key + * @param {Array} availableSubjects - List of available subjects + * @returns {Object} Subject with name and content + */ + getSubject(subjectKey, availableSubjects) { + const name = availableSubjects.find(s => s.key === subjectKey)?.name || subjectKey; + const content = this.boardData.homework[subjectKey]?.content || ""; + + return { + key: subjectKey, + name, + content + }; + }, + + // Data loading and saving methods + /** + * Load data from server + * @param {string} provider - The data provider type + * @param {string} dataKey - The data key + * @param {string} dateString - The date string + * @returns {Promise} Success status + */ + async loadData(provider, dataKey, dateString) { + try { + const response = await dataProvider.loadData(provider, dataKey, dateString); + + if (!response.success) { + if (response.error.code === "NOT_FOUND") { + this.resetData(); + return true; + } + throw new Error(response.error.message); + } + + // Update the store with the retrieved data + this.boardData = { + homework: response.data.homework || {}, + attendance: { + absent: response.data.attendance?.absent || [], + late: response.data.attendance?.late || [], + exclude: response.data.attendance?.exclude || [] + } + }; + + this.synced = true; + return true; + } catch (error) { + console.error("Failed to load data:", error); + this.resetData(); + throw error; + } + }, + + /** + * Save data to server + * @param {string} provider - The data provider type + * @param {string} dataKey - The data key + * @param {string} dateString - The date string + * @returns {Promise} Success status + */ + async saveData(provider, dataKey, dateString) { + try { + const response = await dataProvider.saveData( + provider, + dataKey, + this.boardData, + dateString + ); + + if (!response.success) { + throw new Error(response.error.message); + } + + this.synced = true; + return true; + } catch (error) { + console.error("Failed to save data:", error); + throw error; + } + }, + + /** + * Load student list and config + * @param {string} provider - The data provider type + * @param {string} dataKey - The data key + * @returns {Promise} Success status + */ + async loadConfig(provider, dataKey) { + try { + const response = await dataProvider.loadConfig(provider, dataKey); + + if (!response.success) { + throw new Error(response.error.message); + } + + this.studentList = response.data.studentList || []; + return true; + } catch (error) { + console.error("Failed to load config:", error); + throw error; + } + } +}); + +export default dataStore; \ No newline at end of file diff --git a/src/store/index.js b/src/store/index.js new file mode 100644 index 0000000..00c5edf --- /dev/null +++ b/src/store/index.js @@ -0,0 +1,21 @@ +/** + * Store index - exports all application stores + * This barrel file simplifies imports in components + */ + +import dataStore from './dataStore'; +import uiStore from './uiStateStore'; +import configStore from './configStore'; + +export { + dataStore, + uiStore, + configStore +}; + +// Default export for convenience +export default { + dataStore, + uiStore, + configStore +}; \ No newline at end of file diff --git a/src/store/uiStateStore.js b/src/store/uiStateStore.js new file mode 100644 index 0000000..91c07fc --- /dev/null +++ b/src/store/uiStateStore.js @@ -0,0 +1,136 @@ +/** + * UI State Store - Manages UI-specific state that doesn't need to be persisted to the server + */ +import { reactive } from "vue"; +import { getSetting, setSetting } from "@/utils/settings"; +import { formatDate } from "@/utils/dateUtils"; + +const uiStateStore = reactive({ + // UI State + fontSize: getSetting("font.size"), + contentStyle: { "font-size": `${getSetting("font.size")}px` }, + datePickerVisible: false, + selectedDateObj: new Date(), + confirmDialogVisible: false, + confirmDialogResolve: null, + confirmDialogReject: null, + currentSubjectKey: null, + loadingState: { + download: false, + upload: false + }, + refreshInterval: null, + snackbar: { + visible: false, + text: "", + timeout: 2000 + }, + + // UI State methods + /** + * Update font size + * @param {string} direction - "up" or "out" + */ + zoom(direction) { + const step = 2; + if (direction === "up" && this.fontSize < 100) { + this.fontSize += step; + } else if (direction === "out" && this.fontSize > 16) { + this.fontSize -= step; + } + this.contentStyle = { + "font-size": `${this.fontSize}px` + }; + setSetting("font.size", this.fontSize); + }, + + /** + * Show a snackbar message + * @param {string} text - Message text + * @param {number} timeout - Message timeout in ms + */ + showSnackbar(text, timeout = 2000) { + this.snackbar.text = text; + this.snackbar.timeout = timeout; + this.snackbar.visible = true; + }, + + /** + * Show a confirmation dialog + * @returns {Promise} - Resolves when confirmed, rejects when cancelled + */ + showConfirmDialog() { + return new Promise((resolve, reject) => { + this.confirmDialogVisible = true; + this.confirmDialogResolve = () => { + this.confirmDialogVisible = false; + resolve(true); + }; + this.confirmDialogReject = () => { + this.confirmDialogVisible = false; + reject(new Error("User cancelled")); + }; + }); + }, + + /** + * Set current subject for editing + * @param {string} subjectKey - Subject key + */ + setCurrentSubject(subjectKey) { + this.currentSubjectKey = subjectKey; + }, + + /** + * Handle date selection + * @param {Date} newDate - The selected date + * @returns {string} Formatted date string + */ + selectDate(newDate) { + if (!newDate) return null; + + this.selectedDateObj = new Date(newDate); + return formatDate(this.selectedDateObj); + }, + + /** + * Set up auto refresh + * @param {Function} refreshCallback - Function to call on refresh + */ + setupAutoRefresh(refreshCallback, shouldSkipRefreshFn) { + const autoRefresh = getSetting("refresh.auto"); + const interval = getSetting("refresh.interval"); + + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + + if (autoRefresh && refreshCallback) { + this.refreshInterval = setInterval(() => { + if (!shouldSkipRefreshFn()) { + refreshCallback(); + } + }, interval * 1000); + } + }, + + /** + * Clean up resources + */ + cleanup() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + }, + + /** + * Update UI settings from app settings + */ + updateFromSettings() { + this.fontSize = getSetting("font.size"); + this.contentStyle = { "font-size": `${this.fontSize}px` }; + } +}); + +export default uiStateStore; \ No newline at end of file diff --git a/src/utils/dateUtils.js b/src/utils/dateUtils.js new file mode 100644 index 0000000..6edb92f --- /dev/null +++ b/src/utils/dateUtils.js @@ -0,0 +1,80 @@ +/** + * Ensures the input is a valid Date object + * @param {Date|string} dateInput - A date object or string + * @returns {Date} A valid Date object + */ +export function ensureDate(dateInput) { + if (dateInput instanceof Date) { + return dateInput; + } + if (typeof dateInput === "string") { + const date = new Date(dateInput); + if (!isNaN(date.getTime())) { + return date; + } + } + return new Date(); // If unable to parse, return current date +} + +/** + * Formats a date to YYYY-MM-DD string + * @param {Date|string} dateInput - A date object or string + * @returns {string} Formatted date string + */ +export function formatDate(dateInput) { + const date = ensureDate(dateInput); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** + * Gets today's date + * @returns {Date} Current date + */ +export function getToday() { + return new Date(); +} + +/** + * Checks if a date is today + * @param {Date|string} dateInput - Date to check + * @returns {boolean} True if the date is today + */ +export function isToday(dateInput) { + const today = getToday(); + return formatDate(dateInput) === formatDate(today); +} + +/** + * Gets yesterday's date + * @returns {Date} Yesterday's date + */ +export function getYesterday() { + const today = getToday(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + return yesterday; +} + +/** + * Returns a formatted display text for a date relative to today + * @param {string} dateString - Date string in YYYY-MM-DD format + * @returns {string} Descriptive text + */ +export function getRelativeDateText(dateString) { + const today = getToday(); + const yesterday = getYesterday(); + + const todayStr = formatDate(today); + const yesterdayStr = formatDate(yesterday); + + if (dateString === todayStr) { + return "今天的作业"; + } else if (dateString === yesterdayStr) { + return "昨天的作业"; + } else { + return `${dateString}的作业`; + } +} \ No newline at end of file diff --git a/src/utils/debounce.js b/src/utils/debounce.js index e0b1fc2..2acfbec 100644 --- a/src/utils/debounce.js +++ b/src/utils/debounce.js @@ -1,27 +1,30 @@ -export function debounce(fn, delay) { - let timer = null; +/** + * Creates a debounced function that delays invoking func until after wait milliseconds + * @param {Function} func - The function to debounce + * @param {number} wait - The number of milliseconds to delay + * @returns {Function} - The debounced function + */ +export function debounce(func, wait) { + let timeout; return function (...args) { - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - fn.apply(this, args); - }, delay); + const context = this; + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(context, args), wait); }; } -export function throttle(fn, delay) { - let timer = null; - let last = 0; +/** + * Creates a throttled function that only invokes func at most once per every wait milliseconds + * @param {Function} func - The function to throttle + * @param {number} wait - The number of milliseconds to throttle invocations to + * @returns {Function} - The throttled function + */ +export function throttle(func, wait) { + let lastCall = 0; return function (...args) { const now = Date.now(); - if (now - last < delay) { - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - last = now; - fn.apply(this, args); - }, delay); - } else { - last = now; - fn.apply(this, args); - } + if (now - lastCall < wait) return; + lastCall = now; + return func.apply(this, args); }; } diff --git a/src/utils/gridUtils.js b/src/utils/gridUtils.js new file mode 100644 index 0000000..eff3f3c --- /dev/null +++ b/src/utils/gridUtils.js @@ -0,0 +1,82 @@ +/** + * Optimizes the layout of grid items using a greedy algorithm + * @param {Array} items - Array of items to layout + * @returns {Array} - Array of items with optimized order + */ +export function optimizeGridLayout(items) { + // Set maximum columns based on viewport width + const maxColumns = Math.min(3, Math.floor(window.innerWidth / 300)); + if (maxColumns <= 1) return items; + + // Use greedy algorithm to allocate + const columns = Array.from({ length: maxColumns }, () => ({ + height: 0, + items: [], + })); + + items.forEach((item) => { + const shortestColumn = columns.reduce( + (min, col, i) => (col.height < columns[min].height ? i : min), + 0 + ); + columns[shortestColumn].items.push(item); + columns[shortestColumn].height += item.rowSpan; + }); + + // Flatten result and add order + return columns + .flatMap((col) => col.items) + .map((item, index) => ({ + ...item, + order: index, + })); +} + +/** + * Applies a fixed layout to grid items based on subject groups + * @param {Array} items - Array of items to layout + * @returns {Array} - Array of items with fixed order + */ +export function fixedGridLayout(items) { + const rowSubjects = [ + ["语文", "数学", "英语"], + ["物理", "化学", "生物"], + ["政治", "历史", "地理", "其他"], + ]; + + return items + .sort((a, b) => { + const getRowIndex = (subject) => { + for (let i = 0; i < rowSubjects.length; i++) { + if (rowSubjects[i].includes(subject)) { + return i; + } + } + return rowSubjects.length; + }; + + const getColumnIndex = (subject) => { + for (const row of rowSubjects) { + const index = row.indexOf(subject); + if (index !== -1) return index; + } + return 999; + }; + + const rowA = getRowIndex(a.key); + const rowB = getRowIndex(b.key); + + if (rowA !== rowB) { + return rowA - rowB; + } + + const colA = getColumnIndex(a.key); + const colB = getColumnIndex(b.key); + return colA - colB; + }) + .map((item, index) => ({ + ...item, + order: index, + rowSpan: item.content ? 2 : 1, + })); +} \ No newline at end of file