diff --git a/src/components/ExamConfigEditor.vue b/src/components/ExamConfigEditor.vue index 082da55..95bfa84 100644 --- a/src/components/ExamConfigEditor.vue +++ b/src/components/ExamConfigEditor.vue @@ -69,7 +69,7 @@
+ + 复制远程链接 + + + + JSON 文件 + + + + 部署到 ExamAware2 知试 + + - 请先完善配置信息后再打开 + 请先完善配置信息后再操作
mdi-code-json - JSON配置预览 + 配置预览 + 复制 + -
{{ formattedJson }}
+
{{ formattedStorageJson }}
@@ -226,17 +261,24 @@
- + mdi-information - 基本信息 + 基本信息 - + - + + + + mdi-message-text + 考试提示 + + - - - - mdi-plus - {{ tip }} - - -
- mdi-lightbulb-outline - 点击上方选项快速添加常用考试提示 -
+ +
+ + + mdi-plus + {{ tip.substring(0, 20) }}... + + +
+ mdi-lightbulb-outline + 点击上方选项快速添加常用考试提示 +
+
+
+
- + mdi-format-list-bulleted - 考试科目安排 + 考试科目安排 + + +
+ + + +
+ 添加科目 @@ -304,23 +381,93 @@
- + +
+ + mdi-numeric-{{ index + 1 }}-circle + 第 {{ index + 1 }} 科目 + + + + + + +
+ + 上移 + + + 下移 + + + 删除 + +
+
+ + - + + > + + + @blur="updateStartDateTimeFromInput(index)" + > + + - - + + + mdi-clock-start 选择开始时间 @@ -377,14 +534,7 @@ variant="text" @click="examInfo.startDateMenu = false" > - 取消 - - - 确定 + 关闭 @@ -401,17 +551,28 @@ - - + + + mdi-clock-end 选择结束时间 @@ -419,7 +580,7 @@ - 取消 - - - 确定 + 关闭 - - - mdi-delete - - - mdi-arrow-up - - - mdi-arrow-down - - + + + + + + + +
-
- - mdi-book-plus +
+ + mdi-calendar-blank -

- 暂无考试科目,点击"添加科目"按钮开始添加 -

- - 添加科目 +
暂无考试科目安排
+
+ 点击上方"添加科目"按钮开始配置 +
+ + 立即添加科目
@@ -575,6 +750,9 @@ export default { success: "", isEditMode: false, // 新增:编辑模式状态 showJsonPreview: false, // 新增:JSON预览显示状态 + availableSubjects: [], // 可用的科目列表 + customSubjectInput: "", // 自定义科目输入 + enableCustomAlertTime: false, // 是否启用自定义提醒时间 defaultExamTips: [ "请保持卷面整洁,字迹清晰,诚信应考。在听到终考铃时立刻起立,停止作答。", "沉着 冷静 细心 守记", @@ -584,7 +762,7 @@ export default { }, computed: { /** - * 格式化的JSON字符串 + * 格式化的JSON字符串(旧版,完整数据) */ formattedJson() { try { @@ -595,6 +773,29 @@ export default { } }, + /** + * 格式化的存储格式JSON字符串(只包含核心字段) + */ + formattedStorageJson() { + try { + const storageConfig = { + examName: this.localConfig.examName, + message: this.localConfig.message, + room: this.localConfig.room, + examInfos: this.localConfig.examInfos.map((info) => ({ + name: info.name, + start: this.formatDisplayDateTime(info.start), + end: this.formatDisplayDateTime(info.end), + alertTime: parseInt(info.alertTime) || 15, + })), + }; + return JSON.stringify(storageConfig, null, 2); + } catch (err) { + console.error("格式化存储JSON时出错:", err); + return "无效的JSON格式"; + } + }, + /** * 检查配置是否有效 */ @@ -723,10 +924,317 @@ export default { }, }, }, + created() { + this.loadSubjects(); + }, methods: { /** - * 加载配置数据 + * 加载可用的科目列表 */ + async loadSubjects() { + try { + const response = await dataProvider.loadData("classworks-config-subject"); + if (response && Array.isArray(response)) { + this.availableSubjects = response + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map(subject => ({ + name: subject.name, + order: subject.order ?? 0 + })); + } else { + // 使用默认科目列表 + this.availableSubjects = [ + { 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 }, + ]; + } + } catch (error) { + console.warn('加载科目列表失败,使用默认列表:', error); + // 使用默认科目列表 + this.availableSubjects = [ + { 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 }, + ]; + } + }, + + /** + * 自动填充剩余科目的时间(基于最后一个科目,每个科目间隔10分钟) + */ + autoFillRemaining() { + if (this.localConfig.examInfos.length === 0) return; + + let lastEndTime = null; + + // 找到最后已设置的时间 + for (let i = this.localConfig.examInfos.length - 1; i >= 0; i--) { + if (this.localConfig.examInfos[i].end) { + lastEndTime = new Date(this.localConfig.examInfos[i].end); + break; + } + } + + // 如果没有任何已设置的时间,使用当前时间 + if (!lastEndTime) { + lastEndTime = new Date(); + } + + // 从前往后填充 + for (let i = 0; i < this.localConfig.examInfos.length; i++) { + const examInfo = this.localConfig.examInfos[i]; + + // 如果已经有时间,跳过但更新 lastEndTime + if (examInfo.end) { + lastEndTime = new Date(examInfo.end); + continue; + } + + // 计算新的开始和结束时间 + const startTime = new Date(lastEndTime.getTime() + 10 * 60 * 1000); // 前一个结束时间 + 10分钟 + const endTime = new Date(startTime.getTime() + 2 * 60 * 60 * 1000); // 考试时长2小时 + + // 更新科目时间 + examInfo.start = this.formatDateTimeLocal(startTime); + examInfo.startDate = startTime; + examInfo.startTime = this.formatTimeOnly(startTime); + examInfo.startFormatted = this.formatDisplayDateTime(startTime); + + examInfo.end = this.formatDateTimeLocal(endTime); + examInfo.endDate = endTime; + examInfo.endTime = this.formatTimeOnly(endTime); + examInfo.endFormatted = this.formatDisplayDateTime(endTime); + + lastEndTime = endTime; + } + + this.success = '已自动填充所有科目的时间(间隔10分钟)'; + }, + + /** + * 验证时间格式 + */ + validateTimeFormat(value, fieldName) { + if (!value) return true; // 空值由必填验证处理 + + // 匹配格式: YYYY/MM/DD HH:mm 或 YYYY-MM-DD HH:mm + const match = value.match(/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})\s+(\d{1,2}):(\d{1,2})/); + if (!match) { + return `${fieldName}格式不正确,请使用格式:2025/01/01 09:00`; + } + + const [, year, month, day, hour, minute] = match; + const y = parseInt(year); + const m = parseInt(month); + const d = parseInt(day); + const h = parseInt(hour); + const min = parseInt(minute); + + // 验证日期范围 + if (m < 1 || m > 12) return `${fieldName}月份不合法(1-12)`; + if (d < 1 || d > 31) return `${fieldName}日期不合法(1-31)`; + if (h < 0 || h > 23) return `${fieldName}小时不合法(0-23)`; + if (min < 0 || min > 59) return `${fieldName}分钟不合法(0-59)`; + + // 验证日期是否真实存在 + const date = new Date(y, m - 1, d, h, min); + if (isNaN(date.getTime())) { + return `${fieldName}日期不存在`; + } + + // 验证月份和日期是否匹配(防止2月30日等情况) + if (date.getMonth() !== m - 1 || date.getDate() !== d) { + return `${fieldName}日期不存在`; + } + + return true; + }, + + /** + * 验证结束时间晚于开始时间 + */ + validateEndAfterStart(examInfo) { + if (!examInfo.startFormatted || !examInfo.endFormatted) return true; + + try { + const start = new Date(examInfo.start || examInfo.startFormatted.replace(/\//g, '-')); + const end = new Date(examInfo.end || examInfo.endFormatted.replace(/\//g, '-')); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return true; // 格式错误由其他验证处理 + } + + if (end <= start) { + return '结束时间必须晚于开始时间'; + } + + // 检查时长是否合理(不超过24小时) + const duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); + if (duration > 24) { + return '考试时长不能超过24小时'; + } + + return true; + } catch (error) { + return true; + } + }, + + /** + * 验证科目时间不重叠 + */ + validateNoTimeOverlap(currentExamInfo, currentIndex) { + if (!currentExamInfo.startFormatted || !currentExamInfo.endFormatted) { + return true; // 时间未设置时不验证重叠 + } + + try { + const currentStart = new Date(currentExamInfo.start || currentExamInfo.startFormatted.replace(/\//g, '-')); + const currentEnd = new Date(currentExamInfo.end || currentExamInfo.endFormatted.replace(/\//g, '-')); + + if (isNaN(currentStart.getTime()) || isNaN(currentEnd.getTime())) { + return true; // 格式错误由其他验证处理 + } + + // 检查与其他科目的时间重叠 + for (let i = 0; i < this.localConfig.examInfos.length; i++) { + if (i === currentIndex) continue; // 跳过当前科目 + + const otherExamInfo = this.localConfig.examInfos[i]; + if (!otherExamInfo.start || !otherExamInfo.end) continue; + + const otherStart = new Date(otherExamInfo.start); + const otherEnd = new Date(otherExamInfo.end); + + if (isNaN(otherStart.getTime()) || isNaN(otherEnd.getTime())) continue; + + // 检查时间重叠:当前开始时间在其他时间段内 或 当前结束时间在其他时间段内 或 当前时间段完全包含其他时间段 + const isOverlap = ( + (currentStart >= otherStart && currentStart < otherEnd) || // 当前开始时间在其他时间段内 + (currentEnd > otherStart && currentEnd <= otherEnd) || // 当前结束时间在其他时间段内 + (currentStart <= otherStart && currentEnd >= otherEnd) // 当前时间段完全包含其他时间段 + ); + + if (isOverlap) { + const otherSubjectName = otherExamInfo.name || `第${i + 1}个科目`; + return `时间与"${otherSubjectName}"重叠`; + } + } + + return true; + } catch (error) { + return true; + } + }, + + /** + * 切换提醒时间模式 + */ + toggleAlertTimeMode() { + if (!this.enableCustomAlertTime) { + // 关闭自定义时,将所有提醒时间设为15分钟 + this.localConfig.examInfos.forEach(info => { + info.alertTime = 15; + }); + } + }, + + /** + * 计算考试时长 + */ + getExamDuration(examInfo) { + if (!examInfo.start || !examInfo.end) return ''; + + try { + const startTime = new Date(examInfo.start); + const endTime = new Date(examInfo.end); + + if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) { + return ''; + } + + const durationMs = endTime.getTime() - startTime.getTime(); + const durationMinutes = Math.round(durationMs / (1000 * 60)); + + if (durationMinutes < 60) { + return `${durationMinutes}分钟`; + } + + const hours = Math.floor(durationMinutes / 60); + const minutes = durationMinutes % 60; + + if (minutes === 0) { + return `${hours}小时`; + } else { + return `${hours}小时${minutes}分钟`; + } + } catch (error) { + return ''; + } + }, + + /** + * 从输入框更新开始时间 + */ + updateStartDateTimeFromInput(index) { + if (index === undefined || !this.localConfig.examInfos[index]) return; + + const examInfo = this.localConfig.examInfos[index]; + const formatted = examInfo.startFormatted; + if (!formatted) return; + + // 尝试解析输入格式: 2025/01/01 09:00 或 2025-01-01 09:00 + const match = formatted.match(/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})\s+(\d{1,2}):(\d{1,2})/); + if (!match) return; + + const [, year, month, day, hour, minute] = match; + const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute)); + + if (isNaN(date.getTime())) return; + + examInfo.startDate = date; + examInfo.startTime = this.formatTimeOnly(date); + examInfo.start = this.formatDateTimeLocal(date); + this.updateStartDateTime(index); + }, + + /** + * 从输入框更新结束时间 + */ + updateEndDateTimeFromInput(index) { + if (index === undefined || !this.localConfig.examInfos[index]) return; + + const examInfo = this.localConfig.examInfos[index]; + const formatted = examInfo.endFormatted; + if (!formatted) return; + + // 尝试解析输入格式: 2025/01/01 11:00 或 2025-01-01 11:00 + const match = formatted.match(/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})\s+(\d{1,2}):(\d{1,2})/); + if (!match) return; + + const [, year, month, day, hour, minute] = match; + const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute)); + + if (isNaN(date.getTime())) return; + + examInfo.endDate = date; + examInfo.endTime = this.formatTimeOnly(date); + examInfo.end = this.formatDateTimeLocal(date); + this.updateEndDateTime(index); + }, async loadConfig() { this.loading = true; this.error = ""; @@ -766,7 +1274,38 @@ export default { info.endFormatted = this.formatDisplayDateTime(endDate); info.endDateMenu = false; } + + // 初始化时长(分钟)- 前端计算 + try { + if (info.start && info.end) { + const s = new Date(info.start); + const e = new Date(info.end); + const diff = Math.round((e.getTime() - s.getTime()) / (1000 * 60)); + if (diff > 0 && diff <= 24 * 60) { + info.durationMinutes = diff; + } else { + info.durationMinutes = 120; + } + } else { + info.durationMinutes = 120; + } + } catch (_) { + info.durationMinutes = 120; + } + + // 初始化提醒时间 - 处理数据迁移 + if (info.alertTime === undefined || info.alertTime === null) { + info.alertTime = 15; // 旧数据默认15分钟 + } else { + info.alertTime = parseInt(info.alertTime) || 15; + } }); + + // 检测是否有自定义提醒时间 + const hasCustomAlertTime = this.localConfig.examInfos.some( + info => info.alertTime !== 15 + ); + this.enableCustomAlertTime = hasCustomAlertTime; } else { console.error("加载配置失败:", response); this.error = @@ -801,13 +1340,16 @@ export default { this.error = ""; try { - // 创建保存用的配置副本,转换时间格式 + // 创建保存用的配置副本,只保存核心字段 const configToSave = { - ...this.localConfig, + examName: this.localConfig.examName, + message: this.localConfig.message, + room: this.localConfig.room, examInfos: this.localConfig.examInfos.map((info) => ({ - ...info, + name: info.name, start: this.formatDisplayDateTime(info.start), end: this.formatDisplayDateTime(info.end), + alertTime: parseInt(info.alertTime) || 15, // 默认15分钟 })), }; @@ -839,14 +1381,27 @@ export default { * 添加考试科目 */ addExamInfo() { - const now = new Date(); - const startTime = new Date(now.getTime() + 60 * 60 * 1000); // 1小时后 - const endTime = new Date(startTime.getTime() + 2 * 60 * 60 * 1000); // 2小时后 + // 获取最后一个科目的结束时间,或使用当前时间 + let baseTime = new Date(); + + if (this.localConfig.examInfos.length > 0) { + const lastExamInfo = this.localConfig.examInfos[this.localConfig.examInfos.length - 1]; + if (lastExamInfo.end) { + baseTime = new Date(lastExamInfo.end); + } + } + + // 新科目开始时间 = 上一个科目结束时间 + 10分钟 + const startTime = new Date(baseTime.getTime() + 10 * 60 * 1000); + // 新科目结束时间 = 开始时间 + 2小时 + const endTime = new Date(startTime.getTime() + 2 * 60 * 60 * 1000); const examInfo = { - name: "新科目", + name: "", start: this.formatDateTimeLocal(startTime), end: this.formatDateTimeLocal(endTime), + durationMinutes: 120, + alertTime: 15, // 默认提醒时间15分钟 // 日期选择器相关数据 startDate: startTime, startTime: this.formatTimeOnly(startTime), @@ -880,17 +1435,82 @@ export default { }, /** - * 复制JSON到剪贴板 + * 复制JSON到剪贴板(存储格式) */ async copyToClipboard() { try { - await navigator.clipboard.writeText(this.formattedJson); - this.success = "JSON已复制到剪贴板"; - } catch (err) { + await navigator.clipboard.writeText(this.formattedStorageJson); + this.$message.success('配置已复制到剪贴板'); + } catch (err) { this.error = "复制失败: " + err.message; } }, + /** + * 下载为JSON文件 + */ + downloadAsJson() { + try { + const blob = new Blob([this.formattedStorageJson], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.localConfig.examName || 'exam-config'}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + this.$message?.success('已下载 JSON 文件'); + } catch (err) { + this.error = '下载失败: ' + err.message; + } + }, + + /** + * 下载为EA2文件(ExamAware2格式) + */ + downloadAsEa2() { + try { + const blob = new Blob([this.formattedStorageJson], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.localConfig.examName || 'exam-config'}.ea2`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + this.$message?.success('已下载 ExamAware2 知试 (.ea2)文件'); + + + } catch (err) { + this.error = '下载失败: ' + err.message; + } + }, + + /** + * 复制配置链接(用于ExamSchedule) + */ + async copyConfigUrl() { + try { + // 获取配置的云端访问地址 + const result = await dataProvider.getKeyCloudUrl(`es_${this.configId}`, { + autoMigrate: true, + autoConfig: true + }); + + if (result.success && result.url) { + // 直接复制KV地址 + await navigator.clipboard.writeText(result.url); + this.$message.success('云端地址已复制到剪贴板'); + } else { + throw new Error(result.error || '获取云端地址失败'); + } + } catch (err) { + this.error = '复制链接失败: ' + err.message; + } + }, + /** * 切换编辑模式 */ @@ -923,6 +1543,52 @@ export default { } }, + /** + * 时长提示(人性化显示) + */ + durationHint(examInfo) { + const m = parseInt(examInfo?.durationMinutes); + if (isNaN(m) || m <= 0) return ""; + if (m < 60) return `${m} 分钟`; + const h = Math.floor(m / 60); + const mm = m % 60; + return mm === 0 ? `${h} 小时` : `${h} 小时 ${mm} 分钟`; + }, + + /** + * 从时长输入更新结束时间 + */ + updateDurationFromInput(index) { + const examInfo = this.localConfig.examInfos[index]; + let m = parseInt(examInfo.durationMinutes); + if (isNaN(m) || m <= 0) m = 120; + if (m > 24 * 60) m = 24 * 60; + examInfo.durationMinutes = m; + + // 需要有开始时间作为基准 + if (!examInfo.startDate || !examInfo.startTime) { + // 如果尚未拆分,尝试从 start 解析 + if (examInfo.start) { + const s = new Date(examInfo.start); + if (!isNaN(s.getTime())) { + examInfo.startDate = s; + examInfo.startTime = this.formatTimeOnly(s); + } + } + } + + if (examInfo.startDate && examInfo.startTime) { + const s = new Date(examInfo.startDate); + const [sh, sm] = String(examInfo.startTime).split(":"); + s.setHours(parseInt(sh), parseInt(sm), 0, 0); + const e = new Date(s.getTime() + m * 60 * 1000); + examInfo.endDate = e; + examInfo.endTime = this.formatTimeOnly(e); + examInfo.end = this.formatDateTimeLocal(e); + examInfo.endFormatted = this.formatDisplayDateTime(e); + } + }, + /** * 格式化日期时间为datetime-local输入格式 */ @@ -1027,6 +1693,38 @@ export default { // 更新相关字段 examInfo.start = this.formatDateTimeLocal(date); examInfo.startFormatted = this.formatDisplayDateTime(date); + + // 根据已有时长或默认2小时自动更新结束时间 + let durationMinutes = parseInt(examInfo.durationMinutes); + if (isNaN(durationMinutes) || durationMinutes <= 0 || durationMinutes > 24 * 60) { + // 若未设定有效时长,则尝试根据已设结束时间计算 + try { + let existingEndDate = null; + if (examInfo.endDate && examInfo.endTime) { + existingEndDate = new Date(examInfo.endDate); + const [eh, em] = String(examInfo.endTime).split(":"); + existingEndDate.setHours(parseInt(eh), parseInt(em), 0, 0); + } else if (examInfo.end) { + existingEndDate = new Date(examInfo.end); + } + if (existingEndDate && !isNaN(existingEndDate.getTime())) { + const diff = Math.round((existingEndDate.getTime() - date.getTime()) / (1000 * 60)); + if (diff > 0 && diff <= 24 * 60) { + durationMinutes = diff; + } + } + } catch (_) {} + } + if (isNaN(durationMinutes) || durationMinutes <= 0 || durationMinutes > 24 * 60) { + durationMinutes = 120; + } + + const newEnd = new Date(date.getTime() + durationMinutes * 60 * 1000); + examInfo.endDate = newEnd; + examInfo.endTime = this.formatTimeOnly(newEnd); + examInfo.end = this.formatDateTimeLocal(newEnd); + examInfo.endFormatted = this.formatDisplayDateTime(newEnd); + examInfo.durationMinutes = durationMinutes; }, /** @@ -1044,6 +1742,19 @@ export default { // 更新相关字段 examInfo.end = this.formatDateTimeLocal(date); examInfo.endFormatted = this.formatDisplayDateTime(date); + + // 同步考试时长 + try { + if (examInfo.startDate && examInfo.startTime) { + const s = new Date(examInfo.startDate); + const [sh, sm] = String(examInfo.startTime).split(":"); + s.setHours(parseInt(sh), parseInt(sm), 0, 0); + const diff = Math.round((date.getTime() - s.getTime()) / (1000 * 60)); + if (diff > 0 && diff <= 24 * 60) { + examInfo.durationMinutes = diff; + } + } + } catch (_) {} }, /** @@ -1194,6 +1905,23 @@ export default { background-color: rgba(var(--v-theme-primary), 0.08) !important; } +.bg-success-lighten-5 { + background-color: rgba(var(--v-theme-success), 0.08) !important; +} + +.bg-error-lighten-5 { + background-color: rgba(var(--v-theme-error), 0.08) !important; +} + +/* 科目编辑项悬停效果 */ +.hover-highlight { + transition: background-color 0.2s ease; +} + +.hover-highlight:hover { + background-color: rgba(var(--v-theme-primary), 0.05); +} + .v-btn-toggle .v-btn:first-child { border-top-left-radius: 8px; border-bottom-left-radius: 8px; diff --git a/src/components/HitokotoCard.vue b/src/components/HitokotoCard.vue index ea0a00d..82f457a 100644 --- a/src/components/HitokotoCard.vue +++ b/src/components/HitokotoCard.vue @@ -24,6 +24,19 @@ import { SettingsManager, watchSettings } from '@/utils/settings' import dataProvider from '@/utils/dataProvider' import axios from 'axios' +import { Base64 } from 'js-base64' + +// 全局敏感词列表,强制生效。 +const GLOBAL_SENSITIVE_WORDS_ENCODED = [ + '6IO4', + '5Lmz', + '6JCd6I6J', + '5rer', + '5aW4', +] + +// 解码敏感词列表 +const GLOBAL_SENSITIVE_WORDS = GLOBAL_SENSITIVE_WORDS_ENCODED.map(word => Base64.decode(word)) export default { name: 'HitokotoCard', @@ -123,10 +136,7 @@ export default { } } else if (source === 'jinrishici') { if (this.kvConfig.jinrishiciToken) { - const res = await axios.get('https://v2.jinrishici.com/sentence', { - headers: { - 'X-User-Token': this.kvConfig.jinrishiciToken - } + const res = await axios.get('https://v2.jinrishici.com/one.json?client=npm-sdk/1.0&X-User-Token='+encodeURIComponent(this.kvConfig.jinrishiciToken), { }) if (res.data.status === 'success') { data = res.data.data @@ -145,8 +155,9 @@ export default { } if (content) { - // Sensitive word check - const hasSensitiveWord = this.kvConfig.sensitiveWords.some(word => content.includes(word)) + // Sensitive word check (global + KV) + const combinedWords = [...GLOBAL_SENSITIVE_WORDS, ...this.kvConfig.sensitiveWords] + const hasSensitiveWord = combinedWords.some(word => word && content.includes(word)) if (hasSensitiveWord) { // Retry this.loading = false diff --git a/src/components/HitokotoSettings.vue b/src/components/HitokotoSettings.vue index c6da0c8..26555e7 100644 --- a/src/components/HitokotoSettings.vue +++ b/src/components/HitokotoSettings.vue @@ -61,6 +61,29 @@ /> + +
+ + 测试今日诗词接口 + + + {{ testMessage }} + +
+
+ + + +
+ 已启用的数据源将在获取一言时随机尝试,直到成功获取内容为止。
+ 敏感词过滤会将包含任意敏感词的句子过滤掉,避免显示不当内容。
+
+
正在同步配置...
+ + + + + + + + + + Token + + {{ testResultData.data.token }} + + + + + + + + + + IP 地址 + {{ testResultData.data.ip }} + + + + + + 地区 + {{ testResultData.data.region }} + + + + + + + + + + + +
+ +
+
温度
+
{{ testResultData.data.weatherData.temperature }}°C
+
+
+
+ +
+ +
+
天气
+
{{ testResultData.data.weatherData.weather }}
+
+
+
+ +
+ +
+
湿度
+
{{ testResultData.data.weatherData.humidity }}%
+
+
+
+ +
+ +
+
风向/风力
+
+ {{ testResultData.data.weatherData.windDirection }} {{ testResultData.data.weatherData.windPower }}级 +
+
+
+
+ +
+ +
+
PM2.5
+
{{ testResultData.data.weatherData.pm25 }}
+
+
+
+ +
+ +
+
能见度
+
{{ testResultData.data.weatherData.visibility }}
+
+
+
+
+
+ + + +
+
环境标签
+
+ + {{ tag }} + +
+
+ + + + + + + 北京时间: {{ new Date(testResultData.data.beijingTime).toLocaleString() }} + + +
+
+ + +
+
@@ -102,7 +280,13 @@ export default { sensitiveWords: '', jinrishiciToken: null }, - loading: false + loading: false, + testLoading: false, + testMessage: '', + testColor: 'info', + testResultDialog: false, + testResultData: null, + enableCloudSensitiveWords: true } }, mounted() { @@ -152,6 +336,39 @@ export default { } finally { this.loading = false } + }, + async testJinrishici() { + this.testLoading = true + this.testMessage = '' + this.testColor = 'info' + try { + const headers = {} + if (this.kvConfig.jinrishiciToken) { + headers['X-User-Token'] = this.kvConfig.jinrishiciToken + } + const res = await axios.get('https://v2.jinrishici.com/info?X-User-Token='+encodeURIComponent(this.kvConfig.jinrishiciToken)) + if (res.data && res.data.status === 'success') { + this.testResultData = res.data + this.testResultDialog = true + + const token = res.data.data?.token + const region = res.data.data?.region + const consistent = this.kvConfig.jinrishiciToken ? token === this.kvConfig.jinrishiciToken : true + this.testColor = consistent ? 'success' : 'warning' + this.testMessage = consistent + ? `接口正常,Token 一致:${token}${region ? `,地区:${region}` : ''}` + : `接口返回 Token 与当前设置不一致:${token}${region ? `,地区:${region}` : ''}` + } else { + this.testColor = 'error' + this.testMessage = '接口返回非 success,请检查网络或 Token 配置。' + } + } catch (e) { + console.error('Failed to test jinrishici info', e) + this.testColor = 'error' + this.testMessage = '接口测试失败,请检查网络或 Token。' + } finally { + this.testLoading = false + } } } } diff --git a/src/components/RelativeTimeDisplay.vue b/src/components/RelativeTimeDisplay.vue new file mode 100644 index 0000000..5b20e70 --- /dev/null +++ b/src/components/RelativeTimeDisplay.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/components/home/ConciseExamCard.vue b/src/components/home/ConciseExamCard.vue new file mode 100644 index 0000000..9802af0 --- /dev/null +++ b/src/components/home/ConciseExamCard.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/components/home/ExamScheduleCard.vue b/src/components/home/ExamScheduleCard.vue new file mode 100644 index 0000000..ab93c38 --- /dev/null +++ b/src/components/home/ExamScheduleCard.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/components/home/HomeActions.vue b/src/components/home/HomeActions.vue index d0567b3..f517616 100644 --- a/src/components/home/HomeActions.vue +++ b/src/components/home/HomeActions.vue @@ -24,16 +24,26 @@ > 随机点名
- - 考试看板 - + + 考试看板 + + +
+ +
+ +
+ import HitokotoCard from "@/components/HitokotoCard.vue"; +import ConciseExamCard from "@/components/home/ConciseExamCard.vue"; export default { name: "HomeworkGrid", components: { HitokotoCard, + ConciseExamCard, }, props: { sortedItems: { diff --git a/src/pages/examschedule.vue b/src/pages/examschedule.vue index 7e6ea86..e06f101 100644 --- a/src/pages/examschedule.vue +++ b/src/pages/examschedule.vue @@ -305,7 +305,74 @@ export default { }, methods: { /** - * 初始化示例数据(仅在首次访问时) + * 根据当前日期推算考试类型 + * @returns {Object} { examName: string, message: string } + */ + inferExamType() { + const now = new Date() + const month = now.getMonth() + 1 // 1-12 + const day = now.getDate() + + // 中国学校常见时间节点 + // 春季学期: 2月中旬-7月初 (寒假结束-暑假开始) + // 秋季学期: 9月初-次年1月底 (暑假结束-寒假开始) + + let examName = '新考试' + let message = '请保持卷面整洁,字迹清晰,遵守考场纪律,诚信应考。\n听到终考铃声时,请立即起立并停止作答。' + + // 秋季学期 (9-1月) + if (month >= 9 || month <= 1) { + if (month === 9 && day <= 15) { + // 9月初 - 开学考试 + examName = '开学摸底考试' + } else if (month === 9 && day > 15) { + // 9月中下旬 - 第一次月考 + examName = '第一次月考' + } else if (month === 10) { + // 10月 - 第二次月考 + examName = '第二次月考' + } else if (month === 11 && day <= 20) { + // 11月上中旬 - 期中考试 + examName = '期中考试' + } else if (month === 11 && day > 20) { + // 11月下旬 - 第三次月考 + examName = '第三次月考' + } else if (month === 12) { + // 12月 - 第四次月考 + examName = '第四次月考' + } else if (month === 1 ) { + // 1月上旬 - 期末考试 + examName = '期末考试' + } + } + // 春季学期 (2-7月) + else if (month >= 2 && month <= 7) { + if (month === 2 || (month === 3 && day <= 10)) { + // 2月-3月初 - 开学考试 + examName = '开学摸底考试' + } else if (month === 3 && day > 10) { + // 3月中下旬 - 第一次月考 + examName = '第一次月考' + } else if (month === 4 && day <= 25) { + // 4月 - 期中考试 + examName = '期中考试' + } else if (month === 4 && day > 25) { + // 4月底 - 第二次月考 + examName = '第二次月考' + } else if (month === 5) { + // 5月 - 第三次月考 + examName = '第三次月考' + } else if (month === 6 || month === 7) { + // 6月上中旬 - 期末考试 + examName = '期末考试' + } + } + + return { examName, message } + }, + + /** + * 初始化示例数据(仅在首次访问时) */ async initializeExampleData() { const exampleConfigs = [ @@ -417,15 +484,37 @@ export default { async createNewConfig() { const newId = Date.now().toString() + // 获取明天早上8点的时间 + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + tomorrow.setHours(8, 0, 0, 0) + + // 获取结束时间(开始时间 + 2小时) + const endTime = new Date(tomorrow) + endTime.setHours(endTime.getHours() + 2) + + // 格式化时间为 YYYY/MM/DD HH:mm + const formatTime = (date) => { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}/${month}/${day} ${hours}:${minutes}` + } + + // 自动推算考试类型 + const examTypeInfo = this.inferExamType() + const defaultConfig = { - examName: '新考试配置', - message: '请编辑此配置', - room: getSetting('server.classNumber') || '待定', + examName: examTypeInfo.examName, + message: examTypeInfo.message, + room: getSetting('server.classNumber') || '', examInfos: [ { - name: '科目1', - start: '2025/08/29 16:27', - end: '2025/08/29 17:27' + name: '语文', + start: formatTime(tomorrow), + end: formatTime(endTime) } ] } diff --git a/src/pages/index.vue b/src/pages/index.vue index f48021a..f6324df 100644 --- a/src/pages/index.vue +++ b/src/pages/index.vue @@ -147,6 +147,7 @@ @open-dialog="openDialog" @open-attendance="setAttendanceArea" @disabled-click="handleDisabledClick" + @open-exam-detail="openExamDetail" /> + + + +
+ 检测到未来两天内有以下考试: + + {{ exam.examName }} + +
+ +
@@ -298,6 +333,80 @@
+ + + + + + 编辑考试配置 + + + + + + + + + + 移除卡片 + + + + 关闭 + + + + + + + + + 预览考试看板 + + + + + + + +
+ 暂无考试配置 +
+
+ + + 关闭 + +
+
@@ -331,8 +440,11 @@ 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 ExamScheduleCard from "@/components/home/ExamScheduleCard.vue"; +import ExamConfigEditor from "@/components/ExamConfigEditor.vue"; import HitokotoCard from "@/components/HitokotoCard.vue"; import dataProvider from "@/utils/dataProvider"; +import { useExamStore } from "@/stores/examStore"; import { getSetting, watchSettings, @@ -372,10 +484,13 @@ export default { AttendanceManagementDialog, HomeworkGrid, HomeActions, + ExamScheduleCard, + ExamConfigEditor, }, setup() { const { mobile } = useDisplay(); - return { mobile }; + const examStore = useExamStore(); + return { mobile, examStore }; }, data() { const defaultSubjects = [ @@ -392,6 +507,11 @@ export default { ]; return { + // examCards: [], // Removed + showAddExamDialog: false, + showExamDetailDialog: false, + selectedExamId: null, + upcomingExams: [], dataKey: "", provider: "", useDisplay: useDisplay, @@ -545,6 +665,23 @@ export default { }); } + // 添加考试卡片 + for (const key in this.state.boardData.homework) { + if (key.startsWith('exam-')) { + const card = this.state.boardData.homework[key]; + items.push({ + key: key, + name: '考试安排', + type: 'exam', + data: { + examId: card.examId, + }, + order: -100, // Ensure they appear at the top + rowSpan: 200 // Estimated height + }); + } + } + // 添加作业卡片 for (const subject of this.state.availableSubjects) { const subjectKey = subject.name; @@ -713,6 +850,13 @@ export default { return onHome && isKv && (!token || token === ""); }, // 是否显示紧急通知测试按钮(仅教师和课堂令牌) + hasExamCard() { + for (const key in this.state.boardData.homework) { + if (key.startsWith('exam-')) return true; + } + return false; + }, + shouldShowUrgentTestButton() { // 检查是否使用 KV 服务器 const provider = getSetting("server.provider"); @@ -1062,6 +1206,102 @@ export default { this.state.classNumber = classNum; } await Promise.all([this.downloadData(), this.loadConfig()]); + + // Load exam data + await this.examStore.fetchExamList(); + // Preload details for list items to show names in dialog + for (const exam of this.examStore.examList) { + this.examStore.fetchExam(exam.id); + } + + this.checkUpcomingExams(); + // this.loadExamCards(); // Removed + }, + + async checkUpcomingExams() { + this.upcomingExams = await this.examStore.getUpcomingExams(); + }, + + loadExamCards() { + // No longer needed as exam cards are part of boardData + }, + + saveExamCards() { + // No longer needed + }, + + addExamCard(examId, forceAdd = false, skipSave = false) { + const key = `exam-${examId}`; + if (!forceAdd && this.state.boardData.homework[key]) { + delete this.state.boardData.homework[key]; + } else { + this.state.boardData.homework[key] = { + type: 'exam', + examId: examId, + name: '考试安排', + content: '' // Placeholder + }; + } + this.state.synced = false; + if (!skipSave) { + this.trySave(true); + } + }, + + openExamDetail(examId) { + this.selectedExamId = examId; + this.showExamDetailDialog = true; + }, + + removeCurrentExamCard() { + if (this.selectedExamId) { + this.addExamCard(this.selectedExamId); // Toggle off + this.showExamDetailDialog = false; + } + }, + + async onExamConfigSaved() { + if (this.selectedExamId) { + // Force refresh the exam data in store + // We need to clear the cache first or force fetch + // The store implementation checks loadingDetails[id] but not if it's already loaded? + // Actually fetchExam checks if (this.exams[id]) return this.exams[id] + // So we need to manually clear it or add a force parameter to fetchExam + + // Simple hack: clear the entry in store + delete this.examStore.exams[this.selectedExamId]; + await this.examStore.fetchExam(this.selectedExamId); + this.$message.success("保存成功", "考试配置已更新"); + } + }, + + onExamConfigDeleted() { + this.removeCurrentExamCard(); + this.$message.success("删除成功", "考试配置已删除"); + }, + + isExamCardAdded(examId) { + return !!this.state.boardData.homework[`exam-${examId}`]; + }, + + removeExamCard(index) { + // Deprecated + }, + + addAllUpcomingExams() { + let addedCount = 0; + for (const exam of this.upcomingExams) { + if (!this.isExamCardAdded(exam.id)) { + this.addExamCard(exam.id, true, true); // skipSave = true + addedCount++; + } + } + if (addedCount > 0) { + this.trySave(true); // 统一保存一次 + this.$message.success('添加成功', `已添加 ${addedCount} 个考试安排`); + } else { + this.$message.info('提示', '所有考试已添加'); + } }, async downloadData(forceClear = false) { diff --git a/src/stores/examStore.js b/src/stores/examStore.js new file mode 100644 index 0000000..1476e07 --- /dev/null +++ b/src/stores/examStore.js @@ -0,0 +1,80 @@ +import { defineStore } from 'pinia' +import dataProvider from '@/utils/dataProvider' + +export const useExamStore = defineStore('exam', { + state: () => ({ + examList: [], // List of exam IDs + exams: {}, // Map of ID -> Exam Details + loadingList: false, + loadingDetails: {}, // Map of ID -> boolean + }), + + actions: { + async fetchExamList() { + if (this.loadingList) return + this.loadingList = true + try { + const response = await dataProvider.loadData('es_list') + if (Array.isArray(response)) { + this.examList = response + } else { + this.examList = [] + } + } catch (error) { + console.error('Failed to load exam list:', error) + } finally { + this.loadingList = false + } + }, + + async fetchExam(id) { + if (this.exams[id]) return this.exams[id] // Return cached if available + if (this.loadingDetails[id]) return // Prevent duplicate requests + + this.loadingDetails[id] = true + try { + const response = await dataProvider.loadData(`es_${id}`) + if (response) { + this.exams[id] = response + } + return response + } catch (error) { + console.error(`Failed to load exam details for ${id}:`, error) + } finally { + this.loadingDetails[id] = false + } + }, + + async getUpcomingExams(limit = 25) { + await this.fetchExamList() + + const upcoming = [] + const now = new Date() + const twoDaysLater = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000) + + // Process up to 'limit' exams from the list + const examsToCheck = this.examList.slice(0, limit) + + for (const item of examsToCheck) { + let exam = this.exams[item.id] + if (!exam) { + exam = await this.fetchExam(item.id) + } + + if (exam && exam.examInfos && Array.isArray(exam.examInfos)) { + // Check if any subject in this exam starts within the next 2 days + const hasUpcoming = exam.examInfos.some(info => { + const start = new Date(info.start) + return start >= now && start <= twoDaysLater + }) + + if (hasUpcoming) { + upcoming.push({ id: item.id, ...exam }) + } + } + } + + return upcoming + } + } +})