feat: 历史记录增强+IPC响应式Proxy修复+水印异常处理

This commit is contained in:
2026-07-16 12:55:44 +08:00
parent 08a831b806
commit cc0f80d020
7 changed files with 391 additions and 44 deletions
+66 -16
View File
@@ -43,8 +43,31 @@ function getDb() {
ai_score REAL,
teacher_score REAL,
confidence REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
question_title TEXT DEFAULT '',
max_score REAL DEFAULT 100,
reference_answer TEXT DEFAULT '',
rubric TEXT DEFAULT '',
deductions TEXT DEFAULT '',
comment TEXT DEFAULT '',
result_json TEXT DEFAULT '',
image_data TEXT DEFAULT ''
)`).run()
// migrate: add columns for existing DBs
const migrateCols: { name: string; type: string }[] = [
{ name: 'question_title', type: 'TEXT DEFAULT \'\'' },
{ name: 'max_score', type: 'REAL DEFAULT 100' },
{ name: 'reference_answer', type: 'TEXT DEFAULT \'\'' },
{ name: 'rubric', type: 'TEXT DEFAULT \'\'' },
{ name: 'deductions', type: 'TEXT DEFAULT \'\'' },
{ name: 'comment', type: 'TEXT DEFAULT \'\'' },
{ name: 'result_json', type: 'TEXT DEFAULT \'\'' },
{ name: 'image_data', type: 'TEXT DEFAULT \'\'' },
{ name: 'reference_answer_image', type: 'TEXT DEFAULT \'\'' }
]
for (const col of migrateCols) {
try { db.prepare(`ALTER TABLE history ADD COLUMN ${col.name} ${col.type}`).run() } catch (_) {}
}
db.prepare(`CREATE TABLE IF NOT EXISTS exam (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
@@ -180,6 +203,15 @@ export function setupIpc(win: BrowserWindow): void {
}
})
ipcMain.handle('history:getById', (_event, id: number) => {
try {
const record = getDb().prepare('SELECT * FROM history WHERE id = ?').get(id) as any
return record || null
} catch {
return null
}
})
ipcMain.handle('grading:submit', (_event, data: {
questionId: number
imagePath: string
@@ -187,22 +219,40 @@ export function setupIpc(win: BrowserWindow): void {
teacherScore: number
confidence: number
studentAnswer?: string
questionTitle?: string
maxScore?: number
referenceAnswer?: string
rubric?: string
deductions?: string[]
comment?: string
resultJson?: string
imageData?: string
referenceAnswerImage?: string
}) => {
try {
getDb().prepare(`INSERT INTO history (question_id, image_path, student_answer, ai_score, teacher_score, confidence)
VALUES (?, ?, ?, ?, ?, ?)`).run(
data.questionId || 0,
data.imagePath || '',
data.studentAnswer || '',
data.aiScore,
data.teacherScore,
data.confidence
)
return true
} catch (err) {
log.error('grading:submit error:', err)
return false
}
log.info('grading:submit called', JSON.stringify({ ...data, imageData: data.imageData ? `len=${data.imageData.length}` : 'empty', referenceAnswerImage: data.referenceAnswerImage ? `len=${data.referenceAnswerImage.length}` : 'empty' }))
const deductionsStr = JSON.stringify(data.deductions || [])
getDb().prepare(`INSERT INTO history
(question_id, image_path, student_answer, ai_score, teacher_score, confidence,
question_title, max_score, reference_answer, rubric, deductions, comment, result_json, image_data,
reference_answer_image)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
data.questionId || 0,
data.imagePath || '',
data.studentAnswer || '',
data.aiScore,
data.teacherScore,
data.confidence,
data.questionTitle || '',
data.maxScore ?? 100,
data.referenceAnswer || '',
data.rubric || '',
deductionsStr,
data.comment || '',
data.resultJson || '',
data.imageData || '',
data.referenceAnswerImage || ''
)
return true
})
ipcMain.handle('template:list', () => {
+2 -1
View File
@@ -33,7 +33,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
history: {
list: () => ipcRenderer.invoke('history:list'),
delete: (id: number) => ipcRenderer.invoke('history:delete', id)
delete: (id: number) => ipcRenderer.invoke('history:delete', id),
getById: (id: number) => ipcRenderer.invoke('history:getById', id)
},
grading: {
+12 -3
View File
@@ -76,8 +76,12 @@ onMounted(() => {
if (window.electronAPI) {
cleanupScreenshotListener = window.electronAPI.screenshot.onCompleted(async (base64: string) => {
if (!appStore.isGrading) {
gradingStore.setImage(await addWatermark(base64, '学生答案'))
startGrading()
try {
gradingStore.setImage(await addWatermark(base64, '学生答案'))
startGrading()
} catch (err) {
ElMessage.error('水印处理失败:' + (err as Error).message)
}
}
})
}
@@ -95,7 +99,12 @@ async function selectImage() {
if (!window.electronAPI) return
const data = await window.electronAPI.dialog.openImage()
if (data) {
gradingStore.setImage(await addWatermark(data.base64, '学生答案'))
try {
gradingStore.setImage(await addWatermark(data.base64, '学生答案'))
} catch (err) {
ElMessage.error('水印处理失败:' + (err as Error).message)
return
}
startGrading()
}
}
+40 -12
View File
@@ -6,8 +6,8 @@
<el-button text size="small" @click="closeDrawer"></el-button>
</div>
<div class="error-message" v-if="errorMsg" style="padding: 12px 16px;">{{ errorMsg }}</div>
<div class="drawer-body" v-if="gradingStore.currentResult">
<div class="error-message" v-if="errorMsg">{{ errorMsg }}</div>
<div class="question-nav" v-if="questions.length > 1">
<el-button size="small" @click="prevQuestion">上一题</el-button>
<span class="question-indicator">{{ currentIdx + 1 }} / {{ questions.length }}</span>
@@ -187,14 +187,25 @@ async function prevQuestion() {
async function saveAndAdvance(targetIdx: number) {
try {
await window.electronAPI.grading.submit({
const result = gradingStore.currentResult
const raw = {
questionId: 0,
imagePath: '',
aiScore: gradingStore.currentResult?.score ?? 0,
aiScore: result?.score ?? 0,
teacherScore: teacherScore.value,
confidence: gradingStore.currentResult?.confidence ?? 0,
studentAnswer: ''
})
confidence: result?.confidence ?? 0,
studentAnswer: '',
questionTitle: currentQuestion.value.questionTitle,
maxScore: currentQuestion.value.maxScore,
referenceAnswer: currentQuestion.value.referenceAnswer,
rubric: currentQuestion.value.rubric,
deductions: result?.deductions,
comment: result?.comment,
resultJson: JSON.stringify(result),
imageData: gradingStore.currentImage || '',
referenceAnswerImage: currentQuestion.value.referenceAnswerImage || ''
}
await window.electronAPI.grading.submit(JSON.parse(JSON.stringify(raw)))
await window.electronAPI?.rubric.setNextIndex(targetIdx)
gradingStore.reset()
closeDrawer()
@@ -208,23 +219,40 @@ async function reGrade() {
}
async function confirmScore() {
errorMsg.value = ''
const result = gradingStore.currentResult
if (!result) return
if (!result) {
errorMsg.value = '暂无评分结果可保存(currentResult 为空)'
return
}
if (!window.electronAPI?.grading?.submit) {
errorMsg.value = 'electronAPI.grading.submit 不可用'
return
}
try {
await window.electronAPI.grading.submit({
const raw = {
questionId: 0,
imagePath: '',
aiScore: result.score,
teacherScore: teacherScore.value,
confidence: result.confidence,
studentAnswer: ''
})
ElMessage.success('评分已保存')
studentAnswer: '',
questionTitle: currentQuestion.value.questionTitle,
maxScore: currentQuestion.value.maxScore,
referenceAnswer: currentQuestion.value.referenceAnswer,
rubric: currentQuestion.value.rubric,
deductions: result.deductions,
comment: result.comment,
resultJson: JSON.stringify(result),
imageData: gradingStore.currentImage || '',
referenceAnswerImage: currentQuestion.value.referenceAnswerImage || ''
}
await window.electronAPI.grading.submit(JSON.parse(JSON.stringify(raw)))
gradingStore.reset()
closeDrawer()
} catch (err) {
ElMessage.error('保存失败:' + (err as Error).message)
errorMsg.value = '保存失败:' + (err as Error).message
}
}
</script>
+238 -7
View File
@@ -2,17 +2,18 @@
<el-dialog v-model="visible" title="历史记录" width="800px" fullscreen :modal="false" @close="handleClose">
<el-table :data="records" style="width: 100%" size="small" v-loading="loading">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="ai_score" label="AI分" width="80" />
<el-table-column prop="teacher_score" label="教师分" width="80" />
<el-table-column prop="confidence" label="可信度" width="80">
<el-table-column prop="ai_score" label="AI分" width="70" />
<el-table-column prop="teacher_score" label="教师分" width="70" />
<el-table-column prop="confidence" label="可信度" width="70">
<template #default="{ row }">
{{ Math.round(row.confidence * 100) }}%
</template>
</el-table-column>
<el-table-column prop="student_answer" label="答案摘要" min-width="200" show-overflow-tooltip />
<el-table-column prop="created_at" label="时间" width="160" />
<el-table-column label="操作" width="80" fixed="right">
<el-table-column prop="question_title" label="题目" min-width="120" show-overflow-tooltip />
<el-table-column prop="created_at" label="时间" width="150" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="primary" @click="viewResult(row)">查看</el-button>
<el-button text size="small" type="danger" @click="deleteRecord(row.id)">删除</el-button>
</template>
</el-table-column>
@@ -23,10 +24,75 @@
<el-button size="small" @click="refresh">刷新</el-button>
</template>
</el-dialog>
<div v-if="detailVisible" class="history-result-overlay" @click.self="detailVisible = false">
<div class="history-result-panel">
<div class="panel-header">
<span class="panel-title">评分详情</span>
<el-button text size="small" @click="detailVisible = false"></el-button>
</div>
<div class="panel-body" v-if="detail">
<div class="question-title" v-if="detail.question_title">
{{ detail.question_title }}
</div>
<div class="question-maxscore" v-if="detail.max_score">
满分{{ detail.max_score }}
</div>
<div class="result-section" v-if="detail.rubric">
<div class="section-title">当前评分标准</div>
<div class="rubric-text">{{ detail.rubric }}</div>
</div>
<div class="result-section" v-if="detail.image_data">
<div class="section-title">学生答案图片</div>
<img class="result-image" :src="'data:image/png;base64,' + detail.image_data" />
</div>
<div class="result-section" v-if="detail.reference_answer || detail.reference_answer_image">
<div class="section-title">参考答案</div>
<div class="reference-text" v-if="detail.reference_answer">{{ detail.reference_answer }}</div>
<img v-if="detail.reference_answer_image" class="result-image" :src="detail.reference_answer_image" />
</div>
<div class="result-score">
<div class="score-value">{{ detail.ai_score }}</div>
<div class="score-label">建议分数</div>
</div>
<div class="result-confidence">
<div class="confidence-bar">
<div class="confidence-fill" :style="{ width: Math.round(detail.confidence * 100) + '%' }"></div>
</div>
<div class="confidence-text">可信度{{ Math.round(detail.confidence * 100) }}%</div>
</div>
<div class="result-section" v-if="deductionsList.length">
<div class="section-title">扣分项</div>
<div class="deduction-item" v-for="(item, idx) in deductionsList" :key="idx">
{{ idx + 1 }}. {{ item }}
</div>
</div>
<div class="result-section" v-if="detail.comment">
<div class="section-title">评语</div>
<div class="comment-text">{{ detail.comment }}</div>
</div>
<div class="result-section">
<div class="section-title">教师评分</div>
<div class="teacher-score-display">{{ detail.teacher_score }} / {{ detail.max_score || 100 }}</div>
</div>
</div>
<div class="panel-footer">
<el-button size="small" @click="detailVisible = false">关闭</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAppStore } from '@/stores/app'
import type { HistoryRecord } from '@/types'
@@ -36,6 +102,13 @@ const visible = ref(false)
const loading = ref(false)
const records = ref<HistoryRecord[]>([])
const detailVisible = ref(false)
const detail = ref<HistoryRecord | null>(null)
const deductionsList = computed(() => {
if (!detail.value?.deductions) return []
try { return JSON.parse(detail.value.deductions) } catch { return [] }
})
watch(() => appStore.isHistoryDialogOpen, (val) => {
visible.value = val
if (val) refresh()
@@ -54,6 +127,21 @@ async function refresh() {
}
}
async function viewResult(row: HistoryRecord) {
if (!window.electronAPI) return
try {
const full = await window.electronAPI.history.getById(row.id)
if (full) {
detail.value = full
detailVisible.value = true
} else {
ElMessage.warning('未找到记录详情')
}
} catch (err) {
ElMessage.error('加载详情失败:' + (err as Error).message)
}
}
async function deleteRecord(id: number) {
try {
await ElMessageBox.confirm('确定删除该记录?', '提示')
@@ -69,3 +157,146 @@ function handleClose() {
window.electronAPI?.dialog.close()
}
</script>
<style scoped>
.history-result-overlay {
position: fixed;
inset: 0;
z-index: 99998;
background: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: flex-end;
}
.history-result-panel {
width: 480px;
height: 100%;
background: #fff;
display: flex;
flex-direction: column;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-bottom: 1px solid #eee;
font-size: 15px;
font-weight: 600;
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.panel-footer {
padding: 12px 16px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
}
.question-title {
font-size: 14px;
font-weight: 600;
color: #333;
line-height: 1.5;
}
.question-maxscore {
font-size: 12px;
color: #909399;
}
.result-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: #909399;
}
.rubric-text {
font-size: 13px;
color: #333;
line-height: 1.6;
background: #fff7e6;
padding: 10px;
border-radius: 6px;
white-space: pre-wrap;
}
.reference-text {
font-size: 13px;
color: #666;
line-height: 1.6;
background: #f0f9eb;
padding: 10px;
border-radius: 6px;
white-space: pre-wrap;
}
.result-image {
max-width: 100%;
max-height: 300px;
border-radius: 6px;
border: 1px solid #e4e7ed;
}
.result-score {
text-align: center;
padding: 12px 0;
}
.score-value {
font-size: 42px;
font-weight: 700;
color: #409EFF;
line-height: 1;
}
.score-label {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.result-confidence {
display: flex;
flex-direction: column;
gap: 4px;
}
.confidence-bar {
height: 6px;
background: #e4e7ed;
border-radius: 3px;
overflow: hidden;
}
.confidence-fill {
height: 100%;
background: #67c23a;
border-radius: 3px;
transition: width 0.3s;
}
.confidence-text {
font-size: 11px;
color: #909399;
text-align: right;
}
.deduction-item {
font-size: 13px;
color: #f56c6c;
line-height: 1.6;
padding: 2px 0;
}
.comment-text {
font-size: 13px;
color: #333;
line-height: 1.6;
background: #f5f7fa;
padding: 10px;
border-radius: 6px;
white-space: pre-wrap;
}
.teacher-score-display {
font-size: 16px;
font-weight: 600;
color: #409EFF;
}
</style>
+19
View File
@@ -41,6 +41,15 @@ export interface HistoryRecord {
teacher_score: number
confidence: number
created_at: string
question_title?: string
max_score?: number
reference_answer?: string
rubric?: string
deductions?: string
comment?: string
result_json?: string
image_data?: string
reference_answer_image?: string
}
export interface ElectronAPI {
@@ -67,6 +76,7 @@ export interface ElectronAPI {
history: {
list: () => Promise<HistoryRecord[]>
delete: (id: number) => Promise<boolean>
getById: (id: number) => Promise<HistoryRecord | null>
}
grading: {
submit: (data: {
@@ -76,6 +86,15 @@ export interface ElectronAPI {
teacherScore: number
confidence: number
studentAnswer?: string
questionTitle?: string
maxScore?: number
referenceAnswer?: string
rubric?: string
deductions?: string[]
comment?: string
resultJson?: string
imageData?: string
referenceAnswerImage?: string
}) => Promise<boolean>,
storeData: (data: any) => Promise<void>,
getData: () => Promise<any>
+14 -5
View File
@@ -2,16 +2,25 @@ export function addWatermark(base64: string, text: string): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
let w = img.width
let h = img.height
const MAX = 2048
let scale = 1
if (w > MAX || h > MAX) {
scale = Math.min(MAX / w, MAX / h, 1)
w = Math.round(w * scale)
h = Math.round(h * scale)
}
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return reject(new Error('Canvas 2D context not available'))
ctx.drawImage(img, 0, 0)
ctx.font = `bold ${Math.max(28, Math.round(img.width / 20))}px sans-serif`
ctx.drawImage(img, 0, 0, w, h)
ctx.font = `bold ${Math.max(28, Math.round(w / 20))}px sans-serif`
ctx.fillStyle = 'rgba(255, 0, 0, 0.85)'
ctx.textBaseline = 'top'
const padding = Math.max(8, Math.round(img.width / 60))
const padding = Math.max(8, Math.round(w / 60))
ctx.fillText(text, padding, padding)
resolve(canvas.toDataURL('image/png').replace(/^data:image\/png;base64,/, ''))
}