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

feat: 添加导入配置和AI生成考试配置功能

This commit is contained in:
Sunwuyuan 2025-12-13 21:11:20 +08:00
parent 61d8392d59
commit 4eb8c74d84
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64

View File

@ -54,6 +54,24 @@
>
新建配置
</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
:loading="loading"
color="info"
@ -272,6 +290,227 @@
</v-card-actions>
</v-card>
</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>
</template>
@ -297,12 +536,73 @@ export default {
editingConfig: null,
newConfigName: '',
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() {
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: {
/**
* 根据当前日期推算考试类型
@ -678,6 +978,390 @@ export default {
} else {
this.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()
}
}
}
@ -691,4 +1375,23 @@ export default {
.border-b:last-child {
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>