1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-02-04 16:03:10 +00:00

Compare commits

..

No commits in common. "0d91c8844ab13fcda79dd09ed0018a9534fb9195" and "d2efa1910794fa4351625568c915d605b51ab2d6" have entirely different histories.

11 changed files with 169 additions and 2594 deletions

File diff suppressed because it is too large Load Diff

View File

@ -24,19 +24,6 @@
import { SettingsManager, watchSettings } from '@/utils/settings' import { SettingsManager, watchSettings } from '@/utils/settings'
import dataProvider from '@/utils/dataProvider' import dataProvider from '@/utils/dataProvider'
import axios from 'axios' 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 { export default {
name: 'HitokotoCard', name: 'HitokotoCard',
@ -136,7 +123,10 @@ export default {
} }
} else if (source === 'jinrishici') { } else if (source === 'jinrishici') {
if (this.kvConfig.jinrishiciToken) { if (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), { const res = await axios.get('https://v2.jinrishici.com/sentence', {
headers: {
'X-User-Token': this.kvConfig.jinrishiciToken
}
}) })
if (res.data.status === 'success') { if (res.data.status === 'success') {
data = res.data.data data = res.data.data
@ -155,9 +145,8 @@ export default {
} }
if (content) { if (content) {
// Sensitive word check (global + KV) // Sensitive word check
const combinedWords = [...GLOBAL_SENSITIVE_WORDS, ...this.kvConfig.sensitiveWords] const hasSensitiveWord = this.kvConfig.sensitiveWords.some(word => content.includes(word))
const hasSensitiveWord = combinedWords.some(word => word && content.includes(word))
if (hasSensitiveWord) { if (hasSensitiveWord) {
// Retry // Retry
this.loading = false this.loading = false

View File

@ -61,29 +61,6 @@
/> />
</v-list-item> </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-list-item>
<v-textarea <v-textarea
v-model="kvConfig.sensitiveWords" v-model="kvConfig.sensitiveWords"
@ -97,167 +74,12 @@
@change="saveKvSettings" @change="saveKvSettings"
/> />
</v-list-item> </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"> <div v-if="loading" class="text-center pb-4">
<v-progress-circular indeterminate size="24" color="primary" /> <v-progress-circular indeterminate size="24" color="primary" />
<span class="ml-2 text-caption">正在同步配置...</span> <span class="ml-2 text-caption">正在同步配置...</span>
</div> </div>
</setting-group> </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> </div>
</template> </template>
@ -280,13 +102,7 @@ export default {
sensitiveWords: '', sensitiveWords: '',
jinrishiciToken: null jinrishiciToken: null
}, },
loading: false, loading: false
testLoading: false,
testMessage: '',
testColor: 'info',
testResultDialog: false,
testResultData: null,
enableCloudSensitiveWords: true
} }
}, },
mounted() { mounted() {
@ -336,39 +152,6 @@ export default {
} finally { } finally {
this.loading = false 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

@ -1,56 +0,0 @@
<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

@ -1,161 +0,0 @@
<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

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

View File

@ -16,15 +16,6 @@
<hitokoto-card /> <hitokoto-card />
</div> </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-card
v-else-if="item.type === 'attendance'" v-else-if="item.type === 'attendance'"
@ -194,13 +185,11 @@
<script> <script>
import HitokotoCard from "@/components/HitokotoCard.vue"; import HitokotoCard from "@/components/HitokotoCard.vue";
import ConciseExamCard from "@/components/home/ConciseExamCard.vue";
export default { export default {
name: "HomeworkGrid", name: "HomeworkGrid",
components: { components: {
HitokotoCard, HitokotoCard,
ConciseExamCard,
}, },
props: { props: {
sortedItems: { sortedItems: {

View File

@ -54,24 +54,6 @@
> >
新建配置 新建配置
</v-btn> </v-btn>
<v-btn
class="mr-2"
color="success"
prepend-icon="mdi-import"
variant="outlined"
@click="showImportDialog"
>
导入配置
</v-btn>
<v-btn
class="mr-2"
color="purple"
prepend-icon="mdi-brain"
variant="outlined"
@click="showAIDialog"
>
AI生成
</v-btn>
<v-btn <v-btn
:loading="loading" :loading="loading"
color="info" color="info"
@ -290,227 +272,6 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- 导入配置弹框 -->
<v-dialog v-model="importDialog" max-width="800" persistent>
<v-card>
<v-card-title class="d-flex align-center primary lighten-1 white--text py-3 px-4">
<v-icon class="mr-2" color="white">mdi-import</v-icon>
导入考试配置
<v-spacer></v-spacer>
<v-btn
color="white"
icon="mdi-close"
variant="text"
@click="closeImportDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<v-alert
v-if="importError"
border="start"
class="mb-4"
closable
type="error"
variant="tonal"
@click:close="importError = ''"
>
{{ importError }}
</v-alert>
<v-textarea
v-model="importJson"
:rules="[v => !!v || 'JSON内容不能为空']"
label="请输入JSON配置"
placeholder='{
"examName": "期末考试",
"message": "考试信息",
"room": "01",
"examInfos": [
{
"name": "语文",
"start": "2025/12/14 09:00",
"end": "2025/12/14 11:00"
}
]
}'
prepend-inner-icon="mdi-code-json"
rows="15"
variant="outlined"
></v-textarea>
<v-alert
border="start"
class="mt-2"
density="compact"
type="info"
variant="tonal"
>
<div class="text-caption">
<strong>提示:</strong>
<ul class="mt-1">
<li>日期格式支持: YYYY/MM/DD HH:mm YYYY-MM-DD HH:mm:ss</li>
<li>虚拟日期格式: 0000-00-01 (表示第1天), 0000-00-02 (第2天)...</li>
<li>如使用虚拟日期,系统会要求您指定起始日期</li>
<li>缺省字段将自动填充默认值</li>
</ul>
</div>
</v-alert>
</v-card-text>
<v-card-actions class="pa-4">
<v-btn
color="grey"
prepend-icon="mdi-close"
variant="outlined"
@click="closeImportDialog"
>
取消
</v-btn>
<v-spacer></v-spacer>
<v-btn
:disabled="!importJson"
:loading="importing"
color="success"
prepend-icon="mdi-check"
variant="outlined"
@click="processImport"
>
导入
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 日期选择弹框 -->
<v-dialog v-model="datePickerDialog" max-width="500" persistent>
<v-card>
<v-card-title class="d-flex align-center primary lighten-1 white--text py-3 px-4">
<v-icon class="mr-2" color="white">mdi-calendar</v-icon>
选择起始日期
</v-card-title>
<v-card-text class="pa-4">
<p class="mb-4 text-body-2">
检测到配置中使用了虚拟日期格式 (0000-00-XX)请选择第一天的日期系统将自动推算其他日期
</p>
<v-text-field
v-model="baseDate"
label="起始日期"
prepend-inner-icon="mdi-calendar"
type="date"
variant="outlined"
></v-text-field>
<v-alert
v-if="virtualDateInfo"
border="start"
class="mt-2"
density="compact"
type="info"
variant="tonal"
>
<div class="text-caption">
检测到 {{ virtualDateInfo.count }} 个虚拟日期跨度 {{ virtualDateInfo.span }}
</div>
</v-alert>
</v-card-text>
<v-card-actions class="pa-4">
<v-btn
color="grey"
prepend-icon="mdi-close"
variant="outlined"
@click="cancelDatePicker"
>
取消
</v-btn>
<v-spacer></v-spacer>
<v-btn
:disabled="!baseDate"
color="primary"
prepend-icon="mdi-check"
variant="outlined"
@click="confirmDatePicker"
>
确认
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- AI生成提示词弹框 -->
<v-dialog v-model="aiDialog" max-width="900" persistent>
<v-card>
<v-card-title class="d-flex align-center purple lighten-1 white--text py-3 px-4">
<v-icon class="mr-2" color="white">mdi-brain</v-icon>
AI生成考试配置
<v-spacer></v-spacer>
<v-btn
color="white"
icon="mdi-close"
variant="text"
@click="closeAIDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<v-alert
border="start"
class="mb-4"
type="info"
variant="tonal"
>
<div class="d-flex align-center">
<div>
复制下方提示词到任意AI工具如ChatGPTClaudeCopilot等描述您的考试安排AI将生成符合格式的JSON配置生成后复制JSON内容通过导入配置按钮导入即可
</div>
</div>
</v-alert>
<div class="mb-3">
<div class="d-flex justify-space-between align-center mb-2">
<h3 class="text-h6">提示词模板</h3>
<v-btn
:color="copied ? 'success' : 'primary'"
:prepend-icon="copied ? 'mdi-check' : 'mdi-content-copy'"
size="small"
variant="tonal"
@click="copyPrompt"
>
{{ copied ? '已复制' : '复制提示词' }}
</v-btn>
</div>
<v-card class="pa-4" variant="outlined">
<pre class="ai-prompt-text">{{ aiPrompt }}</pre>
</v-card>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-btn
color="grey"
prepend-icon="mdi-close"
variant="outlined"
@click="closeAIDialog"
>
关闭
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="success"
prepend-icon="mdi-import"
variant="outlined"
@click="goToImport"
>
去导入配置
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container> </v-container>
</template> </template>
@ -536,143 +297,15 @@ export default {
editingConfig: null, editingConfig: null,
newConfigName: '', newConfigName: '',
renaming: false, renaming: false,
saving: false, saving: false
//
importDialog: false,
importJson: '',
importError: '',
importing: false,
//
datePickerDialog: false,
baseDate: '',
virtualDateInfo: null,
pendingImportConfig: null,
// AI
aiDialog: false,
copied: false
} }
}, },
async mounted() { async mounted() {
await this.loadConfigs() await this.loadConfigs()
}, },
computed: {
/**
* AI生成提示词
*/
aiPrompt() {
const currentDate = new Date()
const dateStr = `${currentDate.getFullYear()}${currentDate.getMonth() + 1}${currentDate.getDate()}`
return `Your task is to generate a JSON configuration file for an exam dashboard. Based on the exam information input by the user, generate the configuration strictly following these rules.
Generation Requirements:
* Output using JSON blocks in Markdown
* Use Chinese for all text
Field Definitions:
examName (string)
* The general name of the exam
* Fill with "考试" when not provided by user
message (string)
* Exam reminder message
* Prioritize user-provided content
* When not provided by user, fill with "请保持卷面整洁,字迹清晰,遵守考场纪律,诚信应考。听到终考铃声时,请立即起立并停止作答。"
room (string)
* Exam room number
* Fill in if provided by user, otherwise use empty string ""
examInfos (array)
* Array of information for each exam session
* Each object must include:
* name: The subject or name of that exam session
* start: Start time, format "YYYY-MM-DD HH:mm:ss"
* end: End time, format "YYYY-MM-DD HH:mm:ss"
* alertTime: Minutes before exam end for reminder, fill with 15
Date and Time Handling:
* Current date: ${dateStr}
* When user provides specific dates, use actual dates
* When user does not provide dates, use virtual date format "0000-00-XX"
* XX represents day number: 01=first day, 02=second day, 03=third day...
* Time portion filled according to user description
* For multiple exams, calculate dates sequentially in order
Now please generate the exam configuration based on the above rules:`
}
},
methods: { 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() { async initializeExampleData() {
const exampleConfigs = [ const exampleConfigs = [
@ -784,37 +417,15 @@ Now please generate the exam configuration based on the above rules:`
async createNewConfig() { async createNewConfig() {
const newId = Date.now().toString() 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 = { const defaultConfig = {
examName: examTypeInfo.examName, examName: '新考试配置',
message: examTypeInfo.message, message: '请编辑此配置',
room: getSetting('server.classNumber') || '', room: getSetting('server.classNumber') || '待定',
examInfos: [ examInfos: [
{ {
name: '语文', name: '科目1',
start: formatTime(tomorrow), start: '2025/08/29 16:27',
end: formatTime(endTime) end: '2025/08/29 17:27'
} }
] ]
} }
@ -839,7 +450,8 @@ Now please generate the exam configuration based on the above rules:`
throw new Error(listResponse.error?.message || '更新列表失败') throw new Error(listResponse.error?.message || '更新列表失败')
} }
this.$message.success('新配置创建成功') this.success = '新配置创建成功'
// //
const newConfig = this.configs.find(c => c.id === newId) const newConfig = this.configs.find(c => c.id === newId)
if (newConfig) { if (newConfig) {
@ -847,7 +459,7 @@ this.$message.success('新配置创建成功')
this.editDialog = true this.editDialog = true
} }
} catch (err) { } catch (err) {
this.$message.error('创建配置失败: ' + err.message) this.error = '创建配置失败: ' + err.message
} }
}, },
@ -888,13 +500,12 @@ this.$message.success('新配置创建成功')
this.configs[configIndex].examName = this.newConfigName this.configs[configIndex].examName = this.newConfigName
} }
this.success = '配置重命名成功'
this.$message.success('配置重命名成功')
this.renameDialog = false this.renameDialog = false
this.configToRename = null this.configToRename = null
this.newConfigName = '' this.newConfigName = ''
} catch (err) { } catch (err) {
this.$message.error('重命名配置失败: ' + err.message) this.error = '重命名配置失败: ' + err.message
} finally { } finally {
this.renaming = false this.renaming = false
} }
@ -938,35 +549,31 @@ this.$message.success('新配置创建成功')
* 配置保存成功回调 * 配置保存成功回调
*/ */
onConfigSaved() { onConfigSaved() {
this.success = '配置保存成功!'
this.$message.success('配置保存成功!')
this.loadConfigs() // this.loadConfigs() //
setTimeout(() => {
this.success = ''
this.$message.success('配置保存成功!') }, 3000)
}, },
/** /**
* 配置保存错误回调 * 配置保存错误回调
*/ */
onConfigError(error) { onConfigError(error) {
this.error = error || '保存配置时发生错误'
this.$message.error(error || '保存配置时发生错误') setTimeout(() => {
this.error = ''
this.$message.error(error || '保存配置时发生错误') }, 5000)
}, },
/** /**
* 配置打开成功回调 * 配置打开成功回调
*/ */
onConfigOpened() { onConfigOpened() {
this.success = '配置已在新窗口中打开'
this.$message.success('配置已在新窗口中打开') setTimeout(() => {
this.success = ''
}, 3000)
this.$message.success('配置已在新窗口中打开')
}, },
/** /**
@ -974,400 +581,14 @@ this.$message.success('新配置创建成功')
*/ */
onConfigDeleted(result) { onConfigDeleted(result) {
if (result.success) { if (result.success) {
this.success = result.message || "配置删除成功"
this.$message.success(result.message || "配置删除成功")
// //
this.editDialog = false this.editDialog = false
// //
this.loadConfigs() this.loadConfigs()
} else { } else {
this.error = result.message || "删除失败"
this.$message.error(result.message || "删除失败")
} }
},
/**
* 显示导入对话框
*/
showImportDialog() {
this.importDialog = true
this.importJson = ''
this.importError = ''
},
/**
* 关闭导入对话框
*/
closeImportDialog() {
this.importDialog = false
this.importJson = ''
this.importError = ''
this.importing = false
},
/**
* 检测JSON中是否包含虚拟日期
* @param {Object} config - 配置对象
* @returns {Object|null} { hasVirtual: boolean, count: number, span: number }
*/
detectVirtualDates(config) {
const virtualDatePattern = /^0000-00-(\d{2})/
let hasVirtual = false
let minDay = Infinity
let maxDay = -Infinity
let count = 0
if (config.examInfos && Array.isArray(config.examInfos)) {
for (let exam of config.examInfos) {
if (exam.start) {
const match = exam.start.match(virtualDatePattern)
if (match) {
hasVirtual = true
count++
const day = parseInt(match[1])
minDay = Math.min(minDay, day)
maxDay = Math.max(maxDay, day)
}
}
if (exam.end) {
const match = exam.end.match(virtualDatePattern)
if (match) {
hasVirtual = true
const day = parseInt(match[1])
minDay = Math.min(minDay, day)
maxDay = Math.max(maxDay, day)
}
}
}
}
if (hasVirtual) {
return {
hasVirtual: true,
count,
span: maxDay - minDay + 1,
minDay,
maxDay
}
}
return null
},
/**
* 将虚拟日期转换为真实日期
* @param {string} virtualDateTime - 虚拟日期时间字符串 "0000-00-01 09:00:00"
* @param {string} baseDate - 基准日期 "2025-12-14"
* @returns {string} 真实日期时间字符串
*/
convertVirtualDate(virtualDateTime, baseDate) {
const virtualPattern = /^0000-00-(\d{2})\s+(.+)$/
const match = virtualDateTime.match(virtualPattern)
if (!match) {
return virtualDateTime //
}
const dayNum = parseInt(match[1])
const timePart = match[2]
//
const base = new Date(baseDate)
// 12+1
const target = new Date(base)
target.setDate(base.getDate() + (dayNum - 1))
// YYYY/MM/DD HH:mm
const year = target.getFullYear()
const month = String(target.getMonth() + 1).padStart(2, '0')
const day = String(target.getDate()).padStart(2, '0')
// HH:mm HH:mm:ss
const timeMatch = timePart.match(/(\d{2}):(\d{2})(?::(\d{2}))?/)
if (timeMatch) {
const hours = timeMatch[1]
const minutes = timeMatch[2]
return `${year}/${month}/${day} ${hours}:${minutes}`
}
return `${year}/${month}/${day} ${timePart}`
},
/**
* 规范化日期格式
* @param {string} dateStr - 日期字符串
* @returns {string} 规范化后的日期字符串 YYYY/MM/DD HH:mm
*/
normalizeDateFormat(dateStr) {
if (!dateStr) return ''
// YYYY/MM/DD HH:mm
if (/^\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}$/.test(dateStr)) {
return dateStr
}
// YYYY-MM-DD HH:mm:ss
const pattern1 = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?$/
const match1 = dateStr.match(pattern1)
if (match1) {
return `${match1[1]}/${match1[2]}/${match1[3]} ${match1[4]}:${match1[5]}`
}
// YYYY-MM-DD
const pattern2 = /^(\d{4})-(\d{2})-(\d{2})$/
const match2 = dateStr.match(pattern2)
if (match2) {
return `${match2[1]}/${match2[2]}/${match2[3]} 08:00`
}
return dateStr
},
/**
* 验证并补全配置数据
* @param {Object} config - 原始配置对象
* @returns {Object} 补全后的配置对象
*/
validateAndFillConfig(config) {
const examTypeInfo = this.inferExamType()
//
const filledConfig = {
examName: config.examName || examTypeInfo.examName,
message: config.message || examTypeInfo.message,
room: config.room || getSetting('server.classNumber') || '',
examInfos: []
}
// examInfos
if (!config.examInfos || !Array.isArray(config.examInfos)) {
throw new Error('配置中缺少 examInfos 数组')
}
if (config.examInfos.length === 0) {
throw new Error('examInfos 数组不能为空')
}
for (let i = 0; i < config.examInfos.length; i++) {
const exam = config.examInfos[i]
if (!exam.name) {
throw new Error(`${i + 1} 个考试缺少 name 字段`)
}
if (!exam.start) {
throw new Error(`${i + 1} 个考试缺少 start 字段`)
}
if (!exam.end) {
throw new Error(`${i + 1} 个考试缺少 end 字段`)
}
//
filledConfig.examInfos.push({
name: exam.name,
start: exam.start,
end: exam.end,
alertTime: exam.alertTime !== undefined ? exam.alertTime : 15,
materials: exam.materials || []
})
}
return filledConfig
},
/**
* 处理导入
*/
async processImport() {
this.importing = true
this.importError = ''
try {
// JSON
let config
try {
config = JSON.parse(this.importJson)
} catch (e) {
throw new Error('JSON 格式错误: ' + e.message)
}
//
const filledConfig = this.validateAndFillConfig(config)
//
const virtualInfo = this.detectVirtualDates(filledConfig)
if (virtualInfo) {
//
this.virtualDateInfo = virtualInfo
this.pendingImportConfig = filledConfig
//
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
const year = tomorrow.getFullYear()
const month = String(tomorrow.getMonth() + 1).padStart(2, '0')
const day = String(tomorrow.getDate()).padStart(2, '0')
this.baseDate = `${year}-${month}-${day}`
this.datePickerDialog = true
} else {
//
await this.finalizeImport(filledConfig)
}
} catch (err) {
this.importError = err.message
} finally {
this.importing = false
}
},
/**
* 取消日期选择
*/
cancelDatePicker() {
this.datePickerDialog = false
this.baseDate = ''
this.virtualDateInfo = null
this.pendingImportConfig = null
},
/**
* 确认日期选择
*/
async confirmDatePicker() {
if (!this.baseDate || !this.pendingImportConfig) return
try {
//
const config = JSON.parse(JSON.stringify(this.pendingImportConfig))
for (let exam of config.examInfos) {
if (exam.start && exam.start.startsWith('0000-00-')) {
exam.start = this.convertVirtualDate(exam.start, this.baseDate)
}
if (exam.end && exam.end.startsWith('0000-00-')) {
exam.end = this.convertVirtualDate(exam.end, this.baseDate)
}
}
//
this.datePickerDialog = false
this.baseDate = ''
this.virtualDateInfo = null
this.pendingImportConfig = null
//
await this.finalizeImport(config)
} catch (err) {
this.importError = '日期转换失败: ' + err.message
this.datePickerDialog = false
}
},
/**
* 完成导入保存配置
* @param {Object} config - 处理好的配置对象
*/
async finalizeImport(config) {
const newId = Date.now().toString()
//
for (let exam of config.examInfos) {
exam.start = this.normalizeDateFormat(exam.start)
exam.end = this.normalizeDateFormat(exam.end)
}
try {
//
const saveResponse = await dataProvider.saveData(`es_${newId}`, config)
if (!saveResponse) {
throw new Error(saveResponse.error?.message || '保存失败')
}
//
this.configs.push({
id: newId,
...config
})
//
const currentList = this.configs.map(c => ({id: c.id}))
const listResponse = await dataProvider.saveData('es_list', currentList)
if (!listResponse) {
throw new Error(listResponse.error?.message || '更新列表失败')
}
this.success = '配置导入成功!'
this.closeImportDialog()
//
const newConfig = this.configs.find(c => c.id === newId)
if (newConfig) {
this.editingConfig = newConfig
this.editDialog = true
}
} catch (err) {
throw new Error('保存配置失败: ' + err.message)
}
},
/**
* 显示AI生成对话框
*/
showAIDialog() {
this.aiDialog = true
this.copied = false
},
/**
* 关闭AI生成对话框
*/
closeAIDialog() {
this.aiDialog = false
this.copied = false
},
/**
* 复制提示词到剪贴板
*/
async copyPrompt() {
try {
await navigator.clipboard.writeText(this.aiPrompt)
this.copied = true
// 3
setTimeout(() => {
this.copied = false
}, 3000)
} catch (err) {
// API使
const textArea = document.createElement('textarea')
textArea.value = this.aiPrompt
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
this.copied = true
setTimeout(() => {
this.copied = false
}, 3000)
} catch (err) {
this.error = '复制失败,请手动复制'
}
document.body.removeChild(textArea)
}
},
/**
* 从AI对话框跳转到导入对话框
*/
goToImport() {
this.aiDialog = false
this.showImportDialog()
} }
} }
} }
@ -1381,23 +602,4 @@ this.$message.success('新配置创建成功')
.border-b:last-child { .border-b:last-child {
border-bottom: none; border-bottom: none;
} }
.ai-prompt-text {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
}
.ai-example-json {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre;
overflow-x: auto;
margin: 0;
color: #1976d2;
}
</style> </style>

View File

@ -147,7 +147,6 @@
@open-dialog="openDialog" @open-dialog="openDialog"
@open-attendance="setAttendanceArea" @open-attendance="setAttendanceArea"
@disabled-click="handleDisabledClick" @disabled-click="handleDisabledClick"
@open-exam-detail="openExamDetail"
/> />
<home-actions <home-actions
@ -165,41 +164,7 @@
@open-random-picker="openRandomPicker" @open-random-picker="openRandomPicker"
@toggle-fullscreen="toggleFullscreen" @toggle-fullscreen="toggleFullscreen"
@add-test-card="addTestCard" @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> </v-container>
<!-- 出勤统计区域 --> <!-- 出勤统计区域 -->
@ -333,80 +298,6 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </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-dialog v-model="notificationDetailDialog" max-width="600">
<v-card v-if="currentNotification"> <v-card v-if="currentNotification">
@ -440,11 +331,8 @@ import AttendanceSidebar from "@/components/attendance/AttendanceSidebar.vue";
import AttendanceManagementDialog from "@/components/attendance/AttendanceManagementDialog.vue"; import AttendanceManagementDialog from "@/components/attendance/AttendanceManagementDialog.vue";
import HomeworkGrid from "@/components/home/HomeworkGrid.vue"; import HomeworkGrid from "@/components/home/HomeworkGrid.vue";
import HomeActions from "@/components/home/HomeActions.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 HitokotoCard from "@/components/HitokotoCard.vue";
import dataProvider from "@/utils/dataProvider"; import dataProvider from "@/utils/dataProvider";
import { useExamStore } from "@/stores/examStore";
import { import {
getSetting, getSetting,
watchSettings, watchSettings,
@ -484,13 +372,10 @@ export default {
AttendanceManagementDialog, AttendanceManagementDialog,
HomeworkGrid, HomeworkGrid,
HomeActions, HomeActions,
ExamScheduleCard,
ExamConfigEditor,
}, },
setup() { setup() {
const { mobile } = useDisplay(); const { mobile } = useDisplay();
const examStore = useExamStore(); return { mobile };
return { mobile, examStore };
}, },
data() { data() {
const defaultSubjects = [ const defaultSubjects = [
@ -507,11 +392,6 @@ export default {
]; ];
return { return {
// examCards: [], // Removed
showAddExamDialog: false,
showExamDetailDialog: false,
selectedExamId: null,
upcomingExams: [],
dataKey: "", dataKey: "",
provider: "", provider: "",
useDisplay: useDisplay, useDisplay: useDisplay,
@ -665,23 +545,6 @@ 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) { for (const subject of this.state.availableSubjects) {
const subjectKey = subject.name; const subjectKey = subject.name;
@ -850,13 +713,6 @@ export default {
return onHome && isKv && (!token || token === ""); return onHome && isKv && (!token || token === "");
}, },
// //
hasExamCard() {
for (const key in this.state.boardData.homework) {
if (key.startsWith('exam-')) return true;
}
return false;
},
shouldShowUrgentTestButton() { shouldShowUrgentTestButton() {
// 使 KV // 使 KV
const provider = getSetting("server.provider"); const provider = getSetting("server.provider");
@ -1206,102 +1062,6 @@ export default {
this.state.classNumber = classNum; this.state.classNumber = classNum;
} }
await Promise.all([this.downloadData(), this.loadConfig()]); 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) { async downloadData(forceClear = false) {

View File

@ -1,80 +0,0 @@
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
}
}
})