init: AI Grading Assistant MVP
This commit is contained in:
@@ -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')
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user