1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-02-04 07:53:11 +00:00

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
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
11 changed files with 1865 additions and 149 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -61,6 +61,29 @@
/>
</v-list-item>
<v-list-item v-if="kvConfig.sources.includes('jinrishici')">
<div class="d-flex flex-column flex-sm-row align-center w-100">
<v-btn
:loading="testLoading"
color="primary"
variant="outlined"
class="mr-sm-4 mb-2 mb-sm-0"
@click="testJinrishici"
>
测试今日诗词接口
</v-btn>
<v-alert
v-if="testMessage"
:type="testColor"
density="comfortable"
border="start"
class="flex-grow-1"
>
{{ testMessage }}
</v-alert>
</div>
</v-list-item>
<v-list-item>
<v-textarea
v-model="kvConfig.sensitiveWords"
@ -74,12 +97,167 @@
@change="saveKvSettings"
/>
</v-list-item>
<v-list-item>
<v-checkbox
label="启用云端敏感词列表"
hide-details
v-model="enableCloudSensitiveWords"
density="compact"
disabled
class="mb-2"
/>
<div class="text-caption text-grey">
已启用的数据源将在获取一言时随机尝试直到成功获取内容为止<br/>
敏感词过滤会将包含任意敏感词的句子过滤掉避免显示不当内容<br/>
</div>
</v-list-item>
<div v-if="loading" class="text-center pb-4">
<v-progress-circular indeterminate size="24" color="primary" />
<span class="ml-2 text-caption">正在同步配置...</span>
</div>
</setting-group>
<v-dialog v-model="testResultDialog" max-width="600">
<v-card v-if="testResultData" class="rounded-lg">
<v-card-text class="pa-0">
<v-list lines="two" class="py-0">
<v-list-item class="px-4 py-3">
<template v-slot:prepend>
<v-avatar color="primary" variant="tonal" class="mr-2">
<v-icon icon="mdi-key-variant" />
</v-avatar>
</template>
<v-list-item-title class="text-subtitle-2 font-weight-bold mb-1">Token</v-list-item-title>
<v-list-item-subtitle class="text-body-2 text-high-emphasis" style="word-break: break-all;">
{{ testResultData.data.token }}
</v-list-item-subtitle>
</v-list-item>
<v-divider />
<v-row no-gutters>
<v-col cols="6">
<v-list-item class="px-4 py-2">
<template v-slot:prepend>
<v-icon icon="mdi-ip-network" color="grey-darken-1" class="mr-2" />
</template>
<v-list-item-title class="text-caption text-grey-darken-1">IP 地址</v-list-item-title>
<v-list-item-subtitle class="text-body-2">{{ testResultData.data.ip }}</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="6">
<v-list-item class="px-4 py-2">
<template v-slot:prepend>
<v-icon icon="mdi-map-marker-radius" color="grey-darken-1" class="mr-2" />
</template>
<v-list-item-title class="text-caption text-grey-darken-1">地区</v-list-item-title>
<v-list-item-subtitle class="text-body-2">{{ testResultData.data.region }}</v-list-item-subtitle>
</v-list-item>
</v-col>
</v-row>
<v-divider />
<v-container class="px-4 py-3">
<v-row dense>
<v-col cols="6" sm="4">
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-thermometer" color="orange" class="mr-2" />
<div>
<div class="text-caption text-grey">温度</div>
<div class="text-body-1 font-weight-medium">{{ testResultData.data.weatherData.temperature }}°C</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-weather-cloudy" color="blue-grey" class="mr-2" />
<div>
<div class="text-caption text-grey">天气</div>
<div class="text-body-1 font-weight-medium">{{ testResultData.data.weatherData.weather }}</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-water-percent" color="blue" class="mr-2" />
<div>
<div class="text-caption text-grey">湿度</div>
<div class="text-body-1 font-weight-medium">{{ testResultData.data.weatherData.humidity }}%</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-weather-windy" color="teal" class="mr-2" />
<div>
<div class="text-caption text-grey">风向/风力</div>
<div class="text-body-2 font-weight-medium">
{{ testResultData.data.weatherData.windDirection }} {{ testResultData.data.weatherData.windPower }}
</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-blur" color="grey" class="mr-2" />
<div>
<div class="text-caption text-grey">PM2.5</div>
<div class="text-body-1 font-weight-medium">{{ testResultData.data.weatherData.pm25 }}</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-eye" color="indigo" class="mr-2" />
<div>
<div class="text-caption text-grey">能见度</div>
<div class="text-body-1 font-weight-medium">{{ testResultData.data.weatherData.visibility }}</div>
</div>
</div>
</v-col>
</v-row>
</v-container>
<v-divider />
<div class="px-4 py-3">
<div class="text-caption text-grey mb-2">环境标签</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="tag in testResultData.data.tags"
:key="tag"
size="small"
color="primary"
variant="tonal"
class="mr-1 mb-1"
>
{{ tag }}
</v-chip>
</div>
</div>
<v-divider />
<v-list-item class="px-4 py-2">
<template v-slot:prepend>
<v-icon icon="mdi-clock-outline" size="small" class="mr-2" />
</template>
<v-list-item-title class="text-caption text-grey-darken-1">
北京时间: {{ new Date(testResultData.data.beijingTime).toLocaleString() }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>
@ -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
}
}
}
}

View File

@ -0,0 +1,56 @@
<template>
<span>{{ displayTime }}</span>
</template>
<script>
export default {
name: 'RelativeTimeDisplay',
props: {
time: {
type: [String, Date, Number],
required: true
}
},
computed: {
displayTime() {
if (!this.time) return ''
const date = new Date(this.time)
const now = new Date()
// Reset hours to compare dates only
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const n = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffTime = d.getTime() - n.getTime()
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24))
if (diffDays === 0) return '今天'
if (diffDays === 1) return '明天'
if (diffDays === 2) return '后天'
if (diffDays === -1) return '昨天'
if (diffDays === -2) return '前天'
// Check if in same week (assuming Monday start)
const nDay = n.getDay() || 7 // 1-7
// Start of current week for n
const nStartOfWeek = new Date(n)
nStartOfWeek.setDate(n.getDate() - nDay + 1)
// End of current week for n
const nEndOfWeek = new Date(n)
nEndOfWeek.setDate(n.getDate() + (7 - nDay))
if (d >= nStartOfWeek && d <= nEndOfWeek) {
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return weekDays[date.getDay()]
}
// Default format MD
const month = date.getMonth() + 1
const day = date.getDate()
return `${month}${day}`
}
}
}
</script>

View File

@ -0,0 +1,161 @@
<template>
<v-card
border
class="fill-height d-flex flex-column cursor-pointer hover-elevation"
elevation="0"
@click="$emit('click')"
>
<v-card-title
class="d-flex align-center py-2 px-3 bg-primary-lighten-5 text-subtitle-1 font-weight-bold"
>
<span class="text-truncate">{{ exam?.examName || "加载中..." }}</span>
</v-card-title>
<v-card-text class="flex-grow-1 pa-4 overflow-y-auto" :style="contentStyle">
<div v-if="loading" class="d-flex justify-center align-center py-4">
<v-progress-circular
indeterminate
size="24"
color="primary"
></v-progress-circular>
</div>
<template v-else-if="exam">
<!--<div v-if="exam.message" class="text-caption text-grey mb-2 px-1">
{{ exam.message }}
</div>-->
<div class="d-flex flex-column">
<div v-for="(group, gIndex) in groupedExamInfos" :key="gIndex" class="mb-3">
<div class="text-subtitle-2 font-weight-bold text-primary mb-1">
<RelativeTimeDisplay :time="group.date" />
</div>
<div
v-for="(info, index) in group.infos"
:key="index"
class="d-flex align-center justify-space-between py-1 border-b-sm"
:class="{
'border-none': index === group.infos.length - 1,
'text-grey': isPast(info.end),
}"
>
<div class="font-weight-bold mr-2" style="font-size: 1.1em">
{{ info.name }}
</div>
<div
class="font-weight-medium text-grey-darken-2"
style="font-size: 0.85em"
>
{{ formatTimeOnly(info.start) }} -
{{ formatTimeOnly(info.end) }}
</div>
</div>
</div>
</div>
</template>
<div v-else class="text-center text-caption text-grey py-2">无法加载</div>
</v-card-text>
</v-card>
</template>
<script>
import { useExamStore } from "@/stores/examStore";
import { mapState, mapActions } from "pinia";
import RelativeTimeDisplay from "@/components/RelativeTimeDisplay.vue";
export default {
name: "ConciseExamCard",
components: {
RelativeTimeDisplay,
},
props: {
examId: {
type: String,
required: true,
},
contentStyle: {
type: Object,
default: () => ({}),
},
readonly: {
type: Boolean,
default: false,
},
},
computed: {
...mapState(useExamStore, ["exams", "loadingDetails"]),
exam() {
return this.exams[this.examId];
},
loading() {
return this.loadingDetails[this.examId];
},
groupedExamInfos() {
if (!this.exam || !this.exam.examInfos) return [];
const sortedInfos = [...this.exam.examInfos].sort(
(a, b) => new Date(a.start) - new Date(b.start)
);
const groups = [];
let currentGroup = null;
sortedInfos.forEach((info) => {
const date = new Date(info.start);
const dateKey = date.toDateString();
if (!currentGroup || currentGroup.key !== dateKey) {
currentGroup = {
key: dateKey,
date: info.start,
infos: [],
};
groups.push(currentGroup);
}
currentGroup.infos.push(info);
});
return groups;
},
},
mounted() {
this.fetchExam(this.examId);
},
methods: {
...mapActions(useExamStore, ["fetchExam"]),
formatTimeOnly(timeStr) {
if (!timeStr) return "";
try {
const date = new Date(timeStr);
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
} catch (e) {
return "";
}
},
isPast(timeStr) {
if (!timeStr) return false;
return new Date(timeStr) < new Date();
},
},
};
</script>
<style scoped>
.gap-1 {
gap: 0px;
}
.border-b-sm {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.border-none {
border-bottom: none;
}
.hover-elevation {
transition: box-shadow 0.2s;
}
.hover-elevation:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
}
</style>

View File

@ -0,0 +1,113 @@
<template>
<v-card class="fill-height d-flex flex-column rounded-xl" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4 bg-primary text-white">
<v-icon class="mr-2">mdi-calendar-clock</v-icon>
<span class="text-truncate">{{ exam?.examName || '加载中...' }}</span>
<v-spacer></v-spacer>
<v-btn icon="mdi-close" variant="text" density="comfortable" @click="$emit('close')"></v-btn>
</v-card-title>
<v-card-text class="flex-grow-1 pa-4 overflow-y-auto" :style="contentStyle">
<div v-if="loading" class="d-flex justify-center align-center fill-height" style="min-height: 200px;">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<template v-else-if="exam">
<v-alert
v-if="exam.message"
color="info"
variant="tonal"
class="mb-4 text-body-1"
border="start"
density="compact"
>
{{ exam.message }}
</v-alert>
<v-list density="comfortable" class="pa-0 bg-transparent">
<v-list-item v-for="(info, index) in exam.examInfos" :key="index" class="px-0 mb-3">
<template #prepend>
<v-avatar color="primary" variant="tonal" size="large" class="mr-3 font-weight-bold elevation-1">
{{ info.name.charAt(0) }}
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold text-h6 mb-1">
{{ info.name }}
</v-list-item-title>
<v-list-item-subtitle class="text-body-1">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="success" class="mr-2">mdi-clock-start</v-icon>
<span class="font-weight-medium">{{ formatTime(info.start) }}</span>
</div>
<div class="d-flex align-center">
<v-icon size="small" color="error" class="mr-2">mdi-clock-end</v-icon>
<span class="font-weight-medium">{{ formatTime(info.end) }}</span>
</div>
</v-list-item-subtitle>
</v-list-item>
</v-list>
</template>
<div v-else class="d-flex flex-column align-center justify-center fill-height text-grey mt-4">
<v-icon size="large" class="mb-2">mdi-alert-circle-outline</v-icon>
无法加载考试信息
</div>
</v-card-text>
</v-card>
</template>
<script>
import { useExamStore } from '@/stores/examStore'
import { mapState, mapActions } from 'pinia'
export default {
name: 'ExamScheduleCard',
props: {
examId: {
type: String,
required: true
},
contentStyle: {
type: Object,
default: () => ({})
}
},
computed: {
...mapState(useExamStore, ['exams', 'loadingDetails']),
exam() {
return this.exams[this.examId]
},
loading() {
return this.loadingDetails[this.examId]
}
},
mounted() {
this.fetchExam(this.examId)
},
methods: {
...mapActions(useExamStore, ['fetchExam']),
formatTime(timeStr) {
if (!timeStr) return ''
try {
const date = new Date(timeStr)
// Format: MM-DD HH:mm
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${month}-${day} ${hours}:${minutes}`
} catch (e) {
return timeStr
}
}
}
}
</script>
<style scoped>
.v-list-item-title {
white-space: normal;
}
</style>

View File

@ -24,16 +24,26 @@
>
随机点名
</v-btn>
<v-btn
<v-btn-group
v-if="showExamScheduleButton"
class="ml-2"
color="green"
prepend-icon="mdi-calendar-check"
size="large"
@click="$router.push('/examschedule')"
variant="elevated"
divided
>
考试看板
</v-btn>
<v-btn
prepend-icon="mdi-calendar-check"
size="large"
@click="$router.push('/examschedule')"
>
考试看板
</v-btn>
<v-btn
icon="mdi-plus"
size="large"
@click="$emit('add-exam-card')"
/>
</v-btn-group>
<v-btn
v-if="showListCardButton"
class="ml-2"

View File

@ -16,6 +16,15 @@
<hitokoto-card />
</div>
<!-- 考试卡片 -->
<div v-else-if="item.type === 'exam'" style="height: 100%">
<concise-exam-card
:exam-id="item.data.examId"
:content-style="contentStyle"
@click="$emit('open-exam-detail', item.data.examId)"
/>
</div>
<!-- 出勤卡片 -->
<v-card
v-else-if="item.type === 'attendance'"
@ -185,11 +194,13 @@
<script>
import HitokotoCard from "@/components/HitokotoCard.vue";
import ConciseExamCard from "@/components/home/ConciseExamCard.vue";
export default {
name: "HomeworkGrid",
components: {
HitokotoCard,
ConciseExamCard,
},
props: {
sortedItems: {

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)
}
]
}

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) {

80
src/stores/examStore.js Normal file
View File

@ -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
}
}
})