Compare commits
10
Commits
cc40cd5524
...
8f7276ca1d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f7276ca1d | ||
|
|
02b4150447 | ||
|
|
7d4551e7f2 | ||
|
|
e84b0705e5 | ||
|
|
cc0f80d020 | ||
|
|
08a831b806 | ||
|
|
e7a9cd23ad | ||
|
|
ba7aff17d7 | ||
|
|
25b8cfb787 | ||
|
|
499a55af77 |
+2
-1
@@ -12,4 +12,5 @@ build-release/
|
||||
dist-release/
|
||||
out-electron/
|
||||
out-portable/
|
||||
packaged/
|
||||
packaged/
|
||||
test/
|
||||
@@ -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.
|
||||
@@ -0,0 +1,121 @@
|
||||
# AI 阅卷助手 (AI Grading Assistant)
|
||||
|
||||
> 桌面端 AI Copilot — 零侵入式智能阅卷辅助工具
|
||||
|
||||
AI 阅卷助手是一款基于 **Electron + Vue 3 + TypeScript** 构建的桌面端 AI 阅卷辅助工具。教师可在现有阅卷系统中正常阅卷,通过全局快捷键(默认 `Alt+Q`)截图学生答案,AI 自动根据预设评分标准给出评分建议、扣分项和评语,帮助提升阅卷效率与一致性。
|
||||
|
||||
   
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **零侵入设计** — 独立运行,不干预现有阅卷系统
|
||||
- **全局截图** — `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)。
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 103 KiB |
+300
-101
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 |
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user