feat: 自动化更新

This commit is contained in:
NanGua-QWQ
2026-04-06 19:40:19 +08:00
parent ed4891210d
commit 3caea0afe5
18 changed files with 948 additions and 152 deletions
+12 -1
View File
@@ -1079,7 +1079,7 @@ dependencies = [
"rustc_version", "rustc_version",
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
"vswhom", "vswhom",
"winreg", "winreg 0.55.0",
] ]
[[package]] [[package]]
@@ -4242,6 +4242,7 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"urlencoding", "urlencoding",
"uuid", "uuid",
"winreg 0.52.0",
] ]
[[package]] [[package]]
@@ -6913,6 +6914,16 @@ dependencies = [
"memchr", "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]] [[package]]
name = "winreg" name = "winreg"
version = "0.55.0" version = "0.55.0"
+3
View File
@@ -42,6 +42,9 @@ dirs = "6.0.0"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
urlencoding = "2.1" urlencoding = "2.1"
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
[profile.release] [profile.release]
panic = "abort" panic = "abort"
codegen-units = 1 codegen-units = 1
Binary file not shown.
+56 -2
View File
@@ -5,8 +5,9 @@ use std::sync::Arc;
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
use crate::services::{ use crate::services::{
AutoScoreAction, AutoScoreRule, AutoScoreService, AutoScoreTrigger, PermissionLevel, query_execution_batches, rollback_execution_batch, AutoScoreAction, AutoScoreExecutionBatch,
SettingsKey, SettingsValue, AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService,
AutoScoreTrigger, PermissionLevel, SettingsKey, SettingsValue,
}; };
use crate::state::AppState; use crate::state::AppState;
@@ -28,6 +29,10 @@ pub struct CreateAutoScoreRule {
pub student_names: Vec<String>, pub student_names: Vec<String>,
pub triggers: Vec<AutoScoreTrigger>, pub triggers: Vec<AutoScoreTrigger>,
pub actions: Vec<AutoScoreAction>, pub actions: Vec<AutoScoreAction>,
#[serde(default)]
pub execution: AutoScoreExecutionConfig,
#[serde(default)]
pub filters: AutoScoreFilterConfig,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -39,6 +44,16 @@ pub struct UpdateAutoScoreRule {
pub student_names: Vec<String>, pub student_names: Vec<String>,
pub triggers: Vec<AutoScoreTrigger>, pub triggers: Vec<AutoScoreTrigger>,
pub actions: Vec<AutoScoreAction>, pub actions: Vec<AutoScoreAction>,
#[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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -61,6 +76,8 @@ fn build_rule_from_create(rule: CreateAutoScoreRule) -> AutoScoreRule {
student_names: rule.student_names, student_names: rule.student_names,
triggers: rule.triggers, triggers: rule.triggers,
actions: rule.actions, actions: rule.actions,
execution: rule.execution,
filters: rule.filters,
last_executed: None, last_executed: None,
} }
} }
@@ -73,6 +90,8 @@ fn build_rule_from_update(rule: UpdateAutoScoreRule) -> AutoScoreRule {
student_names: rule.student_names, student_names: rule.student_names,
triggers: rule.triggers, triggers: rule.triggers,
actions: rule.actions, actions: rule.actions,
execution: rule.execution,
filters: rule.filters,
last_executed: None, last_executed: None,
} }
} }
@@ -336,3 +355,38 @@ pub async fn auto_score_sort_rules(
Ok(IpcResponse::success(true)) Ok(IpcResponse::success(true))
} }
#[tauri::command]
pub async fn auto_score_query_batches(
sender_id: Option<u32>,
state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<IpcResponse<Vec<AutoScoreExecutionBatch>>, 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<u32>,
state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<IpcResponse<AutoScoreExecutionBatch>, 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(), &params.batch_id).await?;
Ok(IpcResponse::success(batch))
}
+15 -51
View File
@@ -2,9 +2,14 @@ use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::process::Command;
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter, State}; 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::{ use crate::services::{
settings::{ settings::{
@@ -161,20 +166,6 @@ pub async fn settings_set(
Ok(IpcResponse::success(())) Ok(IpcResponse::success(()))
} }
fn fallback_font_families() -> Vec<String> {
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 { fn normalize_font_name(value: &str) -> String {
value value
.trim() .trim()
@@ -188,33 +179,20 @@ fn normalize_font_name(value: &str) -> String {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn query_system_fonts() -> Vec<String> { fn query_system_fonts() -> Vec<String> {
let output = Command::new("reg") let mut families = BTreeSet::new();
.args([
"query",
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts",
])
.output();
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(); return Vec::new();
}; };
if !output.status.success() {
return Vec::new();
}
let mut families = BTreeSet::new(); for (value_name, _) in key.enum_values().flatten() {
let stdout = String::from_utf8_lossy(&output.stdout); let name = normalize_font_name(&value_name);
for line in stdout.lines() { if name.is_empty() {
if !line.contains("REG_") {
continue; continue;
} }
let Some((left, _)) = line.split_once("REG_") else { families.insert(name);
continue;
};
let name = normalize_font_name(left);
if !name.is_empty() {
families.insert(name);
}
} }
families.into_iter().collect() families.into_iter().collect()
@@ -276,19 +254,5 @@ fn query_system_fonts() -> Vec<String> {
#[tauri::command] #[tauri::command]
pub async fn settings_get_system_fonts() -> Result<IpcResponse<Vec<String>>, String> { pub async fn settings_get_system_fonts() -> Result<IpcResponse<Vec<String>>, String> {
let mut fonts = query_system_fonts(); Ok(IpcResponse::success(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))
} }
+2
View File
@@ -96,6 +96,8 @@ pub fn run() {
auto_score_toggle_rule, auto_score_toggle_rule,
auto_score_get_status, auto_score_get_status,
auto_score_sort_rules, auto_score_sort_rules,
auto_score_query_batches,
auto_score_rollback_batch,
board_get_configs, board_get_configs,
board_save_configs, board_save_configs,
board_query_sql, board_query_sql,
+491 -25
View File
@@ -31,6 +31,42 @@ pub struct AutoScoreAction {
pub value: Option<String>, pub value: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct AutoScoreExecutionConfig {
pub cooldown_minutes: Option<i64>,
pub max_runs_per_day: Option<i64>,
pub max_score_delta_per_day: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct AutoScoreFilterConfig {
pub groups: Vec<String>,
pub grades: Vec<String>,
pub min_score: Option<i32>,
pub max_score: Option<i32>,
pub recent_event_days: Option<i64>,
pub min_recent_event_count: Option<i64>,
}
#[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<String>,
pub created_event_ids: Vec<i32>,
pub added_student_tag_ids: Vec<i32>,
pub score_delta_total: i64,
pub settled: bool,
pub rolled_back: bool,
pub rollback_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
enum IntervalUnit { enum IntervalUnit {
@@ -54,6 +90,10 @@ pub struct AutoScoreRule {
pub student_names: Vec<String>, pub student_names: Vec<String>,
pub triggers: Vec<AutoScoreTrigger>, pub triggers: Vec<AutoScoreTrigger>,
pub actions: Vec<AutoScoreAction>, pub actions: Vec<AutoScoreAction>,
#[serde(default)]
pub execution: AutoScoreExecutionConfig,
#[serde(default)]
pub filters: AutoScoreFilterConfig,
#[serde(rename = "lastExecuted")] #[serde(rename = "lastExecuted")]
pub last_executed: Option<String>, pub last_executed: Option<String>,
} }
@@ -62,6 +102,7 @@ pub struct AutoScoreRule {
enum PlannedAction { enum PlannedAction {
AddScore(i32), AddScore(i32),
AddTags(Vec<String>), AddTags(Vec<String>),
SettleScore,
} }
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
@@ -70,11 +111,16 @@ struct StudentRefs {
names: HashSet<String>, names: HashSet<String>,
} }
#[derive(Debug, Default, Clone, Copy)] #[derive(Debug, Default, Clone)]
struct RuleExecutionStats { struct RuleExecutionStats {
affected_students: usize, affected_students: usize,
affected_student_names: Vec<String>,
created_events: usize, created_events: usize,
added_tags: usize, added_tags: usize,
score_delta_total: i64,
created_event_ids: Vec<i32>,
added_student_tag_ids: Vec<i32>,
settled: bool,
} }
impl StudentRefs { impl StudentRefs {
@@ -98,6 +144,8 @@ impl Default for AutoScoreRule {
student_names: Vec::new(), student_names: Vec::new(),
triggers: Vec::new(), triggers: Vec::new(),
actions: Vec::new(), actions: Vec::new(),
execution: AutoScoreExecutionConfig::default(),
filters: AutoScoreFilterConfig::default(),
last_executed: None, last_executed: None,
} }
} }
@@ -278,6 +326,7 @@ impl AutoScoreService {
async fn run_scheduler_tick(app_handle: &AppHandle) -> Result<(), String> { async fn run_scheduler_tick(app_handle: &AppHandle) -> Result<(), String> {
let state = app_handle.state::<SafeAppState>().inner().clone(); let state = app_handle.state::<SafeAppState>().inner().clone();
let mut execution_batches = load_batches_from_settings(&state).await?;
let rules_snapshot = { let rules_snapshot = {
let state_guard = state.read(); let state_guard = state.read();
@@ -307,7 +356,7 @@ impl AutoScoreService {
continue; continue;
} }
match execute_rule(&conn, rule).await { match execute_rule(&conn, rule, &execution_batches).await {
Ok(stats) => { Ok(stats) => {
if stats.affected_students == 0 { if stats.affected_students == 0 {
Self::log_rule_skipped(&state, rule, "no matched students"); Self::log_rule_skipped(&state, rule, "no matched students");
@@ -315,7 +364,22 @@ impl AutoScoreService {
} }
rule.last_executed = Some(Utc::now().to_rfc3339()); rule.last_executed = Some(Utc::now().to_rfc3339());
changed = true; 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) => { Err(error) => {
Self::log_rule_failed(&state, rule, &error); Self::log_rule_failed(&state, rule, &error);
@@ -328,6 +392,7 @@ impl AutoScoreService {
} }
persist_rules_to_settings(&state, &next_rules).await?; persist_rules_to_settings(&state, &next_rules).await?;
save_batches_to_settings(&state, &execution_batches).await?;
{ {
let state_guard = state.read(); let state_guard = state.read();
@@ -348,7 +413,7 @@ impl AutoScoreService {
Ok(()) 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 state_guard = state.read();
let logger = state_guard.logger.read(); let logger = state_guard.logger.read();
logger.info_with_meta( logger.info_with_meta(
@@ -359,6 +424,8 @@ impl AutoScoreService {
"affected_students": stats.affected_students, "affected_students": stats.affected_students,
"created_events": stats.created_events, "created_events": stats.created_events,
"added_tags": stats.added_tags, "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<AutoScoreRule, String> {
.into_iter() .into_iter()
.map(normalize_action) .map(normalize_action)
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
rule.execution = normalize_execution_config(rule.execution)?;
rule.filters = normalize_filter_config(rule.filters)?;
rule.last_executed = normalize_last_executed(rule.last_executed); rule.last_executed = normalize_last_executed(rule.last_executed);
Ok(rule) Ok(rule)
} }
fn normalize_execution_config(
mut config: AutoScoreExecutionConfig,
) -> Result<AutoScoreExecutionConfig, String> {
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<AutoScoreFilterConfig, String> {
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<AutoScoreTrigger, String> { fn normalize_trigger(trigger: AutoScoreTrigger) -> Result<AutoScoreTrigger, String> {
let event = normalize_required_string(trigger.event, "trigger event")?; let event = normalize_required_string(trigger.event, "trigger event")?;
@@ -483,6 +574,7 @@ fn normalize_action(action: AutoScoreAction) -> Result<AutoScoreAction, String>
value: Some(value), value: Some(value),
}) })
} }
"settle_score" => Ok(AutoScoreAction { event, value: None }),
_ => Err(format!("Unsupported action event: {}", event)), _ => Err(format!("Unsupported action event: {}", event)),
} }
} }
@@ -780,9 +872,135 @@ async fn persist_rules_to_settings(
Ok(()) Ok(())
} }
fn deserialize_batches(value: &JsonValue) -> Vec<AutoScoreExecutionBatch> {
let JsonValue::Array(items) = value else {
return Vec::new();
};
items
.iter()
.filter_map(|item| serde_json::from_value::<AutoScoreExecutionBatch>(item.clone()).ok())
.collect()
}
async fn load_batches_from_settings(state: &SafeAppState) -> Result<Vec<AutoScoreExecutionBatch>, 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<Vec<AutoScoreExecutionBatch>, 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<AutoScoreExecutionBatch, String> {
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( async fn execute_rule(
conn: &DatabaseConnection, conn: &DatabaseConnection,
rule: &AutoScoreRule, rule: &AutoScoreRule,
execution_batches: &[AutoScoreExecutionBatch],
) -> Result<RuleExecutionStats, String> { ) -> Result<RuleExecutionStats, String> {
let mut target_students = resolve_target_students(conn, rule).await?; let mut target_students = resolve_target_students(conn, rule).await?;
@@ -801,16 +1019,32 @@ async fn execute_rule(
return Ok(RuleExecutionStats::default()); 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)?; let planned_actions = plan_actions(&rule.actions)?;
if planned_actions.is_empty() { if planned_actions.is_empty() {
return Err("No executable action".to_string()); 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 txn = conn.begin().await.map_err(|e| e.to_string())?;
let mut stats = RuleExecutionStats { let mut stats = RuleExecutionStats::default();
affected_students: target_students.len(),
..RuleExecutionStats::default()
};
let mut all_action_tags = Vec::new(); let mut all_action_tags = Vec::new();
for action in &planned_actions { for action in &planned_actions {
@@ -827,9 +1061,28 @@ async fn execute_rule(
} }
for mut student in target_students { 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 { for action in &planned_actions {
match action { match action {
PlannedAction::AddScore(delta) => { 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 now = now_iso();
let val_prev = student.score; let val_prev = student.score;
let val_curr = val_prev + delta; let val_curr = val_prev + delta;
@@ -839,15 +1092,17 @@ async fn execute_rule(
id: sea_orm::ActiveValue::NotSet, id: sea_orm::ActiveValue::NotSet,
uuid: Set(Uuid::new_v4().to_string()), uuid: Set(Uuid::new_v4().to_string()),
student_name: Set(student.name.clone()), 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), delta: Set(*delta),
val_prev: Set(val_prev), val_prev: Set(val_prev),
val_curr: Set(val_curr), val_curr: Set(val_curr),
event_time: Set(now.clone()), event_time: Set(now.clone()),
settlement_id: Set(None), 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_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(); let mut student_active: students::ActiveModel = student.clone().into();
student_active.score = Set(val_curr); student_active.score = Set(val_curr);
@@ -860,6 +1115,7 @@ async fn execute_rule(
student.score = val_curr; student.score = val_curr;
student.reward_points = reward_points; student.reward_points = reward_points;
touched = true;
} }
PlannedAction::AddTags(tag_names) => { PlannedAction::AddTags(tag_names) => {
for tag_name in tag_names { for tag_name in tag_names {
@@ -868,23 +1124,34 @@ async fn execute_rule(
}; };
let inserted = let inserted =
attach_tag_to_student_if_missing(&txn, student.id, tag_id).await?; 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_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())?; txn.commit().await.map_err(|e| e.to_string())?;
if should_settle {
execute_settlement(conn).await?;
stats.settled = true;
}
Ok(stats) Ok(stats)
} }
fn plan_actions(actions: &[AutoScoreAction]) -> Result<Vec<PlannedAction>, String> { fn plan_actions(actions: &[AutoScoreAction]) -> Result<Vec<PlannedAction>, String> {
let mut planned = Vec::new(); let mut planned = Vec::new();
for action in actions { for action in actions {
match action.event.as_str() { match action.event.as_str() {
"add_score" => { "add_score" => {
let delta = action let delta = action
.value .value
@@ -901,15 +1168,142 @@ fn plan_actions(actions: &[AutoScoreAction]) -> Result<Vec<PlannedAction>, Strin
} }
planned.push(PlannedAction::AddTags(tags)); 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( async fn resolve_target_students(
conn: &DatabaseConnection, conn: &DatabaseConnection,
rule: &AutoScoreRule, rule: &AutoScoreRule,
) -> Result<Vec<students::Model>, String> { ) -> Result<Vec<students::Model>, String> {
let explicit_sql_values: Vec<String> = rule let explicit_sql_values: Vec<String> = rule
.triggers .triggers
@@ -985,6 +1379,57 @@ async fn resolve_target_students(
.map_err(|e| e.to_string()) .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<bool, String> {
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( async fn query_student_refs_by_sql(
conn: &DatabaseConnection, conn: &DatabaseConnection,
sql_or_expression: &str, sql_or_expression: &str,
@@ -1051,6 +1496,21 @@ fn try_get_i32(row: &sea_orm::QueryResult, column: &str) -> Option<i32> {
}) })
} }
fn try_get_i64(row: &sea_orm::QueryResult, column: &str) -> Option<i64> {
row.try_get::<i64>("", column)
.ok()
.or_else(|| {
row.try_get::<i32>("", column)
.ok()
.map(|value| value as i64)
})
.or_else(|| {
row.try_get::<String>("", column)
.ok()
.and_then(|value| value.trim().parse::<i64>().ok())
})
}
fn try_get_string(row: &sea_orm::QueryResult, column: &str) -> Option<String> { fn try_get_string(row: &sea_orm::QueryResult, column: &str) -> Option<String> {
row.try_get::<String>("", column).ok().and_then(|value| { row.try_get::<String>("", column).ok().and_then(|value| {
let trimmed = value.trim().to_string(); let trimmed = value.trim().to_string();
@@ -1093,7 +1553,7 @@ async fn attach_tag_to_student_if_missing(
txn: &sea_orm::DatabaseTransaction, txn: &sea_orm::DatabaseTransaction,
student_id: i32, student_id: i32,
tag_id: i32, tag_id: i32,
) -> Result<bool, String> { ) -> Result<Option<i32>, String> {
let existing = student_tags::Entity::find() let existing = student_tags::Entity::find()
.filter(student_tags::Column::StudentId.eq(student_id)) .filter(student_tags::Column::StudentId.eq(student_id))
.filter(student_tags::Column::TagId.eq(tag_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())?; .map_err(|e| e.to_string())?;
if existing.is_some() { if existing.is_some() {
return Ok(false); return Ok(None);
} }
let now = now_iso(); let now = now_iso();
student_tags::ActiveModel { let inserted = student_tags::ActiveModel {
id: sea_orm::ActiveValue::NotSet, id: sea_orm::ActiveValue::NotSet,
student_id: Set(student_id), student_id: Set(student_id),
tag_id: Set(tag_id), tag_id: Set(tag_id),
@@ -1116,17 +1576,23 @@ async fn attach_tag_to_student_if_missing(
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(true) Ok(Some(inserted.id))
} }
fn now_iso() -> String { fn now_iso() -> String {
Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_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 { if delta > 0 {
format!("{}: {} (+{})", AUTO_SCORE_REASON_PREFIX, rule_name, delta) format!(
"{}#{}: {} (+{})",
AUTO_SCORE_REASON_PREFIX, rule_id, rule_name, delta
)
} else { } else {
format!("{}: {} ({})", AUTO_SCORE_REASON_PREFIX, rule_name, delta) format!(
"{}#{}: {} ({})",
AUTO_SCORE_REASON_PREFIX, rule_id, rule_name, delta
)
} }
} }
+5 -1
View File
@@ -8,7 +8,11 @@ pub mod settings;
pub mod theme; pub mod theme;
pub use auth::AuthService; 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 data::DataService;
pub use logger::LoggerService; pub use logger::LoggerService;
pub use permission::{PermissionLevel, PermissionService}; pub use permission::{PermissionLevel, PermissionService};
+19
View File
@@ -14,6 +14,7 @@ pub struct SettingsSpec {
pub themes_custom: JsonValue, pub themes_custom: JsonValue,
pub auto_score_enabled: bool, pub auto_score_enabled: bool,
pub auto_score_rules: JsonValue, pub auto_score_rules: JsonValue,
pub auto_score_batches: JsonValue,
pub current_theme_id: String, pub current_theme_id: String,
pub dashboards_config: JsonValue, pub dashboards_config: JsonValue,
pub pg_connection_string: String, pub pg_connection_string: String,
@@ -33,6 +34,7 @@ impl Default for SettingsSpec {
themes_custom: JsonValue::Array(vec![]), themes_custom: JsonValue::Array(vec![]),
auto_score_enabled: false, auto_score_enabled: false,
auto_score_rules: JsonValue::Array(vec![]), auto_score_rules: JsonValue::Array(vec![]),
auto_score_batches: JsonValue::Array(vec![]),
current_theme_id: "light-default".to_string(), current_theme_id: "light-default".to_string(),
dashboards_config: JsonValue::Array(vec![]), dashboards_config: JsonValue::Array(vec![]),
pg_connection_string: String::new(), pg_connection_string: String::new(),
@@ -64,6 +66,7 @@ pub enum SettingsKey {
ThemesCustom, ThemesCustom,
AutoScoreEnabled, AutoScoreEnabled,
AutoScoreRules, AutoScoreRules,
AutoScoreBatches,
CurrentThemeId, CurrentThemeId,
DashboardsConfig, DashboardsConfig,
PgConnectionString, PgConnectionString,
@@ -83,6 +86,7 @@ impl SettingsKey {
SettingsKey::ThemesCustom => "themes_custom", SettingsKey::ThemesCustom => "themes_custom",
SettingsKey::AutoScoreEnabled => "auto_score_enabled", SettingsKey::AutoScoreEnabled => "auto_score_enabled",
SettingsKey::AutoScoreRules => "auto_score_rules", SettingsKey::AutoScoreRules => "auto_score_rules",
SettingsKey::AutoScoreBatches => "auto_score_batches",
SettingsKey::CurrentThemeId => "current_theme_id", SettingsKey::CurrentThemeId => "current_theme_id",
SettingsKey::DashboardsConfig => "dashboards_config", SettingsKey::DashboardsConfig => "dashboards_config",
SettingsKey::PgConnectionString => "pg_connection_string", SettingsKey::PgConnectionString => "pg_connection_string",
@@ -102,6 +106,7 @@ impl SettingsKey {
"themes_custom" => Some(SettingsKey::ThemesCustom), "themes_custom" => Some(SettingsKey::ThemesCustom),
"auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled), "auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled),
"auto_score_rules" => Some(SettingsKey::AutoScoreRules), "auto_score_rules" => Some(SettingsKey::AutoScoreRules),
"auto_score_batches" => Some(SettingsKey::AutoScoreBatches),
"current_theme_id" => Some(SettingsKey::CurrentThemeId), "current_theme_id" => Some(SettingsKey::CurrentThemeId),
"dashboards_config" => Some(SettingsKey::DashboardsConfig), "dashboards_config" => Some(SettingsKey::DashboardsConfig),
"pg_connection_string" => Some(SettingsKey::PgConnectionString), "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( defs.insert(
SettingsKey::CurrentThemeId, SettingsKey::CurrentThemeId,
SettingDefinition { SettingDefinition {
@@ -586,6 +601,10 @@ impl SettingsService {
SettingsValue::Json(j) => j, SettingsValue::Json(j) => j,
_ => JsonValue::Array(vec![]), _ => 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) { current_theme_id: match self.get_value(SettingsKey::CurrentThemeId) {
SettingsValue::String(s) => s, SettingsValue::String(s) => s,
_ => "light-default".to_string(), _ => "light-default".to_string(),
+2 -13
View File
@@ -33,26 +33,15 @@ import { OAuthLogin } from "./components/OAuth/OAuthLogin"
import { OAuthCallback } from "./components/OAuth/OAuthCallback" import { OAuthCallback } from "./components/OAuth/OAuthCallback"
import { ThemeProvider, useTheme } from "./contexts/ThemeContext" import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation" 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_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice( const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(
0, 0,
4 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 applyGlobalFontFamily = (fontValue?: string) => {
const fontFamily = resolveFontFamily(fontValue) const fontFamily = resolveStoredFontFamily(fontValue)
document.documentElement.style.setProperty("--ss-font-family", fontFamily) document.documentElement.style.setProperty("--ss-font-family", fontFamily)
document.body.style.fontFamily = fontFamily document.body.style.fontFamily = fontFamily
} }
+7 -2
View File
@@ -32,6 +32,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
() => [ () => [
{ value: "add_score", label: t("autoScore.actionAddScore") }, { value: "add_score", label: t("autoScore.actionAddScore") },
{ value: "add_tag", label: t("autoScore.actionAddTag") }, { value: "add_tag", label: t("autoScore.actionAddTag") },
{ value: "settle_score", label: t("autoScore.actionSettleScore") },
], ],
[t] [t]
) )
@@ -86,7 +87,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
onChange={(event: ActionEvent) => onChange={(event: ActionEvent) =>
updateAction(action.id, { updateAction(action.id, {
event, event,
value: event === "add_score" ? "1" : [], value: event === "add_score" ? "1" : event === "add_tag" ? [] : "",
}) })
} }
/> />
@@ -100,7 +101,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
updateAction(action.id, { value: nextValue === null ? "" : String(nextValue) }) updateAction(action.id, { value: nextValue === null ? "" : String(nextValue) })
} }
/> />
) : ( ) : action.event === "add_tag" ? (
<Select <Select
mode="multiple" mode="multiple"
showSearch showSearch
@@ -114,6 +115,10 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
options={mergedTagOptions} options={mergedTagOptions}
onChange={(nextValue) => updateAction(action.id, { value: nextValue })} onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
/> />
) : (
<div style={{ minWidth: 260, color: "var(--ss-text-secondary)" }}>
{t("autoScore.actionSettleScoreHint")}
</div>
)} )}
<Button <Button
danger danger
+40 -5
View File
@@ -24,6 +24,27 @@ export interface AutoScoreAction {
value?: string | null value?: string | null
} }
export interface AutoScoreExecutionConfig {
cooldownMinutes?: number | null
maxRunsPerDay?: number | null
maxScoreDeltaPerDay?: number | null
}
export interface AutoScoreExecutionBatch {
id: string
ruleId: number
ruleName: string
runAt: string
affectedStudents: number
affectedStudentNames: string[]
createdEventIds: number[]
addedStudentTagIds: number[]
scoreDeltaTotal: number
settled: boolean
rolledBack: boolean
rollbackAt?: string | null
}
export interface AutoScoreRule { export interface AutoScoreRule {
id: number id: number
name: string name: string
@@ -31,10 +52,11 @@ export interface AutoScoreRule {
studentNames: string[] studentNames: string[]
triggers: AutoScoreTrigger[] triggers: AutoScoreTrigger[]
actions: AutoScoreAction[] actions: AutoScoreAction[]
execution?: AutoScoreExecutionConfig
lastExecuted?: string | null lastExecuted?: string | null
} }
export type ActionEvent = "add_score" | "add_tag" export type ActionEvent = "add_score" | "add_tag" | "settle_score"
export interface AutoScoreTagOption { export interface AutoScoreTagOption {
label: string label: string
@@ -379,7 +401,7 @@ export const createDefaultActionDraft = (): ActionDraft => ({
}) })
const isActionEvent = (value: unknown): value is ActionEvent => const isActionEvent = (value: unknown): value is ActionEvent =>
value === "add_score" || value === "add_tag" value === "add_score" || value === "add_tag" || value === "settle_score"
export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined): ActionDraft[] => { export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined): ActionDraft[] => {
if (!Array.isArray(drafts) || drafts.length === 0) { if (!Array.isArray(drafts) || drafts.length === 0) {
@@ -396,7 +418,9 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
value: value:
draft.event === "add_tag" draft.event === "add_tag"
? parseTagValues(draft.value) ? parseTagValues(draft.value)
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value), : draft.event === "settle_score"
? ""
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
} satisfies ActionDraft } satisfies ActionDraft
}) })
.filter((item): item is ActionDraft => Boolean(item)) .filter((item): item is ActionDraft => Boolean(item))
@@ -407,12 +431,18 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => { export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
const mapped = actions const mapped = actions
.map((action) => { .map((action) => {
if (action.event !== "add_score" && action.event !== "add_tag") return null if (action.event !== "add_score" && action.event !== "add_tag" && action.event !== "settle_score") {
return null
}
return { return {
id: QbUtils.uuid(), id: QbUtils.uuid(),
event: action.event, event: action.event,
value: value:
action.event === "add_tag" ? parseTagValues(action.value) : toStringValue(action.value), action.event === "add_tag"
? parseTagValues(action.value)
: action.event === "settle_score"
? ""
: toStringValue(action.value),
} satisfies ActionDraft } satisfies ActionDraft
}) })
.filter((item): item is ActionDraft => Boolean(item)) .filter((item): item is ActionDraft => Boolean(item))
@@ -439,6 +469,11 @@ export const actionDraftsToPayload = (
continue continue
} }
if (draft.event === "settle_score") {
actions.push({ event: draft.event })
continue
}
const serializedTagValue = stringifyTagValues(parseTagValues(draft.value)) const serializedTagValue = stringifyTagValues(parseTagValues(draft.value))
if (!serializedTagValue) { if (!serializedTagValue) {
return { actions: [], error: "tag_required" } return { actions: [], error: "tag_required" }
+143 -2
View File
@@ -5,6 +5,8 @@ import {
Card, Card,
Form, Form,
Input, Input,
InputNumber,
Modal,
Pagination, Pagination,
Popconfirm, Popconfirm,
Select, Select,
@@ -12,6 +14,7 @@ import {
Switch, Switch,
Table, Table,
Tag, Tag,
Tooltip,
message, message,
} from "antd" } from "antd"
import { type ImmutableTree } from "@react-awesome-query-builder/antd" import { type ImmutableTree } from "@react-awesome-query-builder/antd"
@@ -32,6 +35,8 @@ import {
queryTreeToTriggers, queryTreeToTriggers,
triggersToQueryTree, triggersToQueryTree,
type ActionDraft, type ActionDraft,
type AutoScoreExecutionBatch,
type AutoScoreExecutionConfig,
type AutoScoreRule, type AutoScoreRule,
type AutoScoreTagOption, type AutoScoreTagOption,
} from "./AutoScore/AutoScoreUtils" } from "./AutoScore/AutoScoreUtils"
@@ -49,6 +54,7 @@ interface TagItem {
interface RuleFormValues { interface RuleFormValues {
name?: string name?: string
studentNames?: string[] studentNames?: string[]
execution?: AutoScoreExecutionConfig
} }
interface AutoScoreManagerProps { interface AutoScoreManagerProps {
@@ -82,8 +88,10 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
const [actionDrafts, setActionDrafts] = useState<ActionDraft[]>([createDefaultActionDraft()]) const [actionDrafts, setActionDrafts] = useState<ActionDraft[]>([createDefaultActionDraft()])
const [students, setStudents] = useState<StudentItem[]>([]) const [students, setStudents] = useState<StudentItem[]>([])
const [rules, setRules] = useState<AutoScoreRule[]>([]) const [rules, setRules] = useState<AutoScoreRule[]>([])
const [batches, setBatches] = useState<AutoScoreExecutionBatch[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [rollingBackBatchId, setRollingBackBatchId] = useState<string | null>(null)
const [editingRuleId, setEditingRuleId] = useState<number | null>(null) const [editingRuleId, setEditingRuleId] = useState<number | null>(null)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
@@ -101,7 +109,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
const resetEditor = () => { const resetEditor = () => {
setEditingRuleId(null) setEditingRuleId(null)
form.setFieldsValue({ name: "", studentNames: [] }) form.setFieldsValue({ name: "", studentNames: [], execution: {} })
setTriggerTree(createEmptyTriggerTree(triggerConfig)) setTriggerTree(createEmptyTriggerTree(triggerConfig))
setActionDrafts([createDefaultActionDraft()]) setActionDrafts([createDefaultActionDraft()])
} }
@@ -156,11 +164,25 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
} }
} }
const fetchBatches = async () => {
const api = (window as any).api
if (!api || !canEdit) return
try {
const res = await api.autoScoreQueryBatches()
if (res.success && Array.isArray(res.data)) {
setBatches(res.data)
}
} catch {
void 0
}
}
useEffect(() => { useEffect(() => {
if (!canEdit) return if (!canEdit) return
fetchTags().catch(() => void 0) fetchTags().catch(() => void 0)
fetchStudents().catch(() => void 0) fetchStudents().catch(() => void 0)
fetchRules().catch(() => void 0) fetchRules().catch(() => void 0)
fetchBatches().catch(() => void 0)
}, [canEdit]) }, [canEdit])
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -174,6 +196,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
const values = await form.validateFields() const values = await form.validateFields()
const name = String(values.name || "").trim() const name = String(values.name || "").trim()
const studentNames = Array.isArray(values.studentNames) ? values.studentNames : [] const studentNames = Array.isArray(values.studentNames) ? values.studentNames : []
const execution = values.execution || {}
if (!name) { if (!name) {
messageApi.warning(t("autoScore.nameRequired")) messageApi.warning(t("autoScore.nameRequired"))
@@ -221,6 +244,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
studentNames, studentNames,
triggers, triggers,
actions: actionPayload.actions, actions: actionPayload.actions,
execution,
} }
const res = const res =
editingRuleId === null editingRuleId === null
@@ -233,6 +257,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
) )
resetEditor() resetEditor()
await fetchRules() await fetchRules()
await fetchBatches()
emitDataUpdated() emitDataUpdated()
} else { } else {
messageApi.error( messageApi.error(
@@ -254,6 +279,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
form.setFieldsValue({ form.setFieldsValue({
name: rule.name, name: rule.name,
studentNames: rule.studentNames || [], studentNames: rule.studentNames || [],
execution: rule.execution || {},
}) })
setTriggerTree(triggersToQueryTree(triggerConfig, rule.triggers || [])) setTriggerTree(triggersToQueryTree(triggerConfig, rule.triggers || []))
setActionDrafts(actionsToDrafts(rule.actions || [])) setActionDrafts(actionsToDrafts(rule.actions || []))
@@ -275,6 +301,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
resetEditor() resetEditor()
} }
await fetchRules() await fetchRules()
await fetchBatches()
emitDataUpdated() emitDataUpdated()
} else { } else {
messageApi.error(res.message || t("autoScore.deleteFailed")) messageApi.error(res.message || t("autoScore.deleteFailed"))
@@ -316,6 +343,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
studentNames: rule.studentNames, studentNames: rule.studentNames,
triggers: rule.triggers, triggers: rule.triggers,
actions: rule.actions, actions: rule.actions,
execution: rule.execution || {},
lastExecuted: rule.lastExecuted ?? null, lastExecuted: rule.lastExecuted ?? null,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
} }
@@ -342,6 +370,31 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
return date.toLocaleString() return date.toLocaleString()
} }
const handleRollbackBatch = async (batchId: string) => {
const api = (window as any).api
if (!api || !canEdit) return
Modal.confirm({
title: t("autoScore.batchRollbackConfirm"),
onOk: async () => {
setRollingBackBatchId(batchId)
try {
const res = await api.autoScoreRollbackBatch({ batchId })
if (res.success) {
messageApi.success(t("autoScore.batchRollbackSuccess"))
await fetchBatches()
emitDataUpdated()
} else {
messageApi.error(res.message || t("autoScore.batchRollbackFailed"))
}
} catch {
messageApi.error(t("autoScore.batchRollbackFailed"))
} finally {
setRollingBackBatchId(null)
}
}
})
}
const columns: ColumnsType<AutoScoreRule> = [ const columns: ColumnsType<AutoScoreRule> = [
{ {
title: t("autoScore.name"), title: t("autoScore.name"),
@@ -370,7 +423,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
ellipsis: true, ellipsis: true,
render: (_, row) => render: (_, row) =>
row.studentNames.length > 0 ? ( row.studentNames.length > 0 ? (
<Tag>{t("autoScore.studentCount", { count: row.studentNames.length })}</Tag> <Tooltip title={row.studentNames.join("、")}>
<Tag>{t("autoScore.studentCount", { count: row.studentNames.length })}</Tag>
</Tooltip>
) : ( ) : (
<Tag>{t("autoScore.allStudents")}</Tag> <Tag>{t("autoScore.allStudents")}</Tag>
), ),
@@ -422,6 +477,63 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
}, },
] ]
const batchColumns: ColumnsType<AutoScoreExecutionBatch> = [
{
title: t("autoScore.batchId"),
dataIndex: "id",
key: "id",
ellipsis: true,
width: 220,
render: (value: string) => value.slice(0, 8),
},
{
title: t("autoScore.name"),
dataIndex: "ruleName",
key: "ruleName",
ellipsis: true,
},
{
title: t("autoScore.lastExecuted"),
dataIndex: "runAt",
key: "runAt",
width: 180,
render: (value: string) => formatLastExecuted(value),
},
{
title: t("autoScore.applicableStudents"),
dataIndex: "affectedStudents",
key: "affectedStudents",
width: 100,
render: (value: number) => value,
},
{
title: t("autoScore.actionAddScore"),
dataIndex: "scoreDeltaTotal",
key: "scoreDeltaTotal",
width: 120,
render: (value: number) => value,
},
{
title: t("common.operation"),
key: "operation",
width: 180,
render: (_, row) =>
row.rolledBack ? (
<Tag>{t("autoScore.batchRolledBack")}</Tag>
) : (
<Button
danger
size="small"
loading={rollingBackBatchId === row.id}
disabled={row.settled || !canEdit}
onClick={() => handleRollbackBatch(row.id).catch(() => void 0)}
>
{t("autoScore.batchRollback")}
</Button>
),
},
]
const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize) const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)
return ( return (
@@ -461,6 +573,21 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
/> />
</Form.Item> </Form.Item>
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "12px" }}>
<Form.Item label={t("autoScore.cooldownMinutes")} name={["execution", "cooldownMinutes"]}>
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
</Form.Item>
<Form.Item label={t("autoScore.maxRunsPerDay")} name={["execution", "maxRunsPerDay"]}>
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
</Form.Item>
<Form.Item
label={t("autoScore.maxScoreDeltaPerDay")}
name={["execution", "maxScoreDeltaPerDay"]}
>
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
</Form.Item>
</div>
</Form> </Form>
</Card> </Card>
@@ -517,6 +644,20 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
/> />
</div> </div>
</Card> </Card>
<Card
title={t("autoScore.batchLogs")}
style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}
>
<Table
rowKey="id"
columns={batchColumns}
dataSource={batches.slice(0, 50)}
pagination={false}
tableLayout="fixed"
scroll={{ x: 860 }}
/>
</Card>
</div> </div>
) )
} }
+40 -40
View File
@@ -18,8 +18,14 @@ import {
import { ThemeQuickSettings } from "./ThemeQuickSettings" import { ThemeQuickSettings } from "./ThemeQuickSettings"
import { OAuthLogin } from "./OAuth/OAuthLogin" import { OAuthLogin } from "./OAuth/OAuthLogin"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { pinyin } from "pinyin-pro"
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n" import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
import { useResponsive } from "../hooks/useResponsive" import { useResponsive } from "../hooks/useResponsive"
import {
buildSystemFontFamily,
buildSystemFontValue,
SYSTEM_FONT_STACK,
} from "../shared/fontFamily"
const { Text, Paragraph } = Typography const { Text, Paragraph } = Typography
@@ -37,46 +43,33 @@ interface FontOption {
value: string value: string
label: string label: string
fontFamily: string fontFamily: string
searchText: string
} }
const SYSTEM_FONT_STACK = const CHINESE_FONT_CHAR_PATTERN = /[\u3400-\u9fff]/
'"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_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[] = [ const defaultFontOptions: FontOption[] = [
{ {
value: "system", value: "system",
label: "系统默认", label: "系统默认",
fontFamily: SYSTEM_FONT_STACK, fontFamily: SYSTEM_FONT_STACK,
}, searchText: "系统默认 system default",
{
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",
}, },
] ]
@@ -272,13 +265,17 @@ export const Settings: React.FC<{
const remoteOptions = res.data const remoteOptions = res.data
.map((name: string) => String(name || "").trim()) .map((name: string) => String(name || "").trim())
.filter((name: string) => Boolean(name)) .filter((name: string) => Boolean(name))
.map( .map((name: string) => {
(name: string) => const label = formatFontDisplayName(name)
({ return {
value: `system-${name}`, value: buildSystemFontValue(name),
label: name, label,
fontFamily: `"${name}", ${SYSTEM_FONT_STACK}`, fontFamily: buildSystemFontFamily(name),
}) satisfies FontOption 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]) const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions])
@@ -829,10 +826,13 @@ export const Settings: React.FC<{
options={fontOptions.map((opt) => ({ options={fontOptions.map((opt) => ({
value: opt.value, value: opt.value,
label: opt.label, label: opt.label,
searchText: opt.searchText,
}))} }))}
showSearch showSearch
filterOption={(input, option) => filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase()) String(option?.searchText ?? option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
} }
/> />
<div <div
+22 -3
View File
@@ -358,7 +358,7 @@
"leaderboard": "Leaderboard", "leaderboard": "Leaderboard",
"settlements": "Settlements", "settlements": "Settlements",
"reasons": "Reasons", "reasons": "Reasons",
"autoScore": "Auto Score", "autoScore": "Automation",
"rewardExchange": "Reward Exchange", "rewardExchange": "Reward Exchange",
"rewardSettings": "Reward Settings", "rewardSettings": "Reward Settings",
"settings": "Settings", "settings": "Settings",
@@ -758,7 +758,7 @@
"name": "Name" "name": "Name"
}, },
"autoScore": { "autoScore": {
"title": "Auto Score Management", "title": "Automation Management",
"name": "Automation Name", "name": "Automation Name",
"namePlaceholder": "e.g. Daily Check-in Points", "namePlaceholder": "e.g. Daily Check-in Points",
"nameRequired": "Please enter automation name", "nameRequired": "Please enter automation name",
@@ -814,10 +814,29 @@
"tagNamesPlaceholder": "e.g. excellent_student,class_monitor", "tagNamesPlaceholder": "e.g. excellent_student,class_monitor",
"triggerIntervalTime": "Interval Time", "triggerIntervalTime": "Interval Time",
"triggerStudentTag": "Student Tag", "triggerStudentTag": "Student Tag",
"triggerStudentSql": "Student SQL", "triggerStudentSql": "Custom SQL Condition",
"triggerStudentSqlPlaceholder": "Enter student SQL or a WHERE condition", "triggerStudentSqlPlaceholder": "Enter student SQL or a WHERE condition",
"actionAddScore": "Add Score", "actionAddScore": "Add Score",
"actionAddTag": "Add Tag", "actionAddTag": "Add Tag",
"actionSettleScore": "Settle Score",
"actionSettleScoreHint": "No parameters required",
"cooldownMinutes": "Cooldown (minutes)",
"maxStudentsPerRun": "Max Students Per Run",
"maxRunsPerDay": "Max Runs Per Day",
"maxScoreDeltaPerDay": "Max Score Delta Per Day",
"filterGroups": "Filter by Group",
"filterGrades": "Filter by Grade",
"filterMinScore": "Min Current Score",
"filterMaxScore": "Max Current Score",
"filterRecentEventDays": "Recent Event Days",
"filterMinRecentEventCount": "Min Recent Event Count",
"batchLogs": "Execution Logs",
"batchId": "Batch",
"batchRollback": "Rollback",
"batchRolledBack": "Rolled Back",
"batchRollbackConfirm": "Confirm rollback this execution batch?",
"batchRollbackSuccess": "Batch rollback succeeded",
"batchRollbackFailed": "Batch rollback failed",
"intervalAmountPlaceholder": "Enter interval time", "intervalAmountPlaceholder": "Enter interval time",
"intervalUnitMonth": "month(s) later", "intervalUnitMonth": "month(s) later",
"intervalUnitDay": "day(s) later", "intervalUnitDay": "day(s) later",
+26 -7
View File
@@ -358,7 +358,7 @@
"leaderboard": "排行榜", "leaderboard": "排行榜",
"settlements": "结算历史", "settlements": "结算历史",
"reasons": "理由管理", "reasons": "理由管理",
"autoScore": "自动加分", "autoScore": "自动",
"rewardExchange": "奖励兑换", "rewardExchange": "奖励兑换",
"rewardSettings": "奖励设置", "rewardSettings": "奖励设置",
"settings": "系统设置", "settings": "系统设置",
@@ -758,7 +758,7 @@
"name": "姓名" "name": "姓名"
}, },
"autoScore": { "autoScore": {
"title": "自动化加分管理", "title": "自动化管理",
"name": "自动化名称", "name": "自动化名称",
"namePlaceholder": "例如:每日签到加分", "namePlaceholder": "例如:每日签到加分",
"nameRequired": "请输入自动化名称", "nameRequired": "请输入自动化名称",
@@ -812,14 +812,33 @@
"operationNoteLabel": "操作说明(可选)", "operationNoteLabel": "操作说明(可选)",
"triggerIntervalTime": "间隔时间", "triggerIntervalTime": "间隔时间",
"triggerStudentTag": "学生标签", "triggerStudentTag": "学生标签",
"triggerStudentSql": "学生 SQL 条件", "triggerStudentSql": "自定义 SQL 条件",
"triggerStudentSqlPlaceholder": "输入筛选学生的 SQL 或 WHERE 条件", "triggerStudentSqlPlaceholder": "输入筛选学生的 SQL 或 WHERE 条件",
"actionAddScore": "加分", "actionAddScore": "加分",
"actionAddTag": "添加标签", "actionAddTag": "添加标签",
"intervalAmountPlaceholder": "请输入间隔时间数值", "actionSettleScore": "结算分数",
"intervalUnitMonth": "个月后", "actionSettleScoreHint": "无需参数",
"intervalUnitDay": "天后", "cooldownMinutes": "冷却时间(分钟)",
"intervalUnitMinute": "分钟后", "maxStudentsPerRun": "单次最多学生数",
"maxRunsPerDay": "每日最多执行次数",
"maxScoreDeltaPerDay": "每日最多积分变动",
"filterGroups": "按分组筛选",
"filterGrades": "按年级筛选",
"filterMinScore": "最低当前积分",
"filterMaxScore": "最高当前积分",
"filterRecentEventDays": "最近事件天数",
"filterMinRecentEventCount": "最近事件最少次数",
"batchLogs": "执行日志",
"batchId": "批次",
"batchRollback": "回滚",
"batchRolledBack": "已回滚",
"batchRollbackConfirm": "确认回滚这个执行批次?",
"batchRollbackSuccess": "批次回滚成功",
"batchRollbackFailed": "批次回滚失败",
"intervalAmountPlaceholder": "请输入间隔时间",
"intervalUnitMonth": "个月",
"intervalUnitDay": "天",
"intervalUnitMinute": "分 钟",
"minutesPlaceholder": "请输入分钟数", "minutesPlaceholder": "请输入分钟数",
"minutes": "分钟", "minutes": "分钟",
"tagsPlaceholder": "请输入标签名称", "tagsPlaceholder": "请输入标签名称",
+35
View File
@@ -32,6 +32,27 @@ export interface autoScoreAction {
value?: string | null value?: string | null
} }
export interface autoScoreExecutionConfig {
cooldownMinutes?: number | null
maxRunsPerDay?: number | null
maxScoreDeltaPerDay?: number | null
}
export interface autoScoreExecutionBatch {
id: string
ruleId: number
ruleName: string
runAt: string
affectedStudents: number
affectedStudentNames: string[]
createdEventIds: number[]
addedStudentTagIds: number[]
scoreDeltaTotal: number
settled: boolean
rolledBack: boolean
rollbackAt?: string | null
}
export interface autoScoreRule { export interface autoScoreRule {
id: number id: number
name: string name: string
@@ -39,6 +60,7 @@ export interface autoScoreRule {
studentNames: string[] studentNames: string[]
triggers: autoScoreTrigger[] triggers: autoScoreTrigger[]
actions: autoScoreAction[] actions: autoScoreAction[]
execution?: autoScoreExecutionConfig
lastExecuted?: string | null lastExecuted?: string | null
} }
@@ -52,6 +74,7 @@ export type settingsKey =
| "themes_custom" | "themes_custom"
| "auto_score_enabled" | "auto_score_enabled"
| "auto_score_rules" | "auto_score_rules"
| "auto_score_batches"
| "current_theme_id" | "current_theme_id"
| "dashboards_config" | "dashboards_config"
| "pg_connection_string" | "pg_connection_string"
@@ -68,6 +91,7 @@ export interface settingsSpec {
themes_custom: themeConfig[] themes_custom: themeConfig[]
auto_score_enabled: boolean auto_score_enabled: boolean
auto_score_rules: autoScoreRule[] auto_score_rules: autoScoreRule[]
auto_score_batches: autoScoreExecutionBatch[]
current_theme_id: string current_theme_id: string
dashboards_config: any[] dashboards_config: any[]
pg_connection_string: string pg_connection_string: string
@@ -275,6 +299,7 @@ const api = {
studentNames: string[] studentNames: string[]
triggers: autoScoreTrigger[] triggers: autoScoreTrigger[]
actions: autoScoreAction[] actions: autoScoreAction[]
execution?: autoScoreExecutionConfig
}): Promise<{ success: boolean; data?: number; message?: string }> => }): Promise<{ success: boolean; data?: number; message?: string }> =>
invoke("auto_score_add_rule", { rule }), invoke("auto_score_add_rule", { rule }),
autoScoreUpdateRule: (rule: { autoScoreUpdateRule: (rule: {
@@ -284,6 +309,7 @@ const api = {
studentNames: string[] studentNames: string[]
triggers: autoScoreTrigger[] triggers: autoScoreTrigger[]
actions: autoScoreAction[] actions: autoScoreAction[]
execution?: autoScoreExecutionConfig
}): Promise<{ success: boolean; data?: boolean; message?: string }> => }): Promise<{ success: boolean; data?: boolean; message?: string }> =>
invoke("auto_score_update_rule", { rule }), invoke("auto_score_update_rule", { rule }),
autoScoreDeleteRule: ( autoScoreDeleteRule: (
@@ -306,6 +332,15 @@ const api = {
ruleIds: number[] ruleIds: number[]
): Promise<{ success: boolean; data?: boolean; message?: string }> => ): Promise<{ success: boolean; data?: boolean; message?: string }> =>
invoke("auto_score_sort_rules", { ruleIds }), 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 // Settings & Sync
getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> => getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> =>
+30
View File
@@ -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
}