feat: 评分标准保存为模板功能
This commit is contained in:
+51
-5
@@ -72,8 +72,10 @@ function getDb() {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
course TEXT,
|
course TEXT,
|
||||||
|
questions_json TEXT DEFAULT '',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)`).run()
|
)`).run()
|
||||||
|
try { db.prepare(`ALTER TABLE exam ADD COLUMN questions_json TEXT DEFAULT ''`).run() } catch (_) {}
|
||||||
db.prepare(`CREATE TABLE IF NOT EXISTS question (
|
db.prepare(`CREATE TABLE IF NOT EXISTS question (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
exam_id INTEGER,
|
exam_id INTEGER,
|
||||||
@@ -161,7 +163,12 @@ export function setupIpc(win: BrowserWindow): void {
|
|||||||
return result
|
return result
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
log.error('AI grade failed:', err)
|
log.error('AI grade failed:', err)
|
||||||
throw new Error(err.message || 'AI grading failed')
|
const apiMsg = err?.response?.data?.error?.message || ''
|
||||||
|
const allMsg = (apiMsg + ' ' + (err.message || '')).toLowerCase()
|
||||||
|
if (allMsg.includes('does not support image') || allMsg.includes('image input') || allMsg.includes('not support image')) {
|
||||||
|
throw new Error('当前模型不支持图片输入,请在设置中更换为支持多模态的模型(如 gpt-4o、qwen-vl 等)')
|
||||||
|
}
|
||||||
|
throw new Error(apiMsg || err.message || 'AI grading failed')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -258,17 +265,24 @@ export function setupIpc(win: BrowserWindow): void {
|
|||||||
ipcMain.handle('template:list', () => {
|
ipcMain.handle('template:list', () => {
|
||||||
try {
|
try {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const exams = db.prepare('SELECT * FROM exam ORDER BY created_at DESC').all()
|
const exams = db.prepare('SELECT * FROM exam ORDER BY created_at DESC').all() as any[]
|
||||||
return exams
|
const result = exams.map((e: any) => {
|
||||||
|
const questions = e.questions_json ? JSON.parse(e.questions_json) : []
|
||||||
|
log.info('template:list item', { id: e.id, name: e.name, jsonLen: (e.questions_json || '').length, questionCount: questions.length })
|
||||||
|
return { ...e, questions }
|
||||||
|
})
|
||||||
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('template:save', (_event, data: { name: string; course: string }) => {
|
ipcMain.handle('template:save', (_event, data: { name: string; course: string; questions?: any[] }) => {
|
||||||
try {
|
try {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const result = db.prepare('INSERT INTO exam (name, course) VALUES (?, ?)').run(data.name, data.course)
|
const questionsJson = JSON.stringify(data.questions || [])
|
||||||
|
log.info('template:save', { name: data.name, course: data.course, questionCount: data.questions?.length ?? 0, jsonLen: questionsJson.length })
|
||||||
|
const result = db.prepare('INSERT INTO exam (name, course, questions_json) VALUES (?, ?, ?)').run(data.name, data.course, questionsJson)
|
||||||
return result.lastInsertRowid
|
return result.lastInsertRowid
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('template:save error:', err)
|
log.error('template:save error:', err)
|
||||||
@@ -276,6 +290,38 @@ export function setupIpc(win: BrowserWindow): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('template:delete', (_event, id: number) => {
|
||||||
|
try {
|
||||||
|
getDb().prepare('DELETE FROM exam WHERE id = ?').run(id)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('template:apply', (_event, id: number) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare('SELECT * FROM exam WHERE id = ?').get(id) as any
|
||||||
|
if (!row) throw new Error('模板不存在')
|
||||||
|
const questions = row.questions_json ? JSON.parse(row.questions_json) : []
|
||||||
|
if (!questions.length) throw new Error('模板中没有题目')
|
||||||
|
db.prepare('DELETE FROM rubric').run()
|
||||||
|
const insert = db.prepare(
|
||||||
|
'INSERT INTO rubric (question_title, max_score, reference_answer, rubric, sort_order, reference_answer_image) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i]
|
||||||
|
insert.run(q.questionTitle || '', q.maxScore ?? 100, q.referenceAnswer || '', q.rubric || '', i, q.referenceAnswerImage || '')
|
||||||
|
}
|
||||||
|
rubricNextIndex = 0
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
log.error('template:apply error:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// --- Dialog child windows ---
|
// --- Dialog child windows ---
|
||||||
ipcMain.handle('dialog:open', (_event, { type, data }: { type: string; data?: any }) => {
|
ipcMain.handle('dialog:open', (_event, { type, data }: { type: string; data?: any }) => {
|
||||||
log.info('IPC: dialog:open', type)
|
log.info('IPC: dialog:open', type)
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
template: {
|
template: {
|
||||||
list: () => ipcRenderer.invoke('template:list'),
|
list: () => ipcRenderer.invoke('template:list'),
|
||||||
save: (data: { name: string; course: string }) => ipcRenderer.invoke('template:save', data)
|
save: (data: { name: string; course: string; questions?: any[] }) => ipcRenderer.invoke('template:save', data),
|
||||||
|
delete: (id: number) => ipcRenderer.invoke('template:delete', id),
|
||||||
|
apply: (id: number) => ipcRenderer.invoke('template:apply', id)
|
||||||
},
|
},
|
||||||
|
|
||||||
dialog: {
|
dialog: {
|
||||||
|
|||||||
@@ -46,12 +46,28 @@
|
|||||||
<div class="footer-left">
|
<div class="footer-left">
|
||||||
<el-button size="small" @click="handleImport">导入</el-button>
|
<el-button size="small" @click="handleImport">导入</el-button>
|
||||||
<el-button size="small" @click="handleExport">导出</el-button>
|
<el-button size="small" @click="handleExport">导出</el-button>
|
||||||
|
<el-button size="small" @click="showTemplateForm = !showTemplateForm">
|
||||||
|
{{ showTemplateForm ? '取消' : '保存为模板' }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-right">
|
<div class="footer-right">
|
||||||
<el-button size="small" @click="handleClose">取消</el-button>
|
<el-button size="small" @click="handleClose">取消</el-button>
|
||||||
<el-button type="primary" size="small" @click="handleSave">应用</el-button>
|
<el-button type="primary" size="small" @click="handleSave">应用</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<div v-if="showTemplateForm" class="template-form">
|
||||||
|
<el-form inline size="small">
|
||||||
|
<el-form-item label="模板名称">
|
||||||
|
<el-input v-model="templateName" placeholder="如:期末考试" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="课程">
|
||||||
|
<el-input v-model="templateCourse" placeholder="如:Java" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="success" size="small" @click="saveAsTemplate">保存</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -68,6 +84,9 @@ const gradingStore = useGradingStore()
|
|||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const activeIndex = ref(0)
|
const activeIndex = ref(0)
|
||||||
const questions = ref<QuestionItem[]>([])
|
const questions = ref<QuestionItem[]>([])
|
||||||
|
const showTemplateForm = ref(false)
|
||||||
|
const templateName = ref('')
|
||||||
|
const templateCourse = ref('')
|
||||||
|
|
||||||
function emptyQuestion(): QuestionItem {
|
function emptyQuestion(): QuestionItem {
|
||||||
return { questionTitle: '', maxScore: 100, referenceAnswer: '', rubric: '' }
|
return { questionTitle: '', maxScore: 100, referenceAnswer: '', rubric: '' }
|
||||||
@@ -135,6 +154,28 @@ async function handleImport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveAsTemplate() {
|
||||||
|
if (!templateName.value || !templateCourse.value) {
|
||||||
|
ElMessage.warning('请填写模板名称和课程')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ok = await window.electronAPI?.template.save({
|
||||||
|
name: templateName.value,
|
||||||
|
course: templateCourse.value,
|
||||||
|
questions: JSON.parse(JSON.stringify(questions.value))
|
||||||
|
})
|
||||||
|
if (ok) {
|
||||||
|
ElMessage.success('模板已保存')
|
||||||
|
templateName.value = ''
|
||||||
|
templateCourse.value = ''
|
||||||
|
showTemplateForm.value = false
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error('保存模板失败:' + (err as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
window.electronAPI?.dialog.close()
|
window.electronAPI?.dialog.close()
|
||||||
}
|
}
|
||||||
@@ -217,4 +258,9 @@ function removeRefImage(idx: number) {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #e4e7ed;
|
border: 1px solid #e4e7ed;
|
||||||
}
|
}
|
||||||
|
.template-form {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog v-model="visible" title="评分模板管理" width="600px" :modal="false" @close="handleClose">
|
<el-dialog v-model="visible" title="评分模板管理" width="620px" :modal="false" @close="handleClose">
|
||||||
<div class="template-section">
|
<div class="template-section">
|
||||||
<el-form :model="form" size="small" inline>
|
<el-form :model="form" size="small" inline>
|
||||||
<el-form-item label="考试名称">
|
<el-form-item label="考试名称">
|
||||||
@@ -17,13 +17,17 @@
|
|||||||
<el-divider />
|
<el-divider />
|
||||||
|
|
||||||
<el-table :data="templates" size="small" style="width: 100%">
|
<el-table :data="templates" size="small" style="width: 100%">
|
||||||
<el-table-column prop="id" label="ID" width="60" />
|
<el-table-column prop="id" label="ID" width="50" />
|
||||||
<el-table-column prop="name" label="考试名称" min-width="150" />
|
<el-table-column prop="name" label="考试名称" min-width="130" />
|
||||||
<el-table-column prop="course" label="课程" min-width="120" />
|
<el-table-column prop="course" label="课程" min-width="100" />
|
||||||
<el-table-column prop="created_at" label="创建时间" width="160" />
|
<el-table-column label="题目数" width="70">
|
||||||
<el-table-column label="操作" width="100">
|
<template #default="{ row }">{{ row.questions?.length || 0 }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="150" />
|
||||||
|
<el-table-column label="操作" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button text size="small" @click="selectTemplate(row)">使用</el-button>
|
<el-button text size="small" :disabled="!row.questions?.length" @click="selectTemplate(row)">使用</el-button>
|
||||||
|
<el-button text size="small" type="danger" @click="deleteTemplate(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -35,15 +39,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useGradingStore } from '@/stores/grading'
|
import { useGradingStore } from '@/stores/grading'
|
||||||
|
import type { TemplateItem, QuestionItem } from '@/types'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const gradingStore = useGradingStore()
|
const gradingStore = useGradingStore()
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const templates = ref<any[]>([])
|
const templates = ref<TemplateItem[]>([])
|
||||||
const form = ref({ name: '', course: '' })
|
const form = ref({ name: '', course: '' })
|
||||||
|
|
||||||
watch(() => appStore.isTemplateDialogOpen, (val) => {
|
watch(() => appStore.isTemplateDialogOpen, (val) => {
|
||||||
@@ -63,17 +68,46 @@ async function saveTemplate() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (window.electronAPI) {
|
if (window.electronAPI) {
|
||||||
await window.electronAPI.template.save(form.value)
|
const currentRubric = await window.electronAPI.rubric.getData()
|
||||||
ElMessage.success('模板已保存')
|
const questions = currentRubric?.questions?.length ? JSON.parse(JSON.stringify(currentRubric.questions)) : []
|
||||||
|
if (!questions.length) {
|
||||||
|
ElMessage.warning('当前没有评分标准数据,模板将保存为空。请先在评分标准编辑器中添加题目并点击「应用」')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await window.electronAPI.template.save({
|
||||||
|
name: form.value.name,
|
||||||
|
course: form.value.course,
|
||||||
|
questions
|
||||||
|
})
|
||||||
|
ElMessage.success(`模板已保存(${questions.length} 题)`)
|
||||||
form.value = { name: '', course: '' }
|
form.value = { name: '', course: '' }
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectTemplate(tpl: any) {
|
async function selectTemplate(tpl: TemplateItem) {
|
||||||
gradingStore.setRubric(`考试: ${tpl.name}\n课程: ${tpl.course}\n请根据标准答案评分。`)
|
if (!tpl.questions?.length) {
|
||||||
ElMessage.success('已选择模板:' + tpl.name)
|
ElMessage.warning('该模板没有题目数据')
|
||||||
handleClose()
|
return
|
||||||
|
}
|
||||||
|
const ok = await window.electronAPI?.template.apply(tpl.id)
|
||||||
|
if (ok) {
|
||||||
|
gradingStore.setQuestions(JSON.parse(JSON.stringify(tpl.questions)))
|
||||||
|
ElMessage.success(`已应用模板「${tpl.name}」(${tpl.questions.length} 题)`)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
ElMessage.error('应用模板失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTemplate(tpl: TemplateItem) {
|
||||||
|
if (window.electronAPI) {
|
||||||
|
const ok = await window.electronAPI.template.delete(tpl.id)
|
||||||
|
if (ok) {
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
|
|||||||
+12
-2
@@ -52,6 +52,14 @@ export interface HistoryRecord {
|
|||||||
reference_answer_image?: string
|
reference_answer_image?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TemplateItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
course: string
|
||||||
|
questions: QuestionItem[]
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
window: {
|
window: {
|
||||||
dragMove: (deltaX: number, deltaY: number) => Promise<void>
|
dragMove: (deltaX: number, deltaY: number) => Promise<void>
|
||||||
@@ -109,8 +117,10 @@ export interface ElectronAPI {
|
|||||||
resetNextIndex: () => Promise<void>
|
resetNextIndex: () => Promise<void>
|
||||||
}
|
}
|
||||||
template: {
|
template: {
|
||||||
list: () => Promise<unknown[]>
|
list: () => Promise<TemplateItem[]>
|
||||||
save: (data: { name: string; course: string }) => Promise<number | null>
|
save: (data: { name: string; course: string; questions?: QuestionItem[] }) => Promise<number | null>
|
||||||
|
delete: (id: number) => Promise<boolean>
|
||||||
|
apply: (id: number) => Promise<boolean>
|
||||||
}
|
}
|
||||||
dialog: {
|
dialog: {
|
||||||
open: (type: string, data?: any) => Promise<void>
|
open: (type: string, data?: any) => Promise<void>
|
||||||
|
|||||||
Reference in New Issue
Block a user