feat: Enhance exam management features and add sensitive word filtering

- Implement global sensitive word filtering in HitokotoCard component.
- Add a test button for Jinrishici API in HitokotoSettings component.
- Introduce ConciseExamCard and ExamScheduleCard components for better exam display.
- Add functionality to create and manage exam configurations in examschedule.vue.
- Implement upcoming exam notifications in the main index page.
- Create a new exam store for managing exam data and fetching exam details.
- Add RelativeTimeDisplay component for displaying relative time in a user-friendly format.
This commit is contained in:
Sunwuyuan
2025-12-13 20:42:12 +08:00
parent d2efa19107
commit 61d8392d59
11 changed files with 1865 additions and 149 deletions
+96 -7
View File
@@ -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)
}
]
}
+241 -1
View File
@@ -147,6 +147,7 @@
@open-dialog="openDialog"
@open-attendance="setAttendanceArea"
@disabled-click="handleDisabledClick"
@open-exam-detail="openExamDetail"
/>
<home-actions
@@ -164,7 +165,41 @@
@open-random-picker="openRandomPicker"
@toggle-fullscreen="toggleFullscreen"
@add-test-card="addTestCard"
@add-exam-card="showAddExamDialog = true"
/>
<!-- 推荐添加考试提示 -->
<v-alert
v-if="upcomingExams.length > 0 && !hasExamCard"
class="mt-4"
color="info"
variant="tonal"
closable
icon="mdi-calendar-clock"
title="近期有考试安排"
>
<div class="d-flex align-center flex-wrap">
<span class="mr-2">检测到未来两天内有以下考试:</span>
<v-chip
v-for="exam in upcomingExams"
:key="exam.id"
size="small"
class="mr-1 mb-1"
color="primary"
>
{{ exam.examName }}
</v-chip>
</div>
<template #append>
<v-btn
color="primary"
variant="text"
@click="addAllUpcomingExams"
>
一键添加
</v-btn>
</template>
</v-alert>
</v-container>
<!-- 出勤统计区域 -->
@@ -298,6 +333,80 @@
</v-card-actions>
</v-card>
</v-dialog>
<!-- 考试详情/编辑对话框 -->
<v-dialog v-model="showExamDetailDialog" persistent fullscreen>
<v-card v-if="selectedExamId">
<v-card-title class="d-flex align-center pa-4">
编辑考试配置
<v-spacer></v-spacer>
<v-btn icon="mdi-close" variant="text" @click="showExamDetailDialog = false"></v-btn>
</v-card-title>
<v-card-text class="pa-4" style="max-height: 70vh; overflow-y: auto;">
<exam-config-editor
:config-id="selectedExamId"
:dialog-mode="true"
@saved="onExamConfigSaved"
@deleted="onExamConfigDeleted"
/>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-btn
color="error"
prepend-icon="mdi-delete"
variant="tonal"
@click="removeCurrentExamCard"
>
移除卡片
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="text"
@click="showExamDetailDialog = false"
>
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 添加考试卡片对话框 -->
<v-dialog v-model="showAddExamDialog" max-width="500">
<v-card>
<v-card-title class="text-h6">预览考试看板</v-card-title>
<v-card-text>
<v-list v-if="examStore.examList.length > 0">
<v-list-item
v-for="exam in examStore.examList"
:key="exam.id"
:title="examStore.exams[exam.id]?.examName || exam.id"
:subtitle="exam.id"
@click="addExamCard(exam.id)"
>
<template #prepend>
<v-icon color="primary">mdi-calendar-text</v-icon>
</template>
<template #append>
<v-btn
:icon="isExamCardAdded(exam.id) ? 'mdi-check' : 'mdi-plus'"
:color="isExamCardAdded(exam.id) ? 'success' : 'grey'"
variant="text"
></v-btn>
</template>
</v-list-item>
</v-list>
<div v-else class="text-center py-4 text-grey">
暂无考试配置
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="showAddExamDialog = false">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 通知详情对话框 -->
<v-dialog v-model="notificationDetailDialog" max-width="600">
<v-card v-if="currentNotification">
@@ -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) {