Initial commit: AI 阅卷助手 Tauri v2 + Vue 3

This commit is contained in:
2026-07-19 13:12:37 +08:00
commit 8357bf016d
51 changed files with 24458 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
src-tauri/target/
*.db
.DS_Store
*.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 AI 阅卷助手
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+83
View File
@@ -0,0 +1,83 @@
# AI 阅卷助手 (AI Grading Assistant)
基于 Tauri v2 + Vue 3 的桌面端 AI 辅助阅卷工具,支持截图批改、评分标准管理、历史记录等功能。
## 技术栈
| 层面 | 技术 |
| -------- | --------------------------------------------- |
| 前端 | Vue 3 + TypeScript + Pinia + Vue Router |
| UI | Element Plus |
| 桌面框架 | Tauri v2 (Rust) |
| 后端 | Rust (rusqlite, reqwest, xcap, image, base64) |
| 数据库 | SQLite (bundled via rusqlite) |
## 前置条件
- [Node.js](https://nodejs.org/) >= 18
- [Rust](https://rustup.rs/) (stable)
- Windows: [Microsoft Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) 或 Visual Studio (C++ 工作负载)
- 系统依赖参考 [Tauri v2 前置条件文档](https://v2.tauri.app/start/prerequisites/)
## 开发
```bash
# 1. 安装前端依赖
npm install
# 2. 启动开发模式(自动启动 Vite HMR + Tauri 窗口)
npm run tauri dev
```
Tauri 开发模式会先启动 Vite 开发服务器(端口 5173),然后打开 Tauri 应用窗口,支持热更新。
## 构建
```bash
# 1. 构建前端 + Rust 二进制
npm run tauri build
# 2. 产物位于 src-tauri/target/release/bundle/
# - Windows: MSI 安装包 (.msi) 或 NSIS 安装程序 (.exe)
# - 便携版: src-tauri/target/release/ai-grading-assistant.exe
```
## 项目结构
```
AIGA-Tauri/
├── src/ # Vue 前端源码
│ ├── components/ # 组件(FloatingToolbar, ResultDrawer, 等)
│ ├── pages/ # 页面级组件(HistoryDialog, TemplateDialog
│ ├── stores/ # Pinia 状态管理
│ ├── styles/ # 全局样式
│ ├── types/ # TypeScript 类型定义
│ ├── utils/ # 工具函数
│ ├── App.vue # 根组件
│ └── main.ts # 入口
├── src-tauri/ # Rust 后端源码
│ ├── src/
│ │ ├── commands/ # Tauri IPC 命令
│ │ ├── ai.rs # AI 评分逻辑
│ │ ├── db.rs # SQLite 数据库
│ │ └── lib.rs # Tauri 应用入口、窗口管理
│ ├── icons/ # 应用图标
│ ├── Cargo.toml
│ └── tauri.conf.json
├── package.json
└── README.md
```
## 功能
- **悬浮工具栏**:全局置顶,支持拖拽,快捷键截图
- **截图批改**:框选区域后调用 AI 模型自动评分
- **评分标准管理**:多题目、评分细则、参考答案(含图片)
- **模板管理**:保存/导入/导出评分模板
- **设置**API Key / Base URL / 模型 / 快捷键 / 代理等
- **历史记录**:评分记录查询、详情查看、删除
- **系统托盘**:关闭窗口后后台运行,托盘菜单唤出
## 许可
MIT
+12
View File
@@ -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>
+2443
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "ai-grading-assistant",
"version": "1.0.0",
"description": "AI 阅卷助手 - Tauri版桌面端 AI Copilot",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit --skipLibCheck && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-global-shortcut": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"element-plus": "^2.14.3",
"pinia": "^4.0.1",
"vue": "^3.5.39",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vue-tsc": "^3.3.7"
}
}
+46
View File
@@ -0,0 +1,46 @@
const fs = require('fs');
const path = require('path');
const dir = path.join(__dirname, '..', 'src-tauri', 'icons');
fs.mkdirSync(dir, { recursive: true });
// Pre-computed CRC32 for IHDR, IDAT, IEND chunks
// 1x1 white pixel PNG
const pngBuf = Buffer.from([
0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,
0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52,
0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,
0x08,0x02,0x00,0x00,0x00,0x90,0x77,0x53,
0xDE,
0x00,0x00,0x00,0x0C,0x49,0x44,0x41,0x54,
0x08,0xD7,0x63,0x60,0x60,0x60,0x00,
0x00,0x00,0x04,0x00,0x01,0x27,0x34,0x27,
0x00,0x00,0x00,0x00,0x49,0x45,0x4E,0x44,
0xAE,0x42,0x60,0x82
]);
fs.writeFileSync(path.join(dir, '32x32.png'), pngBuf);
fs.writeFileSync(path.join(dir, '128x128.png'), pngBuf);
fs.writeFileSync(path.join(dir, '128x128@2x.png'), pngBuf);
fs.writeFileSync(path.join(dir, 'icon.png'), pngBuf);
// Create ICO file
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(1, 4);
const entry = Buffer.alloc(16);
entry.writeUInt8(1, 0);
entry.writeUInt8(1, 1);
entry.writeUInt8(0, 2);
entry.writeUInt8(0, 3);
entry.writeUInt16LE(1, 4);
entry.writeUInt16LE(32, 6);
entry.writeUInt32LE(pngBuf.length, 8);
entry.writeUInt32LE(22, 12);
const ico = Buffer.concat([header, entry, pngBuf]);
fs.writeFileSync(path.join(dir, 'icon.ico'), ico);
console.log('All icons created in ' + dir);
+6464
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "ai-grading-assistant"
version = "1.0.0"
edition = "2021"
[lib]
name = "ai_grading_assistant_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[[bin]]
name = "ai-grading-assistant"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-dialog = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-store = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
base64 = "0.22"
xcap = "0.4"
image = "0.25"
log = "0.4"
env_logger = "0.11"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+30
View File
@@ -0,0 +1,30 @@
{
"identifier": "default",
"description": "Default capability set",
"windows": ["*"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-create",
"core:window:allow-close",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-set-focus",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-always-on-top",
"core:window:allow-center",
"core:window:allow-start-dragging",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-emit",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"global-shortcut:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"store:default",
"fs:default"
]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capability set","local":true,"windows":["*"],"permissions":["core:default","core:window:default","core:window:allow-create","core:window:allow-close","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-set-focus","core:window:allow-hide","core:window:allow-show","core:window:allow-set-always-on-top","core:window:allow-center","core:window:allow-start-dragging","core:event:default","core:event:allow-listen","core:event:allow-emit","dialog:default","dialog:allow-open","dialog:allow-save","global-shortcut:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","fs:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+211
View File
@@ -0,0 +1,211 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiConfig {
pub api_key: String,
pub base_url: String,
pub model: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GradeResult {
pub score: f64,
pub confidence: f64,
pub deductions: Vec<String>,
pub comment: String,
}
pub async fn call_ai_api(
image_base64: &str,
rubric: &str,
config: &ApiConfig,
max_score: Option<f64>,
reference_answer: Option<&str>,
question_title: Option<&str>,
reference_answer_image: Option<&str>,
timeout_secs: u64,
) -> Result<GradeResult, String> {
let system_role = "你是一名高校阅卷老师,根据评分标准和答案对学生答案评分。请输出JSON格式的结果。";
let has_ref_image = reference_answer_image.is_some_and(|s| !s.is_empty());
let max_s = max_score.unwrap_or(100.0);
let prompt = if has_ref_image {
format!(
"题目:{}\n评分标准:{}\n参考答案:{}\n满分:{}\n\n图片1是标准答案,图片2是学生答案。\n请根据评分标准和标准答案(图片1)给学生答案(图片2)评分。\n\n评分规则:\n1. 逐项评分,各项得分之和不超过满分{}\n2. 空白或无关答案给0分\n3. 部分正确酌情给分\n\n请输出JSON\n{{\"score\":分数,\"confidence\":置信度0~1,\"deductions\":[\"扣分项\"],\"comment\":\"评语\"}}\n\n只输出JSON,不要包含其他文字和markdown标记。",
question_title.unwrap_or(""),
rubric,
reference_answer.unwrap_or(""),
max_s,
max_s
)
} else {
format!(
"题目:{}\n评分标准:{}\n参考答案:{}\n满分:{}\n\n图片是学生答案,请根据评分标准评分。\n\n评分规则:\n1. 逐项评分,各项得分之和不超过满分{}\n2. 空白或无关答案给0分\n3. 部分正确酌情给分\n\n请输出JSON\n{{\"score\":分数,\"confidence\":置信度0~1,\"deductions\":[\"扣分项\"],\"comment\":\"评语\"}}\n\n只输出JSON,不要包含其他文字和markdown标记。",
question_title.unwrap_or(""),
rubric,
reference_answer.unwrap_or(""),
max_s,
max_s
)
};
let body = if has_ref_image {
serde_json::json!({
"model": config.model,
"messages": [
{"role": "system", "content": system_role},
{"role": "user", "content": [
{"type": "text", "text": &prompt},
{"type": "image_url", "image_url": {"url": reference_answer_image.unwrap_or("")}},
{"type": "image_url", "image_url": {"url": format!("data:image/png;base64,{}", image_base64)}}
]}
],
"max_tokens": 4096,
"temperature": 0.3
})
} else {
serde_json::json!({
"model": config.model,
"messages": [
{"role": "system", "content": system_role},
{"role": "user", "content": [
{"type": "text", "text": &prompt},
{"type": "image_url", "image_url": {"url": format!("data:image/png;base64,{}", image_base64)}}
]}
],
"max_tokens": 4096,
"temperature": 0.3
})
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/'));
let mut last_error = String::new();
for attempt in 1..=3 {
match client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", config.api_key))
.json(&body)
.send()
.await
{
Ok(response) => {
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
if error_text.contains("does not support image") || error_text.contains("does not support") && error_text.contains("image") {
last_error = "当前模型不支持图片输入,请在设置中更换为支持视觉的模型(如 Qwen/Qwen2.5-32B-Instruct、gpt-4o 等)".to_string();
} else {
last_error = format!("API 返回错误 ({}): {}", status, &error_text);
}
log::warn!("AI API attempt {}/3 failed: {}", attempt, last_error);
if attempt < 3 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
continue;
}
return Err(last_error);
}
let body = response.text().await.map_err(|e| e.to_string())?;
if body.contains("does not support image") || body.contains("does not support") && body.contains("image") {
last_error = "当前模型不支持图片输入,请在设置中更换为支持视觉的模型(如 Qwen/Qwen2.5-32B-Instruct、gpt-4o 等)".to_string();
log::warn!("AI API body error: {}", last_error);
return Err(last_error);
}
let json: Value = serde_json::from_str(&body).map_err(|e| format!("解析返回JSON失败: {}", e))?;
if let Some(err_msg) = json["error"]["message"].as_str() {
if err_msg.contains("does not support image") || err_msg.contains("image input") {
last_error = "当前模型不支持图片输入,请在设置中更换为支持视觉的模型(如 Qwen/Qwen2.5-32B-Instruct、gpt-4o 等)".to_string();
return Err(last_error);
}
}
let content = json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.to_string();
if content.is_empty() {
last_error = format!("API 返回为空 (尝试 {}/3)", attempt);
log::warn!("AI API empty response, attempt {}/3", attempt);
if attempt < 3 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
continue;
}
return Err(last_error);
}
match try_parse_json(&content) {
Some(parsed) => {
return Ok(GradeResult {
score: parsed["score"].as_f64().unwrap_or(0.0),
confidence: parsed["confidence"].as_f64().unwrap_or(0.5),
deductions: parsed["deductions"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default(),
comment: parsed["comment"].as_str().or_else(|| parsed["commit"].as_str())
.unwrap_or("")
.to_string(),
});
}
None => {
last_error = format!("AI 返回 JSON 解析失败 (尝试 {}/3)", attempt);
log::warn!("AI JSON parse failed, attempt {}/3: {}", attempt, &content[..content.len().min(200)]);
if attempt < 3 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
continue;
}
return Err(last_error);
}
}
}
Err(e) => {
last_error = format!("API request failed: {}", e);
log::warn!("AI API attempt {}/3 failed: {}", attempt, last_error);
if attempt < 3 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
continue;
}
return Err(last_error);
}
}
}
Err(last_error)
}
fn try_parse_json(text: &str) -> Option<Value> {
let mut cleaned = text.trim().to_string();
// remove markdown code fences
cleaned = cleaned.replace("```json", "").replace("```JSON", "").replace("```", "").trim().to_string();
// if there's still non-JSON text, extract the first {...} object
if !cleaned.starts_with('{') && !cleaned.starts_with('[') {
if let Some(start) = cleaned.find('{') {
if let Some(end) = cleaned.rfind('}') {
cleaned = cleaned[start..=end].to_string();
}
}
}
// attempt direct parse
if let Ok(v) = serde_json::from_str::<Value>(&cleaned) {
return Some(v);
}
// fix single quotes -> double quotes
let fixed = cleaned
.replace('\'', "\"")
.replace(",\n]", "\n]")
.replace(",\n}", "\n}")
.replace(",]", "]")
.replace(",}", "}");
if let Ok(v) = serde_json::from_str::<Value>(&fixed) {
return Some(v);
}
None
}
+63
View File
@@ -0,0 +1,63 @@
use crate::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GradingSubmitData {
pub question_id: Option<i64>,
pub image_path: Option<String>,
pub ai_score: f64,
pub teacher_score: f64,
pub confidence: f64,
pub student_answer: Option<String>,
pub question_title: Option<String>,
pub max_score: Option<f64>,
pub reference_answer: Option<String>,
pub rubric: Option<String>,
pub deductions: Option<Vec<String>>,
pub comment: Option<String>,
pub result_json: Option<String>,
pub image_data: Option<String>,
pub reference_answer_image: Option<String>,
}
#[tauri::command]
pub fn grading_submit(state: State<AppState>, data: GradingSubmitData) -> Result<bool, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let json = serde_json::json!({
"questionId": data.question_id.unwrap_or(0),
"imagePath": data.image_path.unwrap_or_default(),
"studentAnswer": data.student_answer.unwrap_or_default(),
"aiScore": data.ai_score,
"teacherScore": data.teacher_score,
"confidence": data.confidence,
"questionTitle": data.question_title.unwrap_or_default(),
"maxScore": data.max_score.unwrap_or(100.0),
"referenceAnswer": data.reference_answer.unwrap_or_default(),
"rubric": data.rubric.unwrap_or_default(),
"deductions": serde_json::to_string(&data.deductions.unwrap_or_default()).unwrap_or_else(|_| "[]".to_string()),
"comment": data.comment.unwrap_or_default(),
"resultJson": data.result_json.unwrap_or_default(),
"imageData": data.image_data.unwrap_or_default(),
"referenceAnswerImage": data.reference_answer_image.unwrap_or_default(),
});
Ok(db.insert_history(&json))
}
#[tauri::command]
pub fn grading_store_data(state: State<AppState>, data: serde_json::Value) -> Result<(), String> {
let mut payload = state.grading_payload.lock().map_err(|e| e.to_string())?;
*payload = Some(data.to_string());
Ok(())
}
#[tauri::command]
pub fn grading_get_data(state: State<AppState>) -> Result<Option<serde_json::Value>, String> {
let mut payload = state.grading_payload.lock().map_err(|e| e.to_string())?;
let data = payload.take();
match data {
Some(s) => serde_json::from_str(&s).map(Some).map_err(|e| e.to_string()),
None => Ok(None),
}
}
+20
View File
@@ -0,0 +1,20 @@
use crate::AppState;
use tauri::State;
#[tauri::command]
pub fn history_list(state: State<AppState>) -> Result<Vec<serde_json::Value>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
Ok(db.list_history())
}
#[tauri::command]
pub fn history_delete(state: State<AppState>, id: i64) -> Result<bool, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
Ok(db.delete_history(id))
}
#[tauri::command]
pub fn history_get_by_id(state: State<AppState>, id: i64) -> Result<Option<serde_json::Value>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
Ok(db.get_history_by_id(id))
}
+89
View File
@@ -0,0 +1,89 @@
pub mod settings;
pub mod screenshot;
pub mod history;
pub mod grading;
pub mod rubric;
pub mod template;
use crate::ai;
use crate::create_dialog_window;
use base64::Engine;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_store::StoreExt;
#[derive(Debug, Serialize, Deserialize)]
pub struct ImageResult {
pub base64: String,
pub mime: String,
pub file_path: String,
}
#[tauri::command]
pub async fn dialog_open(app: AppHandle, dialog_type: String) -> Result<(), String> {
let sizes: [(&str, f64, f64); 5] = [
("settings", 520.0, 540.0),
("history", 860.0, 660.0),
("templates", 720.0, 540.0),
("rubric", 650.0, 700.0),
("result", 620.0, 680.0),
];
let (_, w, h) = sizes.iter().find(|(t, _, _)| *t == dialog_type).unwrap_or(&("result", 600.0, 780.0));
create_dialog_window(&app, &dialog_type, *w, *h).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn dialog_open_image(app: AppHandle) -> Result<Option<ImageResult>, String> {
let file = app.dialog()
.file()
.add_filter("图片", &["png", "jpg", "jpeg", "bmp", "gif", "webp"])
.blocking_pick_file();
match file {
Some(path) => {
let path_str = path.as_path().unwrap().to_string_lossy().to_string();
let ext = std::path::Path::new(&path_str)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let mime = if ext == "jpg" { "jpeg".to_string() } else { ext.clone() };
let bytes = std::fs::read(&path_str).map_err(|e| format!("Failed to read file: {}", e))?;
let base64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(Some(ImageResult { base64, mime, file_path: path_str }))
}
None => Ok(None),
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GradeRequest {
pub image_base64: String,
pub rubric: String,
pub api_config: ai::ApiConfig,
pub max_score: Option<f64>,
pub reference_answer: Option<String>,
pub question_title: Option<String>,
pub reference_answer_image: Option<String>,
}
#[tauri::command]
pub async fn ai_grade(app: AppHandle, request: GradeRequest) -> Result<ai::GradeResult, String> {
let timeout = app.store("settings.json")
.ok()
.and_then(|store| store.get("timeout").and_then(|v| v.as_i64().map(|i| i as u64)))
.unwrap_or(120);
ai::call_ai_api(
&request.image_base64,
&request.rubric,
&request.api_config,
request.max_score,
request.reference_answer.as_deref(),
request.question_title.as_deref(),
request.reference_answer_image.as_deref(),
timeout,
)
.await
}
+112
View File
@@ -0,0 +1,112 @@
use crate::AppState;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use tauri_plugin_dialog::DialogExt;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RubricData {
pub questions: Vec<QuestionItem>,
pub current_index: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QuestionItem {
pub question_title: String,
pub max_score: f64,
pub reference_answer: String,
pub rubric: String,
pub reference_answer_image: Option<String>,
}
#[tauri::command]
pub fn rubric_store_data(app: AppHandle, state: State<AppState>, data: RubricData) -> Result<(), String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.clear_rubric();
for (i, q) in data.questions.iter().enumerate() {
db.insert_rubric_question(&serde_json::json!(q), i as i32);
}
drop(db);
let _ = app.emit("rubric-updated", ());
Ok(())
}
#[tauri::command]
pub fn rubric_get_data(state: State<AppState>) -> Result<Option<serde_json::Value>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let questions = db.get_rubric_questions();
if questions.is_empty() {
Ok(None)
} else {
Ok(Some(serde_json::json!({
"questions": questions,
"currentIndex": 0
})))
}
}
#[tauri::command]
pub async fn rubric_export_json(app: AppHandle, state: State<'_, AppState>) -> Result<bool, String> {
let questions = {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.get_rubric_questions()
};
if questions.is_empty() {
return Err("暂无评分标准可导出".to_string());
}
let file = app.dialog()
.file()
.add_filter("JSON", &["json"])
.set_file_name("评分标准.json")
.blocking_save_file();
match file {
Some(path) => {
let path_str = path.as_path().unwrap().to_string_lossy().to_string();
let content = serde_json::to_string_pretty(&questions).map_err(|e| e.to_string())?;
std::fs::write(&path_str, &content).map_err(|e| e.to_string())?;
Ok(true)
}
None => Ok(false),
}
}
#[tauri::command]
pub async fn rubric_import_json(app: AppHandle) -> Result<Option<serde_json::Value>, String> {
let file = app.dialog()
.file()
.add_filter("JSON", &["json"])
.blocking_pick_file();
match file {
Some(path) => {
let path_str = path.as_path().unwrap().to_string_lossy().to_string();
let content = std::fs::read_to_string(&path_str).map_err(|e| e.to_string())?;
let questions: Vec<serde_json::Value> = serde_json::from_str(&content).map_err(|e| format!("JSON 格式错误:{}", e))?;
Ok(Some(serde_json::json!({
"questions": questions,
"currentIndex": 0
})))
}
None => Ok(None),
}
}
#[tauri::command]
pub fn rubric_get_next_index(state: State<AppState>) -> Result<i32, String> {
let idx = state.rubric_next_index.lock().map_err(|e| e.to_string())?;
Ok(*idx)
}
#[tauri::command]
pub fn rubric_set_next_index(state: State<AppState>, idx: i32) -> Result<(), String> {
let mut next = state.rubric_next_index.lock().map_err(|e| e.to_string())?;
*next = idx;
Ok(())
}
#[tauri::command]
pub fn rubric_reset_next_index(state: State<AppState>) -> Result<(), String> {
let mut next = state.rubric_next_index.lock().map_err(|e| e.to_string())?;
*next = 0;
Ok(())
}
+105
View File
@@ -0,0 +1,105 @@
use crate::get_screenshot_window;
use base64::Engine;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager};
#[derive(Debug, Serialize, Deserialize)]
pub struct CaptureRegion {
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
}
#[tauri::command]
pub async fn screenshot_start(app: AppHandle) -> Result<(), String> {
if let Some(main) = app.get_webview_window("main") {
let _ = main.hide();
}
get_screenshot_window(&app).map_err(|e| e.to_string())?;
Ok(())
}
fn get_monitor_at_cursor(app: &AppHandle) -> Result<xcap::Monitor, String> {
let monitors = xcap::Monitor::all().map_err(|e| format!("Failed to list monitors: {}", e))?;
let cursor = app.get_webview_window("main").and_then(|w| w.outer_position().ok());
for m in monitors {
let mx = m.x().map_err(|e| format!("Monitor x error: {}", e))?;
let my = m.y().map_err(|e| format!("Monitor y error: {}", e))?;
let mw = m.width().map_err(|e| format!("Monitor width error: {}", e))? as i32;
let mh = m.height().map_err(|e| format!("Monitor height error: {}", e))? as i32;
if let Some(c) = cursor {
let cx = c.x as i32;
let cy = c.y as i32;
if cx >= mx && cx < (mx + mw) && cy >= my && cy < (my + mh) {
return Ok(m);
}
}
}
xcap::Monitor::all()
.map_err(|e| format!("Failed to list monitors: {}", e))?
.into_iter()
.next()
.ok_or_else(|| "No monitor found".to_string())
}
#[tauri::command]
pub async fn screenshot_capture(app: AppHandle, region: CaptureRegion) -> Result<Option<String>, String> {
let target = get_monitor_at_cursor(&app)?;
let img = target.capture_image().map_err(|e| format!("Failed to capture screen: {}", e))?;
let (img_w, img_h) = (img.width() as f64, img.height() as f64);
let (disp_w, disp_h) = (
target.width().map_err(|e| format!("Monitor width error: {}", e))? as f64,
target.height().map_err(|e| format!("Monitor height error: {}", e))? as f64,
);
let ratio_x = img_w / disp_w;
let ratio_y = img_h / disp_h;
let crop_x = (region.x as f64 * ratio_x).max(0.0) as u32;
let crop_y = (region.y as f64 * ratio_y).max(0.0) as u32;
let crop_w = (region.width as f64 * ratio_x).min((img_w - crop_x as f64).max(0.0)) as u32;
let crop_h = (region.height as f64 * ratio_y).min((img_h - crop_y as f64).max(0.0)) as u32;
let cropped = image::DynamicImage::ImageRgba8(img).crop_imm(crop_x, crop_y, crop_w, crop_h);
let mut png_bytes: Vec<u8> = Vec::new();
cropped.write_to(&mut std::io::Cursor::new(&mut png_bytes), image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode PNG: {}", e))?;
let base64_str = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
if let Ok(store) = tauri_plugin_store::StoreExt::store(&app, "settings.json") {
if let Some(path) = store.get("screenshotSavePath").and_then(|v| v.as_str().map(|s| s.to_string())) {
if !path.is_empty() {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis();
let filename = format!("screenshot_{}.png", timestamp);
let full_path = std::path::Path::new(&path).join(&filename);
let _ = std::fs::write(&full_path, &png_bytes);
}
}
}
if let Some(sw) = app.get_webview_window("screenshot") {
let _ = sw.close();
}
if let Some(main) = app.get_webview_window("main") {
let _ = main.show();
let _ = main.set_focus();
}
let _ = app.emit("screenshot-completed", &base64_str);
Ok(Some(base64_str))
}
#[tauri::command]
pub async fn screenshot_cancel(app: AppHandle) -> Result<(), String> {
if let Some(sw) = app.get_webview_window("screenshot") {
let _ = sw.close();
}
if let Some(main) = app.get_webview_window("main") {
let _ = main.show();
let _ = main.set_focus();
}
Ok(())
}
+68
View File
@@ -0,0 +1,68 @@
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
pub api_key: String,
pub base_url: String,
pub model: String,
pub shortcut: String,
pub theme: String,
pub font_size: i32,
pub proxy: String,
pub timeout: i32,
pub screenshot_save_path: String,
}
impl Default for AppSettings {
fn default() -> Self {
AppSettings {
api_key: String::new(),
base_url: "https://api.siliconflow.cn/v1".to_string(),
model: String::new(),
shortcut: "Alt+Q".to_string(),
theme: "light".to_string(),
font_size: 14,
proxy: String::new(),
timeout: 120,
screenshot_save_path: String::new(),
}
}
}
#[tauri::command]
pub async fn settings_get(app: AppHandle) -> Result<AppSettings, String> {
let store = app.store("settings.json").map_err(|e| e.to_string())?;
Ok(AppSettings {
api_key: store.get("apiKey").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default(),
base_url: store.get("baseUrl").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_else(|| "https://api.siliconflow.cn/v1".to_string()),
model: store.get("model").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default(),
shortcut: store.get("shortcut").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_else(|| "Alt+Q".to_string()),
theme: store.get("theme").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_else(|| "light".to_string()),
font_size: store.get("fontSize").and_then(|v| v.as_i64().map(|i| i as i32)).unwrap_or(14),
proxy: store.get("proxy").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default(),
timeout: store.get("timeout").and_then(|v| v.as_i64().map(|i| {
let t = i as i32;
if t < 60 { 120 } else { t }
})).unwrap_or(120),
screenshot_save_path: store.get("screenshotSavePath").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default(),
})
}
#[tauri::command]
pub async fn settings_set(app: AppHandle, settings: AppSettings) -> Result<bool, String> {
let store = app.store("settings.json").map_err(|e| e.to_string())?;
store.set("apiKey", serde_json::Value::String(settings.api_key));
store.set("baseUrl", serde_json::Value::String(settings.base_url));
store.set("model", serde_json::Value::String(settings.model));
store.set("shortcut", serde_json::Value::String(settings.shortcut));
store.set("theme", serde_json::Value::String(settings.theme));
store.set("fontSize", serde_json::Value::Number(serde_json::Number::from(settings.font_size)));
store.set("proxy", serde_json::Value::String(settings.proxy));
store.set("timeout", serde_json::Value::Number(serde_json::Number::from(settings.timeout)));
store.set("screenshotSavePath", serde_json::Value::String(settings.screenshot_save_path));
store.save().map_err(|e| e.to_string())?;
Ok(true)
}
+119
View File
@@ -0,0 +1,119 @@
use crate::AppState;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use tauri_plugin_dialog::DialogExt;
#[derive(Debug, Serialize, Deserialize)]
pub struct TemplateSaveData {
pub name: String,
pub course: String,
pub questions: Option<Vec<serde_json::Value>>,
}
#[tauri::command]
pub fn template_list(state: State<AppState>) -> Result<Vec<serde_json::Value>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
Ok(db.list_templates())
}
#[tauri::command]
pub fn template_save(state: State<AppState>, data: TemplateSaveData) -> Result<i64, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let questions_json = serde_json::to_string(&data.questions.unwrap_or_default()).unwrap_or_else(|_| "[]".to_string());
Ok(db.save_template(&data.name, &data.course, &questions_json))
}
#[tauri::command]
pub fn template_delete(state: State<AppState>, id: i64) -> Result<bool, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
Ok(db.delete_template(id))
}
#[tauri::command]
pub fn template_apply(app: AppHandle, state: State<AppState>, id: i64) -> Result<bool, String> {
let tpl = {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.get_template_by_id(id)
};
match tpl {
Some(t) => {
let questions = t["questions"].as_array().ok_or("模板中没有题目")?.clone();
if questions.is_empty() {
return Err("模板中没有题目".to_string());
}
{
let db = state.db.lock().map_err(|e| e.to_string())?;
db.clear_rubric();
for (i, q) in questions.iter().enumerate() {
db.insert_rubric_question(q, i as i32);
}
}
{
let mut idx = state.rubric_next_index.lock().map_err(|e| e.to_string())?;
*idx = 0;
}
let _ = app.emit("rubric-updated", ());
Ok(true)
}
None => Err("模板不存在".to_string()),
}
}
#[tauri::command]
pub async fn template_export_json(app: AppHandle, state: State<'_, AppState>) -> Result<bool, String> {
let templates = {
let db = state.db.lock().map_err(|e| e.to_string())?;
db.list_templates()
};
if templates.is_empty() {
return Err("暂无模板可导出".to_string());
}
let file = app.dialog()
.file()
.add_filter("JSON", &["json"])
.set_file_name("评分模板.json")
.blocking_save_file();
match file {
Some(path) => {
let path_str = path.as_path().unwrap().to_string_lossy().to_string();
let export_data: Vec<serde_json::Value> = templates.iter().map(|t| {
serde_json::json!({
"name": t["name"],
"course": t["course"],
"questions": t["questions"],
"created_at": t["created_at"]
})
}).collect();
let content = serde_json::to_string_pretty(&export_data).map_err(|e| e.to_string())?;
std::fs::write(&path_str, &content).map_err(|e| e.to_string())?;
Ok(true)
}
None => Ok(false),
}
}
#[tauri::command]
pub async fn template_import_json(app: AppHandle, state: State<'_, AppState>) -> Result<Option<serde_json::Value>, String> {
let file = app.dialog()
.file()
.add_filter("JSON", &["json"])
.blocking_pick_file();
match file {
Some(path) => {
let path_str = path.as_path().unwrap().to_string_lossy().to_string();
let content = std::fs::read_to_string(&path_str).map_err(|e| e.to_string())?;
let data: Vec<serde_json::Value> = serde_json::from_str(&content).map_err(|e| format!("JSON 格式错误:应为数组 - {}", e))?;
let db = state.db.lock().map_err(|e| e.to_string())?;
let mut count = 0;
for tpl in &data {
if let (Some(name), Some(course)) = (tpl["name"].as_str(), tpl["course"].as_str()) {
let questions_json = serde_json::to_string(&tpl["questions"]).unwrap_or_else(|_| "[]".to_string());
db.save_template(name, course, &questions_json);
count += 1;
}
}
Ok(Some(serde_json::json!({ "count": count })))
}
None => Ok(None),
}
}
+240
View File
@@ -0,0 +1,240 @@
use rusqlite::{Connection, params};
use std::path::PathBuf;
pub struct Database {
conn: Connection,
}
impl Database {
pub fn new(path: PathBuf) -> Self {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("Failed to create database directory");
}
let conn = Connection::open(&path).unwrap_or_else(|e| {
panic!("Failed to open database at {}: {}", path.display(), e)
});
conn.execute_batch(
"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,
question_title TEXT DEFAULT '',
max_score REAL DEFAULT 100,
reference_answer TEXT DEFAULT '',
rubric TEXT DEFAULT '',
deductions TEXT DEFAULT '',
comment TEXT DEFAULT '',
result_json TEXT DEFAULT '',
image_data TEXT DEFAULT '',
reference_answer_image TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS exam (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
course TEXT NOT NULL DEFAULT '',
questions_json TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS rubric (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_title TEXT NOT NULL DEFAULT '',
max_score REAL NOT NULL DEFAULT 100,
reference_answer TEXT NOT NULL DEFAULT '',
rubric TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
reference_answer_image TEXT NOT NULL DEFAULT ''
);"
).expect("Failed to create tables");
Database { conn }
}
pub fn get_conn(&self) -> &Connection {
&self.conn
}
// History methods
pub fn list_history(&self) -> Vec<serde_json::Value> {
let mut stmt = self.conn.prepare(
"SELECT * FROM history ORDER BY created_at DESC LIMIT 100"
).unwrap();
let rows = stmt.query_map([], |row| {
let id: i64 = row.get(0)?;
let question_id: i64 = row.get(1)?;
let image_path: String = row.get(2)?;
let student_answer: String = row.get(3)?;
let ai_score: f64 = row.get(4)?;
let teacher_score: f64 = row.get(5)?;
let confidence: f64 = row.get(6)?;
let created_at: String = row.get(7)?;
let question_title: String = row.get(8)?;
let max_score: f64 = row.get(9)?;
let reference_answer: String = row.get(10)?;
let rubric: String = row.get(11)?;
let deductions: String = row.get(12)?;
let comment: String = row.get(13)?;
let result_json: String = row.get(14)?;
let image_data: String = row.get(15)?;
let reference_answer_image: String = row.get(16)?;
Ok(serde_json::json!({
"id": id, "question_id": question_id, "image_path": image_path,
"student_answer": student_answer, "ai_score": ai_score,
"teacher_score": teacher_score, "confidence": confidence,
"created_at": created_at, "question_title": question_title,
"max_score": max_score, "reference_answer": reference_answer,
"rubric": rubric, "deductions": deductions, "comment": comment,
"result_json": result_json, "image_data": image_data,
"reference_answer_image": reference_answer_image
}))
}).unwrap();
rows.filter_map(|r| r.ok()).collect()
}
pub fn get_history_by_id(&self, id: i64) -> Option<serde_json::Value> {
self.conn.prepare(
"SELECT * FROM history WHERE id = ?"
).ok().and_then(|mut stmt| {
stmt.query_row(params![id], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"question_id": row.get::<_, i64>(1)?,
"image_path": row.get::<_, String>(2)?,
"student_answer": row.get::<_, String>(3)?,
"ai_score": row.get::<_, f64>(4)?,
"teacher_score": row.get::<_, f64>(5)?,
"confidence": row.get::<_, f64>(6)?,
"created_at": row.get::<_, String>(7)?,
"question_title": row.get::<_, String>(8)?,
"max_score": row.get::<_, f64>(9)?,
"reference_answer": row.get::<_, String>(10)?,
"rubric": row.get::<_, String>(11)?,
"deductions": row.get::<_, String>(12)?,
"comment": row.get::<_, String>(13)?,
"result_json": row.get::<_, String>(14)?,
"image_data": row.get::<_, String>(15)?,
"reference_answer_image": row.get::<_, String>(16)?
}))
}).ok()
})
}
pub fn delete_history(&self, id: i64) -> bool {
self.conn.execute("DELETE FROM history WHERE id = ?", params![id]).is_ok()
}
pub fn insert_history(&self, data: &serde_json::Value) -> bool {
self.conn.execute(
"INSERT INTO history (question_id, image_path, student_answer, ai_score, teacher_score, confidence, question_title, max_score, reference_answer, rubric, deductions, comment, result_json, image_data, reference_answer_image) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
data["questionId"].as_i64().unwrap_or(0),
data["imagePath"].as_str().unwrap_or(""),
data["studentAnswer"].as_str().unwrap_or(""),
data["aiScore"].as_f64().unwrap_or(0.0),
data["teacherScore"].as_f64().unwrap_or(0.0),
data["confidence"].as_f64().unwrap_or(0.0),
data["questionTitle"].as_str().unwrap_or(""),
data["maxScore"].as_f64().unwrap_or(100.0),
data["referenceAnswer"].as_str().unwrap_or(""),
data["rubric"].as_str().unwrap_or(""),
data["deductions"].as_str().unwrap_or("[]"),
data["comment"].as_str().unwrap_or(""),
data["resultJson"].as_str().unwrap_or(""),
data["imageData"].as_str().unwrap_or(""),
data["referenceAnswerImage"].as_str().unwrap_or(""),
]
).is_ok()
}
// Rubric methods
pub fn get_rubric_questions(&self) -> Vec<serde_json::Value> {
let mut stmt = self.conn.prepare(
"SELECT * FROM rubric ORDER BY sort_order"
).unwrap();
let rows = stmt.query_map([], |row| {
let q_title: String = row.get(1)?;
let q_max: f64 = row.get(2)?;
let q_ref: String = row.get(3)?;
let q_rubric: String = row.get(4)?;
let q_img: String = row.get(5)?;
let ref_img = if q_img.is_empty() || q_img.starts_with("data:") { q_img } else { format!("data:image/png;base64,{}", q_img) };
Ok(serde_json::json!({
"questionTitle": q_title,
"maxScore": q_max,
"referenceAnswer": q_ref,
"rubric": q_rubric,
"referenceAnswerImage": ref_img
}))
}).unwrap();
rows.filter_map(|r| r.ok()).collect()
}
pub fn clear_rubric(&self) {
let _ = self.conn.execute("DELETE FROM rubric", []);
}
pub fn insert_rubric_question(&self, q: &serde_json::Value, sort_order: i32) -> bool {
self.conn.execute(
"INSERT INTO rubric (question_title, max_score, reference_answer, rubric, sort_order, reference_answer_image) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
q["questionTitle"].as_str().unwrap_or(""),
q["maxScore"].as_f64().unwrap_or(100.0),
q["referenceAnswer"].as_str().unwrap_or(""),
q["rubric"].as_str().unwrap_or(""),
sort_order,
q["referenceAnswerImage"].as_str().unwrap_or(""),
]
).is_ok()
}
// Template (exam) methods
pub fn list_templates(&self) -> Vec<serde_json::Value> {
let mut stmt = self.conn.prepare(
"SELECT * FROM exam ORDER BY created_at DESC"
).unwrap();
let rows = stmt.query_map([], |row| {
let id: i64 = row.get(0)?;
let name: String = row.get(1)?;
let course: String = row.get(2)?;
let questions_json: String = row.get(3)?;
let created_at: String = row.get(4)?;
let questions: serde_json::Value = serde_json::from_str(&questions_json).unwrap_or(serde_json::Value::Array(vec![]));
Ok(serde_json::json!({
"id": id, "name": name, "course": course,
"questions": questions, "created_at": created_at
}))
}).unwrap();
rows.filter_map(|r| r.ok()).collect()
}
pub fn get_template_by_id(&self, id: i64) -> Option<serde_json::Value> {
self.conn.prepare("SELECT * FROM exam WHERE id = ?").ok().and_then(|mut stmt| {
stmt.query_row(params![id], |row| {
let questions_json: String = row.get(3)?;
let questions: serde_json::Value = serde_json::from_str(&questions_json).unwrap_or(serde_json::Value::Array(vec![]));
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"name": row.get::<_, String>(1)?,
"course": row.get::<_, String>(2)?,
"questions": questions,
"created_at": row.get::<_, String>(4)?
}))
}).ok()
})
}
pub fn save_template(&self, name: &str, course: &str, questions: &str) -> i64 {
self.conn.execute(
"INSERT INTO exam (name, course, questions_json) VALUES (?1, ?2, ?3)",
params![name, course, questions]
).ok();
self.conn.last_insert_rowid()
}
pub fn delete_template(&self, id: i64) -> bool {
self.conn.execute("DELETE FROM exam WHERE id = ?", params![id]).is_ok()
}
}
+235
View File
@@ -0,0 +1,235 @@
mod ai;
mod commands;
mod db;
use db::Database;
use std::sync::Mutex;
use tauri::{
image::Image,
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, PhysicalPosition, WebviewUrl, WebviewWindowBuilder, WindowEvent,
};
pub struct AppState {
pub db: Mutex<Database>,
pub grading_payload: Mutex<Option<String>>,
pub rubric_next_index: Mutex<i32>,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
env_logger::init();
tauri::Builder::default()
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
let _ = window.hide();
api.prevent_close();
}
}
})
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_fs::init())
.setup(|app| {
let app_handle = app.handle().clone();
let db = Database::new(app_handle.path().app_data_dir()?.join("grading.db"));
app.manage(AppState {
db: Mutex::new(db),
grading_payload: Mutex::new(None),
rubric_next_index: Mutex::new(0),
});
// tray icon
let show_item = MenuItem::with_id(app, "show", "显示", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
let img = image::load_from_memory(include_bytes!("../icons/icon.png"))
.expect("Failed to decode tray icon")
.into_rgba8();
let (iw, ih) = img.dimensions();
let icon = Image::new_owned(img.into_raw(), iw, ih);
TrayIconBuilder::new()
.icon(icon)
.menu(&menu)
.tooltip("AI 阅卷助手")
.on_menu_event(|app, event| {
match event.id().as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
let _ = create_main_window(app.handle());
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::settings::settings_get,
commands::settings::settings_set,
commands::screenshot::screenshot_start,
commands::screenshot::screenshot_capture,
commands::screenshot::screenshot_cancel,
commands::ai_grade,
commands::history::history_list,
commands::history::history_delete,
commands::history::history_get_by_id,
commands::grading::grading_submit,
commands::grading::grading_store_data,
commands::grading::grading_get_data,
commands::rubric::rubric_store_data,
commands::rubric::rubric_get_data,
commands::rubric::rubric_export_json,
commands::rubric::rubric_import_json,
commands::rubric::rubric_get_next_index,
commands::rubric::rubric_set_next_index,
commands::rubric::rubric_reset_next_index,
commands::template::template_list,
commands::template::template_save,
commands::template::template_delete,
commands::template::template_apply,
commands::template::template_export_json,
commands::template::template_import_json,
commands::dialog_open,
commands::dialog_open_image,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
fn create_main_window(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
let (wx, wy) = app.primary_monitor()
.ok()
.flatten()
.map(|m| {
let scale = m.scale_factor();
let w = m.size().width as f64 / scale;
let x = (w - 600.0) / 2.0 + m.position().x as f64 / scale;
let y = m.position().y as f64 / scale;
(x, y)
})
.unwrap_or((0.0, 0.0));
let window = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
.title("AI 阅卷助手")
.inner_size(600.0, 64.0)
.min_inner_size(200.0, 64.0)
.max_inner_size(1200.0, 76.0)
.decorations(false)
.always_on_top(true)
.resizable(false)
.build()?;
window.set_position(PhysicalPosition::new(wx as i32, wy as i32))?;
Ok(())
}
pub fn create_dialog_window(
app: &tauri::AppHandle,
dialog_type: &str,
width: f64,
height: f64,
) -> Result<(), Box<dyn std::error::Error>> {
let label = format!("dialog-{}", dialog_type);
if let Some(window) = app.get_webview_window(&label) {
window.set_focus()?;
return Ok(());
}
let (dx, dy) = get_dialog_position(app, width, height);
WebviewWindowBuilder::new(app, &label, WebviewUrl::App(format!("index.html?dialog={}", dialog_type).into()))
.title(match dialog_type {
"settings" => "设置",
"history" => "历史记录",
"templates" => "模板管理",
"rubric" => "评分标准",
"result" => "评分结果",
_ => "AI 阅卷助手",
})
.inner_size(width, height)
.position(dx, dy)
.decorations(false)
.resizable(false)
.build()?;
Ok(())
}
fn get_dialog_position(app: &tauri::AppHandle, width: f64, _height: f64) -> (f64, f64) {
if let Some(main) = app.get_webview_window("main") {
if let (Ok(pos), Ok(size)) = (main.outer_position(), main.outer_size()) {
let mx = pos.x as f64;
let my = pos.y as f64;
let mw = size.width as f64;
let mh = size.height as f64;
let dx = mx + (mw - width) / 2.0;
let dy = my + mh;
return (dx.max(0.0), dy.max(0.0));
}
}
(0.0, 0.0)
}
fn get_screenshot_window(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
if let Some(sw) = app.get_webview_window("screenshot") {
sw.set_focus()?;
return Ok(());
}
let all_monitors = xcap::Monitor::all()?;
let cursor = app.get_webview_window("main").and_then(|w| w.outer_position().ok());
let monitor = all_monitors.into_iter().find(|m| {
if let (Ok(mx), Ok(my)) = (m.x(), m.y()) {
if let (Ok(mw), Ok(mh)) = (m.width(), m.height()) {
if let Some(c) = cursor {
let cx = c.x as i32;
let cy = c.y as i32;
return cx >= mx && cx < (mx + mw as i32) && cy >= my && cy < (my + mh as i32);
}
}
}
false
}).unwrap_or_else(|| {
xcap::Monitor::all().ok().and_then(|m| m.into_iter().next())
.expect("No monitor found")
});
let scale = monitor.scale_factor().unwrap_or(1.0) as f64;
let w = monitor.width().unwrap_or(1920) as f64 / scale;
let h = monitor.height().unwrap_or(1080) as f64 / scale;
let x = monitor.x().unwrap_or(0) as f64;
let y = monitor.y().unwrap_or(0) as f64;
WebviewWindowBuilder::new(app, "screenshot", WebviewUrl::App("index.html#/screenshot".into()))
.title("截图")
.inner_size(w, h)
.position(x, y)
.decorations(false)
.transparent(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.build()?;
Ok(())
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
ai_grading_assistant_lib::run()
}
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui-org/nicegui-tauri/main/tauri.conf.schema.json",
"productName": "AI阅卷助手",
"version": "1.0.0",
"identifier": "com.ai-grading-assistant.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png"
]
}
}
+75
View File
@@ -0,0 +1,75 @@
<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 { nextTick, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useSettingsStore } from '@/stores/settings'
import { useGradingStore } from '@/stores/grading'
import { LogicalSize, getCurrentWindow } from '@tauri-apps/api/window'
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')
const isScreenshot = window.location.hash.startsWith('#/screenshot')
const winClass = isScreenshot ? 'win-screenshot' : dialogType ? 'win-dialog' : 'win-main'
document.documentElement.dataset.window = winClass
async function resizeToContent() {
await nextTick()
await nextTick()
const el = document.querySelector('.el-dialog') || document.querySelector('.drawer-panel') || document.querySelector('.win-dialog-body')
if (!el) return
const rect = el.getBoundingClientRect()
try {
const w = Math.ceil(rect.width) + 40
const h = Math.ceil(rect.height) + 80
await getCurrentWindow().setSize(new LogicalSize(Math.max(300, w), Math.max(200, h)))
} catch {}
}
onMounted(async () => {
if (dialogType === 'result') {
const { invoke } = await import('@tauri-apps/api/core')
const data: any = await invoke('grading_get_data')
if (data) {
if (data.image) gradingStore.setImage(data.image)
if (data.questions) gradingStore.setQuestions(data.questions)
if (data.result) gradingStore.setResult(data.result)
if (data.currentQuestionIndex !== undefined) gradingStore.setCurrentQuestionIndex(data.currentQuestionIndex)
}
appStore.openResultDrawer()
await resizeToContent()
} 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]?.()
setTimeout(resizeToContent, 200)
} else {
await settingsStore.loadSettings()
}
})
</script>
+216
View File
@@ -0,0 +1,216 @@
<template>
<div ref="toolbarRef" class="floating-toolbar" :class="{ 'is-hidden': !appStore.isToolbarVisible }" @mousedown="onDragStart">
<div class="toolbar-buttons">
<el-button class="toolbar-btn" @click="startScreenshot" :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>
<template v-if="!showMenu">
<el-button class="toolbar-btn" @click="showMenu = true">
<span class="btn-icon">📎</span>
<span class="btn-label">其它</span>
</el-button>
</template>
<template v-else>
<el-button class="toolbar-btn sub-btn" @click="showMenu = false; selectImage()">
<span class="btn-icon">📁</span>
<span class="btn-label">图片阅卷</span>
</el-button>
<el-button class="toolbar-btn sub-btn" @click="showMenu = false; openTemplates()">
<span class="btn-icon">📚</span>
<span class="btn-label">模板</span>
</el-button>
<el-button class="toolbar-btn sub-btn" @click="showMenu = false; openHistory()">
<span class="btn-icon">📜</span>
<span class="btn-label">历史</span>
</el-button>
<el-button class="toolbar-btn collapse-btn" @click="showMenu = false">
<span class="btn-icon"></span>
<span class="btn-label">收起</span>
</el-button>
</template>
<el-button class="toolbar-btn" @click="openSettings">
<span class="btn-icon"></span>
<span class="btn-label">设置</span>
</el-button>
</div>
<div class="status-bar">{{ statusText }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useAppStore } from '@/stores/app'
import { useGradingStore } from '@/stores/grading'
import { useSettingsStore } from '@/stores/settings'
import { addWatermark } from '@/utils/image'
import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window'
const appStore = useAppStore()
const gradingStore = useGradingStore()
const settingsStore = useSettingsStore()
const showMenu = ref(false)
const toolbarRef = ref<HTMLElement | null>(null)
const statusText = computed(() => {
const questions = gradingStore.questions
const idx = gradingStore.currentQuestionIndex
return questions?.length ? `${idx + 1}/${questions.length}` : ''
})
async function resizeWindow() {
await nextTick()
if (toolbarRef.value) {
try {
const w = toolbarRef.value.scrollWidth + 2
await getCurrentWindow().setSize(new LogicalSize(Math.max(200, Math.min(w, 1200)), 64))
} catch {}
}
}
watch(showMenu, resizeWindow)
let unlistenScreenshot: (() => void) | null = null
let unlistenRubric: (() => void) | null = null
onMounted(async () => {
resizeWindow()
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenScreenshot = await listen<string>('screenshot-completed', async (event) => {
if (!appStore.isGrading) {
try {
gradingStore.setImage(await addWatermark(event.payload, '学生答案'))
await startGrading()
} catch (err) {
ElMessage.error('水印处理失败:' + (err as Error).message)
}
}
})
unlistenRubric = await listen('rubric-updated', async () => {
const { invoke } = await import('@tauri-apps/api/core')
const data: any = await invoke('rubric_get_data')
if (data?.questions?.length) {
gradingStore.setQuestions(JSON.parse(JSON.stringify(data.questions)))
ElMessage.success(`已应用模板(${data.questions.length} 题)`)
}
})
} catch {}
})
onUnmounted(() => {
unlistenScreenshot?.()
unlistenRubric?.()
})
async function startScreenshot() {
const { invoke } = await import('@tauri-apps/api/core')
try {
await invoke('screenshot_start')
} catch (err) {
ElMessage.error('截图失败:' + (err as Error).message)
}
}
async function selectImage() {
const { invoke } = await import('@tauri-apps/api/core')
const data: any = await invoke('dialog_open_image')
if (data) {
try { gradingStore.setImage(await addWatermark(data.base64, '学生答案')) } catch (err) {
ElMessage.error('水印处理失败:' + (err as Error).message); return
}
await startGrading()
}
}
async function gradeQuestion(questionIdx: number) {
const { invoke } = await import('@tauri-apps/api/core')
const questions = gradingStore.questions
if (!questions.length) { ElMessage.warning('请先在评分标准中添加题目'); return null }
const q = questions[Math.min(questionIdx, questions.length - 1)]
const result: any = await invoke('ai_grade', {
request: {
imageBase64: gradingStore.currentImage,
rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}`,
maxScore: q.maxScore,
referenceAnswer: q.referenceAnswer || '',
referenceAnswerImage: q.referenceAnswerImage || '',
questionTitle: q.questionTitle || '',
apiConfig: {
apiKey: settingsStore.settings.apiKey,
baseUrl: settingsStore.settings.baseUrl,
model: settingsStore.settings.model
}
}
})
return result
}
async function startGrading() {
if (!gradingStore.currentImage) { ElMessage.info('请先截图或选择图片'); return }
if (!settingsStore.settings.apiKey) {
ElMessage.warning('请先在设置中配置 API Key')
const { invoke } = await import('@tauri-apps/api/core')
await invoke('dialog_open', { dialogType: 'settings' })
return
}
const { invoke } = await import('@tauri-apps/api/core')
const rubricData: any = await invoke('rubric_get_data')
const questions = rubricData?.questions?.length
? JSON.parse(JSON.stringify(rubricData.questions))
: [{ questionTitle: '', maxScore: 100, referenceAnswer: '', rubric: '' }]
const nextIdx = Math.min(await invoke('rubric_get_next_index') as number ?? 0, questions.length - 1)
gradingStore.setQuestions(questions)
gradingStore.setCurrentQuestionIndex(nextIdx)
appStore.isGrading = true
try {
const result = await gradeQuestion(nextIdx)
if (!result) return
gradingStore.setResult(result)
await invoke('grading_store_data', {
data: { result, image: gradingStore.currentImage, questions, currentQuestionIndex: nextIdx }
})
await invoke('dialog_open', { dialogType: 'result' })
} catch (err) { ElMessage.error('AI 阅卷失败:' + (err as Error).message) }
finally { appStore.isGrading = false }
}
function openRubric() {
import('@tauri-apps/api/core').then(({ invoke }) => invoke('dialog_open', { dialogType: 'rubric' }))
}
async function openTemplates() {
const { invoke } = await import('@tauri-apps/api/core')
await invoke('dialog_open', { dialogType: 'templates' })
}
function openHistory() {
import('@tauri-apps/api/core').then(({ invoke }) => invoke('dialog_open', { dialogType: 'history' }))
}
function openSettings() {
import('@tauri-apps/api/core').then(({ invoke }) => invoke('dialog_open', { dialogType: 'settings' }))
}
function onDragStart(e: MouseEvent) {
if ((e.target as HTMLElement)?.closest('.toolbar-btn')) return
e.preventDefault()
getCurrentWindow().startDragging()
}
</script>
<style scoped>
.floating-toolbar { position: fixed; top: 0; left: 0; width: fit-content; min-width: 200px; z-index: 99999; user-select: none; 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); }
.toolbar-buttons { display: flex; gap: 6px; padding: 8px 14px 0; justify-content: center; align-items: center; }
.status-bar { padding: 0 14px 6px; font-size: 11px; font-weight: 700; color: rgba(255,255,255,0.6); white-space: nowrap; text-align: center; }
.sub-btn { padding: 4px 8px !important; }
.collapse-btn { padding: 4px 6px !important; color: rgba(255,255,255,0.5) !important; }
.collapse-btn:hover { color: rgba(255,255,255,0.9) !important; }
.toolbar-btn { 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>
+284
View File
@@ -0,0 +1,284 @@
<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="error-message" v-if="errorMsg" style="padding: 12px 16px;">{{ errorMsg }}</div>
<div class="drawer-body" v-if="gradingStore.currentResult">
<div class="question-nav" v-if="questions.length > 1">
<el-button size="small" @click="prevQuestion">上一题</el-button>
<span class="question-indicator">{{ currentIdx + 1 }} / {{ questions.length }}</span>
<el-button size="small" @click="nextQuestion">{{ currentIdx === questions.length - 1 ? '回到首题' : '下一题' }}</el-button>
</div>
<div class="question-title" v-if="currentQuestion.questionTitle">
{{ currentQuestion.questionTitle }}
</div>
<div class="question-maxscore" v-if="currentQuestion.maxScore">
满分{{ currentQuestion.maxScore }}
</div>
<div class="result-section" v-if="currentQuestion.rubric">
<div class="section-title">当前评分标准</div>
<div class="rubric-text">{{ currentQuestion.rubric }}</div>
</div>
<div class="result-section" v-if="gradingStore.currentImage">
<div class="section-title">学生答案图片</div>
<img class="student-image" :src="'data:image/png;base64,' + gradingStore.currentImage" />
</div>
<div class="result-section" v-if="currentQuestion.referenceAnswer || currentQuestion.referenceAnswerImage">
<div class="section-title">参考答案</div>
<div class="reference-text" v-if="currentQuestion.referenceAnswer">{{ currentQuestion.referenceAnswer }}</div>
<img v-if="currentQuestion.referenceAnswerImage" class="ref-image" :src="currentQuestion.referenceAnswerImage" />
</div>
<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="currentQuestion.maxScore" 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" :loading="isGrading">重新评分</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, computed } from 'vue'
import { useAppStore } from '@/stores/app'
import { useGradingStore } from '@/stores/grading'
import { useSettingsStore } from '@/stores/settings'
import type { QuestionItem } from '@/types'
const appStore = useAppStore()
const gradingStore = useGradingStore()
const settingsStore = useSettingsStore()
const visible = ref(false)
const isGrading = ref(false)
const errorMsg = ref('')
const teacherScore = ref(0)
const teacherComment = ref('')
const questions = ref<QuestionItem[]>([])
const currentIdx = ref(0)
const currentQuestion = computed(() => questions.value[currentIdx.value] || { questionTitle: '', maxScore: 100, referenceAnswer: '', rubric: '' })
watch(() => appStore.isResultDrawerOpen, (val) => {
visible.value = val
})
watch(() => gradingStore.currentResult, (val) => {
if (val) {
teacherScore.value = val.score
teacherComment.value = ''
}
})
watch(visible, async (val) => {
if (val) {
const { invoke } = await import('@tauri-apps/api/core')
const data: any = await invoke('grading_get_data')
if (data) {
gradingStore.setImage(data.image || '')
if (data.questions) {
questions.value = JSON.parse(JSON.stringify(data.questions))
gradingStore.setQuestions(data.questions)
}
currentIdx.value = data.currentQuestionIndex ?? 0
gradingStore.setCurrentQuestionIndex(currentIdx.value)
if (data.result) {
gradingStore.setResult(data.result)
}
}
}
})
function closeDrawer() {
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
}
async function gradeAt(idx: number) {
if (!gradingStore.currentImage) return
isGrading.value = true
errorMsg.value = ''
try {
const q = questions.value[idx]
const { invoke } = await import('@tauri-apps/api/core')
const result: any = await invoke('ai_grade', {
request: {
imageBase64: gradingStore.currentImage,
rubric: q.rubric || `请根据答案内容评分,满分${q.maxScore}`,
maxScore: q.maxScore,
referenceAnswer: q.referenceAnswer || '',
referenceAnswerImage: q.referenceAnswerImage || '',
questionTitle: q.questionTitle || '',
apiConfig: {
apiKey: settingsStore.settings.apiKey,
baseUrl: settingsStore.settings.baseUrl,
model: settingsStore.settings.model
}
}
})
gradingStore.setResult(result)
currentIdx.value = idx
gradingStore.setCurrentQuestionIndex(idx)
const { invoke: inv } = await import('@tauri-apps/api/core')
await inv('grading_store_data', {
data: { result, image: gradingStore.currentImage, questions: questions.value, currentQuestionIndex: idx }
})
ElMessage.success(`${idx + 1} 题评分完成`)
} catch (err) {
errorMsg.value = (err as Error).message
} finally {
isGrading.value = false
}
}
async function nextQuestion() {
const next = (currentIdx.value + 1) % questions.value.length
await saveAndAdvance(next)
}
async function prevQuestion() {
const prev = (currentIdx.value - 1 + questions.value.length) % questions.value.length
await saveAndAdvance(prev)
}
async function saveAndAdvance(targetIdx: number) {
const { invoke } = await import('@tauri-apps/api/core')
try {
const result = gradingStore.currentResult
const raw = {
questionId: 0,
imagePath: '',
aiScore: result?.score ?? 0,
teacherScore: teacherScore.value,
confidence: result?.confidence ?? 0,
studentAnswer: '',
questionTitle: currentQuestion.value.questionTitle,
maxScore: currentQuestion.value.maxScore,
referenceAnswer: currentQuestion.value.referenceAnswer,
rubric: currentQuestion.value.rubric,
deductions: result?.deductions,
comment: result?.comment,
resultJson: JSON.stringify(result),
imageData: gradingStore.currentImage || '',
referenceAnswerImage: currentQuestion.value.referenceAnswerImage || ''
}
await invoke('grading_submit', { data: JSON.parse(JSON.stringify(raw)) })
await invoke('rubric_set_next_index', { idx: targetIdx })
gradingStore.reset()
closeDrawer()
} catch (err) {
ElMessage.error('保存失败:' + (err as Error).message)
}
}
async function reGrade() {
await gradeAt(currentIdx.value)
}
async function confirmScore() {
errorMsg.value = ''
const result = gradingStore.currentResult
if (!result) {
errorMsg.value = '暂无评分结果可保存(currentResult 为空)'
return
}
const { invoke } = await import('@tauri-apps/api/core')
try {
const raw = {
questionId: 0,
imagePath: '',
aiScore: result.score,
teacherScore: teacherScore.value,
confidence: result.confidence,
studentAnswer: '',
questionTitle: currentQuestion.value.questionTitle,
maxScore: currentQuestion.value.maxScore,
referenceAnswer: currentQuestion.value.referenceAnswer,
rubric: currentQuestion.value.rubric,
deductions: result.deductions,
comment: result.comment,
resultJson: JSON.stringify(result),
imageData: gradingStore.currentImage || '',
referenceAnswerImage: currentQuestion.value.referenceAnswerImage || ''
}
await invoke('grading_submit', { data: JSON.parse(JSON.stringify(raw)) })
gradingStore.reset()
closeDrawer()
} catch (err) {
errorMsg.value = '保存失败:' + (err as Error).message
}
}
</script>
<style scoped>
.result-drawer { position: fixed; inset: 0; z-index: 99998; background: transparent; display: flex; justify-content: flex-end; }
.drawer-panel { width: 480px; 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; }
.question-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.question-indicator { font-size: 14px; font-weight: 600; color: #409EFF; }
.question-title { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 4px; }
.question-maxscore { font-size: 12px; color: #999; margin-bottom: 12px; }
.result-score { text-align: center; padding: 16px 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; }
.rubric-text { font-size: 13px; color: #333; line-height: 1.6; background: #fff7e6; padding: 10px; border-radius: 6px; white-space: pre-wrap; }
.reference-text { font-size: 13px; color: #666; line-height: 1.6; background: #f0f9eb; padding: 10px; border-radius: 6px; white-space: pre-wrap; }
.ref-image, .student-image { max-width: 100%; max-height: 300px; border-radius: 6px; margin-top: 8px; border: 1px solid #e4e7ed; }
.error-message { background: #fef0f0; color: #f56c6c; border: 1px solid #fde2e2; border-radius: 6px; padding: 10px; font-size: 13px; line-height: 1.5; margin-bottom: 12px; }
.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>
+249
View File
@@ -0,0 +1,249 @@
<template>
<el-dialog v-model="visible" fullscreen :modal="false" @close="handleClose">
<template #header>
<div class="dialog-header" data-tauri-drag-region>
<span class="dialog-title">评分标准</span>
</div>
</template>
<div class="rubric-layout">
<div class="tab-list">
<div
v-for="(q, idx) in questions"
:key="idx"
class="tab-item"
:class="{ active: idx === activeIndex }"
@click="activeIndex = idx"
>
<span>题目 {{ idx + 1 }}</span>
<el-button text size="small" @click.stop="removeQuestion(idx)" v-if="questions.length > 1"></el-button>
</div>
<el-button size="small" @click="addQuestion">+ 添加题目</el-button>
</div>
<div class="tab-content" v-if="questions[activeIndex]">
<el-form :model="questions[activeIndex]" size="small" label-width="80px">
<el-form-item label="题目">
<el-input v-model="questions[activeIndex].questionTitle" type="textarea" :rows="2" placeholder="输入题目内容" />
</el-form-item>
<el-form-item label="满分">
<el-input-number v-model="questions[activeIndex].maxScore" :min="1" :max="1000" />
</el-form-item>
<el-form-item label="参考答案">
<el-input v-model="questions[activeIndex].referenceAnswer" type="textarea" :rows="3" placeholder="输入参考答案(可选)" />
<div class="ref-image-section">
<template v-if="questions[activeIndex].referenceAnswerImage">
<div class="ref-image-preview">
<img :src="questions[activeIndex].referenceAnswerImage" />
<el-button size="small" type="danger" text @click="removeRefImage(activeIndex)">删除图片</el-button>
</div>
</template>
<el-button size="small" @click="uploadRefImage(activeIndex)">上传参考答案图片</el-button>
</div>
</el-form-item>
<el-form-item label="评分标准">
<el-input v-model="questions[activeIndex].rubric" type="textarea" :rows="6" placeholder="输入评分标准" />
</el-form-item>
</el-form>
</div>
</div>
<template #footer>
<div class="footer-left">
<el-button size="small" @click="handleImport">导入</el-button>
<el-button size="small" @click="handleExport">导出</el-button>
<el-button size="small" @click="showTemplateForm = !showTemplateForm">
{{ showTemplateForm ? '取消' : '保存为模板' }}
</el-button>
</div>
<div class="footer-right">
<el-button size="small" @click="handleClose">取消</el-button>
<el-button type="primary" size="small" @click="handleSave">应用</el-button>
</div>
</template>
<div v-if="showTemplateForm" class="template-form">
<el-form inline size="small">
<el-form-item label="模板名称">
<el-input v-model="templateName" placeholder="如:期末考试" />
</el-form-item>
<el-form-item label="课程">
<el-input v-model="templateCourse" placeholder="如:Java" />
</el-form-item>
<el-form-item>
<el-button type="success" size="small" @click="saveAsTemplate">保存</el-button>
</el-form-item>
</el-form>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAppStore } from '@/stores/app'
import { useGradingStore } from '@/stores/grading'
import type { QuestionItem } from '@/types'
import { addWatermark } from '@/utils/image'
const appStore = useAppStore()
const gradingStore = useGradingStore()
const visible = ref(false)
const activeIndex = ref(0)
const questions = ref<QuestionItem[]>([])
const showTemplateForm = ref(false)
const templateName = ref('')
const templateCourse = ref('')
function emptyQuestion(): QuestionItem {
return { questionTitle: '', maxScore: 100, referenceAnswer: '', rubric: '' }
}
watch(() => appStore.isRubricEditorOpen, async (val) => {
visible.value = val
if (val) {
const { invoke } = await import('@tauri-apps/api/core')
const data: any = await invoke('rubric_get_data')
if (data?.questions?.length) {
questions.value = JSON.parse(JSON.stringify(data.questions))
activeIndex.value = data.currentIndex ?? 0
} else {
questions.value = [emptyQuestion()]
activeIndex.value = 0
}
}
})
function addQuestion() {
questions.value.push(emptyQuestion())
activeIndex.value = questions.value.length - 1
}
function removeQuestion(idx: number) {
questions.value.splice(idx, 1)
if (activeIndex.value >= questions.value.length) {
activeIndex.value = questions.value.length - 1
}
}
async function handleSave() {
const { invoke } = await import('@tauri-apps/api/core')
try {
gradingStore.setQuestions(JSON.parse(JSON.stringify(questions.value)))
await invoke('rubric_store_data', {
data: {
questions: JSON.parse(JSON.stringify(questions.value)),
currentIndex: 0
}
})
await invoke('rubric_reset_next_index')
ElMessage.success('评分标准已保存')
} catch (err) {
ElMessage.error('保存失败:' + (err as Error).message)
}
}
async function handleExport() {
const { invoke } = await import('@tauri-apps/api/core')
try {
await invoke('rubric_export_json')
ElMessage.success('导出成功')
} catch (err) {
ElMessage.error('导出失败:' + (err as Error).message)
}
}
async function handleImport() {
const { invoke } = await import('@tauri-apps/api/core')
try {
const data: any = await invoke('rubric_import_json')
if (data?.questions?.length) {
questions.value = JSON.parse(JSON.stringify(data.questions))
activeIndex.value = 0
ElMessage.success(`已导入 ${data.questions.length}`)
}
} catch (err) {
ElMessage.error('导入失败:' + (err as Error).message)
}
}
async function saveAsTemplate() {
if (!templateName.value || !templateCourse.value) {
ElMessage.warning('请填写模板名称和课程')
return
}
const { invoke } = await import('@tauri-apps/api/core')
try {
const ok = await invoke('template_save', {
data: {
name: templateName.value,
course: templateCourse.value,
questions: JSON.parse(JSON.stringify(questions.value))
}
})
if (ok) {
ElMessage.success('模板已保存')
templateName.value = ''
templateCourse.value = ''
showTemplateForm.value = false
}
} catch (err) {
ElMessage.error('保存模板失败:' + (err as Error).message)
}
}
function handleClose() {
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
}
async function uploadRefImage(idx: number) {
const { invoke } = await import('@tauri-apps/api/core')
const data: any = await invoke('dialog_open_image')
if (data) {
const mime = data.mime || 'image/png'
const base64 = await addWatermark(data.base64, '参考答案')
questions.value[idx].referenceAnswerImage = `data:image/${mime};base64,${base64}`
}
}
function removeRefImage(idx: number) {
delete questions.value[idx].referenceAnswerImage
}
</script>
<style scoped>
.dialog-header {
display: flex; align-items: center; padding: 0;
}
.dialog-title {
font-size: 15px; font-weight: 600;
}
.rubric-layout {
display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0;
}
.tab-list {
display: flex; gap: 4px; flex-wrap: wrap; align-items: center; flex-shrink: 0;
}
.tab-item {
display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 4px;
font-size: 13px; cursor: pointer; background: #f5f7fa; border: 1px solid #e4e7ed;
}
.tab-item.active { background: #409EFF; color: #fff; border-color: #409EFF; }
.tab-content {
border: 1px solid #e4e7ed; border-radius: 6px; padding: 16px;
flex: 1; overflow-y: auto; min-height: 0;
}
.footer-left { display: flex; gap: 8px; }
.footer-right { display: flex; gap: 8px; }
.ref-image-section { margin-top: 8px; display: flex; align-items: center; gap: 8px; }
.ref-image-preview { display: flex; align-items: center; gap: 8px; }
.ref-image-preview img { max-height: 120px; max-width: 200px; border-radius: 4px; border: 1px solid #e4e7ed; }
.template-form { padding: 8px 16px; border-top: 1px solid #eee; background: #fafafa; }
:deep(.el-dialog__body) {
display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 20px;
}
:deep(.el-dialog__footer) {
display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;
}
</style>
+98
View File
@@ -0,0 +1,98 @@
<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" :style="actionsStyle" @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)
const actionsStyle = computed(() => {
if (!hasSelection.value) return {}
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)
const panelW = 160; const gap = 8
let left = x + w - panelW; let top = y + h + gap
if (left < 4) left = 4
if (top + 40 > window.innerHeight) top = y - 40 - gap
return { left: left + 'px', top: top + 'px', transform: 'none', bottom: 'auto' }
})
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()
}
async 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
const { invoke } = await import('@tauri-apps/api/core')
await invoke('screenshot_capture', { region: { x, y, width, height } }).catch(() => {})
const { getCurrentWindow } = await import('@tauri-apps/api/window')
try { await getCurrentWindow().close() } catch {}
}
async function cancel() {
const { invoke } = await import('@tauri-apps/api/core')
await invoke('screenshot_cancel').catch(() => {})
isCapturing.value = false
const { getCurrentWindow } = await import('@tauri-apps/api/window')
try { await getCurrentWindow().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; 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>
+137
View File
@@ -0,0 +1,137 @@
<template>
<el-dialog v-model="visible" fullscreen :modal="false" :close-on-click-modal="false" @close="handleClose">
<template #header>
<div class="dialog-header" data-tauri-drag-region>
<span class="dialog-title">设置</span>
</div>
</template>
<div class="settings-body">
<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-input v-model="form.model" placeholder="手动输入模型名称" />
<div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px;">
<el-tag
v-for="m in visionModels"
:key="m"
size="small"
style="cursor:pointer"
@click="form.model = m"
>{{ m }}</el-tag>
</div>
</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>
</div>
<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 visionModels = [
'Qwen/Qwen2.5-32B-Instruct',
'Qwen/Qwen3.6-35B-A3B',
'gpt-4o',
'claude-3-5-sonnet-20241022',
'gemini-2.0-flash-exp',
'deepseek-vl2'
]
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() {
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
}
async function selectDir() {
const { open } = await import('@tauri-apps/plugin-dialog')
const dir = await open({ directory: true })
if (dir) form.value.screenshotSavePath = dir
}
async function handleSave() {
try {
await settingsStore.saveSettings(JSON.parse(JSON.stringify(form.value)))
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().close()
} catch (err) {
ElMessage.error('保存失败:' + (err as Error).message)
}
}
</script>
<style scoped>
.dialog-header {
display: flex; align-items: center; padding: 0;
}
.dialog-title {
font-size: 15px; font-weight: 600;
}
.settings-body {
flex: 1; overflow-y: auto; min-height: 0;
}
:deep(.el-dialog__body) {
display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 20px;
}
:deep(.el-dialog__footer) {
flex-shrink: 0;
}
</style>
+13
View File
@@ -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')
+182
View File
@@ -0,0 +1,182 @@
<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="70" />
<el-table-column prop="teacher_score" label="教师分" width="70" />
<el-table-column prop="confidence" label="可信度" width="70">
<template #default="{ row }">
{{ Math.round(row.confidence * 100) }}%
</template>
</el-table-column>
<el-table-column prop="question_title" label="题目" min-width="120" show-overflow-tooltip />
<el-table-column prop="created_at" label="时间" width="150" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="primary" @click="viewResult(row)">查看</el-button>
<el-button text size="small" type="danger" @click="deleteRecord(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button size="small" @click="handleClose">关闭</el-button>
<el-button size="small" @click="refresh">刷新</el-button>
</template>
</el-dialog>
<div v-if="detailVisible" class="history-result-overlay" @click.self="detailVisible = false">
<div class="history-result-panel">
<div class="panel-header">
<span class="panel-title">评分详情</span>
<el-button text size="small" @click="detailVisible = false"></el-button>
</div>
<div class="panel-body" v-if="detail">
<div class="question-title" v-if="detail.question_title">
{{ detail.question_title }}
</div>
<div class="question-maxscore" v-if="detail.max_score">
满分{{ detail.max_score }}
</div>
<div class="result-section" v-if="detail.rubric">
<div class="section-title">当前评分标准</div>
<div class="rubric-text">{{ detail.rubric }}</div>
</div>
<div class="result-section" v-if="detail.image_data">
<div class="section-title">学生答案图片</div>
<img class="result-image" :src="'data:image/png;base64,' + detail.image_data" />
</div>
<div class="result-section" v-if="detail.reference_answer || detail.reference_answer_image">
<div class="section-title">参考答案</div>
<div class="reference-text" v-if="detail.reference_answer">{{ detail.reference_answer }}</div>
<img v-if="detail.reference_answer_image" class="result-image" :src="detail.reference_answer_image" />
</div>
<div class="result-score">
<div class="score-value">{{ detail.ai_score }}</div>
<div class="score-label">建议分数</div>
</div>
<div class="result-confidence">
<div class="confidence-bar">
<div class="confidence-fill" :style="{ width: Math.round(detail.confidence * 100) + '%' }"></div>
</div>
<div class="confidence-text">可信度{{ Math.round(detail.confidence * 100) }}%</div>
</div>
<div class="result-section" v-if="deductionsList.length">
<div class="section-title">扣分项</div>
<div class="deduction-item" v-for="(item, idx) in deductionsList" :key="idx">
{{ Number(idx) + 1 }}. {{ item }}
</div>
</div>
<div class="result-section" v-if="detail.comment">
<div class="section-title">评语</div>
<div class="comment-text">{{ detail.comment }}</div>
</div>
<div class="result-section">
<div class="section-title">教师评分</div>
<div class="teacher-score-display">{{ detail.teacher_score }} / {{ detail.max_score || 100 }}</div>
</div>
</div>
<div class="panel-footer">
<el-button size="small" @click="detailVisible = false">关闭</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, 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[]>([])
const detailVisible = ref(false)
const detail = ref<HistoryRecord | null>(null)
const deductionsList = computed(() => {
if (!detail.value?.deductions) return []
try { return JSON.parse(detail.value.deductions) } catch { return [] }
})
watch(() => appStore.isHistoryDialogOpen, (val) => {
visible.value = val
if (val) refresh()
})
async function refresh() {
loading.value = true
try {
const { invoke } = await import('@tauri-apps/api/core')
records.value = await invoke('history_list')
} catch {
records.value = []
} finally {
loading.value = false
}
}
async function viewResult(row: HistoryRecord) {
const { invoke } = await import('@tauri-apps/api/core')
try {
const full: any = await invoke('history_get_by_id', { id: row.id })
if (full) {
detail.value = full
detailVisible.value = true
} else {
ElMessage.warning('未找到记录详情')
}
} catch (err) {
ElMessage.error('加载详情失败:' + (err as Error).message)
}
}
async function deleteRecord(id: number) {
try {
await ElMessageBox.confirm('确定删除该记录?', '提示')
const { invoke } = await import('@tauri-apps/api/core')
await invoke('history_delete', { id })
ElMessage.success('已删除')
refresh()
} catch { /* cancelled */ }
}
function handleClose() {
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
}
</script>
<style scoped>
.history-result-overlay { position: fixed; inset: 0; z-index: 99998; background: rgba(0, 0, 0, 0.3); display: flex; justify-content: flex-end; }
.history-result-panel { width: 480px; height: 100%; background: #fff; display: flex; flex-direction: column; box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); }
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; border-bottom: 1px solid #eee; font-size: 15px; font-weight: 600; }
.panel-body { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 14px; }
.panel-footer { padding: 12px 16px; border-top: 1px solid #eee; display: flex; justify-content: flex-end; }
.question-title { font-size: 14px; font-weight: 600; color: #333; line-height: 1.5; }
.question-maxscore { font-size: 12px; color: #909399; }
.result-section { display: flex; flex-direction: column; gap: 6px; }
.section-title { font-size: 12px; font-weight: 600; color: #909399; }
.rubric-text { font-size: 13px; color: #333; line-height: 1.6; background: #fff7e6; padding: 10px; border-radius: 6px; white-space: pre-wrap; }
.reference-text { font-size: 13px; color: #666; line-height: 1.6; background: #f0f9eb; padding: 10px; border-radius: 6px; white-space: pre-wrap; }
.result-image { max-width: 100%; max-height: 300px; border-radius: 6px; border: 1px solid #e4e7ed; }
.result-score { text-align: center; padding: 12px 0; }
.score-value { font-size: 42px; font-weight: 700; color: #409EFF; line-height: 1; }
.score-label { font-size: 12px; color: #909399; margin-top: 4px; }
.result-confidence { display: flex; flex-direction: column; gap: 4px; }
.confidence-bar { height: 6px; background: #e4e7ed; border-radius: 3px; overflow: hidden; }
.confidence-fill { height: 100%; background: #67c23a; border-radius: 3px; transition: width 0.3s; }
.confidence-text { font-size: 11px; color: #909399; text-align: right; }
.deduction-item { font-size: 13px; color: #f56c6c; line-height: 1.6; padding: 2px 0; }
.comment-text { font-size: 13px; color: #333; line-height: 1.6; background: #f5f7fa; padding: 10px; border-radius: 6px; white-space: pre-wrap; }
.teacher-score-display { font-size: 16px; font-weight: 600; color: #409EFF; }
</style>
+123
View File
@@ -0,0 +1,123 @@
<template>
<el-dialog v-model="visible" fullscreen :modal="false" @close="handleClose">
<template #header>
<div class="dialog-header" data-tauri-drag-region>
<span class="dialog-title">评分模板管理</span>
</div>
</template>
<div class="template-body">
<el-table :data="templates" size="small" style="width: 100%" height="100%">
<el-table-column prop="id" label="ID" width="50" />
<el-table-column prop="name" label="考试名称" min-width="130" />
<el-table-column prop="course" label="课程" min-width="100" />
<el-table-column label="题目数" width="70">
<template #default="{ row }">{{ row.questions?.length || 0 }}</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="150" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button text size="small" :disabled="!row.questions?.length" @click="selectTemplate(row)">使用</el-button>
<el-button text size="small" type="danger" @click="deleteTemplate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<div class="footer-left">
<el-button size="small" @click="exportTemplates">导出</el-button>
<el-button size="small" @click="importTemplates">导入</el-button>
</div>
<el-button size="small" @click="handleClose">关闭</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 type { TemplateItem } from '@/types'
const appStore = useAppStore()
const visible = ref(false)
const templates = ref<TemplateItem[]>([])
watch(() => appStore.isTemplateDialogOpen, (val) => {
visible.value = val
if (val) loadTemplates()
})
async function loadTemplates() {
const { invoke } = await import('@tauri-apps/api/core')
templates.value = await invoke('template_list')
}
async function selectTemplate(tpl: TemplateItem) {
if (!tpl.questions?.length) {
ElMessage.warning('该模板没有题目数据')
return
}
const { invoke } = await import('@tauri-apps/api/core')
const ok: boolean = await invoke('template_apply', { id: tpl.id })
if (ok) {
handleClose()
} else {
ElMessage.error('应用模板失败')
}
}
async function exportTemplates() {
const { invoke } = await import('@tauri-apps/api/core')
const ok: boolean = await invoke('template_export_json')
if (ok) {
ElMessage.success('模板已导出')
} else if (ok === false) {
ElMessage.warning('导出失败或已取消')
}
}
async function importTemplates() {
const { invoke } = await import('@tauri-apps/api/core')
const result: any = await invoke('template_import_json')
if (result) {
ElMessage.success(`已导入 ${result.count} 个模板`)
loadTemplates()
}
}
async function deleteTemplate(tpl: TemplateItem) {
const { invoke } = await import('@tauri-apps/api/core')
const ok: boolean = await invoke('template_delete', { id: tpl.id })
if (ok) {
ElMessage.success('已删除')
loadTemplates()
}
}
function handleClose() {
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().close())
}
</script>
<style scoped>
.dialog-header {
display: flex; align-items: center; padding: 0;
}
.dialog-title {
font-size: 15px; font-weight: 600;
}
.template-body {
flex: 1; min-height: 0;
}
:deep(.el-dialog__body) {
display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 20px;
}
:deep(.el-dialog__footer) {
display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;
}
.footer-left {
display: flex; gap: 8px;
}
</style>
+20
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
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
}
})
+39
View File
@@ -0,0 +1,39 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { GradeResult, QuestionItem } from '@/types'
export const useGradingStore = defineStore('grading', () => {
const currentImage = ref<string | null>(null)
const currentResult = ref<GradeResult | null>(null)
const questions = ref<QuestionItem[]>([])
const currentQuestionIndex = ref(0)
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 setQuestions(list: QuestionItem[]) { questions.value = list }
function setCurrentQuestionIndex(idx: number) { currentQuestionIndex.value = idx }
function reset() {
currentImage.value = null
currentResult.value = null
teacherScore.value = null
teacherComment.value = ''
}
return {
currentImage, currentResult, questions, currentQuestionIndex, rubric,
questionTitle, maxScore, referenceAnswer, teacherScore, teacherComment,
setImage, setResult, setRubric, setQuestionTitle, setMaxScore, setReferenceAnswer,
setQuestions, setCurrentQuestionIndex, reset
}
})
+35
View File
@@ -0,0 +1,35 @@
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: '',
shortcut: 'Alt+Q',
theme: 'light',
fontSize: 14,
proxy: '',
timeout: 120,
screenshotSavePath: ''
})
async function loadSettings() {
try {
const { invoke } = await import('@tauri-apps/api/core')
const result: AppSettings = await invoke('settings_get')
if (result) {
settings.value = { ...settings.value, ...result }
}
} catch {}
}
async function saveSettings(partial: Partial<AppSettings>) {
Object.assign(settings.value, partial)
const { invoke } = await import('@tauri-apps/api/core')
await invoke('settings_set', { settings: settings.value })
}
return { settings, loadSettings, saveSettings }
})
+9
View File
@@ -0,0 +1,9 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
html[data-window="win-main"] { background: #1e1e1e; }
html[data-window="win-dialog"] { background: #f5f7fa; }
html[data-window="win-screenshot"] { background: transparent; }
body { user-select: none; }
#app { width: 100%; height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); border-radius: 3px; }
::-webkit-scrollbar-track { background: transparent; }
+61
View File
@@ -0,0 +1,61 @@
export interface QuestionItem {
questionTitle: string
maxScore: number
referenceAnswer: string
rubric: string
referenceAnswerImage?: string
}
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
question_title?: string
max_score?: number
reference_answer?: string
rubric?: string
deductions?: string
comment?: string
result_json?: string
image_data?: string
reference_answer_image?: string
}
export interface TemplateItem {
id: number
name: string
course: string
questions: QuestionItem[]
created_at: string
}
+20
View File
@@ -0,0 +1,20 @@
export function addWatermark(base64: string, text: string): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
let w = img.width; let h = img.height; const MAX = 2048; let scale = 1
if (w > MAX || h > MAX) { scale = Math.min(MAX / w, MAX / h, 1); w = Math.round(w * scale); h = Math.round(h * scale) }
const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return reject(new Error('Canvas 2D context not available'))
ctx.drawImage(img, 0, 0, w, h)
ctx.font = `bold ${Math.max(28, Math.round(w / 20))}px sans-serif`
ctx.fillStyle = 'rgba(255, 0, 0, 0.85)'; ctx.textBaseline = 'top'
const padding = Math.max(8, Math.round(w / 60))
ctx.fillText(text, padding, padding)
resolve(canvas.toDataURL('image/png').replace(/^data:image\/png;base64,/, ''))
}
img.onerror = () => reject(new Error('Failed to load image for watermark'))
img.src = `data:image/png;base64,${base64}`
})
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+21
View File
@@ -0,0 +1,21 @@
{
"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,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
"exclude": ["node_modules", "dist"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
clearScreen: false,
server: {
port: 5173,
strictPort: true
},
envPrefix: ['VITE_', 'TAURI_'],
build: {
target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari14',
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_DEBUG
}
})