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
+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"
]
}
}