feat: 评分标准保存为模板功能

This commit is contained in:
2026-07-16 13:21:47 +08:00
parent cc0f80d020
commit e84b0705e5
5 changed files with 161 additions and 23 deletions
+51 -5
View File
@@ -72,8 +72,10 @@ function getDb() {
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
course TEXT,
questions_json TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`).run()
try { db.prepare(`ALTER TABLE exam ADD COLUMN questions_json TEXT DEFAULT ''`).run() } catch (_) {}
db.prepare(`CREATE TABLE IF NOT EXISTS question (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exam_id INTEGER,
@@ -161,7 +163,12 @@ export function setupIpc(win: BrowserWindow): void {
return result
} catch (err: any) {
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', () => {
try {
const db = getDb()
const exams = db.prepare('SELECT * FROM exam ORDER BY created_at DESC').all()
return exams
const exams = db.prepare('SELECT * FROM exam ORDER BY created_at DESC').all() as any[]
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 {
return []
}
})
ipcMain.handle('template:save', (_event, data: { name: string; course: string }) => {
ipcMain.handle('template:save', (_event, data: { name: string; course: string; questions?: any[] }) => {
try {
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
} catch (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 ---
ipcMain.handle('dialog:open', (_event, { type, data }: { type: string; data?: any }) => {
log.info('IPC: dialog:open', type)
+3 -1
View File
@@ -62,7 +62,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
template: {
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: {
+46
View File
@@ -46,12 +46,28 @@
<div class="footer-left">
<el-button size="small" @click="handleImport">导入</el-button>
<el-button size="small" @click="handleExport">导出</el-button>
<el-button size="small" @click="showTemplateForm = !showTemplateForm">
{{ showTemplateForm ? '取消' : '保存为模板' }}
</el-button>
</div>
<div class="footer-right">
<el-button size="small" @click="handleClose">取消</el-button>
<el-button type="primary" size="small" @click="handleSave">应用</el-button>
</div>
</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>
</template>
@@ -68,6 +84,9 @@ const gradingStore = useGradingStore()
const visible = ref(false)
const activeIndex = ref(0)
const questions = ref<QuestionItem[]>([])
const showTemplateForm = ref(false)
const templateName = ref('')
const templateCourse = ref('')
function emptyQuestion(): QuestionItem {
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() {
window.electronAPI?.dialog.close()
}
@@ -217,4 +258,9 @@ function removeRefImage(idx: number) {
border-radius: 4px;
border: 1px solid #e4e7ed;
}
.template-form {
padding: 8px 16px;
border-top: 1px solid #eee;
background: #fafafa;
}
</style>
+49 -15
View File
@@ -1,5 +1,5 @@
<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">
<el-form :model="form" size="small" inline>
<el-form-item label="考试名称">
@@ -17,13 +17,17 @@
<el-divider />
<el-table :data="templates" size="small" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="考试名称" min-width="150" />
<el-table-column prop="course" label="课程" min-width="120" />
<el-table-column prop="created_at" label="创建时间" width="160" />
<el-table-column label="操作" width="100">
<el-table-column prop="id" label="ID" width="50" />
<el-table-column prop="name" label="考试名称" min-width="130" />
<el-table-column prop="course" label="课程" min-width="100" />
<el-table-column label="题目数" width="70">
<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 }">
<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>
</el-table-column>
</el-table>
@@ -35,15 +39,16 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAppStore } from '@/stores/app'
import { useGradingStore } from '@/stores/grading'
import type { TemplateItem, QuestionItem } from '@/types'
const appStore = useAppStore()
const gradingStore = useGradingStore()
const visible = ref(false)
const templates = ref<any[]>([])
const templates = ref<TemplateItem[]>([])
const form = ref({ name: '', course: '' })
watch(() => appStore.isTemplateDialogOpen, (val) => {
@@ -63,17 +68,46 @@ async function saveTemplate() {
return
}
if (window.electronAPI) {
await window.electronAPI.template.save(form.value)
ElMessage.success('模板已保存')
const currentRubric = await window.electronAPI.rubric.getData()
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: '' }
loadTemplates()
}
}
function selectTemplate(tpl: any) {
gradingStore.setRubric(`考试: ${tpl.name}\n课程: ${tpl.course}\n请根据标准答案评分。`)
ElMessage.success('已选择模板:' + tpl.name)
handleClose()
async function selectTemplate(tpl: TemplateItem) {
if (!tpl.questions?.length) {
ElMessage.warning('该模板没有题目数据')
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() {
+12 -2
View File
@@ -52,6 +52,14 @@ export interface HistoryRecord {
reference_answer_image?: string
}
export interface TemplateItem {
id: number
name: string
course: string
questions: QuestionItem[]
created_at: string
}
export interface ElectronAPI {
window: {
dragMove: (deltaX: number, deltaY: number) => Promise<void>
@@ -109,8 +117,10 @@ export interface ElectronAPI {
resetNextIndex: () => Promise<void>
}
template: {
list: () => Promise<unknown[]>
save: (data: { name: string; course: string }) => Promise<number | null>
list: () => Promise<TemplateItem[]>
save: (data: { name: string; course: string; questions?: QuestionItem[] }) => Promise<number | null>
delete: (id: number) => Promise<boolean>
apply: (id: number) => Promise<boolean>
}
dialog: {
open: (type: string, data?: any) => Promise<void>