mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2026-02-04 16:03:10 +00:00
Compare commits
3 Commits
d2efa19107
...
0d91c8844a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d91c8844a | ||
|
|
4eb8c74d84 | ||
|
|
61d8392d59 |
File diff suppressed because it is too large
Load Diff
@ -24,6 +24,19 @@
|
|||||||
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',
|
||||||
@ -123,10 +136,7 @@ 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/sentence', {
|
const res = await axios.get('https://v2.jinrishici.com/one.json?client=npm-sdk/1.0&X-User-Token='+encodeURIComponent(this.kvConfig.jinrishiciToken), {
|
||||||
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
|
||||||
@ -145,8 +155,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
// Sensitive word check
|
// Sensitive word check (global + KV)
|
||||||
const hasSensitiveWord = this.kvConfig.sensitiveWords.some(word => content.includes(word))
|
const combinedWords = [...GLOBAL_SENSITIVE_WORDS, ...this.kvConfig.sensitiveWords]
|
||||||
|
const hasSensitiveWord = combinedWords.some(word => word && content.includes(word))
|
||||||
if (hasSensitiveWord) {
|
if (hasSensitiveWord) {
|
||||||
// Retry
|
// Retry
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
|||||||
@ -61,6 +61,29 @@
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@ -74,12 +97,167 @@
|
|||||||
@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>
|
||||||
|
|
||||||
@ -102,7 +280,13 @@ 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() {
|
||||||
@ -152,6 +336,39 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/components/RelativeTimeDisplay.vue
Normal file
56
src/components/RelativeTimeDisplay.vue
Normal 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 M月D日
|
||||||
|
const month = date.getMonth() + 1
|
||||||
|
const day = date.getDate()
|
||||||
|
return `${month}月${day}日`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
161
src/components/home/ConciseExamCard.vue
Normal file
161
src/components/home/ConciseExamCard.vue
Normal 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>
|
||||||
113
src/components/home/ExamScheduleCard.vue
Normal file
113
src/components/home/ExamScheduleCard.vue
Normal 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>
|
||||||
@ -24,16 +24,26 @@
|
|||||||
>
|
>
|
||||||
随机点名
|
随机点名
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn-group
|
||||||
v-if="showExamScheduleButton"
|
v-if="showExamScheduleButton"
|
||||||
class="ml-2"
|
class="ml-2"
|
||||||
color="green"
|
color="green"
|
||||||
prepend-icon="mdi-calendar-check"
|
variant="elevated"
|
||||||
size="large"
|
divided
|
||||||
@click="$router.push('/examschedule')"
|
|
||||||
>
|
>
|
||||||
考试看板
|
<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-btn
|
||||||
v-if="showListCardButton"
|
v-if="showListCardButton"
|
||||||
class="ml-2"
|
class="ml-2"
|
||||||
|
|||||||
@ -16,6 +16,15 @@
|
|||||||
<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'"
|
||||||
@ -185,11 +194,13 @@
|
|||||||
|
|
||||||
<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: {
|
||||||
|
|||||||
@ -54,6 +54,24 @@
|
|||||||
>
|
>
|
||||||
新建配置
|
新建配置
|
||||||
</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"
|
||||||
@ -272,6 +290,227 @@
|
|||||||
</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工具(如ChatGPT、Claude、Copilot等),描述您的考试安排,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>
|
||||||
|
|
||||||
@ -297,15 +536,143 @@ 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 = [
|
||||||
@ -417,15 +784,37 @@ export default {
|
|||||||
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: '新考试配置',
|
examName: examTypeInfo.examName,
|
||||||
message: '请编辑此配置',
|
message: examTypeInfo.message,
|
||||||
room: getSetting('server.classNumber') || '待定',
|
room: getSetting('server.classNumber') || '',
|
||||||
examInfos: [
|
examInfos: [
|
||||||
{
|
{
|
||||||
name: '科目1',
|
name: '语文',
|
||||||
start: '2025/08/29 16:27',
|
start: formatTime(tomorrow),
|
||||||
end: '2025/08/29 17:27'
|
end: formatTime(endTime)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -450,8 +839,7 @@ export default {
|
|||||||
throw new Error(listResponse.error?.message || '更新列表失败')
|
throw new Error(listResponse.error?.message || '更新列表失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.success = '新配置创建成功'
|
this.$message.success('新配置创建成功')
|
||||||
|
|
||||||
// 直接打开编辑对话框
|
// 直接打开编辑对话框
|
||||||
const newConfig = this.configs.find(c => c.id === newId)
|
const newConfig = this.configs.find(c => c.id === newId)
|
||||||
if (newConfig) {
|
if (newConfig) {
|
||||||
@ -459,7 +847,7 @@ export default {
|
|||||||
this.editDialog = true
|
this.editDialog = true
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = '创建配置失败: ' + err.message
|
this.$message.error('创建配置失败: ' + err.message)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -500,12 +888,13 @@ export default {
|
|||||||
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.error = '重命名配置失败: ' + err.message
|
this.$message.error('重命名配置失败: ' + err.message)
|
||||||
} finally {
|
} finally {
|
||||||
this.renaming = false
|
this.renaming = false
|
||||||
}
|
}
|
||||||
@ -549,31 +938,35 @@ export default {
|
|||||||
* 配置保存成功回调
|
* 配置保存成功回调
|
||||||
*/
|
*/
|
||||||
onConfigSaved() {
|
onConfigSaved() {
|
||||||
this.success = '配置保存成功!'
|
|
||||||
|
|
||||||
|
this.$message.success('配置保存成功!')
|
||||||
this.loadConfigs() // 重新加载配置列表
|
this.loadConfigs() // 重新加载配置列表
|
||||||
setTimeout(() => {
|
|
||||||
this.success = ''
|
|
||||||
}, 3000)
|
this.$message.success('配置保存成功!')
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 配置保存错误回调
|
* 配置保存错误回调
|
||||||
*/
|
*/
|
||||||
onConfigError(error) {
|
onConfigError(error) {
|
||||||
this.error = error || '保存配置时发生错误'
|
|
||||||
setTimeout(() => {
|
this.$message.error(error || '保存配置时发生错误')
|
||||||
this.error = ''
|
|
||||||
}, 5000)
|
this.$message.error(error || '保存配置时发生错误')
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 配置打开成功回调
|
* 配置打开成功回调
|
||||||
*/
|
*/
|
||||||
onConfigOpened() {
|
onConfigOpened() {
|
||||||
this.success = '配置已在新窗口中打开'
|
|
||||||
setTimeout(() => {
|
this.$message.success('配置已在新窗口中打开')
|
||||||
this.success = ''
|
|
||||||
}, 3000)
|
|
||||||
|
|
||||||
|
this.$message.success('配置已在新窗口中打开')
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -581,14 +974,400 @@ export default {
|
|||||||
*/
|
*/
|
||||||
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)
|
||||||
|
// 计算目标日期(第1天对应基准日期,第2天是基准日期+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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -602,4 +1381,23 @@ export default {
|
|||||||
.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>
|
||||||
|
|||||||
@ -147,6 +147,7 @@
|
|||||||
@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
|
||||||
@ -164,7 +165,41 @@
|
|||||||
@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>
|
||||||
|
|
||||||
<!-- 出勤统计区域 -->
|
<!-- 出勤统计区域 -->
|
||||||
@ -298,6 +333,80 @@
|
|||||||
</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">
|
||||||
@ -331,8 +440,11 @@ 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,
|
||||||
@ -372,10 +484,13 @@ export default {
|
|||||||
AttendanceManagementDialog,
|
AttendanceManagementDialog,
|
||||||
HomeworkGrid,
|
HomeworkGrid,
|
||||||
HomeActions,
|
HomeActions,
|
||||||
|
ExamScheduleCard,
|
||||||
|
ExamConfigEditor,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { mobile } = useDisplay();
|
const { mobile } = useDisplay();
|
||||||
return { mobile };
|
const examStore = useExamStore();
|
||||||
|
return { mobile, examStore };
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
const defaultSubjects = [
|
const defaultSubjects = [
|
||||||
@ -392,6 +507,11 @@ export default {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// examCards: [], // Removed
|
||||||
|
showAddExamDialog: false,
|
||||||
|
showExamDetailDialog: false,
|
||||||
|
selectedExamId: null,
|
||||||
|
upcomingExams: [],
|
||||||
dataKey: "",
|
dataKey: "",
|
||||||
provider: "",
|
provider: "",
|
||||||
useDisplay: useDisplay,
|
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) {
|
for (const subject of this.state.availableSubjects) {
|
||||||
const subjectKey = subject.name;
|
const subjectKey = subject.name;
|
||||||
@ -713,6 +850,13 @@ 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");
|
||||||
@ -1062,6 +1206,102 @@ 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) {
|
||||||
|
|||||||
80
src/stores/examStore.js
Normal file
80
src/stores/examStore.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user