Initial commit: AI 阅卷助手 Tauri v2 + Vue 3
This commit is contained in:
Generated
+6464
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
ai_grading_assistant_lib::run()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user