diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6c69b4a..7b73cdf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1079,7 +1079,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -4242,6 +4242,7 @@ dependencies = [ "tracing-subscriber", "urlencoding", "uuid", + "winreg 0.52.0", ] [[package]] @@ -6913,6 +6914,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2abbe0c..4e6f8a0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,6 +42,9 @@ dirs = "6.0.0" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } urlencoding = "2.1" +[target.'cfg(windows)'.dependencies] +winreg = "0.52" + [profile.release] panic = "abort" codegen-units = 1 diff --git a/src-tauri/data.sql b/src-tauri/data.sql index 960f453..81f6249 100644 Binary files a/src-tauri/data.sql and b/src-tauri/data.sql differ diff --git a/src-tauri/src/commands/auto_score.rs b/src-tauri/src/commands/auto_score.rs index 8c2e639..8ac3e8b 100644 --- a/src-tauri/src/commands/auto_score.rs +++ b/src-tauri/src/commands/auto_score.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, State}; use crate::services::{ - AutoScoreAction, AutoScoreRule, AutoScoreService, AutoScoreTrigger, PermissionLevel, - SettingsKey, SettingsValue, + query_execution_batches, rollback_execution_batch, AutoScoreAction, AutoScoreExecutionBatch, + AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService, + AutoScoreTrigger, PermissionLevel, SettingsKey, SettingsValue, }; use crate::state::AppState; @@ -28,6 +29,10 @@ pub struct CreateAutoScoreRule { pub student_names: Vec, pub triggers: Vec, pub actions: Vec, + #[serde(default)] + pub execution: AutoScoreExecutionConfig, + #[serde(default)] + pub filters: AutoScoreFilterConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -39,6 +44,16 @@ pub struct UpdateAutoScoreRule { pub student_names: Vec, pub triggers: Vec, pub actions: Vec, + #[serde(default)] + pub execution: AutoScoreExecutionConfig, + #[serde(default)] + pub filters: AutoScoreFilterConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RollbackBatchParams { + #[serde(rename = "batchId")] + pub batch_id: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -61,6 +76,8 @@ fn build_rule_from_create(rule: CreateAutoScoreRule) -> AutoScoreRule { student_names: rule.student_names, triggers: rule.triggers, actions: rule.actions, + execution: rule.execution, + filters: rule.filters, last_executed: None, } } @@ -73,6 +90,8 @@ fn build_rule_from_update(rule: UpdateAutoScoreRule) -> AutoScoreRule { student_names: rule.student_names, triggers: rule.triggers, actions: rule.actions, + execution: rule.execution, + filters: rule.filters, last_executed: None, } } @@ -336,3 +355,38 @@ pub async fn auto_score_sort_rules( Ok(IpcResponse::success(true)) } + +#[tauri::command] +pub async fn auto_score_query_batches( + sender_id: Option, + state: State<'_, Arc>>, +) -> Result>, String> { + { + let state_guard = state.read(); + let mut permissions = state_guard.permissions.write(); + if !check_admin_permission(&mut permissions, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + } + + let batches = query_execution_batches(state.inner()).await?; + Ok(IpcResponse::success(batches)) +} + +#[tauri::command] +pub async fn auto_score_rollback_batch( + params: RollbackBatchParams, + sender_id: Option, + state: State<'_, Arc>>, +) -> Result, String> { + { + let state_guard = state.read(); + let mut permissions = state_guard.permissions.write(); + if !check_admin_permission(&mut permissions, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + } + + let batch = rollback_execution_batch(state.inner(), ¶ms.batch_id).await?; + Ok(IpcResponse::success(batch)) +} diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index d788fc9..9e20276 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -2,9 +2,14 @@ use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::collections::BTreeSet; -use std::process::Command; use std::sync::Arc; use tauri::{AppHandle, Emitter, State}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::process::Command; +#[cfg(target_os = "windows")] +use winreg::enums::HKEY_LOCAL_MACHINE; +#[cfg(target_os = "windows")] +use winreg::RegKey; use crate::services::{ settings::{ @@ -161,20 +166,6 @@ pub async fn settings_set( Ok(IpcResponse::success(())) } -fn fallback_font_families() -> Vec { - vec![ - "系统默认".to_string(), - "Microsoft YaHei UI".to_string(), - "Microsoft YaHei".to_string(), - "PingFang SC".to_string(), - "Noto Sans CJK SC".to_string(), - "Source Han Sans SC".to_string(), - "Segoe UI".to_string(), - "Arial".to_string(), - "Helvetica Neue".to_string(), - ] -} - fn normalize_font_name(value: &str) -> String { value .trim() @@ -188,33 +179,20 @@ fn normalize_font_name(value: &str) -> String { #[cfg(target_os = "windows")] fn query_system_fonts() -> Vec { - let output = Command::new("reg") - .args([ - "query", - r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", - ]) - .output(); + let mut families = BTreeSet::new(); - let Ok(output) = output else { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"); + let Ok(key) = key else { return Vec::new(); }; - if !output.status.success() { - return Vec::new(); - } - let mut families = BTreeSet::new(); - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if !line.contains("REG_") { + for (value_name, _) in key.enum_values().flatten() { + let name = normalize_font_name(&value_name); + if name.is_empty() { continue; } - let Some((left, _)) = line.split_once("REG_") else { - continue; - }; - let name = normalize_font_name(left); - if !name.is_empty() { - families.insert(name); - } + families.insert(name); } families.into_iter().collect() @@ -276,19 +254,5 @@ fn query_system_fonts() -> Vec { #[tauri::command] pub async fn settings_get_system_fonts() -> Result>, String> { - let mut fonts = query_system_fonts(); - if fonts.is_empty() { - fonts = fallback_font_families(); - } else { - let mut merged = BTreeSet::new(); - for item in fallback_font_families() { - merged.insert(item); - } - for item in fonts { - merged.insert(item); - } - fonts = merged.into_iter().collect(); - } - - Ok(IpcResponse::success(fonts)) + Ok(IpcResponse::success(query_system_fonts())) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06a194e..4094bab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -96,6 +96,8 @@ pub fn run() { auto_score_toggle_rule, auto_score_get_status, auto_score_sort_rules, + auto_score_query_batches, + auto_score_rollback_batch, board_get_configs, board_save_configs, board_query_sql, diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index 2ecb225..8b7ff20 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -31,6 +31,42 @@ pub struct AutoScoreAction { pub value: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct AutoScoreExecutionConfig { + pub cooldown_minutes: Option, + pub max_runs_per_day: Option, + pub max_score_delta_per_day: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct AutoScoreFilterConfig { + pub groups: Vec, + pub grades: Vec, + pub min_score: Option, + pub max_score: Option, + pub recent_event_days: Option, + pub min_recent_event_count: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AutoScoreExecutionBatch { + pub id: String, + pub rule_id: i32, + pub rule_name: String, + pub run_at: String, + pub affected_students: usize, + pub affected_student_names: Vec, + pub created_event_ids: Vec, + pub added_student_tag_ids: Vec, + pub score_delta_total: i64, + pub settled: bool, + pub rolled_back: bool, + pub rollback_at: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] enum IntervalUnit { @@ -54,6 +90,10 @@ pub struct AutoScoreRule { pub student_names: Vec, pub triggers: Vec, pub actions: Vec, + #[serde(default)] + pub execution: AutoScoreExecutionConfig, + #[serde(default)] + pub filters: AutoScoreFilterConfig, #[serde(rename = "lastExecuted")] pub last_executed: Option, } @@ -62,6 +102,7 @@ pub struct AutoScoreRule { enum PlannedAction { AddScore(i32), AddTags(Vec), + SettleScore, } #[derive(Debug, Default, Clone)] @@ -70,11 +111,16 @@ struct StudentRefs { names: HashSet, } -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone)] struct RuleExecutionStats { affected_students: usize, + affected_student_names: Vec, created_events: usize, added_tags: usize, + score_delta_total: i64, + created_event_ids: Vec, + added_student_tag_ids: Vec, + settled: bool, } impl StudentRefs { @@ -98,6 +144,8 @@ impl Default for AutoScoreRule { student_names: Vec::new(), triggers: Vec::new(), actions: Vec::new(), + execution: AutoScoreExecutionConfig::default(), + filters: AutoScoreFilterConfig::default(), last_executed: None, } } @@ -278,6 +326,7 @@ impl AutoScoreService { async fn run_scheduler_tick(app_handle: &AppHandle) -> Result<(), String> { let state = app_handle.state::().inner().clone(); + let mut execution_batches = load_batches_from_settings(&state).await?; let rules_snapshot = { let state_guard = state.read(); @@ -307,7 +356,7 @@ impl AutoScoreService { continue; } - match execute_rule(&conn, rule).await { + match execute_rule(&conn, rule, &execution_batches).await { Ok(stats) => { if stats.affected_students == 0 { Self::log_rule_skipped(&state, rule, "no matched students"); @@ -315,7 +364,22 @@ impl AutoScoreService { } rule.last_executed = Some(Utc::now().to_rfc3339()); changed = true; - Self::log_rule_executed(&state, rule, stats); + Self::log_rule_executed(&state, rule, &stats); + let batch = AutoScoreExecutionBatch { + id: Uuid::new_v4().to_string(), + rule_id: rule.id, + rule_name: rule.name.clone(), + run_at: now_iso(), + affected_students: stats.affected_students, + affected_student_names: stats.affected_student_names.clone(), + created_event_ids: stats.created_event_ids.clone(), + added_student_tag_ids: stats.added_student_tag_ids.clone(), + score_delta_total: stats.score_delta_total, + settled: stats.settled, + rolled_back: false, + rollback_at: None, + }; + execution_batches.push(batch); } Err(error) => { Self::log_rule_failed(&state, rule, &error); @@ -328,6 +392,7 @@ impl AutoScoreService { } persist_rules_to_settings(&state, &next_rules).await?; + save_batches_to_settings(&state, &execution_batches).await?; { let state_guard = state.read(); @@ -348,7 +413,7 @@ impl AutoScoreService { Ok(()) } - fn log_rule_executed(state: &SafeAppState, rule: &AutoScoreRule, stats: RuleExecutionStats) { + 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( @@ -359,6 +424,8 @@ impl AutoScoreService { "affected_students": stats.affected_students, "created_events": stats.created_events, "added_tags": stats.added_tags, + "score_delta_total": stats.score_delta_total, + "settled": stats.settled, }), ); } @@ -411,11 +478,35 @@ fn normalize_rule(mut rule: AutoScoreRule) -> Result { .into_iter() .map(normalize_action) .collect::, _>>()?; + rule.execution = normalize_execution_config(rule.execution)?; + rule.filters = normalize_filter_config(rule.filters)?; rule.last_executed = normalize_last_executed(rule.last_executed); Ok(rule) } +fn normalize_execution_config( + mut config: AutoScoreExecutionConfig, +) -> Result { + config.cooldown_minutes = config.cooldown_minutes.filter(|value| *value > 0); + config.max_runs_per_day = config.max_runs_per_day.filter(|value| *value > 0); + config.max_score_delta_per_day = config.max_score_delta_per_day.filter(|value| *value > 0); + Ok(config) +} + +fn normalize_filter_config(mut config: AutoScoreFilterConfig) -> Result { + config.groups = dedupe_trimmed_strings(config.groups); + config.grades = dedupe_trimmed_strings(config.grades); + if let (Some(min_score), Some(max_score)) = (config.min_score, config.max_score) { + if min_score > max_score { + return Err("minScore cannot be greater than maxScore".to_string()); + } + } + config.recent_event_days = config.recent_event_days.filter(|value| *value > 0); + config.min_recent_event_count = config.min_recent_event_count.filter(|value| *value >= 0); + Ok(config) +} + fn normalize_trigger(trigger: AutoScoreTrigger) -> Result { let event = normalize_required_string(trigger.event, "trigger event")?; @@ -483,6 +574,7 @@ fn normalize_action(action: AutoScoreAction) -> Result value: Some(value), }) } + "settle_score" => Ok(AutoScoreAction { event, value: None }), _ => Err(format!("Unsupported action event: {}", event)), } } @@ -780,9 +872,135 @@ async fn persist_rules_to_settings( Ok(()) } +fn deserialize_batches(value: &JsonValue) -> Vec { + let JsonValue::Array(items) = value else { + return Vec::new(); + }; + + items + .iter() + .filter_map(|item| serde_json::from_value::(item.clone()).ok()) + .collect() +} + +async fn load_batches_from_settings(state: &SafeAppState) -> Result, String> { + 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?; + let raw = match settings.get_value(SettingsKey::AutoScoreBatches) { + SettingsValue::Json(value) => value, + _ => JsonValue::Array(vec![]), + }; + Ok(deserialize_batches(&raw)) +} + +async fn save_batches_to_settings( + state: &SafeAppState, + batches: &[AutoScoreExecutionBatch], +) -> Result<(), String> { + let encoded = serde_json::to_value(batches).map_err(|e| e.to_string())?; + 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::AutoScoreBatches, SettingsValue::Json(encoded)) + .await?; + Ok(()) +} + +pub async fn query_execution_batches(state: &SafeAppState) -> Result, String> { + let mut batches = load_batches_from_settings(state).await?; + batches.sort_by(|a, b| b.run_at.cmp(&a.run_at)); + Ok(batches) +} + +pub async fn rollback_execution_batch( + state: &SafeAppState, + batch_id: &str, +) -> Result { + 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 batches = load_batches_from_settings(state).await?; + let Some(index) = batches.iter().position(|batch| batch.id == batch_id) else { + return Err("Execution batch not found".to_string()); + }; + + if batches[index].rolled_back { + return Err("Execution batch already rolled back".to_string()); + } + if batches[index].settled { + return Err("Cannot rollback settled execution batch".to_string()); + } + + let mut batch = batches[index].clone(); + let txn = conn.begin().await.map_err(|e| e.to_string())?; + + for event_id in &batch.created_event_ids { + let event = score_events::Entity::find_by_id(*event_id) + .one(&txn) + .await + .map_err(|e| e.to_string())?; + let Some(event) = event else { + continue; + }; + if event.settlement_id.is_some() { + return Err("Cannot rollback batch containing settled events".to_string()); + } + score_events::Entity::delete_by_id(*event_id) + .exec(&txn) + .await + .map_err(|e| e.to_string())?; + + if let Some(student) = students::Entity::find() + .filter(students::Column::Name.eq(event.student_name.clone())) + .one(&txn) + .await + .map_err(|e| e.to_string())? + { + let current_score = student.score; + let current_reward_points = student.reward_points; + let mut student_active: students::ActiveModel = student.into(); + let next_score = current_score - event.delta; + let next_reward = current_reward_points - event.delta; + student_active.score = Set(next_score); + student_active.reward_points = Set(next_reward); + student_active.updated_at = Set(now_iso()); + student_active + .update(&txn) + .await + .map_err(|e| e.to_string())?; + } + } + + for link_id in &batch.added_student_tag_ids { + student_tags::Entity::delete_by_id(*link_id) + .exec(&txn) + .await + .map_err(|e| e.to_string())?; + } + + txn.commit().await.map_err(|e| e.to_string())?; + + batch.rolled_back = true; + batch.rollback_at = Some(now_iso()); + batches[index] = batch.clone(); + save_batches_to_settings(state, &batches).await?; + Ok(batch) +} + async fn execute_rule( conn: &DatabaseConnection, rule: &AutoScoreRule, + execution_batches: &[AutoScoreExecutionBatch], ) -> Result { let mut target_students = resolve_target_students(conn, rule).await?; @@ -801,16 +1019,32 @@ async fn execute_rule( return Ok(RuleExecutionStats::default()); } + if let Some(max_runs) = rule.execution.max_runs_per_day { + let today_runs = execution_batches + .iter() + .filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at)) + .count() as i64; + if today_runs >= max_runs { + return Ok(RuleExecutionStats::default()); + } + } + let planned_actions = plan_actions(&rule.actions)?; if planned_actions.is_empty() { return Err("No executable action".to_string()); } + let should_settle = planned_actions + .iter() + .any(|action| matches!(action, PlannedAction::SettleScore)); + + let mut daily_score_delta_used: i64 = execution_batches + .iter() + .filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at)) + .map(|batch| batch.score_delta_total.abs()) + .sum(); let txn = conn.begin().await.map_err(|e| e.to_string())?; - let mut stats = RuleExecutionStats { - affected_students: target_students.len(), - ..RuleExecutionStats::default() - }; + let mut stats = RuleExecutionStats::default(); let mut all_action_tags = Vec::new(); for action in &planned_actions { @@ -827,9 +1061,28 @@ async fn execute_rule( } for mut student in target_students { + if !is_student_pass_cooldown( + conn, + execution_batches, + rule, + student.id, + student.name.as_str(), + ) + .await? + { + continue; + } + let mut touched = false; for action in &planned_actions { match action { PlannedAction::AddScore(delta) => { + if let Some(max_delta) = rule.execution.max_score_delta_per_day { + let next = daily_score_delta_used + (*delta as i64).abs(); + if next > max_delta { + continue; + } + daily_score_delta_used = next; + } let now = now_iso(); let val_prev = student.score; let val_curr = val_prev + delta; @@ -839,15 +1092,17 @@ async fn execute_rule( 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)), + reason_content: Set(build_auto_score_reason(rule.id, &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())?; + let inserted_event = event.insert(&txn).await.map_err(|e| e.to_string())?; stats.created_events += 1; + stats.created_event_ids.push(inserted_event.id); + stats.score_delta_total += *delta as i64; let mut student_active: students::ActiveModel = student.clone().into(); student_active.score = Set(val_curr); @@ -860,6 +1115,7 @@ async fn execute_rule( student.score = val_curr; student.reward_points = reward_points; + touched = true; } PlannedAction::AddTags(tag_names) => { for tag_name in tag_names { @@ -868,23 +1124,34 @@ async fn execute_rule( }; let inserted = attach_tag_to_student_if_missing(&txn, student.id, tag_id).await?; - if inserted { + if let Some(link_id) = inserted { stats.added_tags += 1; + stats.added_student_tag_ids.push(link_id); + touched = true; } } } + PlannedAction::SettleScore => {} } } + if touched { + stats.affected_students += 1; + stats.affected_student_names.push(student.name.clone()); + } } txn.commit().await.map_err(|e| e.to_string())?; + if should_settle { + execute_settlement(conn).await?; + stats.settled = true; + } Ok(stats) } fn plan_actions(actions: &[AutoScoreAction]) -> Result, String> { - let mut planned = Vec::new(); - for action in actions { - match action.event.as_str() { + let mut planned = Vec::new(); + for action in actions { + match action.event.as_str() { "add_score" => { let delta = action .value @@ -901,15 +1168,142 @@ fn plan_actions(actions: &[AutoScoreAction]) -> Result, Strin } planned.push(PlannedAction::AddTags(tags)); } + "settle_score" => { + planned.push(PlannedAction::SettleScore); + } _ => {} - } } - Ok(planned) + } + Ok(planned) +} + +async fn execute_settlement(conn: &DatabaseConnection) -> Result<(), String> { + let backend = conn.get_database_backend(); + let count_stmt = Statement::from_string( + backend, + "SELECT COUNT(*) AS cnt FROM score_events WHERE settlement_id IS NULL", + ); + let count_row = conn.query_one(count_stmt).await.map_err(|e| e.to_string())?; + let Some(count_row) = count_row else { + return Ok(()); + }; + let unsettled_count = try_get_i64(&count_row, "cnt").unwrap_or(0); + if unsettled_count <= 0 { + return Ok(()); + } + + let last_end_sql = match backend { + sea_orm::DatabaseBackend::Sqlite => { + "SELECT end_time AS end_time FROM settlements ORDER BY julianday(end_time) DESC LIMIT 1" + } + sea_orm::DatabaseBackend::Postgres => { + "SELECT end_time AS end_time FROM settlements ORDER BY end_time DESC LIMIT 1" + } + _ => "SELECT end_time AS end_time FROM settlements ORDER BY end_time DESC LIMIT 1", + }; + let min_event_sql = + "SELECT MIN(event_time) AS min_event_time FROM score_events WHERE settlement_id IS NULL"; + + let last_end = conn + .query_one(Statement::from_string(backend, last_end_sql)) + .await + .map_err(|e| e.to_string())? + .and_then(|row| try_get_string(&row, "end_time")); + + let min_event_time = conn + .query_one(Statement::from_string(backend, min_event_sql)) + .await + .map_err(|e| e.to_string())? + .and_then(|row| try_get_string(&row, "min_event_time")); + + let end_time = now_iso(); + let start_time = last_end.or(min_event_time).unwrap_or_else(|| end_time.clone()); + let created_at = end_time.clone(); + + match backend { + sea_orm::DatabaseBackend::Postgres => { + let insert_stmt = Statement::from_string( + backend, + format!( + "INSERT INTO settlements (start_time, end_time, created_at) VALUES ('{}', '{}', '{}') RETURNING id", + start_time.replace('\'', "''"), + end_time.replace('\'', "''"), + created_at.replace('\'', "''") + ), + ); + let row = conn + .query_one(insert_stmt) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Failed to create settlement".to_string())?; + let settlement_id = try_get_i32(&row, "id") + .ok_or_else(|| "Failed to read settlement id".to_string())?; + + let update_events_stmt = Statement::from_string( + backend, + format!( + "UPDATE score_events SET settlement_id = {} WHERE settlement_id IS NULL", + settlement_id + ), + ); + conn.execute(update_events_stmt).await.map_err(|e| e.to_string())?; + + let update_students_stmt = Statement::from_string( + backend, + format!( + "UPDATE students SET score = 0, updated_at = '{}' ", + end_time.replace('\'', "''") + ), + ); + conn.execute(update_students_stmt).await.map_err(|e| e.to_string())?; + } + _ => { + let insert_stmt = Statement::from_string( + backend, + format!( + "INSERT INTO settlements (start_time, end_time, created_at) VALUES ('{}', '{}', '{}')", + start_time.replace('\'', "''"), + end_time.replace('\'', "''"), + created_at.replace('\'', "''") + ), + ); + conn.execute(insert_stmt).await.map_err(|e| e.to_string())?; + + let id_stmt = Statement::from_string(backend, "SELECT last_insert_rowid() AS id"); + let row = conn + .query_one(id_stmt) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Failed to read settlement id".to_string())?; + let settlement_id = try_get_i32(&row, "id") + .ok_or_else(|| "Failed to read settlement id".to_string())?; + + let update_events_stmt = Statement::from_string( + backend, + format!( + "UPDATE score_events SET settlement_id = {} WHERE settlement_id IS NULL", + settlement_id + ), + ); + conn.execute(update_events_stmt).await.map_err(|e| e.to_string())?; + + let update_students_stmt = Statement::from_string( + backend, + format!( + "UPDATE students SET score = 0, updated_at = '{}'", + end_time.replace('\'', "''") + ), + ); + conn.execute(update_students_stmt).await.map_err(|e| e.to_string())?; + } + } + + Ok(()) } async fn resolve_target_students( - conn: &DatabaseConnection, - rule: &AutoScoreRule, + conn: &DatabaseConnection, + rule: &AutoScoreRule, ) -> Result, String> { let explicit_sql_values: Vec = rule .triggers @@ -985,6 +1379,57 @@ async fn resolve_target_students( .map_err(|e| e.to_string()) } + +async fn is_student_pass_cooldown( + conn: &DatabaseConnection, + execution_batches: &[AutoScoreExecutionBatch], + rule: &AutoScoreRule, + student_id: i32, + student_name: &str, +) -> Result { + let Some(cooldown_minutes) = rule.execution.cooldown_minutes else { + return Ok(true); + }; + if cooldown_minutes <= 0 { + return Ok(true); + } + + let _ = student_id; + let cutoff = Utc::now() - chrono::Duration::minutes(cooldown_minutes); + for batch in execution_batches + .iter() + .filter(|batch| batch.rule_id == rule.id && !batch.rolled_back) + { + let run_at = DateTime::parse_from_rfc3339(batch.run_at.as_str()) + .map(|value| value.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now() - chrono::Duration::days(3650)); + if run_at >= cutoff && batch.affected_student_names.iter().any(|name| name == student_name) + { + return Ok(false); + } + } + + let prefix = format!("{}#{}:", AUTO_SCORE_REASON_PREFIX, rule.id); + let since = cutoff + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + + let exists = score_events::Entity::find() + .filter(score_events::Column::StudentName.eq(student_name.to_string())) + .filter(score_events::Column::ReasonContent.like(format!("{}%", prefix))) + .filter(score_events::Column::EventTime.gte(since)) + .one(conn) + .await + .map_err(|e| e.to_string())?; + Ok(exists.is_none()) +} + +fn is_same_utc_day(timestamp: &str) -> bool { + DateTime::parse_from_rfc3339(timestamp) + .map(|value| value.with_timezone(&Utc).date_naive() == Utc::now().date_naive()) + .unwrap_or(false) +} + async fn query_student_refs_by_sql( conn: &DatabaseConnection, sql_or_expression: &str, @@ -1051,6 +1496,21 @@ fn try_get_i32(row: &sea_orm::QueryResult, column: &str) -> Option { }) } +fn try_get_i64(row: &sea_orm::QueryResult, column: &str) -> Option { + row.try_get::("", column) + .ok() + .or_else(|| { + row.try_get::("", column) + .ok() + .map(|value| value as i64) + }) + .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(); @@ -1093,7 +1553,7 @@ async fn attach_tag_to_student_if_missing( txn: &sea_orm::DatabaseTransaction, student_id: i32, tag_id: i32, -) -> Result { +) -> Result, String> { let existing = student_tags::Entity::find() .filter(student_tags::Column::StudentId.eq(student_id)) .filter(student_tags::Column::TagId.eq(tag_id)) @@ -1102,11 +1562,11 @@ async fn attach_tag_to_student_if_missing( .map_err(|e| e.to_string())?; if existing.is_some() { - return Ok(false); + return Ok(None); } let now = now_iso(); - student_tags::ActiveModel { + let inserted = student_tags::ActiveModel { id: sea_orm::ActiveValue::NotSet, student_id: Set(student_id), tag_id: Set(tag_id), @@ -1116,17 +1576,23 @@ async fn attach_tag_to_student_if_missing( .await .map_err(|e| e.to_string())?; - Ok(true) + Ok(Some(inserted.id)) } 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 { +fn build_auto_score_reason(rule_id: i32, rule_name: &str, delta: i32) -> String { if delta > 0 { - format!("{}: {} (+{})", AUTO_SCORE_REASON_PREFIX, rule_name, delta) + format!( + "{}#{}: {} (+{})", + AUTO_SCORE_REASON_PREFIX, rule_id, rule_name, delta + ) } else { - format!("{}: {} ({})", AUTO_SCORE_REASON_PREFIX, rule_name, delta) + format!( + "{}#{}: {} ({})", + AUTO_SCORE_REASON_PREFIX, rule_id, rule_name, delta + ) } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 65c89ef..7a7ea77 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -8,7 +8,11 @@ pub mod settings; pub mod theme; pub use auth::AuthService; -pub use auto_score::{AutoScoreAction, AutoScoreRule, AutoScoreService, AutoScoreTrigger}; +pub use auto_score::{ + query_execution_batches, rollback_execution_batch, AutoScoreAction, AutoScoreExecutionBatch, + AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService, + AutoScoreTrigger, +}; pub use data::DataService; pub use logger::LoggerService; pub use permission::{PermissionLevel, PermissionService}; diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index 775d749..8a4d40e 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -14,6 +14,7 @@ pub struct SettingsSpec { pub themes_custom: JsonValue, pub auto_score_enabled: bool, pub auto_score_rules: JsonValue, + pub auto_score_batches: JsonValue, pub current_theme_id: String, pub dashboards_config: JsonValue, pub pg_connection_string: String, @@ -33,6 +34,7 @@ impl Default for SettingsSpec { themes_custom: JsonValue::Array(vec![]), auto_score_enabled: false, auto_score_rules: JsonValue::Array(vec![]), + auto_score_batches: JsonValue::Array(vec![]), current_theme_id: "light-default".to_string(), dashboards_config: JsonValue::Array(vec![]), pg_connection_string: String::new(), @@ -64,6 +66,7 @@ pub enum SettingsKey { ThemesCustom, AutoScoreEnabled, AutoScoreRules, + AutoScoreBatches, CurrentThemeId, DashboardsConfig, PgConnectionString, @@ -83,6 +86,7 @@ impl SettingsKey { SettingsKey::ThemesCustom => "themes_custom", SettingsKey::AutoScoreEnabled => "auto_score_enabled", SettingsKey::AutoScoreRules => "auto_score_rules", + SettingsKey::AutoScoreBatches => "auto_score_batches", SettingsKey::CurrentThemeId => "current_theme_id", SettingsKey::DashboardsConfig => "dashboards_config", SettingsKey::PgConnectionString => "pg_connection_string", @@ -102,6 +106,7 @@ impl SettingsKey { "themes_custom" => Some(SettingsKey::ThemesCustom), "auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled), "auto_score_rules" => Some(SettingsKey::AutoScoreRules), + "auto_score_batches" => Some(SettingsKey::AutoScoreBatches), "current_theme_id" => Some(SettingsKey::CurrentThemeId), "dashboards_config" => Some(SettingsKey::DashboardsConfig), "pg_connection_string" => Some(SettingsKey::PgConnectionString), @@ -378,6 +383,16 @@ impl SettingsService { }, ); + defs.insert( + SettingsKey::AutoScoreBatches, + SettingDefinition { + kind: SettingValueKind::Json, + default_value: SettingsValue::Json(JsonValue::Array(vec![])), + write_permission: PermissionRequirement::Admin, + validate: None, + }, + ); + defs.insert( SettingsKey::CurrentThemeId, SettingDefinition { @@ -586,6 +601,10 @@ impl SettingsService { SettingsValue::Json(j) => j, _ => JsonValue::Array(vec![]), }, + auto_score_batches: match self.get_value(SettingsKey::AutoScoreBatches) { + SettingsValue::Json(j) => j, + _ => JsonValue::Array(vec![]), + }, current_theme_id: match self.get_value(SettingsKey::CurrentThemeId) { SettingsValue::String(s) => s, _ => "light-default".to_string(), diff --git a/src/App.tsx b/src/App.tsx index 6f6d5b5..3708538 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,26 +33,15 @@ import { OAuthLogin } from "./components/OAuth/OAuthLogin" import { OAuthCallback } from "./components/OAuth/OAuthCallback" import { ThemeProvider, useTheme } from "./contexts/ThemeContext" import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation" +import { resolveStoredFontFamily } from "./shared/fontFamily" const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key) const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice( 0, 4 ) -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 resolveFontFamily = (fontValue?: string): string => { - if (!fontValue || fontValue === "system") return SYSTEM_FONT_STACK - if (fontValue.startsWith("system-")) { - const family = fontValue.slice("system-".length).trim() - if (family) return `"${family}", ${SYSTEM_FONT_STACK}` - } - return SYSTEM_FONT_STACK -} - const applyGlobalFontFamily = (fontValue?: string) => { - const fontFamily = resolveFontFamily(fontValue) + const fontFamily = resolveStoredFontFamily(fontValue) document.documentElement.style.setProperty("--ss-font-family", fontFamily) document.body.style.fontFamily = fontFamily } diff --git a/src/components/AutoScore/ActionEditor.tsx b/src/components/AutoScore/ActionEditor.tsx index b17b44c..c1dd700 100644 --- a/src/components/AutoScore/ActionEditor.tsx +++ b/src/components/AutoScore/ActionEditor.tsx @@ -32,6 +32,7 @@ export const ActionEditor: React.FC = ({ () => [ { value: "add_score", label: t("autoScore.actionAddScore") }, { value: "add_tag", label: t("autoScore.actionAddTag") }, + { value: "settle_score", label: t("autoScore.actionSettleScore") }, ], [t] ) @@ -86,7 +87,7 @@ export const ActionEditor: React.FC = ({ onChange={(event: ActionEvent) => updateAction(action.id, { event, - value: event === "add_score" ? "1" : [], + value: event === "add_score" ? "1" : event === "add_tag" ? [] : "", }) } /> @@ -100,7 +101,7 @@ export const ActionEditor: React.FC = ({ updateAction(action.id, { value: nextValue === null ? "" : String(nextValue) }) } /> - ) : ( + ) : action.event === "add_tag" ? (