Compare commits

...
10 Commits
17 changed files with 1053 additions and 222 deletions
+2 -1
View File
@@ -12,4 +12,5 @@ build-release/
dist-release/
out-electron/
out-portable/
packaged/
packaged/
test/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 AI-Grading-Assistant
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+121
View File
@@ -0,0 +1,121 @@
# AI 阅卷助手 (AI Grading Assistant)
> 桌面端 AI Copilot — 零侵入式智能阅卷辅助工具
AI 阅卷助手是一款基于 **Electron + Vue 3 + TypeScript** 构建的桌面端 AI 阅卷辅助工具。教师可在现有阅卷系统中正常阅卷,通过全局快捷键(默认 `Alt+Q`)截图学生答案,AI 自动根据预设评分标准给出评分建议、扣分项和评语,帮助提升阅卷效率与一致性。
![Electron](https://img.shields.io/badge/Electron-42-blue) ![Vue](https://img.shields.io/badge/Vue-3.5-4FC08D) ![TypeScript](https://img.shields.io/badge/TypeScript-7-3178C6) ![License](https://img.shields.io/badge/License-MIT-yellow)
---
## 功能特性
- **零侵入设计** — 独立运行,不干预现有阅卷系统
- **全局截图** — `Alt+Q` 一键截图,支持矩形区域选择
- **AI 智能评分** — 支持 GPT-4o / Qwen-VL / Claude 等多模态模型
- **灵活评分标准** — 多题目、多维度评分标准配置,支持文本和图片参考答案
- **结果确认** — 显示 AI 评分、置信度、扣分明细,教师可修改后确认
- **历史记录** — 本地 SQLite 存储,支持搜索、浏览和复盘
- **模板管理** — 评分标准可保存为模板,支持 JSON 导入/导出
- **系统托盘** — 托盘常驻,后台运行,随时唤出
- **高度可配置** — API 地址、模型、快捷键、主题、字体大小等均可自定义
---
## 快速开始
### 环境要求
- Node.js >= 20
- npm >= 9
### 安装与运行
```bash
# 克隆仓库
git clone https://github.com/your-username/ai-grading-assistant.git
cd ai-grading-assistant
# 安装依赖
npm install
# 开发模式(Vite 热更新 + Electron
npm run dev
```
### 打包构建
```bash
# 构建生产版本
npm run build
# 打包为 Windows 便携版
npm run pack
# 打包为 Windows 安装程序 (NSIS)
npm run dist
```
---
## 使用流程
1. 启动应用,系统托盘显示图标,浮动工具条自动置顶
2. 在设置中配置 AI API Key 和模型
3. 在评分标准编辑器中添加题目、参考答案和评分细则
4. 打开学生答卷,按下 `Alt+Q` 截图答题区域
5. AI 自动分析并返回评分建议、扣分项和评语
6. 确认/修改评分后提交,结果保存至本地数据库
---
## 技术栈
| 类别 | 技术 |
|------|------|
| 桌面框架 | Electron 42 |
| 前端框架 | Vue 3.5 + TypeScript 7 |
| 构建工具 | Vite 8 |
| UI 组件库 | Element Plus 2 |
| 状态管理 | Pinia 4 |
| 本地存储 | better-sqlite3 + electron-store |
| HTTP 客户端 | Axios |
| 打包分发 | electron-builder (Windows x64) |
---
## 项目结构
```
├── electron/ # Electron 主进程 (TypeScript)
│ ├── main/ # 窗口、截图、快捷键、IPC、托盘
│ └── preload/ # 预加载脚本 (contextBridge)
├── src/ # Vue 3 渲染进程
│ ├── components/ # 浮动工具条、截图覆盖层、结果面板、设置、评分标准编辑器
│ ├── pages/ # 历史记录、模板管理
│ ├── stores/ # Pinia 状态管理
│ ├── types/ # TypeScript 类型定义
│ ├── utils/ # 工具函数
│ └── styles/ # 全局样式
├── database/ # SQLite 数据库 Schema
├── build/ # 应用图标
├── dist/ # Vue 构建输出
├── dist-electron/ # Electron 构建输出
└── release/ # 打包输出
```
---
## AI 接口
支持兼容 OpenAI Chat Completions 格式的任意多模态模型:
- 默认接口:`https://api.siliconflow.cn/v1/chat/completions`
- 默认模型:`gpt-4o`
- 返回格式:结构化 JSON(评分、置信度、扣分明细、评语)
---
## 许可证
本项目采用 [MIT License](LICENSE)。
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 103 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 103 KiB

+300 -101
View File
@@ -43,14 +43,39 @@ 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,
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,
@@ -64,8 +89,11 @@ function getDb() {
max_score REAL NOT NULL DEFAULT 100,
reference_answer TEXT NOT NULL DEFAULT '',
rubric TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0
sort_order INTEGER NOT NULL DEFAULT 0,
reference_answer_image TEXT NOT NULL DEFAULT ''
)`).run()
// migrate: add column for existing DBs
try { db.prepare('ALTER TABLE rubric ADD COLUMN reference_answer_image TEXT NOT NULL DEFAULT \'\'').run() } catch (_) {}
}
return db
}
@@ -81,6 +109,11 @@ export function setupIpc(win: BrowserWindow): void {
win.setPosition(x, y)
})
ipcMain.handle('window:resizeMain', (_event, width: number) => {
const [wx, wy] = win.getPosition()
win.setBounds({ x: wx, y: wy, width: Math.max(200, Math.min(width, 1200)), height: 76 })
})
ipcMain.handle('screenshot:start', () => {
log.info('IPC: screenshot:start')
startScreenshot(win)
@@ -115,21 +148,28 @@ export function setupIpc(win: BrowserWindow): void {
}, 100)
})
ipcMain.handle('ai:grade', async (_event, { imageBase64, rubric, apiConfig, maxScore, referenceAnswer, questionTitle }: {
ipcMain.handle('ai:grade', async (_event, { imageBase64, rubric, apiConfig, maxScore, referenceAnswer, questionTitle, referenceAnswerImage }: {
imageBase64: string
rubric: string
apiConfig: { apiKey: string; baseUrl: string; model: string }
maxScore?: number
referenceAnswer?: string
questionTitle?: string
referenceAnswerImage?: string
}) => {
log.info('IPC: ai:grade')
try {
const result = await callAiApi(imageBase64, rubric, apiConfig, { maxScore, referenceAnswer, questionTitle })
const result = await callAiApi(imageBase64, rubric, apiConfig, { maxScore, referenceAnswer, questionTitle, referenceAnswerImage })
return result
} catch (err: any) {
log.error('AI grade failed:', err)
throw new Error(err.message || 'AI grading failed')
const res = err?.response?.data
const apiMsg = res?.error?.message || res?.error || res?.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')
}
})
@@ -171,6 +211,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
@@ -178,38 +227,113 @@ 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', () => {
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:exportJson', async () => {
try {
const d = getDb()
const rows: any[] = d.prepare('SELECT * FROM exam ORDER BY created_at DESC').all()
if (!rows.length) throw new Error('暂无模板可导出')
const templates = rows.map(r => ({
name: r.name,
course: r.course,
questions: r.questions_json ? JSON.parse(r.questions_json) : [],
created_at: r.created_at
}))
const result = await dialogBox.showSaveDialog({
defaultPath: '评分模板.json',
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || !result.filePath) return false
fs.writeFileSync(result.filePath, JSON.stringify(templates, null, 2), 'utf-8')
return true
} catch (err) {
log.error('template:exportJson error:', err)
return false
}
})
ipcMain.handle('template:importJson', async () => {
try {
const result = await dialogBox.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || result.filePaths.length === 0) return null
const content = fs.readFileSync(result.filePaths[0], 'utf-8')
const data = JSON.parse(content)
if (!Array.isArray(data)) throw new Error('JSON 格式错误:应为数组')
const db = getDb()
const insert = db.prepare('INSERT INTO exam (name, course, questions_json) VALUES (?, ?, ?)')
let count = 0
for (const tpl of data) {
if (tpl.name && tpl.course) {
insert.run(tpl.name, tpl.course, JSON.stringify(tpl.questions || []))
count++
}
}
return { count }
} catch (err) {
log.error('template:importJson error:', err)
return null
}
})
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)
@@ -217,6 +341,40 @@ 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
const mainWin = getMainWindow()
if (mainWin) mainWin.webContents.send('rubric:updated')
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)
@@ -258,10 +416,10 @@ export function setupIpc(win: BrowserWindow): void {
log.info(`rubric:storeData: ${questions.length} questions`, JSON.stringify(questions.map((q: any) => ({ title: q.questionTitle, rubricLen: q.rubric?.length }))))
if (questions.length === 0) return
d.prepare('DELETE FROM rubric').run()
const insert = d.prepare('INSERT INTO rubric (question_title, max_score, reference_answer, rubric, sort_order) VALUES (?, ?, ?, ?, ?)')
const insert = d.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)
insert.run(q.questionTitle || '', q.maxScore ?? 100, q.referenceAnswer || '', q.rubric || '', i, q.referenceAnswerImage || '')
}
})
@@ -271,12 +429,16 @@ export function setupIpc(win: BrowserWindow): void {
log.info(`rubric:getData: ${rows.length} rows`)
if (rows.length) {
return {
questions: rows.map(r => ({
questionTitle: r.question_title,
maxScore: r.max_score,
referenceAnswer: r.reference_answer,
rubric: r.rubric
})),
questions: rows.map(r => {
const img = r.reference_answer_image
return {
questionTitle: r.question_title,
maxScore: r.max_score,
referenceAnswer: r.reference_answer,
rubric: r.rubric,
referenceAnswerImage: img && !img.startsWith('data:') ? `data:image/png;base64,${img}` : (img || undefined)
}
}),
currentIndex: 0
}
}
@@ -287,12 +449,16 @@ export function setupIpc(win: BrowserWindow): void {
const d = getDb()
const rows: any[] = d.prepare('SELECT * FROM rubric ORDER BY sort_order').all()
if (!rows.length) throw new Error('暂无评分标准可导出')
const questions = rows.map(r => ({
questionTitle: r.question_title,
maxScore: r.max_score,
referenceAnswer: r.reference_answer,
rubric: r.rubric
}))
const questions = rows.map(r => {
const img = r.reference_answer_image
return {
questionTitle: r.question_title,
maxScore: r.max_score,
referenceAnswer: r.reference_answer,
rubric: r.rubric,
referenceAnswerImage: img && !img.startsWith('data:') ? `data:image/png;base64,${img}` : (img || undefined)
}
})
const result = await dialogBox.showSaveDialog({
defaultPath: '评分标准.json',
filters: [{ name: 'JSON', extensions: ['json'] }]
@@ -331,85 +497,118 @@ export function setupIpc(win: BrowserWindow): void {
const dialogPayloads: Record<string, any> = {}
let gradingPayload: any = null
async function callAiApi(imageBase64: string, rubric: string, config: { apiKey: string; baseUrl: string; model: string }, extras?: { maxScore?: number; referenceAnswer?: string; questionTitle?: string }): Promise<{
async function callAiApi(imageBase64: string, rubric: string, config: { apiKey: string; baseUrl: string; model: string }, extras?: { maxScore?: number; referenceAnswer?: string; questionTitle?: string; referenceAnswerImage?: string }): Promise<{
score: number
confidence: number
deductions: string[]
comment: string
}> {
const systemRole = `你是高校阅卷专家,严格按照评分标准逐项评分。输出JSON,不包含任何其他文字`
const systemRole = `你是一名高校阅卷老师,根据评分标准和答案对学生答案评分。输出JSON格式的结果`
const prompt = `${extras?.questionTitle ? `【题目】\n${extras.questionTitle}\n` : ''}【评分标准】
${rubric || '无'}
const hasRefImage = !!extras?.referenceAnswerImage
【参考答案】
${extras?.referenceAnswer || ''}
const prompt = hasRefImage
? `题目:${extras?.questionTitle || ''}
评分标准:${rubric || '无'}
参考答案:${extras?.referenceAnswer || '无'}
满分:${extras?.maxScore ?? 100}
【满分】
${extras?.maxScore ?? 100}
图片1是标准答案,图片2是学生答案。
请根据评分标准和标准答案(图片1)给学生答案(图片2)评分。
【学生答案】
请看图片中的内容。
评分规则:
1. 逐项评分,各项得分之和不超过满分${extras?.maxScore ?? 100}
2. 空白或无关答案给0分
3. 部分正确酌情给分
---
请输出JSON
{"score":分数,"confidence":置信度0~1,"deductions":["扣分项"],"comment":"评语"}
【评分规则】
1. 根据评分标准中的各项指标逐项评分,每项给出得分和说明
2. 各评分项得分之和不得超过满分${extras?.maxScore ?? 100}
3. 如果学生答案为空、空白或明显与题目无关,直接给0分
4. 部分正确时按评分标准酌情给分
只输出JSON,不要包含其他文字和markdown标记。`
: `题目:${extras?.questionTitle || ''}
评分标准:${rubric || '无'}
参考答案:${extras?.referenceAnswer || '无'}
满分:${extras?.maxScore ?? 100}
【输出要求】
只输出以下JSON,不要包含markdown代码块、\`\`\`标记、注释或任何其他文字:
{
"score": <总分>,
"confidence": <置信度0~1>,
"deductions": ["扣分项说明1", "扣分项说明2"],
"comment": "总评语及逐项得分说明"
}`
图片是学生答案,请根据评分标准评分。
评分规则:
1. 逐项评分,各项得分之和不超过满分${extras?.maxScore ?? 100}
2. 空白或无关答案给0分
3. 部分正确酌情给分
请输出JSON
{"score":分数,"confidence":置信度0~1,"deductions":["扣分项"],"comment":"评语"}
只输出JSON,不要包含其他文字和markdown标记。`
const axios = require('axios')
const response = await axios.post(`${config.baseUrl}/chat/completions`, {
model: config.model,
messages: [
{
role: 'system',
content: systemRole
},
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } },
{ type: 'text', text: prompt }
]
}
],
max_tokens: 4096,
temperature: 0.3
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`
},
timeout: ((getStore().get('timeout') as number) ?? 120) * 1000
})
const content = response.data?.choices?.[0]?.message?.content
if (!content) {
log.error('AI API 返回为空或格式异常:', JSON.stringify(response.data).slice(0, 1000))
throw new Error(`API 返回为空,请检查模型名是否正确 (当前: ${config.model})`)
}
const parsed = tryParseJson(content)
if (!parsed) {
log.error('AI 返回 JSON 解析失败:', content.slice(0, 500))
throw new Error(`AI 返回格式异常,无法解析为 JSON。原始返回:\n${content}`)
}
return {
score: parsed.score,
confidence: parsed.confidence ?? 0.5,
deductions: parsed.deductions ?? [],
comment: parsed.comment ?? parsed.commit ?? ''
const userContent: any[] = hasRefImage
? [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: extras!.referenceAnswerImage } },
{ type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } }
]
: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: `data:image/png;base64,${imageBase64}` } }
]
let lastError: Error | null = null
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const response = await axios.post(`${config.baseUrl}/chat/completions`, {
model: config.model,
messages: [
{
role: 'system',
content: systemRole
},
{
role: 'user',
content: userContent
}
],
max_tokens: 4096,
temperature: 0.3
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`
},
timeout: ((getStore().get('timeout') as number) ?? 120) * 1000
})
const content = response.data?.choices?.[0]?.message?.content
if (!content) {
lastError = new Error(`API 返回为空 (尝试 ${attempt}/3)`)
log.warn(`AI API empty response, attempt ${attempt}/3`)
if (attempt < 3) continue
throw lastError
}
const parsed = tryParseJson(content)
if (!parsed) {
lastError = new Error(`AI 返回 JSON 解析失败 (尝试 ${attempt}/3)`)
log.warn(`AI JSON parse failed, attempt ${attempt}/3:`, content.slice(0, 200))
if (attempt < 3) continue
throw lastError
}
return {
score: parsed.score,
confidence: parsed.confidence ?? 0.5,
deductions: parsed.deductions ?? [],
comment: parsed.comment ?? parsed.commit ?? ''
}
} catch (err: any) {
lastError = err
if (attempt < 3) {
log.info(`AI API attempt ${attempt}/3 failed, retrying...`)
await new Promise(r => setTimeout(r, 2000))
}
}
}
throw lastError || new Error('AI API 调用失败')
}
function tryParseJson(text: string): any {
+5 -5
View File
@@ -62,11 +62,11 @@ export function openDialogWindow(type: string): BrowserWindow | null {
}
const sizes: Record<string, { width: number; height: number }> = {
settings: { width: 520, height: 560 },
history: { width: 800, height: 600 },
templates: { width: 640, height: 520 },
rubric: { width: 640, height: 700 },
result: { width: 520, height: 700 }
settings: { width: 640, height: 680 },
history: { width: 1000, height: 750 },
templates: { width: 720, height: 600 },
rubric: { width: 750, height: 800 },
result: { width: 600, height: 780 }
}
const size = sizes[type] || { width: 500, height: 500 }
+15 -4
View File
@@ -3,7 +3,8 @@ import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
window: {
dragMove: (deltaX: number, deltaY: number) => ipcRenderer.invoke('window:dragMove', { deltaX, deltaY }),
dragEnd: (x: number, y: number) => ipcRenderer.invoke('window:dragEnd', { x, y })
dragEnd: (x: number, y: number) => ipcRenderer.invoke('window:dragEnd', { x, y }),
resizeMain: (width: number) => ipcRenderer.invoke('window:resizeMain', width)
},
screenshot: {
@@ -32,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: {
@@ -55,12 +57,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
importJson: () => ipcRenderer.invoke('rubric:importJson'),
getNextIndex: () => ipcRenderer.invoke('rubric:getNextIndex'),
setNextIndex: (idx: number) => ipcRenderer.invoke('rubric:setNextIndex', idx),
resetNextIndex: () => ipcRenderer.invoke('rubric:resetNextIndex')
resetNextIndex: () => ipcRenderer.invoke('rubric:resetNextIndex'),
onUpdated: (callback: () => void) => {
const handler = () => callback()
ipcRenderer.on('rubric:updated', handler)
return () => ipcRenderer.removeListener('rubric:updated', handler)
}
},
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),
exportJson: () => ipcRenderer.invoke('template:exportJson'),
importJson: () => ipcRenderer.invoke('template:importJson')
},
dialog: {
-24
View File
@@ -1,24 +0,0 @@
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\App.vue
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\main.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\vite-env.d.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\assets\icon.png
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\components\FloatingToolbar.vue
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\components\ResultDrawer.vue
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\components\ScreenshotOverlay.vue
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\components\SettingDialog.vue
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\pages\HistoryPage.vue
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\pages\TemplatePage.vue
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\router\index.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\stores\app.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\stores\grading.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\stores\settings.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\styles\main.css
D:\Workspace\Learn\AI\AI-Grading-Assistant\src\types\index.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\electron\main\ipc.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\electron\main\main.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\electron\main\screenshot.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\electron\main\shortcut.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\electron\main\tray.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\electron\main\window.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\electron\preload\preload.ts
D:\Workspace\Learn\AI\AI-Grading-Assistant\database\schema.sql
Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

+91 -21
View File
@@ -1,5 +1,5 @@
<template>
<div class="floating-toolbar" :class="{ 'is-hidden': !appStore.isToolbarVisible }">
<div ref="toolbarRef" class="floating-toolbar" :class="{ 'is-hidden': !appStore.isToolbarVisible }">
<div class="toolbar-buttons">
<el-button class="toolbar-btn" @click="startScreenshot" :loading="appStore.isGrading">
<span class="btn-icon">📷</span>
@@ -11,47 +11,92 @@
<span class="btn-label">评分标准</span>
</el-button>
<el-dropdown trigger="click" @command="handleMenu">
<el-button class="toolbar-btn">
<template v-if="!showMenu">
<el-button class="toolbar-btn" @click="showMenu = true">
<span class="btn-icon">📎</span>
<span class="btn-label">其它</span>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="image">📁 图片阅卷</el-dropdown-item>
<el-dropdown-item command="templates">📚 模板</el-dropdown-item>
<el-dropdown-item command="history">📜 历史</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template v-else>
<el-button class="toolbar-btn sub-btn" @click="showMenu = false; selectImage()">
<span class="btn-icon">📁</span>
<span class="btn-label">图片阅卷</span>
</el-button>
<el-button class="toolbar-btn sub-btn" @click="showMenu = false; openTemplates()">
<span class="btn-icon">📚</span>
<span class="btn-label">模板</span>
</el-button>
<el-button class="toolbar-btn sub-btn" @click="showMenu = false; openHistory()">
<span class="btn-icon">📜</span>
<span class="btn-label">历史</span>
</el-button>
<el-button class="toolbar-btn collapse-btn" @click="showMenu = false">
<span class="btn-icon"></span>
<span class="btn-label">收起</span>
</el-button>
</template>
<el-button class="toolbar-btn" @click="openSettings">
<span class="btn-icon"></span>
<span class="btn-label">设置</span>
</el-button>
</div>
<div class="status-bar">{{ statusText }}</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { ref, computed, nextTick, watch, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useAppStore } from '@/stores/app'
import { useGradingStore } from '@/stores/grading'
import { useSettingsStore } from '@/stores/settings'
import { addWatermark } from '@/utils/image'
const appStore = useAppStore()
const gradingStore = useGradingStore()
const settingsStore = useSettingsStore()
const showMenu = ref(false)
const toolbarRef = ref<HTMLElement | null>(null)
const statusText = computed(() => {
const questions = gradingStore.questions
const idx = gradingStore.currentQuestionIndex
return questions?.length ? `${idx + 1}/${questions.length}` : ''
})
async function resizeWindow() {
await nextTick()
if (toolbarRef.value && window.electronAPI) {
const w = toolbarRef.value.scrollWidth + 2
window.electronAPI.window.resizeMain(w)
}
}
watch(showMenu, resizeWindow)
let cleanupScreenshotListener: (() => void) | null = null
let cleanupRubricListener: (() => void) | null = null
onMounted(() => {
resizeWindow()
if (window.electronAPI) {
cleanupScreenshotListener = window.electronAPI.screenshot.onCompleted((base64: string) => {
cleanupScreenshotListener = window.electronAPI.screenshot.onCompleted(async (base64: string) => {
if (!appStore.isGrading) {
gradingStore.setImage(base64)
startGrading()
try {
gradingStore.setImage(await addWatermark(base64, '学生答案'))
startGrading()
} catch (err) {
ElMessage.error('水印处理失败:' + (err as Error).message)
}
}
})
cleanupRubricListener = window.electronAPI.rubric.onUpdated(async () => {
const data = await window.electronAPI!.rubric.getData()
if (data?.questions?.length) {
gradingStore.setQuestions(JSON.parse(JSON.stringify(data.questions)))
ElMessage.success(`已应用模板(${data.questions.length} 题)`)
}
})
}
@@ -59,6 +104,7 @@ onMounted(() => {
onUnmounted(() => {
cleanupScreenshotListener?.()
cleanupRubricListener?.()
})
function startScreenshot() {
@@ -69,7 +115,12 @@ async function selectImage() {
if (!window.electronAPI) return
const data = await window.electronAPI.dialog.openImage()
if (data) {
gradingStore.setImage(data.base64)
try {
gradingStore.setImage(await addWatermark(data.base64, '学生答案'))
} catch (err) {
ElMessage.error('水印处理失败:' + (err as Error).message)
return
}
startGrading()
}
}
@@ -92,6 +143,7 @@ async function gradeQuestion(questionIdx: number) {
rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}`,
maxScore: q.maxScore,
referenceAnswer: q.referenceAnswer,
referenceAnswerImage: q.referenceAnswerImage,
questionTitle: q.questionTitle,
apiConfig: {
apiKey: settingsStore.settings.apiKey,
@@ -157,7 +209,10 @@ function openSettings() { window.electronAPI?.dialog.open('settings') }
<style scoped>
.floating-toolbar {
position: fixed;
inset: 0;
top: 0;
left: 0;
width: fit-content;
min-width: 200px;
z-index: 99999;
user-select: none;
-webkit-app-region: drag;
@@ -171,14 +226,29 @@ function openSettings() { window.electronAPI?.dialog.open('settings') }
.toolbar-buttons {
display: flex;
gap: 6px;
padding: 8px 14px;
padding: 8px 14px 0;
justify-content: center;
align-items: center;
height: 100%;
}
.toolbar-buttons :deep(.el-dropdown) {
-webkit-app-region: no-drag;
.status-bar {
padding: 0 14px 6px;
font-size: 11px;
font-weight: 700;
color: rgba(255, 255, 255, 0.6);
white-space: nowrap;
text-align: center;
}
.sub-btn {
padding: 4px 8px !important;
}
.collapse-btn {
padding: 4px 6px !important;
color: rgba(255, 255, 255, 0.5) !important;
}
.collapse-btn:hover {
color: rgba(255, 255, 255, 0.9) !important;
}
.toolbar-btn {
+57 -14
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>
@@ -25,9 +25,16 @@
<div class="section-title">当前评分标准</div>
<div class="rubric-text">{{ currentQuestion.rubric }}</div>
</div>
<div class="result-section" v-if="currentQuestion.referenceAnswer">
<div class="result-section" v-if="gradingStore.currentImage">
<div class="section-title">学生答案图片</div>
<img class="student-image" :src="'data:image/png;base64,' + gradingStore.currentImage" />
</div>
<div class="result-section" v-if="currentQuestion.referenceAnswer || currentQuestion.referenceAnswerImage">
<div class="section-title">参考答案</div>
<div class="reference-text">{{ currentQuestion.referenceAnswer }}</div>
<div class="reference-text" v-if="currentQuestion.referenceAnswer">{{ currentQuestion.referenceAnswer }}</div>
<img v-if="currentQuestion.referenceAnswerImage" class="ref-image" :src="currentQuestion.referenceAnswerImage" />
</div>
<div class="result-score">
@@ -143,6 +150,7 @@ async function gradeAt(idx: number) {
rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}`,
maxScore: q.maxScore,
referenceAnswer: q.referenceAnswer,
referenceAnswerImage: q.referenceAnswerImage,
questionTitle: q.questionTitle,
apiConfig: {
apiKey: settingsStore.settings.apiKey,
@@ -179,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()
@@ -200,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>
@@ -353,6 +389,13 @@ async function confirmScore() {
border-radius: 6px;
white-space: pre-wrap;
}
.ref-image, .student-image {
max-width: 100%;
max-height: 300px;
border-radius: 6px;
margin-top: 8px;
border: 1px solid #e4e7ed;
}
.error-message {
background: #fef0f0;
color: #f56c6c;
+85
View File
@@ -25,6 +25,15 @@
</el-form-item>
<el-form-item label="参考答案">
<el-input v-model="questions[activeIndex].referenceAnswer" type="textarea" :rows="3" placeholder="输入参考答案(可选)" />
<div class="ref-image-section">
<template v-if="questions[activeIndex].referenceAnswerImage">
<div class="ref-image-preview">
<img :src="questions[activeIndex].referenceAnswerImage" />
<el-button size="small" type="danger" text @click="removeRefImage(activeIndex)">删除图片</el-button>
</div>
</template>
<el-button size="small" @click="uploadRefImage(activeIndex)">上传参考答案图片</el-button>
</div>
</el-form-item>
<el-form-item label="评分标准">
<el-input v-model="questions[activeIndex].rubric" type="textarea" :rows="6" placeholder="输入评分标准" />
@@ -37,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>
@@ -52,12 +77,16 @@ import { ElMessage } from 'element-plus'
import { useAppStore } from '@/stores/app'
import { useGradingStore } from '@/stores/grading'
import type { QuestionItem } from '@/types'
import { addWatermark } from '@/utils/image'
const appStore = useAppStore()
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: '' }
@@ -125,9 +154,43 @@ 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()
}
async function uploadRefImage(idx: number) {
if (!window.electronAPI) return
const data = await window.electronAPI.dialog.openImage()
if (data) {
questions.value[idx].referenceAnswerImage = `data:image/${data.mime};base64,${await addWatermark(data.base64, '参考答案')}`
}
}
function removeRefImage(idx: number) {
delete questions.value[idx].referenceAnswerImage
}
</script>
<style scoped>
@@ -178,4 +241,26 @@ function handleClose() {
display: flex;
gap: 8px;
}
.ref-image-section {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.ref-image-preview {
display: flex;
align-items: center;
gap: 8px;
}
.ref-image-preview img {
max-height: 120px;
max-width: 200px;
border-radius: 4px;
border: 1px solid #e4e7ed;
}
.template-form {
padding: 8px 16px;
border-top: 1px solid #eee;
background: #fafafa;
}
</style>
+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>
+51 -42
View File
@@ -1,33 +1,26 @@
<template>
<el-dialog v-model="visible" title="评分模板管理" width="600px" :modal="false" @close="handleClose">
<div class="template-section">
<el-form :model="form" size="small" inline>
<el-form-item label="考试名称">
<el-input v-model="form.name" placeholder="如:期末考试" />
</el-form-item>
<el-form-item label="课程">
<el-input v-model="form.course" placeholder="如:Java" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="saveTemplate">保存模板</el-button>
</el-form-item>
</el-form>
</div>
<el-divider />
<el-dialog v-model="visible" title="评分模板管理" width="680px" :modal="false" @close="handleClose">
<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>
<div style="margin-top: 12px; display: flex; gap: 8px;">
<el-button size="small" @click="exportTemplates">导出</el-button>
<el-button size="small" @click="importTemplates">导入</el-button>
</div>
<template #footer>
<el-button size="small" @click="handleClose">关闭</el-button>
</template>
@@ -35,16 +28,14 @@
</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 } from '@/types'
const appStore = useAppStore()
const gradingStore = useGradingStore()
const visible = ref(false)
const templates = ref<any[]>([])
const form = ref({ name: '', course: '' })
const templates = ref<TemplateItem[]>([])
watch(() => appStore.isTemplateDialogOpen, (val) => {
visible.value = val
@@ -57,23 +48,44 @@ async function loadTemplates() {
}
}
async function saveTemplate() {
if (!form.value.name || !form.value.course) {
ElMessage.warning('请填写考试名称和课程')
async function selectTemplate(tpl: TemplateItem) {
if (!tpl.questions?.length) {
ElMessage.warning('该模板没有题目数据')
return
}
if (window.electronAPI) {
await window.electronAPI.template.save(form.value)
ElMessage.success('模板已保存')
form.value = { name: '', course: '' }
const ok = await window.electronAPI?.template.apply(tpl.id)
if (ok) {
handleClose()
} else {
ElMessage.error('应用模板失败')
}
}
async function exportTemplates() {
const ok = await window.electronAPI?.template.exportJson()
if (ok) {
ElMessage.success('模板已导出')
} else if (ok === false) {
ElMessage.warning('导出失败或已取消')
}
}
async function importTemplates() {
const result = await window.electronAPI?.template.importJson()
if (result) {
ElMessage.success(`已导入 ${result.count} 个模板`)
loadTemplates()
}
}
function selectTemplate(tpl: any) {
gradingStore.setRubric(`考试: ${tpl.name}\n课程: ${tpl.course}\n请根据标准答案评分。`)
ElMessage.success('已选择模板:' + tpl.name)
handleClose()
async function deleteTemplate(tpl: TemplateItem) {
if (window.electronAPI) {
const ok = await window.electronAPI.template.delete(tpl.id)
if (ok) {
ElMessage.success('已删除')
loadTemplates()
}
}
}
function handleClose() {
@@ -82,7 +94,4 @@ function handleClose() {
</script>
<style scoped>
.template-section {
padding: 8px 0;
}
</style>
+37 -3
View File
@@ -3,6 +3,7 @@ export interface QuestionItem {
maxScore: number
referenceAnswer: string
rubric: string
referenceAnswerImage?: string
}
export interface GradeResult {
@@ -40,12 +41,30 @@ 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 TemplateItem {
id: number
name: string
course: string
questions: QuestionItem[]
created_at: string
}
export interface ElectronAPI {
window: {
dragMove: (deltaX: number, deltaY: number) => Promise<void>
dragEnd: (x: number, y: number) => Promise<void>
resizeMain: (width: number) => Promise<void>
}
screenshot: {
start: () => Promise<void>
@@ -55,7 +74,7 @@ export interface ElectronAPI {
onCompleted: (callback: (base64: string) => void) => () => void
}
ai: {
grade: (data: { imageBase64: string; rubric: string; apiConfig: ApiConfig; maxScore?: number; referenceAnswer?: string; questionTitle?: string }) => Promise<GradeResult>
grade: (data: { imageBase64: string; rubric: string; apiConfig: ApiConfig; maxScore?: number; referenceAnswer?: string; questionTitle?: string; referenceAnswerImage?: string }) => Promise<GradeResult>
}
settings: {
get: () => Promise<AppSettings>
@@ -65,6 +84,7 @@ export interface ElectronAPI {
history: {
list: () => Promise<HistoryRecord[]>
delete: (id: number) => Promise<boolean>
getById: (id: number) => Promise<HistoryRecord | null>
}
grading: {
submit: (data: {
@@ -74,6 +94,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>
@@ -86,10 +115,15 @@ export interface ElectronAPI {
getNextIndex: () => Promise<number>
setNextIndex: (idx: number) => Promise<void>
resetNextIndex: () => Promise<void>
onUpdated: (callback: () => void) => () => 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>
exportJson: () => Promise<boolean>
importJson: () => Promise<{ count: number } | null>
}
dialog: {
open: (type: string, data?: any) => Promise<void>
+30
View File
@@ -0,0 +1,30 @@
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 = 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, 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(w / 60))
ctx.fillText(text, padding, padding)
resolve(canvas.toDataURL('image/png').replace(/^data:image\/png;base64,/, ''))
}
img.onerror = () => reject(new Error('Failed to load image for watermark'))
img.src = `data:image/png;base64,${base64}`
})
}