From ed4891210dfb08e7d70fad2cebc1217ff2758567 Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Mon, 6 Apr 2026 14:41:27 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E5=8C=96?= =?UTF-8?q?=E5=8A=A0=E5=88=86=E5=9F=BA=E6=9C=AC=E4=BF=AE=E5=A4=8D=E5=AE=8C?= =?UTF-8?q?=E6=AF=95=20=E7=8E=B0=E5=9C=A8=E6=98=AF=E5=85=A8=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E8=87=AA=E5=8A=A8=E5=8C=96qwqqq=EF=BC=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/data.sql | Bin 110592 -> 110592 bytes src-tauri/src/commands/auth.rs | 28 +- src-tauri/src/commands/filesystem.rs | 53 ++ src-tauri/src/commands/oauth_server.rs | 687 +++++++++---------- src-tauri/src/lib.rs | 1 + src-tauri/src/services/auto_score.rs | 748 ++++++++++++++++++++- src/components/AutoScore/AutoScoreUtils.ts | 60 ++ src/components/AutoScoreManager.tsx | 49 +- src/i18n/locales/en-US.json | 10 +- src/i18n/locales/zh-CN.json | 12 +- src/preload/types.ts | 5 + 11 files changed, 1259 insertions(+), 394 deletions(-) diff --git a/src-tauri/data.sql b/src-tauri/data.sql index e03ea456dfa317b3cf5d9d4409a59c9d1ff01df0..960f453ba0648a894af70d03fa5a4ab908c42c99 100644 GIT binary patch delta 1118 zcmb7DPe>F|7@u)wM^=zv89$%nuNjDY+Mwt3k-XBfH`SY#+CA0l{~Z&G3yh?zc@V0ady#5WKofvaVCj1h8F zLhXnKNj{-Lcd*`NVy;0tf)I3P1EzaO*NsSnPY~_)vPkoMTeppF*K@UhNcamtBS$bS zYiPbF_cp0OvjEFr!Z195&(O>#w4bPTNQ2^8CZj}!r#pPQ!Lg|DERB+*!Ykex1WWbh z(m?W3;=7QFFG)@B;S;=rXD|a}FaSGE&mk6Hs<%zg#cTH`n`Jc9Ppe@mSMf?^mMd2a zta9g`O1&^&Dppj7@7W|PhN`1d-~UskX4FYqs4^bY)T!Z&%8Jx2Fkf{7TBxv$n&(?B zqZVi-ym^;-)iBDbCkARbwdmInJ)6y17+=NM!I)oNq~p=e(QD>jC9L`!zK;hZHY%Sc zkMdWea@??-A01`o<23Wdkd-;VT$mqcxd~dSaP;Ghr-m=bCDd7eIol)4&?cO(?>#=Z zz(>9*z)#qRJ@^LOuvt${d;m?B1W}1hEHZLrq{uW#k`Qka1VMpS0e-+&_yW7I0jvDj b8%Q@wtsqYH5E9R_?;N|7xFu87F00fpX?NW2q?0ifqy;! z4WP(k{>|(2g9K#x5UdOyHcXAn(^b diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 097a4b5..c6cab7c 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -335,7 +335,10 @@ pub async fn oauth_get_authorization_url( use rand::Rng; let mut rng = rand::thread_rng(); let random_bytes: Vec = (0..32).map(|_| rng.gen()).collect(); - base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &random_bytes) + base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + &random_bytes, + ) }); let url = format!( @@ -345,7 +348,10 @@ pub async fn oauth_get_authorization_url( urlencoding::encode(&state) ); - Ok(IpcResponse::success(OAuthAuthorizationUrlResponse { url, state })) + Ok(IpcResponse::success(OAuthAuthorizationUrlResponse { + url, + state, + })) } #[tauri::command] @@ -355,8 +361,11 @@ pub async fn oauth_exchange_code( platform_secret: String, callback_url: String, ) -> Result, String> { - println!("[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}", code, platform_id, callback_url); - + println!( + "[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}", + code, platform_id, callback_url + ); + let client = reqwest::Client::new(); let params = [ ("grant_type", "authorization_code"), @@ -365,9 +374,9 @@ pub async fn oauth_exchange_code( ("client_secret", &platform_secret), ("redirect_uri", &callback_url), ]; - + println!("[OAuth] 请求参数:{:?}", params); - + let response = client .post("https://sectl.top/api/oauth/token") .form(¶ms) @@ -376,8 +385,11 @@ pub async fn oauth_exchange_code( .map_err(|e| format!("Request failed: {}", e))?; println!("[OAuth] 响应状态:{}", response.status()); - - let response_text = response.text().await.map_err(|e| format!("Failed to read response: {}", e))?; + + let response_text = response + .text() + .await + .map_err(|e| format!("Failed to read response: {}", e))?; println!("[OAuth] 响应内容:{}", response_text); if !response_text.is_empty() && response_text.contains("error") { diff --git a/src-tauri/src/commands/filesystem.rs b/src-tauri/src/commands/filesystem.rs index 537c0fb..7a50197 100644 --- a/src-tauri/src/commands/filesystem.rs +++ b/src-tauri/src/commands/filesystem.rs @@ -3,6 +3,7 @@ use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +use std::process::Command; use std::sync::Arc; use tauri::State; @@ -284,3 +285,55 @@ pub async fn fs_file_exists( Ok(IpcResponse::success(file_path.exists())) } + +#[tauri::command] +pub async fn fs_open_path( + relative_path: String, + folder: String, + _state: State<'_, Arc>>, +) -> Result, String> { + let folder_type = ConfigFolder::from_str(&folder) + .ok_or_else(|| format!("Invalid folder type: {}", folder))?; + + let folder_path = ensure_folder_exists(&folder_type)?; + let file_path = folder_path.join(&relative_path); + + if !file_path.exists() { + return Ok(IpcResponse::error("File not found")); + } + + open_path_with_system(&file_path)?; + Ok(IpcResponse::success_empty()) +} + +fn open_path_with_system(path: &PathBuf) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + Command::new("cmd") + .args(["/C", "start", "", path.to_string_lossy().as_ref()]) + .spawn() + .map_err(|e| e.to_string())?; + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(path) + .spawn() + .map_err(|e| e.to_string())?; + return Ok(()); + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + Command::new("xdg-open") + .arg(path) + .spawn() + .map_err(|e| e.to_string())?; + return Ok(()); + } + + #[allow(unreachable_code)] + Err("Unsupported platform".to_string()) +} diff --git a/src-tauri/src/commands/oauth_server.rs b/src-tauri/src/commands/oauth_server.rs index a1e2a41..0731bb4 100644 --- a/src-tauri/src/commands/oauth_server.rs +++ b/src-tauri/src/commands/oauth_server.rs @@ -1,342 +1,345 @@ -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; -use std::net::SocketAddr; -use std::sync::Arc; -use tauri::Emitter; -use tauri::State; -use tokio::sync::Mutex; -use tokio::sync::oneshot; - -use crate::state::AppState; - -use super::response::IpcResponse; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthServerStartResult { - pub url: String, - pub port: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthCallbackResult { - pub code: Option, - pub state: Option, - pub error: Option, - pub error_description: Option, -} - -static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy>>>> = - once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None))); - -#[tauri::command] -pub async fn oauth_start_callback_server( - app_handle: tauri::AppHandle, - _state: State<'_, Arc>>, -) -> Result, String> { - let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; - - // 如果服务器已经在运行,直接返回 URL - if shutdown_tx.is_some() { - let port = 16888u16; - let url = format!("http://127.0.0.1:{}/oauth/callback", port); - return Ok(IpcResponse::success(OAuthServerStartResult { url, port })); - } - - let port = 16888u16; - let addr: SocketAddr = ([127, 0, 0, 1], port).into(); - let url = format!("http://127.0.0.1:{}/oauth/callback", port); - let url_for_spawn = url.clone(); - - let (tx, rx) = oneshot::channel::<()>(); - *shutdown_tx = Some(tx); - drop(shutdown_tx); - - let app_handle_clone = app_handle.clone(); - - tokio::spawn(async move { - let app = axum::Router::new() - .route("/oauth/callback", axum::routing::get(handle_oauth_callback)) - .layer(axum::extract::Extension(app_handle_clone)); - - let listener = match tokio::net::TcpListener::bind(addr).await { - Ok(l) => l, - Err(e) => { - eprintln!("Failed to bind OAuth callback server: {}", e); - return; - } - }; - - println!("OAuth callback server started at {}", url_for_spawn); - - let server = axum::serve(listener, app); - - tokio::select! { - _ = server => {}, - _ = rx => { - println!("OAuth callback server shutting down"); - } - } - }); - - Ok(IpcResponse::success(OAuthServerStartResult { url, port })) -} - -#[tauri::command] -pub async fn oauth_stop_callback_server( - _state: State<'_, Arc>>, -) -> Result, String> { - let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; - - if let Some(tx) = shutdown_tx.take() { - let _ = tx.send(()); - } - - Ok(IpcResponse::success(())) -} - -async fn handle_oauth_callback( - axum::extract::Query(params): axum::extract::Query>, - axum::extract::Extension(app_handle): axum::extract::Extension, -) -> impl axum::response::IntoResponse { - let code = params.get("code").cloned(); - let state = params.get("state").cloned(); - let error = params.get("error").cloned(); - let error_description = params.get("error_description").cloned(); - - println!("[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}", code, state, error); - - let result = OAuthCallbackResult { - code: code.clone(), - state: state.clone(), - error: error.clone(), - error_description: error_description.clone(), - }; - - match app_handle.emit("oauth-callback", result) { - Ok(_) => println!("[OAuth Callback] Event 发送成功"), - Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e), - } - - let html = r#" - - - - - OAuth 授权完成 - - - -
-
- - - -
-

授权成功

-

请返回 SecScore 应用查看登录结果

-

此窗口可以关闭

-
- - - - "#; - - let error_html = r#" - - - - - OAuth 授权失败 - - - -
-
- - - -
-

授权失败

-

请返回 SecScore 应用查看错误信息

-

此窗口可以关闭

-
- - - "#; - - let response_html = if error.is_some() { error_html } else { html }; - - ( - axum::http::StatusCode::OK, - [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], - response_html, - ) -} +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use std::sync::Arc; +use tauri::Emitter; +use tauri::State; +use tokio::sync::oneshot; +use tokio::sync::Mutex; + +use crate::state::AppState; + +use super::response::IpcResponse; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthServerStartResult { + pub url: String, + pub port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthCallbackResult { + pub code: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy>>>> = + once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None))); + +#[tauri::command] +pub async fn oauth_start_callback_server( + app_handle: tauri::AppHandle, + _state: State<'_, Arc>>, +) -> Result, String> { + let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; + + // 如果服务器已经在运行,直接返回 URL + if shutdown_tx.is_some() { + let port = 16888u16; + let url = format!("http://127.0.0.1:{}/oauth/callback", port); + return Ok(IpcResponse::success(OAuthServerStartResult { url, port })); + } + + let port = 16888u16; + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + let url = format!("http://127.0.0.1:{}/oauth/callback", port); + let url_for_spawn = url.clone(); + + let (tx, rx) = oneshot::channel::<()>(); + *shutdown_tx = Some(tx); + drop(shutdown_tx); + + let app_handle_clone = app_handle.clone(); + + tokio::spawn(async move { + let app = axum::Router::new() + .route("/oauth/callback", axum::routing::get(handle_oauth_callback)) + .layer(axum::extract::Extension(app_handle_clone)); + + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("Failed to bind OAuth callback server: {}", e); + return; + } + }; + + println!("OAuth callback server started at {}", url_for_spawn); + + let server = axum::serve(listener, app); + + tokio::select! { + _ = server => {}, + _ = rx => { + println!("OAuth callback server shutting down"); + } + } + }); + + Ok(IpcResponse::success(OAuthServerStartResult { url, port })) +} + +#[tauri::command] +pub async fn oauth_stop_callback_server( + _state: State<'_, Arc>>, +) -> Result, String> { + let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await; + + if let Some(tx) = shutdown_tx.take() { + let _ = tx.send(()); + } + + Ok(IpcResponse::success(())) +} + +async fn handle_oauth_callback( + axum::extract::Query(params): axum::extract::Query>, + axum::extract::Extension(app_handle): axum::extract::Extension, +) -> impl axum::response::IntoResponse { + let code = params.get("code").cloned(); + let state = params.get("state").cloned(); + let error = params.get("error").cloned(); + let error_description = params.get("error_description").cloned(); + + println!( + "[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}", + code, state, error + ); + + let result = OAuthCallbackResult { + code: code.clone(), + state: state.clone(), + error: error.clone(), + error_description: error_description.clone(), + }; + + match app_handle.emit("oauth-callback", result) { + Ok(_) => println!("[OAuth Callback] Event 发送成功"), + Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e), + } + + let html = r#" + + + + + OAuth 授权完成 + + + +
+
+ + + +
+

授权成功

+

请返回 SecScore 应用查看登录结果

+

此窗口可以关闭

+
+ + + + "#; + + let error_html = r#" + + + + + OAuth 授权失败 + + + +
+
+ + + +
+

授权失败

+

请返回 SecScore 应用查看错误信息

+

此窗口可以关闭

+
+ + + "#; + + let response_html = if error.is_some() { error_html } else { html }; + + ( + axum::http::StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + response_html, + ) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72273fd..06a194e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -126,6 +126,7 @@ pub fn run() { fs_delete_file, fs_list_files, fs_file_exists, + fs_open_path, http_server_start, http_server_stop, http_server_status, diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index b3dcdf2..2ecb225 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -1,10 +1,23 @@ use chrono::{DateTime, Months, Utc}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, EntityTrait, + QueryFilter, Set, Statement, TransactionTrait, +}; use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; +use serde_json::{json, Value as JsonValue}; use std::collections::HashSet; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::time::{interval, Duration}; +use uuid::Uuid; + +use crate::db::entities::{score_events, student_tags, students, tags}; +use crate::services::settings::{SettingsKey, SettingsValue}; +use crate::state::SafeAppState; const DEFAULT_INTERVAL_MINUTES: i64 = 30; +const AUTO_SCORE_TICK_SECONDS: u64 = 15; +const AUTO_SCORE_SQL_LIMIT: u64 = 5000; +const AUTO_SCORE_REASON_PREFIX: &str = "自动化"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AutoScoreTrigger { @@ -45,6 +58,37 @@ pub struct AutoScoreRule { pub last_executed: Option, } +#[derive(Debug, Clone)] +enum PlannedAction { + AddScore(i32), + AddTags(Vec), +} + +#[derive(Debug, Default, Clone)] +struct StudentRefs { + ids: HashSet, + names: HashSet, +} + +#[derive(Debug, Default, Clone, Copy)] +struct RuleExecutionStats { + affected_students: usize, + created_events: usize, + added_tags: usize, +} + +impl StudentRefs { + fn is_empty(&self) -> bool { + self.ids.is_empty() && self.names.is_empty() + } + + fn intersect(mut self, other: StudentRefs) -> StudentRefs { + self.ids.retain(|id| other.ids.contains(id)); + self.names.retain(|name| other.names.contains(name)); + self + } +} + impl Default for AutoScoreRule { fn default() -> Self { Self { @@ -87,12 +131,14 @@ impl AutoScoreService { pub async fn initialize( &mut self, - _app_handle: &AppHandle, + app_handle: &AppHandle, ) -> Result<(), Box> { if self.initialized { return Ok(()); } + self.initialized = true; + Self::spawn_scheduler(app_handle.clone()); Ok(()) } @@ -211,38 +257,137 @@ impl AutoScoreService { } pub fn check_interval_trigger(&self, rule: &AutoScoreRule) -> Option { - let now = Utc::now(); - - for trigger in &rule.triggers { - if trigger.event != "interval_time_passed" { - continue; - } - - let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or( - IntervalTriggerValue { - amount: DEFAULT_INTERVAL_MINUTES, - unit: IntervalUnit::Minute, - }, - ); - - let base_time = rule - .last_executed - .as_ref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) - .unwrap_or(now); - - let next_execute_time = add_interval_to_time(base_time, &interval)?; - let delay_ms = (next_execute_time - now).num_milliseconds(); - return Some(delay_ms.max(0)); - } - - None + check_interval_trigger(rule) } pub async fn notify_rules_changed(&self, app_handle: &AppHandle) { let _ = app_handle.emit("auto-score:rulesChanged", &self.rules); } + + fn spawn_scheduler(app_handle: AppHandle) { + tauri::async_runtime::spawn(async move { + let mut ticker = interval(Duration::from_secs(AUTO_SCORE_TICK_SECONDS)); + loop { + ticker.tick().await; + if let Err(error) = Self::run_scheduler_tick(&app_handle).await { + eprintln!("auto score scheduler tick failed: {}", error); + } + } + }); + } + + async fn run_scheduler_tick(app_handle: &AppHandle) -> Result<(), String> { + let state = app_handle.state::().inner().clone(); + + let rules_snapshot = { + let state_guard = state.read(); + let auto_score = state_guard.auto_score.read(); + auto_score.get_rules().to_vec() + }; + + if !rules_snapshot.iter().any(|rule| rule.enabled) { + return Ok(()); + } + + let conn = { + let state_guard = state.read(); + let db_conn = state_guard.db.read().clone(); + db_conn + } + .ok_or_else(|| "Database not connected".to_string())?; + + let mut next_rules = rules_snapshot.clone(); + let mut changed = false; + + for rule in next_rules.iter_mut().filter(|rule| rule.enabled) { + let Some(delay_ms) = check_interval_trigger(rule) else { + continue; + }; + if delay_ms > 0 { + continue; + } + + match execute_rule(&conn, rule).await { + Ok(stats) => { + if stats.affected_students == 0 { + Self::log_rule_skipped(&state, rule, "no matched students"); + continue; + } + rule.last_executed = Some(Utc::now().to_rfc3339()); + changed = true; + Self::log_rule_executed(&state, rule, stats); + } + Err(error) => { + Self::log_rule_failed(&state, rule, &error); + } + } + } + + if !changed { + return Ok(()); + } + + persist_rules_to_settings(&state, &next_rules).await?; + + { + let state_guard = state.read(); + let mut auto_score = state_guard.auto_score.write(); + auto_score.replace_rules(next_rules.clone()); + } + + let enabled = next_rules.iter().any(|rule| rule.enabled); + let _ = app_handle.emit("auto-score:rulesChanged", &next_rules); + let _ = app_handle.emit( + "settings:changed", + json!({ + "key": "auto_score_enabled", + "value": enabled, + }), + ); + + Ok(()) + } + + fn log_rule_executed(state: &SafeAppState, rule: &AutoScoreRule, stats: RuleExecutionStats) { + let state_guard = state.read(); + let logger = state_guard.logger.read(); + logger.info_with_meta( + "auto_score:rule_executed", + json!({ + "rule_id": rule.id, + "rule_name": rule.name, + "affected_students": stats.affected_students, + "created_events": stats.created_events, + "added_tags": stats.added_tags, + }), + ); + } + + fn log_rule_failed(state: &SafeAppState, rule: &AutoScoreRule, error: &str) { + let state_guard = state.read(); + let logger = state_guard.logger.read(); + logger.warn_with_meta( + "auto_score:rule_failed", + json!({ + "rule_id": rule.id, + "rule_name": rule.name, + "error": error, + }), + ); + } + + fn log_rule_skipped(state: &SafeAppState, rule: &AutoScoreRule, reason: &str) { + let state_guard = state.read(); + let logger = state_guard.logger.read(); + logger.info_with_meta( + "auto_score:rule_skipped", + json!({ + "rule_id": rule.id, + "rule_name": rule.name, + "reason": reason, + }), + ); + } } fn normalize_rule(mut rule: AutoScoreRule) -> Result { @@ -296,6 +441,21 @@ fn normalize_trigger(trigger: AutoScoreTrigger) -> Result { + let raw_sql = trigger + .value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "SQL trigger requires non-empty SQL".to_string())?; + if !is_valid_auto_score_sql(&raw_sql) { + return Err("Only read-only SQL expression/query is allowed".to_string()); + } + + Ok(AutoScoreTrigger { + event, + value: Some(raw_sql), + }) + } _ => Err(format!("Unsupported trigger event: {}", event)), } } @@ -305,8 +465,8 @@ fn normalize_action(action: AutoScoreAction) -> Result match event.as_str() { "add_score" => { - let value = normalize_non_zero_numeric_string(action.value.as_deref()) - .ok_or_else(|| "Add score action requires a non-zero numeric value".to_string())?; + let value = normalize_non_zero_integer_string(action.value.as_deref()) + .ok_or_else(|| "Add score action requires a non-zero integer value".to_string())?; Ok(AutoScoreAction { event, @@ -384,18 +544,18 @@ fn stringify_tag_values(values: &[String]) -> Option { serde_json::to_string(values).ok() } -fn normalize_non_zero_numeric_string(value: Option<&str>) -> Option { +fn normalize_non_zero_integer_string(value: Option<&str>) -> Option { let normalized = value?.trim(); if normalized.is_empty() { return None; } - let parsed = normalized.parse::().ok()?; - if !parsed.is_finite() || parsed == 0.0 { + let parsed = normalized.parse::().ok()?; + if parsed == 0 { return None; } - Some(normalized.to_string()) + Some(parsed.to_string()) } fn normalize_last_executed(value: Option) -> Option { @@ -454,3 +614,519 @@ fn add_interval_to_time( } } } + +fn check_interval_trigger(rule: &AutoScoreRule) -> Option { + let now = Utc::now(); + + for trigger in &rule.triggers { + if trigger.event != "interval_time_passed" { + continue; + } + + let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or( + IntervalTriggerValue { + amount: DEFAULT_INTERVAL_MINUTES, + unit: IntervalUnit::Minute, + }, + ); + + let Some(base_time) = rule + .last_executed + .as_ref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&Utc)) + else { + return Some(0); + }; + + let next_execute_time = add_interval_to_time(base_time, &interval)?; + let delay_ms = (next_execute_time - now).num_milliseconds(); + return Some(delay_ms.max(0)); + } + + None +} + +fn contains_forbidden_keyword(sql: &str) -> bool { + let forbidden: HashSet<&str> = [ + "insert", "update", "delete", "drop", "alter", "create", "truncate", "reindex", "vacuum", + "grant", "revoke", "commit", "rollback", "begin", "attach", "detach", "pragma", "analyze", + "merge", "call", "execute", + ] + .into_iter() + .collect(); + + let mut token = String::new(); + for ch in sql.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + token.push(ch); + continue; + } + + if !token.is_empty() { + if forbidden.contains(token.as_str()) { + return true; + } + token.clear(); + } + } + + !token.is_empty() && forbidden.contains(token.as_str()) +} + +fn starts_with_select_or_with(sql: &str) -> bool { + sql.split_whitespace() + .next() + .map(|first| { + let first_lower = first.to_ascii_lowercase(); + first_lower == "select" || first_lower == "with" + }) + .unwrap_or(false) +} + +fn has_sql_comment_or_multi_statement(sql: &str) -> bool { + sql.contains(';') || sql.contains("--") || sql.contains("/*") || sql.contains("*/") +} + +fn is_readonly_query(sql: &str) -> bool { + let trimmed = sql.trim(); + if trimmed.is_empty() { + return false; + } + + if has_sql_comment_or_multi_statement(trimmed) { + return false; + } + + if !starts_with_select_or_with(trimmed) { + return false; + } + + !contains_forbidden_keyword(&trimmed.to_ascii_lowercase()) +} + +fn is_safe_sql_expression(sql: &str) -> bool { + let trimmed = sql.trim(); + if trimmed.is_empty() { + return false; + } + + if has_sql_comment_or_multi_statement(trimmed) { + return false; + } + + !contains_forbidden_keyword(&trimmed.to_ascii_lowercase()) +} + +fn is_valid_auto_score_sql(sql: &str) -> bool { + let trimmed = sql.trim(); + if trimmed.is_empty() { + return false; + } + + if starts_with_select_or_with(trimmed) { + is_readonly_query(trimmed) + } else { + is_safe_sql_expression(trimmed) + } +} + +fn build_readonly_student_query(sql_or_expression: &str) -> Result { + let trimmed = sql_or_expression.trim(); + if trimmed.is_empty() { + return Err("Empty SQL".to_string()); + } + + let normalized = if starts_with_select_or_with(trimmed) { + if !is_readonly_query(trimmed) { + return Err("Only read-only SELECT/CTE query is allowed".to_string()); + } + trimmed.to_string() + } else { + if !is_safe_sql_expression(trimmed) { + return Err("Only safe SQL expression is allowed".to_string()); + } + format!("SELECT id, name FROM students WHERE ({})", trimmed) + }; + + Ok(format!( + "SELECT * FROM ({}) AS ss_auto_score_query LIMIT {}", + normalized, AUTO_SCORE_SQL_LIMIT + )) +} + +async fn persist_rules_to_settings( + state: &SafeAppState, + rules: &[AutoScoreRule], +) -> Result<(), String> { + let rules_json = AutoScoreService::serialize_rules(rules)?; + let enabled = rules.iter().any(|rule| rule.enabled); + + let state_guard = state.read(); + let db_conn = state_guard.db.read().clone(); + let mut settings = state_guard.settings.write(); + settings.attach_db(db_conn); + settings.initialize().await?; + settings + .set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json)) + .await?; + settings + .set_value( + SettingsKey::AutoScoreEnabled, + SettingsValue::Boolean(enabled), + ) + .await?; + + Ok(()) +} + +async fn execute_rule( + conn: &DatabaseConnection, + rule: &AutoScoreRule, +) -> Result { + let mut target_students = resolve_target_students(conn, rule).await?; + + if !rule.student_names.is_empty() { + let whitelist: HashSet = rule + .student_names + .iter() + .map(|name| name.trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect(); + target_students.retain(|student| whitelist.contains(&student.name)); + } + + if target_students.is_empty() { + return Ok(RuleExecutionStats::default()); + } + + let planned_actions = plan_actions(&rule.actions)?; + if planned_actions.is_empty() { + return Err("No executable action".to_string()); + } + + let txn = conn.begin().await.map_err(|e| e.to_string())?; + let mut stats = RuleExecutionStats { + affected_students: target_students.len(), + ..RuleExecutionStats::default() + }; + + let mut all_action_tags = Vec::new(); + for action in &planned_actions { + if let PlannedAction::AddTags(tags) = action { + all_action_tags.extend(tags.iter().cloned()); + } + } + let all_action_tags = dedupe_trimmed_strings(all_action_tags); + + let mut tag_name_to_id = std::collections::HashMap::new(); + for tag_name in all_action_tags { + let tag_id = ensure_tag_exists_in_txn(&txn, &tag_name).await?; + tag_name_to_id.insert(tag_name, tag_id); + } + + for mut student in target_students { + for action in &planned_actions { + match action { + PlannedAction::AddScore(delta) => { + let now = now_iso(); + let val_prev = student.score; + let val_curr = val_prev + delta; + let reward_points = student.reward_points + delta; + + let event = score_events::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + uuid: Set(Uuid::new_v4().to_string()), + student_name: Set(student.name.clone()), + reason_content: Set(build_auto_score_reason(&rule.name, *delta)), + delta: Set(*delta), + val_prev: Set(val_prev), + val_curr: Set(val_curr), + event_time: Set(now.clone()), + settlement_id: Set(None), + }; + event.insert(&txn).await.map_err(|e| e.to_string())?; + stats.created_events += 1; + + let mut student_active: students::ActiveModel = student.clone().into(); + student_active.score = Set(val_curr); + student_active.reward_points = Set(reward_points); + student_active.updated_at = Set(now); + student_active + .update(&txn) + .await + .map_err(|e| e.to_string())?; + + student.score = val_curr; + student.reward_points = reward_points; + } + PlannedAction::AddTags(tag_names) => { + for tag_name in tag_names { + let Some(tag_id) = tag_name_to_id.get(tag_name).copied() else { + continue; + }; + let inserted = + attach_tag_to_student_if_missing(&txn, student.id, tag_id).await?; + if inserted { + stats.added_tags += 1; + } + } + } + } + } + } + + txn.commit().await.map_err(|e| e.to_string())?; + Ok(stats) +} + +fn plan_actions(actions: &[AutoScoreAction]) -> Result, String> { + let mut planned = Vec::new(); + for action in actions { + match action.event.as_str() { + "add_score" => { + let delta = action + .value + .as_deref() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value != 0) + .ok_or_else(|| "Invalid add_score value".to_string())?; + planned.push(PlannedAction::AddScore(delta)); + } + "add_tag" => { + let tags = parse_tag_values(action.value.as_deref()); + if tags.is_empty() { + return Err("Invalid add_tag value".to_string()); + } + planned.push(PlannedAction::AddTags(tags)); + } + _ => {} + } + } + Ok(planned) +} + +async fn resolve_target_students( + conn: &DatabaseConnection, + rule: &AutoScoreRule, +) -> Result, String> { + let explicit_sql_values: Vec = rule + .triggers + .iter() + .filter(|trigger| { + matches!( + trigger.event.as_str(), + "query_sql" | "student_query_sql" | "student_sql" + ) + }) + .filter_map(|trigger| trigger.value.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect(); + + if !explicit_sql_values.is_empty() { + let mut refs: Option = None; + for sql_value in explicit_sql_values { + let next_refs = query_student_refs_by_sql(conn, &sql_value).await?; + refs = Some(match refs { + None => next_refs, + Some(existing) => existing.intersect(next_refs), + }); + } + + let refs = refs.unwrap_or_default(); + if refs.is_empty() { + return Ok(vec![]); + } + return load_students_from_refs(conn, refs).await; + } + + let tag_filters: Vec = rule + .triggers + .iter() + .filter(|trigger| trigger.event == "student_has_tag") + .flat_map(|trigger| parse_tag_values(trigger.value.as_deref())) + .collect(); + + let tag_filters = dedupe_trimmed_strings(tag_filters); + if tag_filters.is_empty() { + return students::Entity::find() + .all(conn) + .await + .map_err(|e| e.to_string()); + } + + let tag_models = tags::Entity::find() + .filter(tags::Column::Name.is_in(tag_filters)) + .all(conn) + .await + .map_err(|e| e.to_string())?; + if tag_models.is_empty() { + return Ok(vec![]); + } + + let tag_ids: Vec = tag_models.iter().map(|tag| tag.id).collect(); + let links = student_tags::Entity::find() + .filter(student_tags::Column::TagId.is_in(tag_ids)) + .all(conn) + .await + .map_err(|e| e.to_string())?; + if links.is_empty() { + return Ok(vec![]); + } + + let student_ids: HashSet = links.iter().map(|link| link.student_id).collect(); + students::Entity::find() + .filter(students::Column::Id.is_in(student_ids)) + .all(conn) + .await + .map_err(|e| e.to_string()) +} + +async fn query_student_refs_by_sql( + conn: &DatabaseConnection, + sql_or_expression: &str, +) -> Result { + let wrapped_sql = build_readonly_student_query(sql_or_expression)?; + let rows = conn + .query_all(Statement::from_string( + conn.get_database_backend(), + wrapped_sql, + )) + .await + .map_err(|e| e.to_string())?; + + let mut refs = StudentRefs::default(); + for row in &rows { + if let Some(id) = try_get_i32(row, "id").or_else(|| try_get_i32(row, "student_id")) { + refs.ids.insert(id); + } + if let Some(name) = + try_get_string(row, "name").or_else(|| try_get_string(row, "student_name")) + { + refs.names.insert(name); + } + } + + Ok(refs) +} + +async fn load_students_from_refs( + conn: &DatabaseConnection, + refs: StudentRefs, +) -> Result, String> { + if refs.is_empty() { + return Ok(vec![]); + } + + let mut condition = Condition::any(); + if !refs.ids.is_empty() { + condition = condition.add(students::Column::Id.is_in(refs.ids)); + } + if !refs.names.is_empty() { + condition = condition.add(students::Column::Name.is_in(refs.names)); + } + + students::Entity::find() + .filter(condition) + .all(conn) + .await + .map_err(|e| e.to_string()) +} + +fn try_get_i32(row: &sea_orm::QueryResult, column: &str) -> Option { + row.try_get::("", column) + .ok() + .or_else(|| { + row.try_get::("", column) + .ok() + .and_then(|value| i32::try_from(value).ok()) + }) + .or_else(|| { + row.try_get::("", column) + .ok() + .and_then(|value| value.trim().parse::().ok()) + }) +} + +fn try_get_string(row: &sea_orm::QueryResult, column: &str) -> Option { + row.try_get::("", column).ok().and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +async fn ensure_tag_exists_in_txn( + txn: &sea_orm::DatabaseTransaction, + tag_name: &str, +) -> Result { + if let Some(existing) = tags::Entity::find() + .filter(tags::Column::Name.eq(tag_name)) + .one(txn) + .await + .map_err(|e| e.to_string())? + { + return Ok(existing.id); + } + + let now = now_iso(); + let inserted = tags::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + name: Set(tag_name.to_string()), + created_at: Set(now.clone()), + updated_at: Set(now), + } + .insert(txn) + .await + .map_err(|e| e.to_string())?; + + Ok(inserted.id) +} + +async fn attach_tag_to_student_if_missing( + txn: &sea_orm::DatabaseTransaction, + student_id: i32, + tag_id: i32, +) -> Result { + let existing = student_tags::Entity::find() + .filter(student_tags::Column::StudentId.eq(student_id)) + .filter(student_tags::Column::TagId.eq(tag_id)) + .one(txn) + .await + .map_err(|e| e.to_string())?; + + if existing.is_some() { + return Ok(false); + } + + let now = now_iso(); + student_tags::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + student_id: Set(student_id), + tag_id: Set(tag_id), + created_at: Set(now), + } + .insert(txn) + .await + .map_err(|e| e.to_string())?; + + Ok(true) +} + +fn now_iso() -> String { + Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +fn build_auto_score_reason(rule_name: &str, delta: i32) -> String { + if delta > 0 { + format!("{}: {} (+{})", AUTO_SCORE_REASON_PREFIX, rule_name, delta) + } else { + format!("{}: {} ({})", AUTO_SCORE_REASON_PREFIX, rule_name, delta) + } +} diff --git a/src/components/AutoScore/AutoScoreUtils.ts b/src/components/AutoScore/AutoScoreUtils.ts index ccce73a..8498ea6 100644 --- a/src/components/AutoScore/AutoScoreUtils.ts +++ b/src/components/AutoScore/AutoScoreUtils.ts @@ -51,6 +51,7 @@ export type ActionDraftError = "action_required" | "score_required" | "tag_requi const TRIGGER_FIELD_INTERVAL = "interval_minutes" const TRIGGER_FIELD_TAG = "student_tag" +const TRIGGER_FIELD_SQL = "student_sql" const TRIGGER_TYPE_INTERVAL = "interval_duration" const TRIGGER_WIDGET_INTERVAL = "interval_duration" const OP_EQUAL = "equal" @@ -146,6 +147,24 @@ const ruleFromTrigger = (trigger: AutoScoreTrigger): JsonRule | null => { } } + if ( + trigger.event === "query_sql" || + trigger.event === "student_query_sql" || + trigger.event === "student_sql" + ) { + const sql = toStringValue(trigger.value).trim() + if (!sql) return null + return { + id: QbUtils.uuid(), + type: "rule", + properties: { + field: TRIGGER_FIELD_SQL, + operator: OP_EQUAL, + value: [sql], + }, + } + } + return null } @@ -175,6 +194,15 @@ const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => { } } + if (field === TRIGGER_FIELD_SQL) { + const sql = toStringValue(value).trim() + if (!sql) return null + return { + event: "query_sql", + value: sql, + } + } + return null } @@ -197,6 +225,25 @@ const collectTriggersFromItems = (items: JsonItem[] | undefined): AutoScoreTrigg export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagOption[]): Config => ({ ...AntdConfig, + conjunctions: { + ...AntdConfig.conjunctions, + AND: { + ...AntdConfig.conjunctions.AND, + label: t("autoScore.relationAnd"), + }, + OR: { + ...AntdConfig.conjunctions.OR, + label: t("autoScore.relationOr"), + }, + }, + operators: { + ...AntdConfig.operators, + [OP_MULTISELECT_CONTAINS]: { + ...AntdConfig.operators[OP_MULTISELECT_CONTAINS], + label: t("autoScore.operatorContains"), + labelForFormat: t("autoScore.operatorContains"), + }, + }, widgets: { ...AntdConfig.widgets, [TRIGGER_WIDGET_INTERVAL]: { @@ -250,6 +297,15 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO showSearch: true, }, }, + [TRIGGER_FIELD_SQL]: { + label: t("autoScore.triggerStudentSql"), + type: "text", + operators: [OP_EQUAL], + valueSources: ["value"], + fieldSettings: { + placeholder: t("autoScore.triggerStudentSqlPlaceholder"), + }, + }, }, settings: { ...AntdConfig.settings, @@ -257,6 +313,7 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO compactMode: false, renderSize: "medium", showNot: true, + notLabel: t("autoScore.relationNot"), forceShowConj: true, canLeaveEmptyGroup: false, canReorder: true, @@ -267,6 +324,9 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO export const createEmptyTriggerTree = (config: Config): ImmutableTree => QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config) +export const normalizeTriggerTree = (tree: ImmutableTree, config: Config): ImmutableTree => + QbUtils.checkTree(tree, config) + export const triggersToQueryTree = ( config: Config, triggers: AutoScoreTrigger[] diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx index a4e1871..8cbbc19 100644 --- a/src/components/AutoScoreManager.tsx +++ b/src/components/AutoScoreManager.tsx @@ -27,6 +27,7 @@ import { createEmptyTriggerTree, createTriggerQueryConfig, hasUnsupportedTriggerLogic, + normalizeTriggerTree, normalizeActionDrafts, queryTreeToTriggers, triggersToQueryTree, @@ -54,6 +55,8 @@ interface AutoScoreManagerProps { canEdit: boolean } +const getRuleFileRelativePath = (ruleId: number) => `auto-score/rule-${ruleId}.json` + function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { const { t, i18n } = useTranslation() const [form] = Form.useForm() @@ -92,6 +95,10 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } }, [currentPage, pageSize, rules.length]) + useEffect(() => { + setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig)) + }, [triggerConfig]) + const resetEditor = () => { setEditingRuleId(null) form.setFieldsValue({ name: "", studentNames: [] }) @@ -262,6 +269,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element const res = await api.autoScoreDeleteRule(ruleId) if (res.success) { + await api.fsDeleteFile(getRuleFileRelativePath(ruleId), "automatic").catch(() => void 0) messageApi.success(t("autoScore.deleteSuccess")) if (editingRuleId === ruleId) { resetEditor() @@ -295,6 +303,38 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } } + const handleOpenRuleFile = async (rule: AutoScoreRule) => { + const api = (window as any).api + if (!api) return + + try { + const relativePath = getRuleFileRelativePath(rule.id) + const fileContent = { + id: rule.id, + name: rule.name, + enabled: rule.enabled, + studentNames: rule.studentNames, + triggers: rule.triggers, + actions: rule.actions, + lastExecuted: rule.lastExecuted ?? null, + exportedAt: new Date().toISOString(), + } + + const writeRes = await api.fsWriteJson(relativePath, fileContent, "automatic") + + if (!writeRes?.success) { + throw new Error("prepare rule file failed") + } + + const openRes = await api.fsOpenPath(relativePath, "automatic") + if (!openRes?.success) { + throw new Error(openRes?.message || "open rule file failed") + } + } catch { + messageApi.error(t("autoScore.openFileFailed")) + } + } + const formatLastExecuted = (value?: string | null) => { if (!value) return t("autoScore.notExecuted") const date = new Date(value) @@ -358,12 +398,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { title: t("common.operation"), key: "operation", - width: 140, + width: 220, render: (_, row) => ( + { @@ -391,7 +434,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element type="warning" showIcon style={{ marginBottom: "16px" }} - message={t("autoScore.adminRequired")} + title={t("autoScore.adminRequired")} /> )} @@ -403,7 +446,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element name="name" rules={[{ required: true, message: t("autoScore.nameRequired") }]} > - + = ({ options={mergedTagOptions} onChange={(nextValue) => updateAction(action.id, { value: nextValue })} /> + ) : ( +
+ {t("autoScore.actionSettleScoreHint")} +
)} + ), + }, + ] + const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize) return ( @@ -461,6 +573,21 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element />
+ +
+ + + + + + + + + +
@@ -517,6 +644,20 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element /> + + + + ) } diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 6f5d772..1e9aa75 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -18,8 +18,14 @@ import { import { ThemeQuickSettings } from "./ThemeQuickSettings" import { OAuthLogin } from "./OAuth/OAuthLogin" import { useTranslation } from "react-i18next" +import { pinyin } from "pinyin-pro" import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n" import { useResponsive } from "../hooks/useResponsive" +import { + buildSystemFontFamily, + buildSystemFontValue, + SYSTEM_FONT_STACK, +} from "../shared/fontFamily" const { Text, Paragraph } = Typography @@ -37,46 +43,33 @@ interface FontOption { value: string label: string fontFamily: string + searchText: string } -const SYSTEM_FONT_STACK = - '"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif' +const CHINESE_FONT_CHAR_PATTERN = /[\u3400-\u9fff]/ +const CHINESE_FONT_SEGMENT_PATTERN = /[\u3400-\u9fff]+/g + +const toPascalCasePinyin = (value: string): string => + pinyin(value, { toneType: "none" }) + .split(/\s+/) + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase()) + .join("") + +const formatFontDisplayName = (name: string): string => { + if (!CHINESE_FONT_CHAR_PATTERN.test(name)) return name + return name.replace(CHINESE_FONT_SEGMENT_PATTERN, (segment) => toPascalCasePinyin(segment)) +} + +const buildFontSearchText = (name: string, label: string): string => + `${name} ${label}`.toLowerCase() const defaultFontOptions: FontOption[] = [ { value: "system", label: "系统默认", fontFamily: SYSTEM_FONT_STACK, - }, - { - value: "system-Microsoft YaHei UI", - label: "Microsoft YaHei UI", - fontFamily: '"Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif', - }, - { - value: "system-Microsoft YaHei", - label: "Microsoft YaHei", - fontFamily: '"Microsoft YaHei", "微软雅黑", sans-serif', - }, - { - value: "system-PingFang SC", - label: "PingFang SC", - fontFamily: '"PingFang SC", "Hiragino Sans GB", sans-serif', - }, - { - value: "system-Noto Sans CJK SC", - label: "Noto Sans CJK SC", - fontFamily: '"Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", sans-serif', - }, - { - value: "system-Segoe UI", - label: "Segoe UI", - fontFamily: '"Segoe UI", sans-serif', - }, - { - value: "system-Arial", - label: "Arial", - fontFamily: "Arial, sans-serif", + searchText: "系统默认 system default", }, ] @@ -272,13 +265,17 @@ export const Settings: React.FC<{ const remoteOptions = res.data .map((name: string) => String(name || "").trim()) .filter((name: string) => Boolean(name)) - .map( - (name: string) => - ({ - value: `system-${name}`, - label: name, - fontFamily: `"${name}", ${SYSTEM_FONT_STACK}`, - }) satisfies FontOption + .map((name: string) => { + const label = formatFontDisplayName(name) + return { + value: buildSystemFontValue(name), + label, + fontFamily: buildSystemFontFamily(name), + searchText: buildFontSearchText(name, label), + } satisfies FontOption + }) + .sort((a: FontOption, b: FontOption) => + a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin") ) const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions]) @@ -829,10 +826,13 @@ export const Settings: React.FC<{ options={fontOptions.map((opt) => ({ value: opt.value, label: opt.label, + searchText: opt.searchText, }))} showSearch filterOption={(input, option) => - (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) + String(option?.searchText ?? option?.label ?? "") + .toLowerCase() + .includes(input.toLowerCase()) } />
=> invoke("auto_score_add_rule", { rule }), autoScoreUpdateRule: (rule: { @@ -284,6 +309,7 @@ const api = { studentNames: string[] triggers: autoScoreTrigger[] actions: autoScoreAction[] + execution?: autoScoreExecutionConfig }): Promise<{ success: boolean; data?: boolean; message?: string }> => invoke("auto_score_update_rule", { rule }), autoScoreDeleteRule: ( @@ -306,6 +332,15 @@ const api = { ruleIds: number[] ): Promise<{ success: boolean; data?: boolean; message?: string }> => invoke("auto_score_sort_rules", { ruleIds }), + autoScoreQueryBatches: (): Promise<{ + success: boolean + data?: autoScoreExecutionBatch[] + message?: string + }> => invoke("auto_score_query_batches"), + autoScoreRollbackBatch: (params: { + batchId: string + }): Promise<{ success: boolean; data?: autoScoreExecutionBatch; message?: string }> => + invoke("auto_score_rollback_batch", { params }), // Settings & Sync getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> => diff --git a/src/shared/fontFamily.ts b/src/shared/fontFamily.ts new file mode 100644 index 0000000..c165640 --- /dev/null +++ b/src/shared/fontFamily.ts @@ -0,0 +1,30 @@ +export const SYSTEM_FONT_STACK = + '"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif' + +export const normalizeSystemFontName = (name: string): string => + String(name || "") + .trim() + .replace(/^["']+|["']+$/g, "") + +export const quoteFontFamilyName = (name: string): string => + `"${normalizeSystemFontName(name).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` + +export const buildSystemFontFamily = (name: string): string => { + const normalized = normalizeSystemFontName(name) + if (!normalized) return SYSTEM_FONT_STACK + return `${quoteFontFamilyName(normalized)}, ${SYSTEM_FONT_STACK}` +} + +export const buildSystemFontValue = (name: string): string => { + const normalized = normalizeSystemFontName(name) + if (!normalized) return "system" + return `system-${normalized}` +} + +export const resolveStoredFontFamily = (fontValue?: string): string => { + if (!fontValue || fontValue === "system") return SYSTEM_FONT_STACK + if (fontValue.startsWith("system-")) { + return buildSystemFontFamily(fontValue.slice("system-".length)) + } + return SYSTEM_FONT_STACK +} From eef6b703f7a62544051a813fdcd9371652f6ffe5 Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Fri, 10 Apr 2026 21:09:04 +0800 Subject: [PATCH 3/4] feat: Enhance AutoScore functionality with interval triggers and JSON tree support - Added support for trigger trees in AutoScoreRule interface. - Implemented functions to convert query trees to JSON and vice versa. - Updated IntervalValue to use days, hours, and minutes instead of a single amount and unit. - Refactored IntervalValueWidget to allow separate input for days, hours, and minutes. - Added validation to ensure at least one interval trigger is present. - Enhanced user interface with informative alerts and tooltips for better user experience. - Updated localization files to include new strings for interval triggers and UI elements. --- src-tauri/data.sql | Bin 110592 -> 126976 bytes src-tauri/src/commands/auto_score.rs | 6 + src-tauri/src/commands/event.rs | 4 + src-tauri/src/services/auto_score.rs | 752 ++++++++++++++---- src/components/AutoScore/AutoScoreUtils.ts | 65 ++ .../AutoScore/IntervalValueCodec.ts | 96 ++- .../AutoScore/IntervalValueWidget.tsx | 159 ++-- .../AutoScore/TriggerRuleBuilder.tsx | 8 +- src/components/AutoScoreManager.tsx | 69 +- src/components/ScoreManager.tsx | 22 +- src/i18n/locales/en-US.json | 5 + src/i18n/locales/zh-CN.json | 7 +- src/preload/types.ts | 32 +- 13 files changed, 931 insertions(+), 294 deletions(-) diff --git a/src-tauri/data.sql b/src-tauri/data.sql index 81f6249753701495d17cff6b976514cf00591071..0612fe9a9cecd4ce724715372ff05c745201c9fd 100644 GIT binary patch literal 126976 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 diff --git a/src-tauri/src/commands/auto_score.rs b/src-tauri/src/commands/auto_score.rs index 8ac3e8b..2d6ad22 100644 --- a/src-tauri/src/commands/auto_score.rs +++ b/src-tauri/src/commands/auto_score.rs @@ -28,6 +28,8 @@ pub struct CreateAutoScoreRule { #[serde(rename = "studentNames")] pub student_names: Vec, pub triggers: Vec, + #[serde(rename = "triggerTree", default)] + pub trigger_tree: Option, pub actions: Vec, #[serde(default)] pub execution: AutoScoreExecutionConfig, @@ -43,6 +45,8 @@ pub struct UpdateAutoScoreRule { #[serde(rename = "studentNames")] pub student_names: Vec, pub triggers: Vec, + #[serde(rename = "triggerTree", default)] + pub trigger_tree: Option, pub actions: Vec, #[serde(default)] pub execution: AutoScoreExecutionConfig, @@ -75,6 +79,7 @@ fn build_rule_from_create(rule: CreateAutoScoreRule) -> AutoScoreRule { enabled: rule.enabled, student_names: rule.student_names, triggers: rule.triggers, + trigger_tree: rule.trigger_tree, actions: rule.actions, execution: rule.execution, filters: rule.filters, @@ -89,6 +94,7 @@ fn build_rule_from_update(rule: UpdateAutoScoreRule) -> AutoScoreRule { enabled: rule.enabled, student_names: rule.student_names, triggers: rule.triggers, + trigger_tree: rule.trigger_tree, actions: rule.actions, execution: rule.execution, filters: rule.filters, diff --git a/src-tauri/src/commands/event.rs b/src-tauri/src/commands/event.rs index ff2a8d6..368aa32 100644 --- a/src-tauri/src/commands/event.rs +++ b/src-tauri/src/commands/event.rs @@ -32,7 +32,9 @@ pub struct ScoreEvent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateScoreEvent { + #[serde(alias = "studentName")] pub student_name: String, + #[serde(alias = "reasonContent")] pub reason_content: String, pub delta: i32, } @@ -44,8 +46,10 @@ pub struct QueryEventParams { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QueryByStudentParams { + #[serde(alias = "studentName")] pub student_name: String, pub limit: Option, + #[serde(alias = "startTime")] pub start_time: Option, } diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index 8b7ff20..ad4bc4a 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -1,11 +1,11 @@ use chrono::{DateTime, Months, Utc}; use sea_orm::{ - ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, EntityTrait, + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, Statement, TransactionTrait, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as JsonValue}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use tauri::{AppHandle, Emitter, Manager}; use tokio::time::{interval, Duration}; use uuid::Uuid; @@ -75,10 +75,10 @@ enum IntervalUnit { Month, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -struct IntervalTriggerValue { - amount: i64, - unit: IntervalUnit, +#[derive(Debug, Clone, PartialEq, Eq)] +enum IntervalTriggerValue { + Legacy { amount: i64, unit: IntervalUnit }, + Composite { days: i64, hours: i64, minutes: i64 }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -89,6 +89,8 @@ pub struct AutoScoreRule { #[serde(rename = "studentNames")] pub student_names: Vec, pub triggers: Vec, + #[serde(rename = "triggerTree", default)] + pub trigger_tree: Option, pub actions: Vec, #[serde(default)] pub execution: AutoScoreExecutionConfig, @@ -123,18 +125,6 @@ struct RuleExecutionStats { settled: bool, } -impl StudentRefs { - fn is_empty(&self) -> bool { - self.ids.is_empty() && self.names.is_empty() - } - - fn intersect(mut self, other: StudentRefs) -> StudentRefs { - self.ids.retain(|id| other.ids.contains(id)); - self.names.retain(|name| other.names.contains(name)); - self - } -} - impl Default for AutoScoreRule { fn default() -> Self { Self { @@ -143,6 +133,7 @@ impl Default for AutoScoreRule { enabled: true, student_names: Vec::new(), triggers: Vec::new(), + trigger_tree: None, actions: Vec::new(), execution: AutoScoreExecutionConfig::default(), filters: AutoScoreFilterConfig::default(), @@ -461,18 +452,30 @@ fn normalize_rule(mut rule: AutoScoreRule) -> Result { rule.name = normalize_required_string(rule.name, "automation name")?; rule.student_names = dedupe_trimmed_strings(rule.student_names); - if rule.triggers.is_empty() { + if rule.triggers.is_empty() && rule.trigger_tree.is_none() { return Err("At least one trigger is required".to_string()); } if rule.actions.is_empty() { return Err("At least one action is required".to_string()); } - rule.triggers = rule + let normalized_triggers = rule .triggers .into_iter() .map(normalize_trigger) .collect::, _>>()?; + let normalized_tree = if let Some(tree) = rule.trigger_tree.take() { + normalize_trigger_tree(tree)? + } else { + build_trigger_tree_from_triggers(&normalized_triggers) + }; + let tree_triggers = collect_triggers_from_tree(&normalized_tree)?; + if tree_triggers.is_empty() { + return Err("At least one trigger is required".to_string()); + } + + rule.triggers = tree_triggers; + rule.trigger_tree = Some(normalized_tree); rule.actions = rule .actions .into_iter() @@ -507,6 +510,285 @@ fn normalize_filter_config(mut config: AutoScoreFilterConfig) -> Result JsonValue { + JsonValue::Object( + [ + ("id".to_string(), JsonValue::String(Uuid::new_v4().to_string())), + ("type".to_string(), JsonValue::String("group".to_string())), + ( + "properties".to_string(), + json!({ + "conjunction": "AND" + }), + ), + ( + "children1".to_string(), + JsonValue::Array( + triggers + .iter() + .map(build_trigger_rule_node) + .collect::>(), + ), + ), + ] + .into_iter() + .collect(), + ) +} + +fn build_trigger_rule_node(trigger: &AutoScoreTrigger) -> JsonValue { + let (field, operator, value) = match trigger.event.as_str() { + "interval_time_passed" => ( + "interval_minutes", + "equal", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), + "student_has_tag" => { + let tag_values = parse_tag_values(trigger.value.as_deref()) + .into_iter() + .map(JsonValue::String) + .collect::>(); + ( + "student_tag", + "multiselect_contains", + JsonValue::Array(vec![JsonValue::Array(tag_values)]), + ) + } + "query_sql" | "student_query_sql" | "student_sql" => ( + "student_sql", + "equal", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), + _ => ( + "student_sql", + "equal", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), + }; + + JsonValue::Object( + [ + ("id".to_string(), JsonValue::String(Uuid::new_v4().to_string())), + ("type".to_string(), JsonValue::String("rule".to_string())), + ( + "properties".to_string(), + json!({ + "field": field, + "operator": operator, + "value": value, + }), + ), + ] + .into_iter() + .collect(), + ) +} + +fn normalize_trigger_tree(tree: JsonValue) -> Result { + let normalized = normalize_trigger_tree_node(tree)?; + if !matches!( + normalized + .get("type") + .and_then(JsonValue::as_str) + .unwrap_or_default(), + "group" + ) { + return Err("Trigger tree root must be a group".to_string()); + } + Ok(normalized) +} + +fn normalize_trigger_tree_node(node: JsonValue) -> Result { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => normalize_trigger_tree_group(node), + "rule" => normalize_trigger_tree_rule(node), + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), + } +} + +fn normalize_trigger_tree_group(node: JsonValue) -> Result { + let conjunction = node + .get("properties") + .and_then(|value| value.get("conjunction")) + .and_then(JsonValue::as_str) + .unwrap_or("AND") + .to_uppercase(); + if conjunction != "AND" && conjunction != "OR" { + return Err("Trigger tree group conjunction must be AND or OR".to_string()); + } + + let not = node + .get("properties") + .and_then(|value| value.get("not")) + .and_then(JsonValue::as_bool) + .unwrap_or(false); + + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + if children.is_empty() { + return Err("Trigger tree group must contain at least one child".to_string()); + } + + let normalized_children = children + .into_iter() + .map(normalize_trigger_tree_node) + .collect::, _>>()?; + + Ok(JsonValue::Object( + [ + ( + "id".to_string(), + JsonValue::String( + node.get("id") + .and_then(JsonValue::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + ), + ), + ("type".to_string(), JsonValue::String("group".to_string())), + ( + "properties".to_string(), + json!({ + "conjunction": conjunction, + "not": not, + }), + ), + ("children1".to_string(), JsonValue::Array(normalized_children)), + ] + .into_iter() + .collect(), + )) +} + +fn normalize_trigger_tree_rule(node: JsonValue) -> Result { + let trigger = trigger_from_rule_node(&node)?; + + Ok(JsonValue::Object( + [ + ( + "id".to_string(), + JsonValue::String( + node.get("id") + .and_then(JsonValue::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + ), + ), + ("type".to_string(), JsonValue::String("rule".to_string())), + ( + "properties".to_string(), + build_trigger_rule_node(&trigger) + .get("properties") + .cloned() + .unwrap_or_else(|| json!({})), + ), + ] + .into_iter() + .collect(), + )) +} + +fn collect_triggers_from_tree(tree: &JsonValue) -> Result, String> { + let mut collected = Vec::new(); + collect_triggers_from_tree_node(tree, &mut collected)?; + Ok(collected) +} + +fn collect_triggers_from_tree_node( + node: &JsonValue, + collected: &mut Vec, +) -> Result<(), String> { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => { + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .ok_or_else(|| "Trigger tree group children1 must be an array".to_string())?; + for child in children { + collect_triggers_from_tree_node(child, collected)?; + } + Ok(()) + } + "rule" => { + collected.push(trigger_from_rule_node(node)?); + Ok(()) + } + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), + } +} + +fn trigger_from_rule_node(node: &JsonValue) -> Result { + let field = node + .get("properties") + .and_then(|value| value.get("field")) + .and_then(JsonValue::as_str) + .unwrap_or_default(); + let first_value = node + .get("properties") + .and_then(|value| value.get("value")) + .and_then(JsonValue::as_array) + .and_then(|values| values.first()) + .cloned(); + + match field { + "interval_minutes" => { + let trigger = AutoScoreTrigger { + event: "interval_time_passed".to_string(), + value: first_value + .as_ref() + .and_then(JsonValue::as_str) + .map(str::to_string), + }; + normalize_trigger(trigger) + } + "student_tag" => { + let tags = match first_value { + Some(JsonValue::Array(items)) => items + .into_iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect::>(), + Some(JsonValue::String(value)) => vec![value], + _ => Vec::new(), + }; + let trigger = AutoScoreTrigger { + event: "student_has_tag".to_string(), + value: stringify_tag_values(&tags), + }; + normalize_trigger(trigger) + } + "student_sql" => { + let trigger = AutoScoreTrigger { + event: "query_sql".to_string(), + value: first_value + .as_ref() + .and_then(JsonValue::as_str) + .map(str::to_string), + }; + normalize_trigger(trigger) + } + _ => Err(format!("Unsupported trigger tree field: {}", field)), + } +} + fn normalize_trigger(trigger: AutoScoreTrigger) -> Result { let event = normalize_required_string(trigger.event, "trigger event")?; @@ -657,6 +939,22 @@ fn normalize_last_executed(value: Option) -> Option { .map(|parsed| parsed.with_timezone(&Utc).to_rfc3339()) } +fn parse_positive_i64(value: &JsonValue) -> Option { + match value { + JsonValue::Number(number) => number.as_i64().filter(|parsed| *parsed > 0), + JsonValue::String(text) => text.trim().parse::().ok().filter(|parsed| *parsed > 0), + _ => None, + } +} + +fn parse_non_negative_i64(value: &JsonValue) -> Option { + match value { + JsonValue::Number(number) => number.as_i64().filter(|parsed| *parsed >= 0), + JsonValue::String(text) => text.trim().parse::().ok().filter(|parsed| *parsed >= 0), + _ => None, + } +} + fn parse_interval_trigger_value(value: Option<&str>) -> Option { let raw_value = value.map(str::trim)?; if raw_value.is_empty() { @@ -665,29 +963,75 @@ fn parse_interval_trigger_value(value: Option<&str>) -> Option() { if minutes > 0 { - return Some(IntervalTriggerValue { + return Some(IntervalTriggerValue::Legacy { amount: minutes, unit: IntervalUnit::Minute, }); } } - let parsed_value = serde_json::from_str::(raw_value).ok()?; - if parsed_value.amount <= 0 { - return None; + let parsed_value = serde_json::from_str::(raw_value).ok()?; + let object = parsed_value.as_object()?; + + if let (Some(days), Some(hours), Some(minutes)) = ( + object.get("days").and_then(parse_non_negative_i64), + object.get("hours").and_then(parse_non_negative_i64), + object.get("minutes").and_then(parse_non_negative_i64), + ) { + if days > 0 || hours > 0 || minutes > 0 { + return Some(IntervalTriggerValue::Composite { + days, + hours, + minutes, + }); + } } - Some(parsed_value) + let amount = object.get("amount").and_then(parse_positive_i64)?; + let unit = match object.get("unit").and_then(JsonValue::as_str) { + Some("minute") => IntervalUnit::Minute, + Some("day") => IntervalUnit::Day, + Some("month") => IntervalUnit::Month, + _ => return None, + }; + + Some(IntervalTriggerValue::Legacy { amount, unit }) } fn stringify_interval_trigger_value(value: &IntervalTriggerValue) -> Option { - if value.amount <= 0 { - return None; - } + match value { + IntervalTriggerValue::Legacy { amount, unit } => { + if *amount <= 0 { + return None; + } - match value.unit { - IntervalUnit::Minute => Some(value.amount.to_string()), - IntervalUnit::Day | IntervalUnit::Month => serde_json::to_string(value).ok(), + match unit { + IntervalUnit::Minute => Some(amount.to_string()), + IntervalUnit::Day | IntervalUnit::Month => serde_json::to_string( + &json!({ + "amount": amount, + "unit": unit, + }), + ) + .ok(), + } + } + IntervalTriggerValue::Composite { + days, + hours, + minutes, + } => { + if *days <= 0 && *hours <= 0 && *minutes <= 0 { + return None; + } + + serde_json::to_string(&json!({ + "days": days, + "hours": hours, + "minutes": minutes, + })) + .ok() + } } } @@ -695,48 +1039,73 @@ fn add_interval_to_time( base_time: DateTime, interval: &IntervalTriggerValue, ) -> Option> { - match interval.unit { - IntervalUnit::Minute => { - base_time.checked_add_signed(chrono::Duration::minutes(interval.amount)) - } - IntervalUnit::Day => base_time.checked_add_signed(chrono::Duration::days(interval.amount)), - IntervalUnit::Month => { - let months = u32::try_from(interval.amount).ok()?; - base_time.checked_add_months(Months::new(months)) + match interval { + IntervalTriggerValue::Legacy { amount, unit } => match unit { + IntervalUnit::Minute => { + base_time.checked_add_signed(chrono::Duration::minutes(*amount)) + } + IntervalUnit::Day => { + base_time.checked_add_signed(chrono::Duration::days(*amount)) + } + IntervalUnit::Month => { + let months = u32::try_from(*amount).ok()?; + base_time.checked_add_months(Months::new(months)) + } + }, + IntervalTriggerValue::Composite { + days, + hours, + minutes, + } => { + let total_minutes = days + .saturating_mul(24 * 60) + .saturating_add(hours.saturating_mul(60)) + .saturating_add(*minutes); + if total_minutes <= 0 { + return None; + } + base_time.checked_add_signed(chrono::Duration::minutes(total_minutes)) } } } fn check_interval_trigger(rule: &AutoScoreRule) -> Option { - let now = Utc::now(); + let delays = rule + .triggers + .iter() + .filter(|trigger| trigger.event == "interval_time_passed") + .map(|trigger| get_interval_delay_ms(rule, trigger.value.as_deref())) + .collect::>>(); - for trigger in &rule.triggers { - if trigger.event != "interval_time_passed" { - continue; - } - - let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or( - IntervalTriggerValue { - amount: DEFAULT_INTERVAL_MINUTES, - unit: IntervalUnit::Minute, - }, - ); - - let Some(base_time) = rule - .last_executed - .as_ref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) - else { - return Some(0); - }; - - let next_execute_time = add_interval_to_time(base_time, &interval)?; - let delay_ms = (next_execute_time - now).num_milliseconds(); - return Some(delay_ms.max(0)); + if delays.is_empty() { + return None; } - None + delays.into_iter().flatten().min() +} + +fn get_interval_delay_ms(rule: &AutoScoreRule, value: Option<&str>) -> Option { + let now = Utc::now(); + let interval = parse_interval_trigger_value(value).unwrap_or(IntervalTriggerValue::Legacy { + amount: DEFAULT_INTERVAL_MINUTES, + unit: IntervalUnit::Minute, + }); + + let Some(base_time) = rule + .last_executed + .as_ref() + .and_then(|raw| DateTime::parse_from_rfc3339(raw).ok()) + .map(|parsed| parsed.with_timezone(&Utc)) + else { + return Some(0); + }; + + let next_execute_time = add_interval_to_time(base_time, &interval)?; + Some((next_execute_time - now).num_milliseconds().max(0)) +} + +fn is_interval_due(rule: &AutoScoreRule, value: Option<&str>) -> bool { + get_interval_delay_ms(rule, value).unwrap_or(0) <= 0 } fn contains_forbidden_keyword(sql: &str) -> bool { @@ -1301,82 +1670,198 @@ async fn execute_settlement(conn: &DatabaseConnection) -> Result<(), String> { Ok(()) } -async fn resolve_target_students( - conn: &DatabaseConnection, - rule: &AutoScoreRule, -) -> Result, String> { - let explicit_sql_values: Vec = rule - .triggers - .iter() - .filter(|trigger| { - matches!( +#[derive(Debug, Default)] +struct TriggerEvalContext { + student_tags_by_id: HashMap>, + sql_refs_by_query: HashMap, +} + +fn collect_sql_queries_from_tree(node: &JsonValue, queries: &mut Vec) -> Result<(), String> { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => { + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .ok_or_else(|| "Trigger tree group children1 must be an array".to_string())?; + for child in children { + collect_sql_queries_from_tree(child, queries)?; + } + Ok(()) + } + "rule" => { + let trigger = trigger_from_rule_node(node)?; + if matches!( trigger.event.as_str(), "query_sql" | "student_query_sql" | "student_sql" - ) - }) - .filter_map(|trigger| trigger.value.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .collect(); - - if !explicit_sql_values.is_empty() { - let mut refs: Option = None; - for sql_value in explicit_sql_values { - let next_refs = query_student_refs_by_sql(conn, &sql_value).await?; - refs = Some(match refs { - None => next_refs, - Some(existing) => existing.intersect(next_refs), - }); + ) { + if let Some(sql) = trigger.value.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + queries.push(sql.to_string()); + } + } + Ok(()) } - - let refs = refs.unwrap_or_default(); - if refs.is_empty() { - return Ok(vec![]); - } - return load_students_from_refs(conn, refs).await; - } - - let tag_filters: Vec = rule - .triggers - .iter() - .filter(|trigger| trigger.event == "student_has_tag") - .flat_map(|trigger| parse_tag_values(trigger.value.as_deref())) - .collect(); - - let tag_filters = dedupe_trimmed_strings(tag_filters); - if tag_filters.is_empty() { - return students::Entity::find() - .all(conn) - .await - .map_err(|e| e.to_string()); + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), } +} +async fn load_student_tags_by_student_id( + conn: &DatabaseConnection, +) -> Result>, String> { let tag_models = tags::Entity::find() - .filter(tags::Column::Name.is_in(tag_filters)) .all(conn) .await .map_err(|e| e.to_string())?; if tag_models.is_empty() { - return Ok(vec![]); + return Ok(HashMap::new()); } - let tag_ids: Vec = tag_models.iter().map(|tag| tag.id).collect(); + let tag_name_by_id: HashMap = tag_models + .into_iter() + .map(|tag| (tag.id, tag.name)) + .collect(); let links = student_tags::Entity::find() - .filter(student_tags::Column::TagId.is_in(tag_ids)) .all(conn) .await .map_err(|e| e.to_string())?; - if links.is_empty() { + + let mut student_tags_by_id: HashMap> = HashMap::new(); + for link in links { + let Some(tag_name) = tag_name_by_id.get(&link.tag_id) else { + continue; + }; + student_tags_by_id + .entry(link.student_id) + .or_default() + .insert(tag_name.clone()); + } + + Ok(student_tags_by_id) +} + +fn evaluate_trigger_tree_for_student( + node: &JsonValue, + student: &students::Model, + rule: &AutoScoreRule, + ctx: &TriggerEvalContext, +) -> Result { + let Some(node_type) = node.get("type").and_then(JsonValue::as_str) else { + return Err("Trigger tree node type is required".to_string()); + }; + + match node_type { + "group" => { + let conjunction = node + .get("properties") + .and_then(|value| value.get("conjunction")) + .and_then(JsonValue::as_str) + .unwrap_or("AND") + .to_uppercase(); + let not = node + .get("properties") + .and_then(|value| value.get("not")) + .and_then(JsonValue::as_bool) + .unwrap_or(false); + let children = node + .get("children1") + .and_then(JsonValue::as_array) + .ok_or_else(|| "Trigger tree group children1 must be an array".to_string())?; + if children.is_empty() { + return Ok(false); + } + + let base = if conjunction == "OR" { + children.iter().try_fold(false, |matched, child| { + if matched { + return Ok(true); + } + evaluate_trigger_tree_for_student(child, student, rule, ctx) + })? + } else { + children.iter().try_fold(true, |matched, child| { + if !matched { + return Ok(false); + } + evaluate_trigger_tree_for_student(child, student, rule, ctx) + })? + }; + + Ok(if not { !base } else { base }) + } + "rule" => { + let trigger = trigger_from_rule_node(node)?; + let matched = match trigger.event.as_str() { + "interval_time_passed" => is_interval_due(rule, trigger.value.as_deref()), + "student_has_tag" => { + let required_tags = parse_tag_values(trigger.value.as_deref()); + let student_tags = ctx.student_tags_by_id.get(&student.id); + required_tags.iter().any(|tag| { + student_tags + .map(|values| values.contains(tag)) + .unwrap_or(false) + }) + } + "query_sql" | "student_query_sql" | "student_sql" => { + let sql = trigger + .value + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_string(); + ctx.sql_refs_by_query + .get(&sql) + .map(|refs| refs.ids.contains(&student.id) || refs.names.contains(&student.name)) + .unwrap_or(false) + } + _ => false, + }; + Ok(matched) + } + _ => Err(format!("Unsupported trigger tree node type: {}", node_type)), + } +} + +async fn resolve_target_students( + conn: &DatabaseConnection, + rule: &AutoScoreRule, +) -> Result, String> { + let all_students = students::Entity::find() + .all(conn) + .await + .map_err(|e| e.to_string())?; + if all_students.is_empty() { return Ok(vec![]); } - let student_ids: HashSet = links.iter().map(|link| link.student_id).collect(); - students::Entity::find() - .filter(students::Column::Id.is_in(student_ids)) - .all(conn) - .await - .map_err(|e| e.to_string()) + let fallback_tree = build_trigger_tree_from_triggers(&rule.triggers); + let trigger_tree = rule.trigger_tree.as_ref().unwrap_or(&fallback_tree); + + let mut sql_queries = Vec::new(); + collect_sql_queries_from_tree(trigger_tree, &mut sql_queries)?; + let sql_queries = dedupe_trimmed_strings(sql_queries); + + let mut sql_refs_by_query = HashMap::new(); + for sql in sql_queries { + let refs = query_student_refs_by_sql(conn, &sql).await?; + sql_refs_by_query.insert(sql, refs); + } + + let ctx = TriggerEvalContext { + student_tags_by_id: load_student_tags_by_student_id(conn).await?, + sql_refs_by_query, + }; + + let mut matched = Vec::new(); + for student in all_students { + if evaluate_trigger_tree_for_student(trigger_tree, &student, rule, &ctx)? { + matched.push(student); + } + } + + Ok(matched) } @@ -1458,29 +1943,6 @@ async fn query_student_refs_by_sql( Ok(refs) } -async fn load_students_from_refs( - conn: &DatabaseConnection, - refs: StudentRefs, -) -> Result, String> { - if refs.is_empty() { - return Ok(vec![]); - } - - let mut condition = Condition::any(); - if !refs.ids.is_empty() { - condition = condition.add(students::Column::Id.is_in(refs.ids)); - } - if !refs.names.is_empty() { - condition = condition.add(students::Column::Name.is_in(refs.names)); - } - - students::Entity::find() - .filter(condition) - .all(conn) - .await - .map_err(|e| e.to_string()) -} - fn try_get_i32(row: &sea_orm::QueryResult, column: &str) -> Option { row.try_get::("", column) .ok() diff --git a/src/components/AutoScore/AutoScoreUtils.ts b/src/components/AutoScore/AutoScoreUtils.ts index 458a851..d1188f1 100644 --- a/src/components/AutoScore/AutoScoreUtils.ts +++ b/src/components/AutoScore/AutoScoreUtils.ts @@ -51,6 +51,7 @@ export interface AutoScoreRule { enabled: boolean studentNames: string[] triggers: AutoScoreTrigger[] + triggerTree?: JsonGroup | null actions: AutoScoreAction[] execution?: AutoScoreExecutionConfig lastExecuted?: string | null @@ -368,6 +369,70 @@ export const queryTreeToTriggers = (tree: ImmutableTree, config: Config): AutoSc return collectTriggersFromItems(jsonTree.children1) } +export const queryTreeToJson = (tree: ImmutableTree, config: Config): JsonGroup | null => { + const checkedTree = QbUtils.checkTree(tree, config) + const jsonTree = QbUtils.getTree(checkedTree, false, true) + if (!jsonTree || jsonTree.type !== "group") return null + return jsonTree as JsonGroup +} + +const hydrateTriggerTreeNode = ( + node: JsonItem, + fallbackTriggers: AutoScoreTrigger[], + fallbackIndex: { current: number } +): JsonItem | null => { + if (node.type === "group") { + const children = Array.isArray(node.children1) + ? node.children1 + .map((child) => hydrateTriggerTreeNode(child, fallbackTriggers, fallbackIndex)) + .filter((child): child is JsonItem => Boolean(child)) + : [] + return { + ...node, + children1: children, + } + } + + if (node.type !== "rule") { + return node + } + + const fallbackTrigger = fallbackTriggers[fallbackIndex.current] + fallbackIndex.current += 1 + const parsed = triggerFromRule(node) + if (parsed) { + return node + } + + if (!fallbackTrigger) { + return node + } + + const fallbackRule = ruleFromTrigger(fallbackTrigger) + if (!fallbackRule) { + return node + } + + return { + ...node, + properties: fallbackRule.properties, + } +} + +export const triggerTreeJsonToQueryTree = ( + config: Config, + triggerTree?: JsonGroup | null, + fallbackTriggers: AutoScoreTrigger[] = [] +): ImmutableTree => { + if (triggerTree && triggerTree.type === "group") { + const hydratedTree = hydrateTriggerTreeNode(triggerTree, fallbackTriggers, { current: 0 }) + if (hydratedTree && hydratedTree.type === "group") { + return QbUtils.checkTree(QbUtils.loadTree(hydratedTree), config) + } + } + return triggersToQueryTree(config, fallbackTriggers) +} + const hasUnsupportedLogicInGroup = (group: JsonGroup): boolean => { const conjunction = typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND" diff --git a/src/components/AutoScore/IntervalValueCodec.ts b/src/components/AutoScore/IntervalValueCodec.ts index 66b0a18..66ed942 100644 --- a/src/components/AutoScore/IntervalValueCodec.ts +++ b/src/components/AutoScore/IntervalValueCodec.ts @@ -1,44 +1,87 @@ export type IntervalUnit = "minute" | "day" | "month" export interface IntervalValue { + days: number + hours: number + minutes: number +} + +interface LegacyIntervalValue { amount: number unit: IntervalUnit } -export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day" - -const normalizePositiveInteger = (value: unknown): number | null => { +const normalizeNonNegativeInteger = (value: unknown): number | null => { if (typeof value === "number" && Number.isFinite(value)) { const normalized = Math.floor(value) - return normalized > 0 ? normalized : null + return normalized >= 0 ? normalized : null } if (typeof value === "string" && value.trim()) { const parsed = Number(value) if (Number.isFinite(parsed)) { const normalized = Math.floor(parsed) - return normalized > 0 ? normalized : null + return normalized >= 0 ? normalized : null } } return null } +const normalizePositiveInteger = (value: unknown): number | null => { + const normalized = normalizeNonNegativeInteger(value) + if (normalized === null || normalized <= 0) return null + return normalized +} + const isIntervalUnit = (value: unknown): value is IntervalUnit => value === "minute" || value === "day" || value === "month" export const getDefaultIntervalValue = (): IntervalValue => ({ - amount: 1, - unit: DEFAULT_INTERVAL_UNIT, + days: 1, + hours: 0, + minutes: 0, }) +const fromTotalMinutes = (totalMinutes: number): IntervalValue => { + const normalized = Math.max(0, Math.floor(totalMinutes)) + const days = Math.floor(normalized / (24 * 60)) + const hours = Math.floor((normalized % (24 * 60)) / 60) + const minutes = normalized % 60 + return { days, hours, minutes } +} + +const toTotalMinutes = (value: IntervalValue): number => + value.days * 24 * 60 + value.hours * 60 + value.minutes + +const normalizeCompositeValue = (value: Partial): IntervalValue | null => { + const days = normalizeNonNegativeInteger(value.days) ?? 0 + const hours = normalizeNonNegativeInteger(value.hours) ?? 0 + const minutes = normalizeNonNegativeInteger(value.minutes) ?? 0 + + const totalMinutes = days * 24 * 60 + hours * 60 + minutes + if (totalMinutes <= 0) return null + return fromTotalMinutes(totalMinutes) +} + +const legacyToComposite = (value: Partial): IntervalValue | null => { + const amount = normalizePositiveInteger(value.amount) + if (!amount || !isIntervalUnit(value.unit)) return null + + if (value.unit === "minute") return fromTotalMinutes(amount) + if (value.unit === "day") return fromTotalMinutes(amount * 24 * 60) + + // Legacy month values are approximated as 30 days in the editor. + return fromTotalMinutes(amount * 30 * 24 * 60) +} + export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => { if (typeof value === "object" && value !== null) { - const amount = normalizePositiveInteger((value as IntervalValue).amount) - const unit = (value as IntervalValue).unit - if (amount && isIntervalUnit(unit)) { - return { amount, unit } - } + const composite = normalizeCompositeValue(value as Partial) + if (composite) return composite + + const legacy = legacyToComposite(value as Partial) + if (legacy) return legacy } const text = value === null || value === undefined ? "" : String(value).trim() @@ -46,15 +89,16 @@ export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null const legacyMinutes = normalizePositiveInteger(text) if (legacyMinutes !== null && !text.startsWith("{")) { - return { amount: legacyMinutes, unit: "minute" } + return fromTotalMinutes(legacyMinutes) } try { - const parsed = JSON.parse(text) as Partial - const amount = normalizePositiveInteger(parsed.amount) - if (amount && isIntervalUnit(parsed.unit)) { - return { amount, unit: parsed.unit } - } + const parsed = JSON.parse(text) as Partial + const composite = normalizeCompositeValue(parsed) + if (composite) return composite + + const legacy = legacyToComposite(parsed) + if (legacy) return legacy } catch { void 0 } @@ -63,17 +107,15 @@ export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null } export const stringifyIntervalTriggerValue = (value: IntervalValue): string | null => { - const amount = normalizePositiveInteger(value.amount) - if (!amount || !isIntervalUnit(value.unit)) { - return null - } + const normalized = normalizeCompositeValue(value) + if (!normalized) return null - if (value.unit === "minute") { - return String(amount) - } + const totalMinutes = toTotalMinutes(normalized) + if (totalMinutes <= 0) return null return JSON.stringify({ - amount, - unit: value.unit, + days: normalized.days, + hours: normalized.hours, + minutes: normalized.minutes, }) } diff --git a/src/components/AutoScore/IntervalValueWidget.tsx b/src/components/AutoScore/IntervalValueWidget.tsx index bdc1562..76d82f2 100644 --- a/src/components/AutoScore/IntervalValueWidget.tsx +++ b/src/components/AutoScore/IntervalValueWidget.tsx @@ -1,116 +1,87 @@ -import React, { useMemo } from "react" -import { InputNumber, Select } from "antd" +import React from "react" +import { InputNumber } from "antd" import type { WidgetProps } from "@react-awesome-query-builder/antd" import { useTranslation } from "react-i18next" +import { + getDefaultIntervalValue, + parseIntervalTriggerValue, + stringifyIntervalTriggerValue, + type IntervalValue, +} from "./IntervalValueCodec" -export type IntervalUnit = "month" | "day" | "minute" +export { parseIntervalTriggerValue, stringifyIntervalTriggerValue } from "./IntervalValueCodec" +export type { IntervalValue, IntervalUnit } from "./IntervalValueCodec" -export interface IntervalValue { - amount: number - unit: IntervalUnit -} - -export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day" - -export const getDefaultIntervalValue = (): IntervalValue => ({ - amount: 1, - unit: DEFAULT_INTERVAL_UNIT, -}) - -export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => { - if (value === null || value === undefined) return null - - const str = String(value).trim() - if (!str) return null - - try { - const parsed = JSON.parse(str) - if ( - typeof parsed === "object" && - parsed !== null && - typeof parsed.amount === "number" && - typeof parsed.unit === "string" - ) { - const validUnits: IntervalUnit[] = ["month", "day", "minute"] - if (validUnits.includes(parsed.unit as IntervalUnit)) { - return { - amount: Math.max(1, Math.floor(parsed.amount)), - unit: parsed.unit as IntervalUnit, - } - } - } - } catch { - void 0 +const buildNextIntervalValue = ( + currentValue: IntervalValue | null, + patch: Partial +): IntervalValue => { + const base = currentValue ?? getDefaultIntervalValue() + return { + days: patch.days ?? base.days, + hours: patch.hours ?? base.hours, + minutes: patch.minutes ?? base.minutes, } - - return null } -export const stringifyIntervalTriggerValue = (value: IntervalValue): string => { - return JSON.stringify({ - amount: value.amount, - unit: value.unit, - }) -} +const getDisplayValue = (value: number | undefined, fallback = 0) => + typeof value === "number" && Number.isFinite(value) ? value : fallback export const IntervalValueWidget: React.FC = ({ value, setValue, readonly, - placeholder, }) => { const { t } = useTranslation() const parsedValue = parseIntervalTriggerValue(value) - const unitOptions = useMemo( - () => [ - { label: t("autoScore.intervalUnitMonth"), value: "month" }, - { label: t("autoScore.intervalUnitDay"), value: "day" }, - { label: t("autoScore.intervalUnitMinute"), value: "minute" }, - ], - [t] - ) - - const handleAmountChange = (nextAmount: number | null) => { - if (nextAmount === null) { - setValue(null) - return - } - - const serializedValue = stringifyIntervalTriggerValue({ - amount: nextAmount, - unit: parsedValue?.unit ?? DEFAULT_INTERVAL_UNIT, - }) - setValue(serializedValue) - } - - const handleUnitChange = (nextUnit: IntervalUnit) => { - const baseValue = parsedValue ?? getDefaultIntervalValue() - const serializedValue = stringifyIntervalTriggerValue({ - amount: baseValue.amount, - unit: nextUnit, - }) - setValue(serializedValue) + const updateValue = (patch: Partial) => { + const nextValue = buildNextIntervalValue(parsedValue, patch) + const serialized = stringifyIntervalTriggerValue(nextValue) + setValue(serialized) } return ( -
- - matchStudentName(getOptionLabel(option), input)} - options={students.map((s) => ({ label: s.name, value: s.name }))} - /> + + + +
+
+ t("common.total", { count: total })} + onChange={(page, size) => { + setBatchCurrentPage(page) + setBatchPageSize(size) + }} + /> +
) diff --git a/src/components/ScoreManager.tsx b/src/components/ScoreManager.tsx index 647ee60..515ac2b 100644 --- a/src/components/ScoreManager.tsx +++ b/src/components/ScoreManager.tsx @@ -84,12 +84,21 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [students, setStudents] = useState([]) const [reasons, setReasons] = useState([]) const [events, setEvents] = useState([]) + const [recordCurrentPage, setRecordCurrentPage] = useState(1) + const [recordPageSize, setRecordPageSize] = useState(10) const [loading, setLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false) const [form] = Form.useForm() const [messageApi, contextHolder] = message.useMessage() const selectedStudentNames = Form.useWatch("student_name", form) as string[] | undefined + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(events.length / recordPageSize)) + if (recordCurrentPage > maxPage) { + setRecordCurrentPage(maxPage) + } + }, [events.length, recordCurrentPage, recordPageSize]) + const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => { window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) } @@ -102,7 +111,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [stuRes, reaRes, eveRes] = await Promise.all([ (window as any).api.queryStudents({}), (window as any).api.queryReasons(), - (window as any).api.queryEvents({ limit: 10 }), + (window as any).api.queryEvents({ limit: 100 }), ]) if (stuRes.success) setStudents(stuRes.data) @@ -231,12 +240,12 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } const columns: ColumnsType = [ - { title: t("score.student"), dataIndex: "student_name", key: "student_name", width: 100 }, + { title: t("score.student"), dataIndex: "student_name", key: "student_name", width: 120 }, { title: t("score.change"), dataIndex: "delta", key: "delta", - width: 80, + width: 92, render: (delta: number) => ( 0 ? "success" : "error"}>{delta > 0 ? `+${delta}` : delta} ), @@ -245,6 +254,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { title: t("score.reason"), dataIndex: "reason_content", key: "reason_content", + width: 280, ellipsis: true, }, { @@ -257,7 +267,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { { title: t("common.operation"), key: "operation", - width: 80, + width: 96, render: (_, row) => ( handleUndo(row.uuid)}>