init: AI Grading Assistant MVP
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
*.db
|
||||||
|
logs/
|
||||||
|
config/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS exam (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
course TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS question (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
exam_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
max_score INTEGER NOT NULL DEFAULT 100,
|
||||||
|
rubric TEXT,
|
||||||
|
FOREIGN KEY (exam_id) REFERENCES exam(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
question_id INTEGER DEFAULT 0,
|
||||||
|
image_path TEXT DEFAULT '',
|
||||||
|
student_answer TEXT DEFAULT '',
|
||||||
|
ai_score REAL DEFAULT 0,
|
||||||
|
teacher_score REAL DEFAULT 0,
|
||||||
|
confidence REAL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { BrowserWindow, ipcMain, dialog as dialogBox } from 'electron'
|
||||||
|
import { captureScreen, closeScreenshot, getCapturedImage, getCapturedBase64, clearCapturedImage, startScreenshot } from './screenshot'
|
||||||
|
import { getMainWindow, openDialogWindow } from './window'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import log from 'electron-log'
|
||||||
|
|
||||||
|
let store: any = null
|
||||||
|
function getStore() {
|
||||||
|
if (!store) {
|
||||||
|
const Store = require('electron-store').default
|
||||||
|
store = new Store({
|
||||||
|
defaults: {
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: 'https://api.siliconflow.cn/v1',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
shortcut: 'Alt+Q',
|
||||||
|
theme: 'light',
|
||||||
|
fontSize: 14,
|
||||||
|
proxy: '',
|
||||||
|
timeout: 120,
|
||||||
|
screenshotSavePath: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// migrate old low timeout values
|
||||||
|
const t = store.get('timeout') as number | undefined
|
||||||
|
if (t !== undefined && t < 60) store.set('timeout', 120)
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: any = null
|
||||||
|
function getDb() {
|
||||||
|
if (!db) {
|
||||||
|
const Database = require('better-sqlite3')
|
||||||
|
db = new Database('grading.db')
|
||||||
|
db.prepare(`CREATE TABLE IF NOT EXISTS history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
question_id INTEGER,
|
||||||
|
image_path TEXT,
|
||||||
|
student_answer TEXT,
|
||||||
|
ai_score REAL,
|
||||||
|
teacher_score REAL,
|
||||||
|
confidence REAL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`).run()
|
||||||
|
db.prepare(`CREATE TABLE IF NOT EXISTS exam (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT,
|
||||||
|
course TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`).run()
|
||||||
|
db.prepare(`CREATE TABLE IF NOT EXISTS question (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
exam_id INTEGER,
|
||||||
|
title TEXT,
|
||||||
|
max_score INTEGER,
|
||||||
|
rubric TEXT
|
||||||
|
)`).run()
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupIpc(win: BrowserWindow): void {
|
||||||
|
|
||||||
|
ipcMain.handle('window:dragMove', (_event, { deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
|
||||||
|
const [x, y] = win.getPosition()
|
||||||
|
win.setPosition(x + deltaX, y + deltaY)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('window:dragEnd', (_event, { x, y }: { x: number; y: number }) => {
|
||||||
|
win.setPosition(x, y)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('screenshot:start', () => {
|
||||||
|
log.info('IPC: screenshot:start')
|
||||||
|
startScreenshot(win)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('screenshot:capture', async (_event, region: { x: number; y: number; width: number; height: number }) => {
|
||||||
|
log.info('IPC: screenshot:capture', region)
|
||||||
|
const base64 = await captureScreen(region)
|
||||||
|
closeScreenshot()
|
||||||
|
setTimeout(() => {
|
||||||
|
const mainWin = getMainWindow()
|
||||||
|
if (mainWin) {
|
||||||
|
mainWin.show()
|
||||||
|
mainWin.webContents.send('screenshot:completed', base64)
|
||||||
|
}
|
||||||
|
require('./shortcut').enableShortcut()
|
||||||
|
}, 100)
|
||||||
|
return base64
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('screenshot:getLastImage', () => {
|
||||||
|
return getCapturedBase64()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('screenshot:cancel', () => {
|
||||||
|
log.info('IPC: screenshot:cancel')
|
||||||
|
closeScreenshot()
|
||||||
|
setTimeout(() => {
|
||||||
|
const mainWin = getMainWindow()
|
||||||
|
if (mainWin) mainWin.show()
|
||||||
|
require('./shortcut').enableShortcut()
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('ai:grade', async (_event, { imageBase64, rubric, apiConfig, maxScore, referenceAnswer, questionTitle }: {
|
||||||
|
imageBase64: string
|
||||||
|
rubric: string
|
||||||
|
apiConfig: { apiKey: string; baseUrl: string; model: string }
|
||||||
|
maxScore?: number
|
||||||
|
referenceAnswer?: string
|
||||||
|
questionTitle?: string
|
||||||
|
}) => {
|
||||||
|
log.info('IPC: ai:grade')
|
||||||
|
try {
|
||||||
|
const result = await callAiApi(imageBase64, rubric, apiConfig, { maxScore, referenceAnswer, questionTitle })
|
||||||
|
return result
|
||||||
|
} catch (err: any) {
|
||||||
|
log.error('AI grade failed:', err)
|
||||||
|
throw new Error(err.message || 'AI grading failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('settings:get', () => {
|
||||||
|
return getStore().store
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('settings:set', (_event, settings: Record<string, unknown>) => {
|
||||||
|
const s = getStore()
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
s.set(key, value)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('settings:selectDirectory', async () => {
|
||||||
|
const result = await dialogBox.showOpenDialog({
|
||||||
|
properties: ['openDirectory']
|
||||||
|
})
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return null
|
||||||
|
return result.filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('history:list', () => {
|
||||||
|
try {
|
||||||
|
return getDb().prepare('SELECT * FROM history ORDER BY created_at DESC LIMIT 100').all()
|
||||||
|
} catch (err) {
|
||||||
|
log.error('history:list error:', err)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('history:delete', (_event, id: number) => {
|
||||||
|
try {
|
||||||
|
getDb().prepare('DELETE FROM history WHERE id = ?').run(id)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('grading:submit', (_event, data: {
|
||||||
|
questionId: number
|
||||||
|
imagePath: string
|
||||||
|
aiScore: number
|
||||||
|
teacherScore: number
|
||||||
|
confidence: number
|
||||||
|
studentAnswer?: 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('template:list', () => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const exams = db.prepare('SELECT * FROM exam ORDER BY created_at DESC').all()
|
||||||
|
return exams
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('template:save', (_event, data: { name: string; course: string }) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db.prepare('INSERT INTO exam (name, course) VALUES (?, ?)').run(data.name, data.course)
|
||||||
|
return result.lastInsertRowid
|
||||||
|
} catch (err) {
|
||||||
|
log.error('template:save error:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Dialog child windows ---
|
||||||
|
ipcMain.handle('dialog:open', (_event, { type, data }: { type: string; data?: any }) => {
|
||||||
|
log.info('IPC: dialog:open', type)
|
||||||
|
if (data !== undefined) {
|
||||||
|
dialogPayloads[type] = data
|
||||||
|
}
|
||||||
|
openDialogWindow(type)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('dialog:close', (_event) => {
|
||||||
|
const w = BrowserWindow.fromWebContents(_event.sender)
|
||||||
|
if (w) w.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('dialog:getPayload', (_event, type: string) => {
|
||||||
|
const data = dialogPayloads[type]
|
||||||
|
delete dialogPayloads[type]
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Open image file from disk ---
|
||||||
|
ipcMain.handle('dialog:openImage', async () => {
|
||||||
|
const result = await dialogBox.showOpenDialog({
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'bmp', 'gif', 'webp'] }]
|
||||||
|
})
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return null
|
||||||
|
const filePath = result.filePaths[0]
|
||||||
|
const ext = path.extname(filePath).toLowerCase().replace('.', '')
|
||||||
|
const mime = ext === 'jpg' ? 'jpeg' : ext
|
||||||
|
const buffer = fs.readFileSync(filePath)
|
||||||
|
return { base64: buffer.toString('base64'), mime, filePath }
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Rubric data bridge (toolbar ↔ rubric window) ---
|
||||||
|
ipcMain.handle('rubric:storeData', (_event, data: any) => {
|
||||||
|
rubricPayload = data
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('rubric:getData', () => rubricPayload)
|
||||||
|
|
||||||
|
// --- Grading data bridge (toolbar → result window) ---
|
||||||
|
ipcMain.handle('grading:storeData', (_event, data: any) => {
|
||||||
|
gradingPayload = data
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('grading:getData', () => gradingPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogPayloads: Record<string, any> = {}
|
||||||
|
let rubricPayload: any = null
|
||||||
|
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<{
|
||||||
|
score: number
|
||||||
|
confidence: number
|
||||||
|
deductions: string[]
|
||||||
|
comment: string
|
||||||
|
}> {
|
||||||
|
const systemRole = `你是一名经验丰富的高校教师。你必须严格依据评分标准逐项评分。
|
||||||
|
|
||||||
|
【输出要求】
|
||||||
|
1. 仅输出纯JSON,不要包含任何markdown代码块、\`\`\`标记、或额外文字
|
||||||
|
2. 不得在JSON前后添加任何说明、注释或分隔符
|
||||||
|
3. 字符串必须使用双引号,不得使用单引号
|
||||||
|
4. 不得有多余的逗号
|
||||||
|
|
||||||
|
【输出格式】
|
||||||
|
{
|
||||||
|
"score": 分数,
|
||||||
|
"confidence": 置信度(0-1),
|
||||||
|
"deductions": ["扣分项1", "扣分项2"],
|
||||||
|
"comment": "总评语及逐项得分说明"
|
||||||
|
}`
|
||||||
|
|
||||||
|
const prompt = `${extras?.questionTitle ? `【题目】\n${extras.questionTitle}\n` : ''}【评分标准】
|
||||||
|
${rubric}
|
||||||
|
|
||||||
|
【参考答案】
|
||||||
|
${extras?.referenceAnswer || '无'}
|
||||||
|
|
||||||
|
【总分】
|
||||||
|
${extras?.maxScore ?? 100}
|
||||||
|
|
||||||
|
【学生答案】
|
||||||
|
请看图片中的内容。`
|
||||||
|
|
||||||
|
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: 2048,
|
||||||
|
temperature: 0.1
|
||||||
|
}, {
|
||||||
|
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
|
||||||
|
const parsed = tryParseJson(content)
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error(`AI 返回格式异常,无法解析为 JSON。原始返回:\n${content}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
score: parsed.score,
|
||||||
|
confidence: parsed.confidence ?? 0.5,
|
||||||
|
deductions: parsed.deductions ?? [],
|
||||||
|
comment: parsed.comment ?? parsed.commit ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson(text: string): any {
|
||||||
|
let cleaned = text.trim()
|
||||||
|
// remove markdown code fences
|
||||||
|
cleaned = cleaned.replace(/```(?:json)?\s*/gi, '').replace(/```\s*/g, '').trim()
|
||||||
|
// if there's still non-JSON text, extract the first {...} object
|
||||||
|
if (!cleaned.startsWith('{') && !cleaned.startsWith('[')) {
|
||||||
|
const match = cleaned.match(/\{[\s\S]*\}/)
|
||||||
|
if (match) cleaned = match[0]
|
||||||
|
}
|
||||||
|
// attempt direct parse
|
||||||
|
try {
|
||||||
|
return JSON.parse(cleaned)
|
||||||
|
} catch {}
|
||||||
|
// fix single quotes -> double quotes (but keep escaped ones)
|
||||||
|
try {
|
||||||
|
const fixed = cleaned
|
||||||
|
.replace(/(?<!\\)'/g, '"')
|
||||||
|
.replace(/,(\s*[}\]])/g, '$1') // trailing commas
|
||||||
|
return JSON.parse(fixed)
|
||||||
|
} catch {}
|
||||||
|
// fix unquoted keys
|
||||||
|
try {
|
||||||
|
const fixed = cleaned
|
||||||
|
.replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3')
|
||||||
|
.replace(/,(\s*[}\]])/g, '$1')
|
||||||
|
return JSON.parse(fixed)
|
||||||
|
} catch {}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
import { createMainWindow } from './window'
|
||||||
|
import { registerShortcut } from './shortcut'
|
||||||
|
import { setupIpc } from './ipc'
|
||||||
|
import { createTray } from './tray'
|
||||||
|
import log from 'electron-log'
|
||||||
|
|
||||||
|
log.info('App starting...')
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
const win = createMainWindow()
|
||||||
|
registerShortcut(win)
|
||||||
|
setupIpc(win)
|
||||||
|
createTray(win)
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createMainWindow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { BrowserWindow, desktopCapturer, screen } from 'electron'
|
||||||
|
import * as path from 'path'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import log from 'electron-log'
|
||||||
|
|
||||||
|
let screenshotWindow: BrowserWindow | null = null
|
||||||
|
let capturedImage: Buffer | null = null
|
||||||
|
let capturedBase64: string | null = null
|
||||||
|
let isScreenshotActive = false
|
||||||
|
let lastScreenshotTime = 0
|
||||||
|
const SCREENSHOT_COOLDOWN = 2000
|
||||||
|
|
||||||
|
export function startScreenshot(mainWin: BrowserWindow): void {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastScreenshotTime < SCREENSHOT_COOLDOWN) {
|
||||||
|
log.warn('Screenshot on cooldown, ignoring')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isScreenshotActive) {
|
||||||
|
log.warn('Screenshot already active, ignoring')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isScreenshotActive = true
|
||||||
|
lastScreenshotTime = now
|
||||||
|
require('./shortcut').disableShortcut()
|
||||||
|
mainWin.hide()
|
||||||
|
|
||||||
|
const cursor = screen.getCursorScreenPoint()
|
||||||
|
const displays = screen.getAllDisplays()
|
||||||
|
const targetDisplay = displays.find(d => {
|
||||||
|
return cursor.x >= d.bounds.x && cursor.x <= d.bounds.x + d.bounds.width &&
|
||||||
|
cursor.y >= d.bounds.y && cursor.y <= d.bounds.y + d.bounds.height
|
||||||
|
}) || displays[0]
|
||||||
|
|
||||||
|
const bounds = targetDisplay.bounds
|
||||||
|
|
||||||
|
screenshotWindow = new BrowserWindow({
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
resizable: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '../preload/preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development' || process.argv.includes('--dev')) {
|
||||||
|
screenshotWindow.loadURL(`http://localhost:5173/#/screenshot`)
|
||||||
|
} else {
|
||||||
|
screenshotWindow.loadFile(path.join(__dirname, '../../dist/index.html'), { hash: '/screenshot' })
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshotWindow.on('closed', () => {
|
||||||
|
isScreenshotActive = false
|
||||||
|
screenshotWindow = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureScreen(region: { x: number; y: number; width: number; height: number }): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const displays = screen.getAllDisplays()
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ['screen'],
|
||||||
|
thumbnailSize: { width: 4096, height: 2160 }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (sources.length === 0) return null
|
||||||
|
|
||||||
|
const cursor = screen.getCursorScreenPoint()
|
||||||
|
const targetDisplay = displays.find(d => {
|
||||||
|
return cursor.x >= d.bounds.x && cursor.x <= d.bounds.x + d.bounds.width &&
|
||||||
|
cursor.y >= d.bounds.y && cursor.y <= d.bounds.y + d.bounds.height
|
||||||
|
}) || displays[0]
|
||||||
|
|
||||||
|
const sourceIndex = displays.findIndex(d => d.id === targetDisplay.id)
|
||||||
|
const source = sources[Math.min(sourceIndex, sources.length - 1)]
|
||||||
|
|
||||||
|
const fullImage = source.thumbnail
|
||||||
|
const displayWidth = targetDisplay.bounds.width
|
||||||
|
const displayHeight = targetDisplay.bounds.height
|
||||||
|
const scaleX = fullImage.getSize().width / displayWidth
|
||||||
|
const scaleY = fullImage.getSize().height / displayHeight
|
||||||
|
|
||||||
|
const cropX = Math.round(region.x * scaleX)
|
||||||
|
const cropY = Math.round(region.y * scaleY)
|
||||||
|
const cropW = Math.round(region.width * scaleX)
|
||||||
|
const cropH = Math.round(region.height * scaleY)
|
||||||
|
|
||||||
|
const cropped = fullImage.crop({
|
||||||
|
x: Math.max(0, cropX),
|
||||||
|
y: Math.max(0, cropY),
|
||||||
|
width: Math.min(cropW, fullImage.getSize().width - cropX),
|
||||||
|
height: Math.min(cropH, fullImage.getSize().height - cropY)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pngBuffer = cropped.toPNG()
|
||||||
|
const base64 = pngBuffer.toString('base64')
|
||||||
|
capturedImage = pngBuffer
|
||||||
|
capturedBase64 = base64
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Store = require('electron-store').default
|
||||||
|
const store = new Store()
|
||||||
|
const savePath = store.get('screenshotSavePath', '')
|
||||||
|
if (savePath) {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const filename = `screenshot_${timestamp}.png`
|
||||||
|
const fullPath = path.join(savePath, filename)
|
||||||
|
fs.writeFileSync(fullPath, pngBuffer)
|
||||||
|
log.info('Screenshot saved to', fullPath)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.error('Failed to save screenshot:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Screenshot captured', { width: region.width, height: region.height })
|
||||||
|
return base64
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Screenshot capture error:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeScreenshot(): void {
|
||||||
|
if (screenshotWindow && !screenshotWindow.isDestroyed()) {
|
||||||
|
screenshotWindow.close()
|
||||||
|
if (!screenshotWindow.isDestroyed()) {
|
||||||
|
screenshotWindow.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
screenshotWindow = null
|
||||||
|
isScreenshotActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCapturedImage(): Buffer | null {
|
||||||
|
return capturedImage
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCapturedBase64(): string | null {
|
||||||
|
return capturedBase64
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCapturedImage(): void {
|
||||||
|
capturedImage = null
|
||||||
|
capturedBase64 = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { globalShortcut, BrowserWindow } from 'electron'
|
||||||
|
import log from 'electron-log'
|
||||||
|
|
||||||
|
let registeredWin: BrowserWindow | null = null
|
||||||
|
let shortcutEnabled = false
|
||||||
|
|
||||||
|
const handler = () => {
|
||||||
|
log.info('Shortcut Alt+Q triggered')
|
||||||
|
if (registeredWin) {
|
||||||
|
require('./screenshot').startScreenshot(registeredWin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerShortcut(win: BrowserWindow): void {
|
||||||
|
registeredWin = win
|
||||||
|
if (!shortcutEnabled) {
|
||||||
|
globalShortcut.register('Alt+Q', handler)
|
||||||
|
shortcutEnabled = true
|
||||||
|
log.info('Shortcut Alt+Q registered')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disableShortcut(): void {
|
||||||
|
if (shortcutEnabled) {
|
||||||
|
globalShortcut.unregister('Alt+Q')
|
||||||
|
shortcutEnabled = false
|
||||||
|
log.info('Shortcut Alt+Q disabled')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enableShortcut(): void {
|
||||||
|
if (!shortcutEnabled && registeredWin) {
|
||||||
|
globalShortcut.register('Alt+Q', handler)
|
||||||
|
shortcutEnabled = true
|
||||||
|
log.info('Shortcut Alt+Q re-enabled')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterShortcut(): void {
|
||||||
|
disableShortcut()
|
||||||
|
registeredWin = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Tray, Menu, BrowserWindow, app, nativeImage } from 'electron'
|
||||||
|
import * as path from 'path'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
|
||||||
|
let tray: Tray | null = null
|
||||||
|
|
||||||
|
export function createTray(win: BrowserWindow): void {
|
||||||
|
const iconPath = path.join(__dirname, '../../src/assets/icon.png')
|
||||||
|
|
||||||
|
let icon: Electron.NativeImage
|
||||||
|
if (fs.existsSync(iconPath)) {
|
||||||
|
icon = nativeImage.createFromPath(iconPath)
|
||||||
|
} else {
|
||||||
|
icon = nativeImage.createEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
tray = new Tray(icon)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: '显示主面板',
|
||||||
|
click: () => {
|
||||||
|
win.show()
|
||||||
|
win.focus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: '退出',
|
||||||
|
click: () => {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
tray.setToolTip('AI 阅卷助手')
|
||||||
|
tray.setContextMenu(contextMenu)
|
||||||
|
|
||||||
|
tray.on('click', () => {
|
||||||
|
win.show()
|
||||||
|
win.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { BrowserWindow, screen } from 'electron'
|
||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
export let baseAppUrl = ''
|
||||||
|
|
||||||
|
export function createMainWindow(): BrowserWindow {
|
||||||
|
const cursor = screen.getCursorScreenPoint()
|
||||||
|
const displays = screen.getAllDisplays()
|
||||||
|
const targetDisplay = displays.find((d: Electron.Display) =>
|
||||||
|
cursor.x >= d.bounds.x && cursor.x <= d.bounds.x + d.bounds.width &&
|
||||||
|
cursor.y >= d.bounds.y && cursor.y <= d.bounds.y + d.bounds.height
|
||||||
|
) || displays[0]
|
||||||
|
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
x: Math.round(targetDisplay.bounds.x + targetDisplay.bounds.width / 2 - 300),
|
||||||
|
y: targetDisplay.bounds.y + 100,
|
||||||
|
width: 600,
|
||||||
|
height: 64,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
resizable: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '../preload/preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development' || process.argv.includes('--dev')) {
|
||||||
|
baseAppUrl = 'http://localhost:5173'
|
||||||
|
mainWindow.loadURL(baseAppUrl)
|
||||||
|
} else {
|
||||||
|
baseAppUrl = `file://${path.join(__dirname, '../../dist/index.html').replace(/\\/g, '/')}`
|
||||||
|
mainWindow.loadFile(path.join(__dirname, '../../dist/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return mainWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
|
return mainWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogWindows: Record<string, BrowserWindow | null> = {}
|
||||||
|
|
||||||
|
export function openDialogWindow(type: string): BrowserWindow | null {
|
||||||
|
const existing = dialogWindows[type]
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
existing.focus()
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 520, height: 480 },
|
||||||
|
result: { width: 440, height: 560 }
|
||||||
|
}
|
||||||
|
const size = sizes[type] || { width: 500, height: 500 }
|
||||||
|
|
||||||
|
const child = new BrowserWindow({
|
||||||
|
...size,
|
||||||
|
show: false,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
resizable: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '../preload/preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development' || process.argv.includes('--dev')) {
|
||||||
|
child.loadURL(`${baseAppUrl}?dialog=${type}`)
|
||||||
|
} else {
|
||||||
|
child.loadFile(path.join(__dirname, '../../dist/index.html'), { query: { dialog: type } })
|
||||||
|
}
|
||||||
|
child.show()
|
||||||
|
|
||||||
|
dialogWindows[type] = child
|
||||||
|
child.on('closed', () => { dialogWindows[type] = null })
|
||||||
|
return child
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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 })
|
||||||
|
},
|
||||||
|
|
||||||
|
screenshot: {
|
||||||
|
start: () => ipcRenderer.invoke('screenshot:start'),
|
||||||
|
capture: (region: { x: number; y: number; width: number; height: number }) =>
|
||||||
|
ipcRenderer.invoke('screenshot:capture', region),
|
||||||
|
cancel: () => ipcRenderer.invoke('screenshot:cancel'),
|
||||||
|
getLastImage: () => ipcRenderer.invoke('screenshot:getLastImage'),
|
||||||
|
onCompleted: (callback: (base64: string) => void) => {
|
||||||
|
const handler = (_event: any, base64: string) => callback(base64)
|
||||||
|
ipcRenderer.on('screenshot:completed', handler)
|
||||||
|
return () => ipcRenderer.removeListener('screenshot:completed', handler)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
ai: {
|
||||||
|
grade: (data: { imageBase64: string; rubric: string; apiConfig: { apiKey: string; baseUrl: string; model: string } }) =>
|
||||||
|
ipcRenderer.invoke('ai:grade', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
get: () => ipcRenderer.invoke('settings:get'),
|
||||||
|
set: (settings: Record<string, unknown>) => ipcRenderer.invoke('settings:set', settings),
|
||||||
|
selectDirectory: () => ipcRenderer.invoke('settings:selectDirectory')
|
||||||
|
},
|
||||||
|
|
||||||
|
history: {
|
||||||
|
list: () => ipcRenderer.invoke('history:list'),
|
||||||
|
delete: (id: number) => ipcRenderer.invoke('history:delete', id)
|
||||||
|
},
|
||||||
|
|
||||||
|
grading: {
|
||||||
|
submit: (data: {
|
||||||
|
questionId: number
|
||||||
|
imagePath: string
|
||||||
|
aiScore: number
|
||||||
|
teacherScore: number
|
||||||
|
confidence: number
|
||||||
|
studentAnswer?: string
|
||||||
|
}) => ipcRenderer.invoke('grading:submit', data),
|
||||||
|
storeData: (data: any) => ipcRenderer.invoke('grading:storeData', data),
|
||||||
|
getData: () => ipcRenderer.invoke('grading:getData')
|
||||||
|
},
|
||||||
|
|
||||||
|
rubric: {
|
||||||
|
storeData: (data: any) => ipcRenderer.invoke('rubric:storeData', data),
|
||||||
|
getData: () => ipcRenderer.invoke('rubric:getData')
|
||||||
|
},
|
||||||
|
|
||||||
|
template: {
|
||||||
|
list: () => ipcRenderer.invoke('template:list'),
|
||||||
|
save: (data: { name: string; course: string }) => ipcRenderer.invoke('template:save', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
dialog: {
|
||||||
|
open: (type: string, data?: any) => ipcRenderer.invoke('dialog:open', { type, data }),
|
||||||
|
close: () => ipcRenderer.send('dialog:close'),
|
||||||
|
getPayload: (type: string) => ipcRenderer.invoke('dialog:getPayload', type),
|
||||||
|
openImage: () => ipcRenderer.invoke('dialog:openImage')
|
||||||
|
}
|
||||||
|
})
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI 阅卷助手</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+7103
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-grading-assistant",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "AI 阅卷助手 - 桌面端 AI Copilot",
|
||||||
|
"main": "dist-electron/main/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -k \"npm run dev:renderer\" \"npm run dev:electron\"",
|
||||||
|
"dev:renderer": "vite",
|
||||||
|
"dev:electron": "wait-on http://localhost:5173 && tsc -p tsconfig.electron.json && cross-env NODE_ENV=development electron . --dev",
|
||||||
|
"build:renderer": "vite build",
|
||||||
|
"build:electron": "tsc -p tsconfig.electron.json",
|
||||||
|
"build": "npm run build:renderer && npm run build:electron",
|
||||||
|
"start": "electron .",
|
||||||
|
"lint": "vue-tsc --noEmit --skipLibCheck"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ai",
|
||||||
|
"grading",
|
||||||
|
"education",
|
||||||
|
"electron"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"better-sqlite3": "^12.11.1",
|
||||||
|
"electron": "^42.0.0",
|
||||||
|
"electron-log": "^5.4.4",
|
||||||
|
"electron-store": "^11.0.2",
|
||||||
|
"element-plus": "^2.14.3",
|
||||||
|
"pinia": "^4.0.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
|
"vue": "^3.5.39",
|
||||||
|
"vue-router": "^5.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.1.4",
|
||||||
|
"vue-tsc": "^3.3.7",
|
||||||
|
"wait-on": "^8.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="dialogType">
|
||||||
|
<SettingDialog v-if="dialogType === 'settings'" />
|
||||||
|
<HistoryDialog v-if="dialogType === 'history'" />
|
||||||
|
<TemplateDialog v-if="dialogType === 'templates'" />
|
||||||
|
<RubricEditor v-if="dialogType === 'rubric'" />
|
||||||
|
<ResultDrawer v-if="dialogType === 'result'" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import { useGradingStore } from '@/stores/grading'
|
||||||
|
import ResultDrawer from '@/components/ResultDrawer.vue'
|
||||||
|
import SettingDialog from '@/components/SettingDialog.vue'
|
||||||
|
import HistoryDialog from '@/pages/HistoryDialog.vue'
|
||||||
|
import TemplateDialog from '@/pages/TemplateDialog.vue'
|
||||||
|
import RubricEditor from '@/components/RubricEditor.vue'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const gradingStore = useGradingStore()
|
||||||
|
|
||||||
|
const dialogType = new URLSearchParams(window.location.search).get('dialog')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (dialogType === 'result') {
|
||||||
|
const data = await window.electronAPI?.grading.getData()
|
||||||
|
if (data) {
|
||||||
|
if (data.image) gradingStore.setImage(data.image)
|
||||||
|
if (data.rubric) gradingStore.setRubric(data.rubric)
|
||||||
|
if (data.result) gradingStore.setResult(data.result)
|
||||||
|
if (data.questionTitle) gradingStore.setQuestionTitle(data.questionTitle)
|
||||||
|
if (data.maxScore) gradingStore.setMaxScore(data.maxScore)
|
||||||
|
if (data.referenceAnswer) gradingStore.setReferenceAnswer(data.referenceAnswer)
|
||||||
|
}
|
||||||
|
appStore.openResultDrawer()
|
||||||
|
} else if (dialogType) {
|
||||||
|
if (dialogType === 'settings') await settingsStore.loadSettings()
|
||||||
|
const actions: Record<string, () => void> = {
|
||||||
|
settings: () => appStore.openSettingDialog(),
|
||||||
|
history: () => appStore.openHistoryDialog(),
|
||||||
|
templates: () => appStore.openTemplateDialog(),
|
||||||
|
rubric: () => appStore.openRubricEditor()
|
||||||
|
}
|
||||||
|
actions[dialogType]?.()
|
||||||
|
} else {
|
||||||
|
settingsStore.loadSettings()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 483 B |
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<div class="floating-toolbar" :class="{ 'is-hidden': !appStore.isToolbarVisible }">
|
||||||
|
<div class="toolbar-buttons">
|
||||||
|
<el-button class="toolbar-btn" @click="startScreenshot">
|
||||||
|
<span class="btn-icon">📷</span>
|
||||||
|
<span class="btn-label">截图</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button class="toolbar-btn" @click="selectImage">
|
||||||
|
<span class="btn-icon">📁</span>
|
||||||
|
<span class="btn-label">图片</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button class="toolbar-btn" @click="startGrading" :loading="appStore.isGrading">
|
||||||
|
<span class="btn-icon">⚡</span>
|
||||||
|
<span class="btn-label">{{ appStore.isGrading ? '阅卷中...' : '阅卷' }}</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button class="toolbar-btn" @click="openRubric">
|
||||||
|
<span class="btn-icon">📋</span>
|
||||||
|
<span class="btn-label">标准</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button class="toolbar-btn" @click="openTemplates">
|
||||||
|
<span class="btn-icon">📚</span>
|
||||||
|
<span class="btn-label">模板</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button class="toolbar-btn" @click="openHistory">
|
||||||
|
<span class="btn-icon">📜</span>
|
||||||
|
<span class="btn-label">历史</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button class="toolbar-btn" @click="openSettings">
|
||||||
|
<span class="btn-icon">⚙</span>
|
||||||
|
<span class="btn-label">设置</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useGradingStore } from '@/stores/grading'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const gradingStore = useGradingStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
let cleanupScreenshotListener: (() => void) | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (window.electronAPI) {
|
||||||
|
cleanupScreenshotListener = window.electronAPI.screenshot.onCompleted((base64: string) => {
|
||||||
|
if (!appStore.isGrading) {
|
||||||
|
gradingStore.setImage(base64)
|
||||||
|
startGrading()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanupScreenshotListener?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
function startScreenshot() {
|
||||||
|
if (window.electronAPI) window.electronAPI.screenshot.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectImage() {
|
||||||
|
if (!window.electronAPI) return
|
||||||
|
const data = await window.electronAPI.dialog.openImage()
|
||||||
|
if (data) {
|
||||||
|
gradingStore.setImage(data.base64)
|
||||||
|
ElMessage.success('已选择图片')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGrading() {
|
||||||
|
if (!gradingStore.currentImage) {
|
||||||
|
ElMessage.info('请先截图或选择图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!settingsStore.settings.apiKey) {
|
||||||
|
ElMessage.warning('请先在设置中配置 API Key')
|
||||||
|
if (window.electronAPI) window.electronAPI.dialog.open('settings')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appStore.isGrading = true
|
||||||
|
try {
|
||||||
|
// load latest rubric data from main process (may have been updated by rubric dialog)
|
||||||
|
const rubricData = await window.electronAPI?.rubric.getData()
|
||||||
|
if (rubricData) {
|
||||||
|
if (rubricData.rubric) gradingStore.setRubric(rubricData.rubric)
|
||||||
|
if (rubricData.questionTitle) gradingStore.setQuestionTitle(rubricData.questionTitle)
|
||||||
|
if (rubricData.maxScore) gradingStore.setMaxScore(rubricData.maxScore)
|
||||||
|
if (rubricData.referenceAnswer) gradingStore.setReferenceAnswer(rubricData.referenceAnswer)
|
||||||
|
}
|
||||||
|
const result = await window.electronAPI.ai.grade({
|
||||||
|
imageBase64: gradingStore.currentImage,
|
||||||
|
rubric: gradingStore.rubric || `请根据答案内容评分,满分${gradingStore.maxScore}分`,
|
||||||
|
maxScore: gradingStore.maxScore,
|
||||||
|
referenceAnswer: gradingStore.referenceAnswer,
|
||||||
|
questionTitle: gradingStore.questionTitle,
|
||||||
|
apiConfig: {
|
||||||
|
apiKey: settingsStore.settings.apiKey,
|
||||||
|
baseUrl: settingsStore.settings.baseUrl,
|
||||||
|
model: settingsStore.settings.model
|
||||||
|
}
|
||||||
|
})
|
||||||
|
gradingStore.setResult(result)
|
||||||
|
if (window.electronAPI) {
|
||||||
|
await window.electronAPI.grading.storeData({
|
||||||
|
result,
|
||||||
|
image: gradingStore.currentImage,
|
||||||
|
rubric: gradingStore.rubric,
|
||||||
|
questionTitle: gradingStore.questionTitle,
|
||||||
|
maxScore: gradingStore.maxScore,
|
||||||
|
referenceAnswer: gradingStore.referenceAnswer
|
||||||
|
})
|
||||||
|
// also update rubric bridge
|
||||||
|
await window.electronAPI.rubric.storeData({
|
||||||
|
questionTitle: gradingStore.questionTitle,
|
||||||
|
maxScore: gradingStore.maxScore,
|
||||||
|
referenceAnswer: gradingStore.referenceAnswer,
|
||||||
|
rubric: gradingStore.rubric
|
||||||
|
})
|
||||||
|
await window.electronAPI.dialog.open('result')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error('AI 阅卷失败:' + (err as Error).message)
|
||||||
|
} finally {
|
||||||
|
appStore.isGrading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRubric() {
|
||||||
|
window.electronAPI?.dialog.open('rubric')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTemplates() { window.electronAPI?.dialog.open('templates') }
|
||||||
|
function openHistory() { window.electronAPI?.dialog.open('history') }
|
||||||
|
function openSettings() { window.electronAPI?.dialog.open('settings') }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.floating-toolbar {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99999;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
background: rgba(30, 30, 30, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: rgba(255, 255, 255, 0.85) !important;
|
||||||
|
height: 46px !important;
|
||||||
|
padding: 4px 14px !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-label {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<template>
|
||||||
|
<div class="result-drawer" v-show="visible" @click.self="closeDrawer">
|
||||||
|
<div class="drawer-panel">
|
||||||
|
<div class="drawer-header">
|
||||||
|
<span class="drawer-title">AI 评分结果</span>
|
||||||
|
<el-button text size="small" @click="closeDrawer">✕</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-body" v-if="gradingStore.currentResult">
|
||||||
|
<div class="result-score">
|
||||||
|
<div class="score-value">{{ gradingStore.currentResult.score }}</div>
|
||||||
|
<div class="score-label">建议分数</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-confidence">
|
||||||
|
<div class="confidence-bar">
|
||||||
|
<div class="confidence-fill" :style="{ width: (gradingStore.currentResult.confidence * 100) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="confidence-text">可信度:{{ Math.round(gradingStore.currentResult.confidence * 100) }}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-section" v-if="gradingStore.currentResult.deductions.length">
|
||||||
|
<div class="section-title">扣分项</div>
|
||||||
|
<div class="deduction-item" v-for="(item, idx) in gradingStore.currentResult.deductions" :key="idx">
|
||||||
|
{{ idx + 1 }}. {{ item }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-section">
|
||||||
|
<div class="section-title">评语</div>
|
||||||
|
<div class="comment-text">{{ gradingStore.currentResult.comment }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-section">
|
||||||
|
<div class="section-title">教师评分(可修改)</div>
|
||||||
|
<div class="teacher-score-input">
|
||||||
|
<el-input-number
|
||||||
|
v-model="teacherScore"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-section">
|
||||||
|
<div class="section-title">教师评语</div>
|
||||||
|
<el-input
|
||||||
|
v-model="teacherComment"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
size="small"
|
||||||
|
placeholder="输入评语(可选)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<el-button size="small" @click="reGrade">重新评分</el-button>
|
||||||
|
<el-button type="primary" size="small" @click="confirmScore">确认评分</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useGradingStore } from '@/stores/grading'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const gradingStore = useGradingStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const teacherScore = ref(0)
|
||||||
|
const teacherComment = ref('')
|
||||||
|
|
||||||
|
watch(() => appStore.isResultDrawerOpen, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => gradingStore.currentResult, (val) => {
|
||||||
|
if (val) {
|
||||||
|
teacherScore.value = val.score
|
||||||
|
teacherComment.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
window.electronAPI?.dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reGrade() {
|
||||||
|
if (!gradingStore.currentImage) return
|
||||||
|
appStore.isGrading = true
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.ai.grade({
|
||||||
|
imageBase64: gradingStore.currentImage,
|
||||||
|
rubric: gradingStore.rubric || `请根据答案内容评分,满分${gradingStore.maxScore}分`,
|
||||||
|
maxScore: gradingStore.maxScore,
|
||||||
|
referenceAnswer: gradingStore.referenceAnswer,
|
||||||
|
questionTitle: gradingStore.questionTitle,
|
||||||
|
apiConfig: {
|
||||||
|
apiKey: settingsStore.settings.apiKey,
|
||||||
|
baseUrl: settingsStore.settings.baseUrl,
|
||||||
|
model: settingsStore.settings.model
|
||||||
|
}
|
||||||
|
})
|
||||||
|
gradingStore.setResult(result)
|
||||||
|
ElMessage.success('重新评分完成')
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error('重新评分失败:' + (err as Error).message)
|
||||||
|
} finally {
|
||||||
|
appStore.isGrading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmScore() {
|
||||||
|
const result = gradingStore.currentResult
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.electronAPI.grading.submit({
|
||||||
|
questionId: 0,
|
||||||
|
imagePath: '',
|
||||||
|
aiScore: result.score,
|
||||||
|
teacherScore: teacherScore.value,
|
||||||
|
confidence: result.confidence,
|
||||||
|
studentAnswer: ''
|
||||||
|
})
|
||||||
|
ElMessage.success('评分已保存')
|
||||||
|
gradingStore.reset()
|
||||||
|
closeDrawer()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error('保存失败:' + (err as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.result-drawer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99998;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel {
|
||||||
|
width: 360px;
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-score {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-value {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #409EFF;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-confidence {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: #eee;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #67C23A, #409EFF);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deduction-item {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #F56C6C;
|
||||||
|
padding: 4px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-score-input {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="评分标准" width="520px" :modal="false" @close="handleClose">
|
||||||
|
<el-form :model="form" size="small">
|
||||||
|
<el-form-item label="题目">
|
||||||
|
<el-input
|
||||||
|
v-model="form.questionTitle"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="输入题目内容"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="满分">
|
||||||
|
<el-input-number v-model="form.maxScore" :min="1" :max="1000" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="参考答案">
|
||||||
|
<el-input
|
||||||
|
v-model="form.referenceAnswer"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="输入参考答案(可选)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="评分标准">
|
||||||
|
<el-input
|
||||||
|
v-model="form.rubric"
|
||||||
|
type="textarea"
|
||||||
|
:rows="8"
|
||||||
|
placeholder="输入评分标准,如: 实体识别完整(3分) 联系类型正确(4分) 图形规范(2分)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button size="small" @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" size="small" @click="handleSave">应用</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useGradingStore } from '@/stores/grading'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const gradingStore = useGradingStore()
|
||||||
|
const visible = ref(false)
|
||||||
|
const form = ref({ questionTitle: '', maxScore: 100, referenceAnswer: '', rubric: '' })
|
||||||
|
|
||||||
|
watch(() => appStore.isRubricEditorOpen, async (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) {
|
||||||
|
// load rubric data from main process (set by toolbar window before opening)
|
||||||
|
const data = await window.electronAPI?.rubric.getData()
|
||||||
|
if (data) {
|
||||||
|
form.value = {
|
||||||
|
questionTitle: data.questionTitle || '',
|
||||||
|
maxScore: data.maxScore ?? 100,
|
||||||
|
referenceAnswer: data.referenceAnswer || '',
|
||||||
|
rubric: data.rubric || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
// update local store
|
||||||
|
gradingStore.setQuestionTitle(form.value.questionTitle)
|
||||||
|
gradingStore.setRubric(form.value.rubric)
|
||||||
|
gradingStore.setMaxScore(form.value.maxScore)
|
||||||
|
gradingStore.setReferenceAnswer(form.value.referenceAnswer)
|
||||||
|
// persist to main process so toolbar window can read it
|
||||||
|
await window.electronAPI?.rubric.storeData({
|
||||||
|
questionTitle: form.value.questionTitle,
|
||||||
|
maxScore: form.value.maxScore,
|
||||||
|
referenceAnswer: form.value.referenceAnswer,
|
||||||
|
rubric: form.value.rubric
|
||||||
|
})
|
||||||
|
ElMessage.success('评分标准已应用')
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
window.electronAPI?.dialog.close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="screenshot-overlay"
|
||||||
|
@mousedown="onMouseDown"
|
||||||
|
@mousemove="onMouseMove"
|
||||||
|
@mouseup="onMouseUp"
|
||||||
|
:class="{ 'has-selection': hasSelection }"
|
||||||
|
>
|
||||||
|
<canvas ref="canvasRef" class="screenshot-canvas"></canvas>
|
||||||
|
<div v-if="!hasSelection" class="screenshot-tip">拖动选择截图区域 · Esc取消</div>
|
||||||
|
<div v-else class="screenshot-actions" @mousedown.stop @mouseup.stop>
|
||||||
|
<button class="action-btn confirm" @click="confirmCapture">✓ 确认</button>
|
||||||
|
<button class="action-btn cancel" @click="cancel">✕ 取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
|
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const state = {
|
||||||
|
isSelecting: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
endX: 0,
|
||||||
|
endY: 0,
|
||||||
|
}
|
||||||
|
const hasSelection = ref(false)
|
||||||
|
const isCapturing = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
hasSelection.value = false
|
||||||
|
state.isSelecting = true
|
||||||
|
state.startX = e.clientX
|
||||||
|
state.startY = e.clientY
|
||||||
|
state.endX = e.clientX
|
||||||
|
state.endY = e.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!state.isSelecting) return
|
||||||
|
state.endX = e.clientX
|
||||||
|
state.endY = e.clientY
|
||||||
|
drawOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
if (!state.isSelecting) return
|
||||||
|
state.isSelecting = false
|
||||||
|
|
||||||
|
const w = Math.abs(state.endX - state.startX)
|
||||||
|
const h = Math.abs(state.endY - state.startY)
|
||||||
|
if (w < 10 || h < 10) return
|
||||||
|
|
||||||
|
hasSelection.value = true
|
||||||
|
drawOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCapture() {
|
||||||
|
const x = Math.min(state.startX, state.endX)
|
||||||
|
const y = Math.min(state.startY, state.endY)
|
||||||
|
const width = Math.abs(state.endX - state.startX)
|
||||||
|
const height = Math.abs(state.endY - state.startY)
|
||||||
|
|
||||||
|
if (width < 10 || height < 10) return
|
||||||
|
if (isCapturing.value) return
|
||||||
|
isCapturing.value = true
|
||||||
|
|
||||||
|
if (window.electronAPI) {
|
||||||
|
window.electronAPI.screenshot.capture({ x, y, width, height })
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
try { window.close() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
if (window.electronAPI) {
|
||||||
|
window.electronAPI.screenshot.cancel()
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
isCapturing.value = false
|
||||||
|
try { window.close() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOverlay() {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
canvas.width = window.innerWidth
|
||||||
|
canvas.height = window.innerHeight
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
const x = Math.min(state.startX, state.endX)
|
||||||
|
const y = Math.min(state.startY, state.endY)
|
||||||
|
const w = Math.abs(state.endX - state.startX)
|
||||||
|
const h = Math.abs(state.endY - state.startY)
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.45)'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
ctx.clearRect(x, y, w, h)
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#409EFF'
|
||||||
|
ctx.lineWidth = 2.5
|
||||||
|
ctx.strokeRect(x, y, w, h)
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(64, 158, 255, 0.08)'
|
||||||
|
ctx.fillRect(x, y, w, h)
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'
|
||||||
|
ctx.font = '13px sans-serif'
|
||||||
|
ctx.fillText(`${w} × ${h}`, x + 6, y - 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && hasSelection.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
confirmCapture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.screenshot-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99999;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-overlay.has-selection {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-canvas {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-tip {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 40px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 40px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
border: none;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.confirm {
|
||||||
|
background: #409EFF;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.confirm:hover {
|
||||||
|
background: #66b1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.cancel {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.cancel:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="设置" width="480px" :modal="false" :close-on-click-modal="false" @close="handleClose">
|
||||||
|
<el-form :model="form" label-width="100px" size="small">
|
||||||
|
<el-form-item label="API Key">
|
||||||
|
<el-input v-model="form.apiKey" type="password" show-password placeholder="请输入 API Key" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Base URL">
|
||||||
|
<el-input v-model="form.baseUrl" placeholder="https://api.openai.com/v1" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="模型">
|
||||||
|
<el-select v-model="form.model" style="width: 100%">
|
||||||
|
<el-option label="Qwen/Qwen3.6-35B-A3B" value="Qwen/Qwen3.6-35B-A3B" />
|
||||||
|
<el-option label="Qwen/Qwen2.5-32B-Instruct" value="Qwen/Qwen2.5-32B-Instruct" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="快捷键">
|
||||||
|
<el-input v-model="form.shortcut" placeholder="Alt+Q" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="超时(秒)">
|
||||||
|
<el-input-number v-model="form.timeout" :min="5" :max="300" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="主题">
|
||||||
|
<el-select v-model="form.theme" style="width: 100%">
|
||||||
|
<el-option label="浅色" value="light" />
|
||||||
|
<el-option label="深色" value="dark" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="字体大小">
|
||||||
|
<el-input-number v-model="form.fontSize" :min="12" :max="20" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="代理">
|
||||||
|
<el-input v-model="form.proxy" placeholder="可选,如 http://127.0.0.1:7890" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="截图保存路径">
|
||||||
|
<div style="display:flex;gap:8px;width:100%">
|
||||||
|
<el-input v-model="form.screenshotSavePath" placeholder="留空不自动保存截图" />
|
||||||
|
<el-button @click="selectDir">选择</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button size="small" @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" size="small" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
|
||||||
|
watch(() => appStore.isSettingDialogOpen, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) {
|
||||||
|
form.value = { ...settingsStore.settings }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = ref({ ...settingsStore.settings })
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
window.electronAPI?.dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectDir() {
|
||||||
|
if (window.electronAPI) {
|
||||||
|
const dir = await window.electronAPI.settings.selectDirectory()
|
||||||
|
if (dir) form.value.screenshotSavePath = dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
await settingsStore.saveSettings(JSON.parse(JSON.stringify(form.value)))
|
||||||
|
window.electronAPI?.dialog.close()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error('保存失败:' + (err as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './styles/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text size="small" type="danger" @click="deleteRecord(row.id)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button size="small" @click="handleClose">关闭</el-button>
|
||||||
|
<el-button size="small" @click="refresh">刷新</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import type { HistoryRecord } from '@/types'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const records = ref<HistoryRecord[]>([])
|
||||||
|
|
||||||
|
watch(() => appStore.isHistoryDialogOpen, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (window.electronAPI) {
|
||||||
|
records.value = await window.electronAPI.history.list()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
records.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRecord(id: number) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定删除该记录?', '提示')
|
||||||
|
if (window.electronAPI) {
|
||||||
|
await window.electronAPI.history.delete(id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
} catch { /* cancelled */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
window.electronAPI?.dialog.close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<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-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">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text size="small" @click="selectTemplate(row)">使用</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button size="small" @click="handleClose">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useGradingStore } from '@/stores/grading'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const gradingStore = useGradingStore()
|
||||||
|
const visible = ref(false)
|
||||||
|
const templates = ref<any[]>([])
|
||||||
|
const form = ref({ name: '', course: '' })
|
||||||
|
|
||||||
|
watch(() => appStore.isTemplateDialogOpen, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) loadTemplates()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
if (window.electronAPI) {
|
||||||
|
templates.value = await window.electronAPI.template.list()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTemplate() {
|
||||||
|
if (!form.value.name || !form.value.course) {
|
||||||
|
ElMessage.warning('请填写考试名称和课程')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (window.electronAPI) {
|
||||||
|
await window.electronAPI.template.save(form.value)
|
||||||
|
ElMessage.success('模板已保存')
|
||||||
|
form.value = { name: '', course: '' }
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTemplate(tpl: any) {
|
||||||
|
gradingStore.setRubric(`考试: ${tpl.name}\n课程: ${tpl.course}\n请根据标准答案评分。`)
|
||||||
|
ElMessage.success('已选择模板:' + tpl.name)
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
window.electronAPI?.dialog.close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.template-section {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import FloatingToolbar from '@/components/FloatingToolbar.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'toolbar',
|
||||||
|
component: FloatingToolbar
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/screenshot',
|
||||||
|
name: 'screenshot',
|
||||||
|
component: () => import('@/components/ScreenshotOverlay.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const useAppStore = defineStore('app', () => {
|
||||||
|
const isToolbarVisible = ref(true)
|
||||||
|
const isResultDrawerOpen = ref(false)
|
||||||
|
const isSettingDialogOpen = ref(false)
|
||||||
|
const isHistoryDialogOpen = ref(false)
|
||||||
|
const isTemplateDialogOpen = ref(false)
|
||||||
|
const isRubricEditorOpen = ref(false)
|
||||||
|
const isGrading = ref(false)
|
||||||
|
|
||||||
|
function toggleToolbar() {
|
||||||
|
isToolbarVisible.value = !isToolbarVisible.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResultDrawer() { isResultDrawerOpen.value = true }
|
||||||
|
function closeResultDrawer() { isResultDrawerOpen.value = false }
|
||||||
|
|
||||||
|
function openSettingDialog() { isSettingDialogOpen.value = true }
|
||||||
|
function closeSettingDialog() { isSettingDialogOpen.value = false }
|
||||||
|
|
||||||
|
function openHistoryDialog() { isHistoryDialogOpen.value = true }
|
||||||
|
function closeHistoryDialog() { isHistoryDialogOpen.value = false }
|
||||||
|
|
||||||
|
function openTemplateDialog() { isTemplateDialogOpen.value = true }
|
||||||
|
function closeTemplateDialog() { isTemplateDialogOpen.value = false }
|
||||||
|
|
||||||
|
function openRubricEditor() { isRubricEditorOpen.value = true }
|
||||||
|
function closeRubricEditor() { isRubricEditorOpen.value = false }
|
||||||
|
|
||||||
|
return {
|
||||||
|
isToolbarVisible,
|
||||||
|
isResultDrawerOpen,
|
||||||
|
isSettingDialogOpen,
|
||||||
|
isHistoryDialogOpen,
|
||||||
|
isTemplateDialogOpen,
|
||||||
|
isRubricEditorOpen,
|
||||||
|
isGrading,
|
||||||
|
toggleToolbar,
|
||||||
|
openResultDrawer,
|
||||||
|
closeResultDrawer,
|
||||||
|
openSettingDialog,
|
||||||
|
closeSettingDialog,
|
||||||
|
openHistoryDialog,
|
||||||
|
closeHistoryDialog,
|
||||||
|
openTemplateDialog,
|
||||||
|
closeTemplateDialog,
|
||||||
|
openRubricEditor,
|
||||||
|
closeRubricEditor
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { GradeResult } from '@/types'
|
||||||
|
|
||||||
|
export const useGradingStore = defineStore('grading', () => {
|
||||||
|
const currentImage = ref<string | null>(null)
|
||||||
|
const currentResult = ref<GradeResult | null>(null)
|
||||||
|
const rubric = ref('')
|
||||||
|
const questionTitle = ref('')
|
||||||
|
const maxScore = ref(100)
|
||||||
|
const referenceAnswer = ref('')
|
||||||
|
const teacherScore = ref<number | null>(null)
|
||||||
|
const teacherComment = ref('')
|
||||||
|
|
||||||
|
function setImage(base64: string) {
|
||||||
|
currentImage.value = base64
|
||||||
|
}
|
||||||
|
|
||||||
|
function setResult(result: GradeResult) {
|
||||||
|
currentResult.value = result
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRubric(text: string) {
|
||||||
|
rubric.value = text
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuestionTitle(text: string) { questionTitle.value = text }
|
||||||
|
function setMaxScore(val: number) { maxScore.value = val }
|
||||||
|
function setReferenceAnswer(val: string) { referenceAnswer.value = val }
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
currentImage.value = null
|
||||||
|
currentResult.value = null
|
||||||
|
teacherScore.value = null
|
||||||
|
teacherComment.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentImage,
|
||||||
|
currentResult,
|
||||||
|
rubric,
|
||||||
|
questionTitle,
|
||||||
|
maxScore,
|
||||||
|
referenceAnswer,
|
||||||
|
teacherScore,
|
||||||
|
teacherComment,
|
||||||
|
setImage,
|
||||||
|
setResult,
|
||||||
|
setRubric,
|
||||||
|
setQuestionTitle,
|
||||||
|
setMaxScore,
|
||||||
|
setReferenceAnswer,
|
||||||
|
reset
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { AppSettings } from '@/types'
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
|
const settings = ref<AppSettings>({
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: 'https://api.siliconflow.cn/v1',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
shortcut: 'Alt+Q',
|
||||||
|
theme: 'light',
|
||||||
|
fontSize: 14,
|
||||||
|
proxy: '',
|
||||||
|
timeout: 120,
|
||||||
|
screenshotSavePath: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
if (window.electronAPI) {
|
||||||
|
const result = await window.electronAPI.settings.get()
|
||||||
|
if (result) {
|
||||||
|
settings.value = { ...settings.value, ...result }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(partial: Partial<AppSettings>) {
|
||||||
|
Object.assign(settings.value, partial)
|
||||||
|
if (window.electronAPI) {
|
||||||
|
await window.electronAPI.settings.set(partial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
loadSettings,
|
||||||
|
saveSettings
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export interface GradeResult {
|
||||||
|
score: number
|
||||||
|
confidence: number
|
||||||
|
deductions: string[]
|
||||||
|
comment: string
|
||||||
|
commit?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiConfig {
|
||||||
|
apiKey: string
|
||||||
|
baseUrl: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
apiKey: string
|
||||||
|
baseUrl: string
|
||||||
|
model: string
|
||||||
|
shortcut: string
|
||||||
|
theme: string
|
||||||
|
fontSize: number
|
||||||
|
proxy: string
|
||||||
|
timeout: number
|
||||||
|
screenshotSavePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryRecord {
|
||||||
|
id: number
|
||||||
|
question_id: number
|
||||||
|
image_path: string
|
||||||
|
student_answer: string
|
||||||
|
ai_score: number
|
||||||
|
teacher_score: number
|
||||||
|
confidence: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectronAPI {
|
||||||
|
window: {
|
||||||
|
dragMove: (deltaX: number, deltaY: number) => Promise<void>
|
||||||
|
dragEnd: (x: number, y: number) => Promise<void>
|
||||||
|
}
|
||||||
|
screenshot: {
|
||||||
|
start: () => Promise<void>
|
||||||
|
capture: (region: { x: number; y: number; width: number; height: number }) => Promise<string | null>
|
||||||
|
cancel: () => Promise<void>
|
||||||
|
getLastImage: () => Promise<string | null>
|
||||||
|
onCompleted: (callback: (base64: string) => void) => () => void
|
||||||
|
}
|
||||||
|
ai: {
|
||||||
|
grade: (data: { imageBase64: string; rubric: string; apiConfig: ApiConfig; maxScore?: number; referenceAnswer?: string; questionTitle?: string }) => Promise<GradeResult>
|
||||||
|
}
|
||||||
|
settings: {
|
||||||
|
get: () => Promise<AppSettings>
|
||||||
|
set: (settings: Record<string, unknown>) => Promise<boolean>
|
||||||
|
selectDirectory: () => Promise<string | null>
|
||||||
|
}
|
||||||
|
history: {
|
||||||
|
list: () => Promise<HistoryRecord[]>
|
||||||
|
delete: (id: number) => Promise<boolean>
|
||||||
|
}
|
||||||
|
grading: {
|
||||||
|
submit: (data: {
|
||||||
|
questionId: number
|
||||||
|
imagePath: string
|
||||||
|
aiScore: number
|
||||||
|
teacherScore: number
|
||||||
|
confidence: number
|
||||||
|
studentAnswer?: string
|
||||||
|
}) => Promise<boolean>,
|
||||||
|
storeData: (data: any) => Promise<void>,
|
||||||
|
getData: () => Promise<any>
|
||||||
|
}
|
||||||
|
rubric: {
|
||||||
|
storeData: (data: any) => Promise<void>
|
||||||
|
getData: () => Promise<any>
|
||||||
|
}
|
||||||
|
template: {
|
||||||
|
list: () => Promise<unknown[]>
|
||||||
|
save: (data: { name: string; course: string }) => Promise<number | null>
|
||||||
|
}
|
||||||
|
dialog: {
|
||||||
|
open: (type: string, data?: any) => Promise<void>
|
||||||
|
close: () => void
|
||||||
|
getPayload: (type: string) => Promise<any>
|
||||||
|
openImage: () => Promise<{ base64: string; mime: string; filePath: string } | null>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: ElectronAPI
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
+807
@@ -0,0 +1,807 @@
|
|||||||
|
````markdown
|
||||||
|
# AI 阅卷助手(AI Grading Assistant)
|
||||||
|
|
||||||
|
> **技术开发文档 V1.0**
|
||||||
|
>
|
||||||
|
> 技术栈:**Electron + Vue3 + TypeScript + Node.js**
|
||||||
|
>
|
||||||
|
> 更新时间:2026-07-15
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 项目简介
|
||||||
|
|
||||||
|
## 1.1 项目背景
|
||||||
|
|
||||||
|
目前高校、中小学普遍已使用网上阅卷系统,但教师仍需要逐题阅读学生答案并手动评分,存在以下痛点:
|
||||||
|
|
||||||
|
- 重复性劳动强
|
||||||
|
- 主观评分容易受疲劳影响
|
||||||
|
- 阅卷效率低
|
||||||
|
- 长时间阅卷容易漏判、误判
|
||||||
|
|
||||||
|
AI 阅卷助手定位为**桌面端 AI Copilot**,通过悬浮工具栏辅助教师完成阅卷,而不是替换现有阅卷系统。
|
||||||
|
|
||||||
|
整体工作流程如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
学校阅卷系统
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
教师查看学生答案
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
快捷键截图
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AI 自动评分
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
展示建议分数及扣分原因
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
教师确认或修改
|
||||||
|
```
|
||||||
|
|
||||||
|
整个过程无需修改学校现有系统,实现"零侵入式"接入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 项目目标
|
||||||
|
|
||||||
|
构建一款跨平台桌面应用,实现以下能力:
|
||||||
|
|
||||||
|
- 全局悬浮工具栏
|
||||||
|
- 全局快捷键截图
|
||||||
|
- AI 图片识别
|
||||||
|
- AI 智能阅卷
|
||||||
|
- 评分标准管理
|
||||||
|
- 历史记录管理
|
||||||
|
- 多模型切换
|
||||||
|
- 本地模型支持(后续)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. 技术架构
|
||||||
|
|
||||||
|
## 3.1 技术栈
|
||||||
|
|
||||||
|
| 模块 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 桌面框架 | Electron |
|
||||||
|
| 前端框架 | Vue3 |
|
||||||
|
| 开发语言 | TypeScript |
|
||||||
|
| UI组件 | Element Plus |
|
||||||
|
| 状态管理 | Pinia |
|
||||||
|
| 路由 | Vue Router |
|
||||||
|
| 网络请求 | Axios |
|
||||||
|
| AI接口 | OpenAI Compatible API |
|
||||||
|
| 数据库 | SQLite |
|
||||||
|
| ORM | Prisma(可选) |
|
||||||
|
| 配置管理 | electron-store |
|
||||||
|
| 日志 | electron-log |
|
||||||
|
| 图片处理 | Sharp |
|
||||||
|
| OCR(可选) | PaddleOCR |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.2 系统架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
Electron
|
||||||
|
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Main Process │
|
||||||
|
└──────────┬────────────┘
|
||||||
|
│ IPC
|
||||||
|
┌────────────────────┼────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
Renderer Screenshot AI Service
|
||||||
|
(Vue3) Module (Node)
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ Image Processing │
|
||||||
|
│ │
|
||||||
|
└───────────────► SQLite ◄────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 项目目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
ai-grading-assistant
|
||||||
|
|
||||||
|
├── electron
|
||||||
|
│ ├── main
|
||||||
|
│ │ ├── main.ts
|
||||||
|
│ │ ├── window.ts
|
||||||
|
│ │ ├── shortcut.ts
|
||||||
|
│ │ ├── screenshot.ts
|
||||||
|
│ │ ├── ipc.ts
|
||||||
|
│ │ └── tray.ts
|
||||||
|
│ │
|
||||||
|
│ └── preload
|
||||||
|
│ └── preload.ts
|
||||||
|
│
|
||||||
|
├── src
|
||||||
|
│ ├── api
|
||||||
|
│ ├── assets
|
||||||
|
│ ├── components
|
||||||
|
│ ├── composables
|
||||||
|
│ ├── layouts
|
||||||
|
│ ├── pages
|
||||||
|
│ ├── router
|
||||||
|
│ ├── stores
|
||||||
|
│ ├── services
|
||||||
|
│ ├── styles
|
||||||
|
│ ├── types
|
||||||
|
│ ├── utils
|
||||||
|
│ ├── App.vue
|
||||||
|
│ └── main.ts
|
||||||
|
│
|
||||||
|
├── database
|
||||||
|
│ ├── sqlite.ts
|
||||||
|
│ ├── schema.sql
|
||||||
|
│ └── migration
|
||||||
|
│
|
||||||
|
├── prompts
|
||||||
|
│
|
||||||
|
├── config
|
||||||
|
│
|
||||||
|
├── logs
|
||||||
|
│
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. 功能模块设计
|
||||||
|
|
||||||
|
## 5.1 悬浮工具栏
|
||||||
|
|
||||||
|
### 功能
|
||||||
|
|
||||||
|
始终悬浮于所有窗口顶部。
|
||||||
|
|
||||||
|
功能按钮:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ 📷截图 │ ⚡阅卷 │ 📚模板 │ 📜历史 │ ⚙设置 │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 无边框
|
||||||
|
- 半透明
|
||||||
|
- 可拖拽
|
||||||
|
- 自动吸附屏幕边缘
|
||||||
|
- 支持隐藏
|
||||||
|
|
||||||
|
Electron 配置:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
new BrowserWindow({
|
||||||
|
width: 420,
|
||||||
|
height: 60,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
resizable: false
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.2 全局快捷键
|
||||||
|
|
||||||
|
默认:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Alt + Q
|
||||||
|
```
|
||||||
|
|
||||||
|
支持:
|
||||||
|
|
||||||
|
- 修改快捷键
|
||||||
|
- 多快捷键配置
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Alt+Q
|
||||||
|
|
||||||
|
开始截图
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.3 截图模块
|
||||||
|
|
||||||
|
流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
快捷键
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
显示遮罩层
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
鼠标拖动
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
生成截图
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
发送AI
|
||||||
|
```
|
||||||
|
|
||||||
|
功能:
|
||||||
|
|
||||||
|
- 全屏截图
|
||||||
|
- 区域截图
|
||||||
|
- Esc取消
|
||||||
|
- Enter确认
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PNG
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.4 AI阅卷模块
|
||||||
|
|
||||||
|
支持模型:
|
||||||
|
|
||||||
|
- GPT
|
||||||
|
- Claude
|
||||||
|
- Gemini
|
||||||
|
- Qwen-VL
|
||||||
|
- GLM
|
||||||
|
- DeepSeek
|
||||||
|
|
||||||
|
支持:
|
||||||
|
|
||||||
|
图片直接发送。
|
||||||
|
|
||||||
|
无需OCR。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
|
||||||
|
```
|
||||||
|
图片
|
||||||
|
|
||||||
|
评分标准
|
||||||
|
|
||||||
|
Prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"score":9,
|
||||||
|
"confidence":0.96,
|
||||||
|
"deductions":[
|
||||||
|
"未说明ACK作用"
|
||||||
|
],
|
||||||
|
"comment":"回答完整"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.5 评分标准模块
|
||||||
|
|
||||||
|
每场考试:
|
||||||
|
|
||||||
|
```
|
||||||
|
课程
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
试卷
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
题目
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
评分标准
|
||||||
|
```
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Java
|
||||||
|
|
||||||
|
期末考试
|
||||||
|
|
||||||
|
第5题
|
||||||
|
```
|
||||||
|
|
||||||
|
评分标准:
|
||||||
|
|
||||||
|
```text
|
||||||
|
满分
|
||||||
|
|
||||||
|
10
|
||||||
|
|
||||||
|
评分点
|
||||||
|
|
||||||
|
① 多态
|
||||||
|
|
||||||
|
② 继承
|
||||||
|
|
||||||
|
③ 封装
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库保存。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.6 AI评分展示
|
||||||
|
|
||||||
|
右侧弹窗:
|
||||||
|
|
||||||
|
```text
|
||||||
|
────────────────────
|
||||||
|
|
||||||
|
AI建议:
|
||||||
|
|
||||||
|
9分
|
||||||
|
|
||||||
|
可信度:
|
||||||
|
|
||||||
|
96%
|
||||||
|
|
||||||
|
扣分:
|
||||||
|
|
||||||
|
① 未说明第三次握手意义
|
||||||
|
|
||||||
|
评价:
|
||||||
|
|
||||||
|
回答基本完整。
|
||||||
|
|
||||||
|
────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
按钮:
|
||||||
|
|
||||||
|
```
|
||||||
|
确认
|
||||||
|
|
||||||
|
修改
|
||||||
|
|
||||||
|
重新评分
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.7 历史记录
|
||||||
|
|
||||||
|
保存:
|
||||||
|
|
||||||
|
- 截图
|
||||||
|
- AI评分
|
||||||
|
- 教师评分
|
||||||
|
- 时间
|
||||||
|
- AI耗时
|
||||||
|
|
||||||
|
支持:
|
||||||
|
|
||||||
|
- 查询
|
||||||
|
- 删除
|
||||||
|
- 导出Excel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. 系统流程
|
||||||
|
|
||||||
|
## AI阅卷流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
开始阅卷
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
快捷键
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
截图
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
AI识别图片
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
评分标准匹配
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
AI评分
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
返回JSON
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
前端展示
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
教师确认
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Vue 前端架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
App
|
||||||
|
|
||||||
|
│
|
||||||
|
|
||||||
|
├── FloatingToolbar
|
||||||
|
|
||||||
|
├── ScreenshotOverlay
|
||||||
|
|
||||||
|
├── ResultDrawer
|
||||||
|
|
||||||
|
├── SettingDialog
|
||||||
|
|
||||||
|
├── HistoryPage
|
||||||
|
|
||||||
|
├── TemplatePage
|
||||||
|
|
||||||
|
└── AboutPage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Electron IPC 通信
|
||||||
|
|
||||||
|
```text
|
||||||
|
Vue
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
ipcRenderer.invoke()
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Main Process
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
执行任务
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
返回数据
|
||||||
|
```
|
||||||
|
|
||||||
|
事件:
|
||||||
|
|
||||||
|
| 名称 | 描述 |
|
||||||
|
|------|------|
|
||||||
|
| screenshot:start | 开始截图 |
|
||||||
|
| screenshot:end | 截图完成 |
|
||||||
|
| ai:grade | AI阅卷 |
|
||||||
|
| ai:result | AI返回 |
|
||||||
|
| history:list | 获取历史 |
|
||||||
|
| settings:get | 获取配置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. 数据库设计
|
||||||
|
|
||||||
|
## exam
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id INTEGER PRIMARY KEY
|
||||||
|
|
||||||
|
name TEXT
|
||||||
|
|
||||||
|
course TEXT
|
||||||
|
|
||||||
|
created_at DATETIME
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## question
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id INTEGER PRIMARY KEY
|
||||||
|
|
||||||
|
exam_id INTEGER
|
||||||
|
|
||||||
|
title TEXT
|
||||||
|
|
||||||
|
max_score INTEGER
|
||||||
|
|
||||||
|
rubric TEXT
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## history
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id INTEGER PRIMARY KEY
|
||||||
|
|
||||||
|
question_id INTEGER
|
||||||
|
|
||||||
|
image_path TEXT
|
||||||
|
|
||||||
|
student_answer TEXT
|
||||||
|
|
||||||
|
ai_score REAL
|
||||||
|
|
||||||
|
teacher_score REAL
|
||||||
|
|
||||||
|
confidence REAL
|
||||||
|
|
||||||
|
created_at DATETIME
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. AI接口封装
|
||||||
|
|
||||||
|
统一接口:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AIProvider {
|
||||||
|
|
||||||
|
grade(
|
||||||
|
image:string,
|
||||||
|
rubric:string
|
||||||
|
):Promise<GradeResult>
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实现:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OpenAIProvider
|
||||||
|
|
||||||
|
GeminiProvider
|
||||||
|
|
||||||
|
ClaudeProvider
|
||||||
|
|
||||||
|
QwenProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
方便切换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Prompt设计
|
||||||
|
|
||||||
|
系统Prompt:
|
||||||
|
|
||||||
|
```text
|
||||||
|
你是一名经验丰富的高校教师。
|
||||||
|
|
||||||
|
请依据评分标准严格评分。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1、识别学生答案
|
||||||
|
|
||||||
|
2、分析答案
|
||||||
|
|
||||||
|
3、依据评分标准逐项评分
|
||||||
|
|
||||||
|
4、输出JSON
|
||||||
|
|
||||||
|
禁止输出Markdown。
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"score":0,
|
||||||
|
"confidence":0,
|
||||||
|
"deductions":[],
|
||||||
|
"comment":""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. 配置管理
|
||||||
|
|
||||||
|
支持:
|
||||||
|
|
||||||
|
```text
|
||||||
|
API Key
|
||||||
|
|
||||||
|
Base URL
|
||||||
|
|
||||||
|
模型
|
||||||
|
|
||||||
|
快捷键
|
||||||
|
|
||||||
|
主题
|
||||||
|
|
||||||
|
字体
|
||||||
|
|
||||||
|
代理
|
||||||
|
|
||||||
|
超时时间
|
||||||
|
```
|
||||||
|
|
||||||
|
保存:
|
||||||
|
|
||||||
|
```
|
||||||
|
electron-store
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. 日志系统
|
||||||
|
|
||||||
|
记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
程序启动
|
||||||
|
|
||||||
|
截图
|
||||||
|
|
||||||
|
AI请求
|
||||||
|
|
||||||
|
AI耗时
|
||||||
|
|
||||||
|
异常
|
||||||
|
|
||||||
|
退出
|
||||||
|
```
|
||||||
|
|
||||||
|
使用:
|
||||||
|
|
||||||
|
```
|
||||||
|
electron-log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. 异常处理
|
||||||
|
|
||||||
|
截图失败:
|
||||||
|
|
||||||
|
```
|
||||||
|
重新截图
|
||||||
|
```
|
||||||
|
|
||||||
|
AI失败:
|
||||||
|
|
||||||
|
```
|
||||||
|
重新评分
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON解析失败:
|
||||||
|
|
||||||
|
```
|
||||||
|
重新生成
|
||||||
|
```
|
||||||
|
|
||||||
|
网络异常:
|
||||||
|
|
||||||
|
```
|
||||||
|
请检查网络连接
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. 开发计划
|
||||||
|
|
||||||
|
## 第一阶段(MVP)
|
||||||
|
|
||||||
|
预计:2 周
|
||||||
|
|
||||||
|
完成:
|
||||||
|
|
||||||
|
- Electron 初始化
|
||||||
|
- Vue3 页面
|
||||||
|
- 悬浮工具栏
|
||||||
|
- 快捷键截图
|
||||||
|
- AI 调用
|
||||||
|
- AI 评分展示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二阶段
|
||||||
|
|
||||||
|
预计:2 周
|
||||||
|
|
||||||
|
完成:
|
||||||
|
|
||||||
|
- 评分模板
|
||||||
|
- 历史记录
|
||||||
|
- 设置页面
|
||||||
|
- 多模型支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三阶段
|
||||||
|
|
||||||
|
预计:3 周
|
||||||
|
|
||||||
|
完成:
|
||||||
|
|
||||||
|
- OCR 支持
|
||||||
|
- 本地模型
|
||||||
|
- 自动识别题号
|
||||||
|
- 自动填分
|
||||||
|
- 自动切换下一题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. 后续优化方向
|
||||||
|
|
||||||
|
## AI能力
|
||||||
|
|
||||||
|
- 本地部署 Qwen-VL
|
||||||
|
- DeepSeek-R1 推理
|
||||||
|
- 自定义 Prompt
|
||||||
|
- AI 连续阅卷
|
||||||
|
|
||||||
|
## 产品能力
|
||||||
|
|
||||||
|
- 自动识别当前题号
|
||||||
|
- 自动关联评分模板
|
||||||
|
- 一键批量阅卷
|
||||||
|
- 自动填写分数(RPA)
|
||||||
|
- 教师评分统计
|
||||||
|
- AI 与教师评分一致率分析
|
||||||
|
- 导出阅卷报告
|
||||||
|
|
||||||
|
## 企业级能力
|
||||||
|
|
||||||
|
- 用户登录
|
||||||
|
- 云端同步评分模板
|
||||||
|
- 多教师共享评分标准
|
||||||
|
- AI 模型统一配置
|
||||||
|
- 学校私有化部署
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 17. MVP 核心功能
|
||||||
|
|
||||||
|
✅ Electron 桌面应用
|
||||||
|
|
||||||
|
✅ Vue3 + TypeScript 前端
|
||||||
|
|
||||||
|
✅ 悬浮工具栏
|
||||||
|
|
||||||
|
✅ 全局快捷键截图
|
||||||
|
|
||||||
|
✅ AI 图片阅卷
|
||||||
|
|
||||||
|
✅ 建议分数展示
|
||||||
|
|
||||||
|
✅ 扣分原因分析
|
||||||
|
|
||||||
|
✅ 教师人工确认
|
||||||
|
|
||||||
|
该版本可作为最小可行产品(MVP),能够在不改造现有阅卷系统的前提下,为教师提供高效、可靠的 AI 辅助阅卷体验。
|
||||||
|
````
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"outDir": "dist-electron",
|
||||||
|
"rootDir": "electron",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"include": ["electron/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"ignoreDeprecations":"6.0",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user