mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat: 自动化更新
This commit is contained in:
Generated
+12
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
pub actions: Vec<AutoScoreAction>,
|
||||
#[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<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
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)]
|
||||
@@ -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<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(), ¶ms.batch_id).await?;
|
||||
Ok(IpcResponse::success(batch))
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
value
|
||||
.trim()
|
||||
@@ -188,33 +179,20 @@ fn normalize_font_name(value: &str) -> String {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn query_system_fonts() -> Vec<String> {
|
||||
let output = Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts",
|
||||
])
|
||||
.output();
|
||||
|
||||
let Ok(output) = output 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_") {
|
||||
continue;
|
||||
}
|
||||
let Some((left, _)) = line.split_once("REG_") else {
|
||||
continue;
|
||||
|
||||
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();
|
||||
};
|
||||
let name = normalize_font_name(left);
|
||||
if !name.is_empty() {
|
||||
families.insert(name);
|
||||
|
||||
for (value_name, _) in key.enum_values().flatten() {
|
||||
let name = normalize_font_name(&value_name);
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
families.insert(name);
|
||||
}
|
||||
|
||||
families.into_iter().collect()
|
||||
@@ -276,19 +254,5 @@ fn query_system_fonts() -> Vec<String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn settings_get_system_fonts() -> Result<IpcResponse<Vec<String>>, 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()))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,42 @@ pub struct AutoScoreAction {
|
||||
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)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum IntervalUnit {
|
||||
@@ -54,6 +90,10 @@ pub struct AutoScoreRule {
|
||||
pub student_names: Vec<String>,
|
||||
pub triggers: Vec<AutoScoreTrigger>,
|
||||
pub actions: Vec<AutoScoreAction>,
|
||||
#[serde(default)]
|
||||
pub execution: AutoScoreExecutionConfig,
|
||||
#[serde(default)]
|
||||
pub filters: AutoScoreFilterConfig,
|
||||
#[serde(rename = "lastExecuted")]
|
||||
pub last_executed: Option<String>,
|
||||
}
|
||||
@@ -62,6 +102,7 @@ pub struct AutoScoreRule {
|
||||
enum PlannedAction {
|
||||
AddScore(i32),
|
||||
AddTags(Vec<String>),
|
||||
SettleScore,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
@@ -70,11 +111,16 @@ struct StudentRefs {
|
||||
names: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct RuleExecutionStats {
|
||||
affected_students: usize,
|
||||
affected_student_names: Vec<String>,
|
||||
created_events: usize,
|
||||
added_tags: usize,
|
||||
score_delta_total: i64,
|
||||
created_event_ids: Vec<i32>,
|
||||
added_student_tag_ids: Vec<i32>,
|
||||
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::<SafeAppState>().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<AutoScoreRule, String> {
|
||||
.into_iter()
|
||||
.map(normalize_action)
|
||||
.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);
|
||||
|
||||
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> {
|
||||
let event = normalize_required_string(trigger.event, "trigger event")?;
|
||||
|
||||
@@ -483,6 +574,7 @@ fn normalize_action(action: AutoScoreAction) -> Result<AutoScoreAction, String>
|
||||
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<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(
|
||||
conn: &DatabaseConnection,
|
||||
rule: &AutoScoreRule,
|
||||
execution_batches: &[AutoScoreExecutionBatch],
|
||||
) -> Result<RuleExecutionStats, String> {
|
||||
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,16 +1124,27 @@ 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)
|
||||
}
|
||||
|
||||
@@ -901,12 +1168,139 @@ fn plan_actions(actions: &[AutoScoreAction]) -> Result<Vec<PlannedAction>, Strin
|
||||
}
|
||||
planned.push(PlannedAction::AddTags(tags));
|
||||
}
|
||||
"settle_score" => {
|
||||
planned.push(PlannedAction::SettleScore);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -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<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(
|
||||
conn: &DatabaseConnection,
|
||||
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> {
|
||||
row.try_get::<String>("", 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<bool, String> {
|
||||
) -> Result<Option<i32>, 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+2
-13
@@ -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
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
() => [
|
||||
{ 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<ActionEditorProps> = ({
|
||||
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<ActionEditorProps> = ({
|
||||
updateAction(action.id, { value: nextValue === null ? "" : String(nextValue) })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
) : action.event === "add_tag" ? (
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
@@ -114,6 +115,10 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
options={mergedTagOptions}
|
||||
onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ minWidth: 260, color: "var(--ss-text-secondary)" }}>
|
||||
{t("autoScore.actionSettleScoreHint")}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
danger
|
||||
|
||||
@@ -24,6 +24,27 @@ export interface AutoScoreAction {
|
||||
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 {
|
||||
id: number
|
||||
name: string
|
||||
@@ -31,10 +52,11 @@ export interface AutoScoreRule {
|
||||
studentNames: string[]
|
||||
triggers: AutoScoreTrigger[]
|
||||
actions: AutoScoreAction[]
|
||||
execution?: AutoScoreExecutionConfig
|
||||
lastExecuted?: string | null
|
||||
}
|
||||
|
||||
export type ActionEvent = "add_score" | "add_tag"
|
||||
export type ActionEvent = "add_score" | "add_tag" | "settle_score"
|
||||
|
||||
export interface AutoScoreTagOption {
|
||||
label: string
|
||||
@@ -379,7 +401,7 @@ export const createDefaultActionDraft = (): ActionDraft => ({
|
||||
})
|
||||
|
||||
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[] => {
|
||||
if (!Array.isArray(drafts) || drafts.length === 0) {
|
||||
@@ -396,6 +418,8 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
|
||||
value:
|
||||
draft.event === "add_tag"
|
||||
? parseTagValues(draft.value)
|
||||
: draft.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
||||
} satisfies ActionDraft
|
||||
})
|
||||
@@ -407,12 +431,18 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
|
||||
export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
|
||||
const mapped = actions
|
||||
.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 {
|
||||
id: QbUtils.uuid(),
|
||||
event: action.event,
|
||||
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
|
||||
})
|
||||
.filter((item): item is ActionDraft => Boolean(item))
|
||||
@@ -439,6 +469,11 @@ export const actionDraftsToPayload = (
|
||||
continue
|
||||
}
|
||||
|
||||
if (draft.event === "settle_score") {
|
||||
actions.push({ event: draft.event })
|
||||
continue
|
||||
}
|
||||
|
||||
const serializedTagValue = stringifyTagValues(parseTagValues(draft.value))
|
||||
if (!serializedTagValue) {
|
||||
return { actions: [], error: "tag_required" }
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Select,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
message,
|
||||
} from "antd"
|
||||
import { type ImmutableTree } from "@react-awesome-query-builder/antd"
|
||||
@@ -32,6 +35,8 @@ import {
|
||||
queryTreeToTriggers,
|
||||
triggersToQueryTree,
|
||||
type ActionDraft,
|
||||
type AutoScoreExecutionBatch,
|
||||
type AutoScoreExecutionConfig,
|
||||
type AutoScoreRule,
|
||||
type AutoScoreTagOption,
|
||||
} from "./AutoScore/AutoScoreUtils"
|
||||
@@ -49,6 +54,7 @@ interface TagItem {
|
||||
interface RuleFormValues {
|
||||
name?: string
|
||||
studentNames?: string[]
|
||||
execution?: AutoScoreExecutionConfig
|
||||
}
|
||||
|
||||
interface AutoScoreManagerProps {
|
||||
@@ -82,8 +88,10 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
const [actionDrafts, setActionDrafts] = useState<ActionDraft[]>([createDefaultActionDraft()])
|
||||
const [students, setStudents] = useState<StudentItem[]>([])
|
||||
const [rules, setRules] = useState<AutoScoreRule[]>([])
|
||||
const [batches, setBatches] = useState<AutoScoreExecutionBatch[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [rollingBackBatchId, setRollingBackBatchId] = useState<string | null>(null)
|
||||
const [editingRuleId, setEditingRuleId] = useState<number | null>(null)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
@@ -101,7 +109,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
|
||||
const resetEditor = () => {
|
||||
setEditingRuleId(null)
|
||||
form.setFieldsValue({ name: "", studentNames: [] })
|
||||
form.setFieldsValue({ name: "", studentNames: [], execution: {} })
|
||||
setTriggerTree(createEmptyTriggerTree(triggerConfig))
|
||||
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(() => {
|
||||
if (!canEdit) return
|
||||
fetchTags().catch(() => void 0)
|
||||
fetchStudents().catch(() => void 0)
|
||||
fetchRules().catch(() => void 0)
|
||||
fetchBatches().catch(() => void 0)
|
||||
}, [canEdit])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -174,6 +196,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
const values = await form.validateFields()
|
||||
const name = String(values.name || "").trim()
|
||||
const studentNames = Array.isArray(values.studentNames) ? values.studentNames : []
|
||||
const execution = values.execution || {}
|
||||
|
||||
if (!name) {
|
||||
messageApi.warning(t("autoScore.nameRequired"))
|
||||
@@ -221,6 +244,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
studentNames,
|
||||
triggers,
|
||||
actions: actionPayload.actions,
|
||||
execution,
|
||||
}
|
||||
const res =
|
||||
editingRuleId === null
|
||||
@@ -233,6 +257,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
)
|
||||
resetEditor()
|
||||
await fetchRules()
|
||||
await fetchBatches()
|
||||
emitDataUpdated()
|
||||
} else {
|
||||
messageApi.error(
|
||||
@@ -254,6 +279,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
form.setFieldsValue({
|
||||
name: rule.name,
|
||||
studentNames: rule.studentNames || [],
|
||||
execution: rule.execution || {},
|
||||
})
|
||||
setTriggerTree(triggersToQueryTree(triggerConfig, rule.triggers || []))
|
||||
setActionDrafts(actionsToDrafts(rule.actions || []))
|
||||
@@ -275,6 +301,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
resetEditor()
|
||||
}
|
||||
await fetchRules()
|
||||
await fetchBatches()
|
||||
emitDataUpdated()
|
||||
} else {
|
||||
messageApi.error(res.message || t("autoScore.deleteFailed"))
|
||||
@@ -316,6 +343,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
studentNames: rule.studentNames,
|
||||
triggers: rule.triggers,
|
||||
actions: rule.actions,
|
||||
execution: rule.execution || {},
|
||||
lastExecuted: rule.lastExecuted ?? null,
|
||||
exportedAt: new Date().toISOString(),
|
||||
}
|
||||
@@ -342,6 +370,31 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
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> = [
|
||||
{
|
||||
title: t("autoScore.name"),
|
||||
@@ -370,7 +423,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
ellipsis: true,
|
||||
render: (_, row) =>
|
||||
row.studentNames.length > 0 ? (
|
||||
<Tooltip title={row.studentNames.join("、")}>
|
||||
<Tag>{t("autoScore.studentCount", { count: row.studentNames.length })}</Tag>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<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)
|
||||
|
||||
return (
|
||||
@@ -461,6 +573,21 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
/>
|
||||
</Form.Item>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -517,6 +644,20 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+40
-40
@@ -18,8 +18,14 @@ import {
|
||||
import { ThemeQuickSettings } from "./ThemeQuickSettings"
|
||||
import { OAuthLogin } from "./OAuth/OAuthLogin"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { pinyin } from "pinyin-pro"
|
||||
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
|
||||
import { useResponsive } from "../hooks/useResponsive"
|
||||
import {
|
||||
buildSystemFontFamily,
|
||||
buildSystemFontValue,
|
||||
SYSTEM_FONT_STACK,
|
||||
} from "../shared/fontFamily"
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
@@ -37,46 +43,33 @@ interface FontOption {
|
||||
value: string
|
||||
label: string
|
||||
fontFamily: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const SYSTEM_FONT_STACK =
|
||||
'"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'
|
||||
const CHINESE_FONT_CHAR_PATTERN = /[\u3400-\u9fff]/
|
||||
const CHINESE_FONT_SEGMENT_PATTERN = /[\u3400-\u9fff]+/g
|
||||
|
||||
const toPascalCasePinyin = (value: string): string =>
|
||||
pinyin(value, { toneType: "none" })
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
|
||||
.join("")
|
||||
|
||||
const formatFontDisplayName = (name: string): string => {
|
||||
if (!CHINESE_FONT_CHAR_PATTERN.test(name)) return name
|
||||
return name.replace(CHINESE_FONT_SEGMENT_PATTERN, (segment) => toPascalCasePinyin(segment))
|
||||
}
|
||||
|
||||
const buildFontSearchText = (name: string, label: string): string =>
|
||||
`${name} ${label}`.toLowerCase()
|
||||
|
||||
const defaultFontOptions: FontOption[] = [
|
||||
{
|
||||
value: "system",
|
||||
label: "系统默认",
|
||||
fontFamily: SYSTEM_FONT_STACK,
|
||||
},
|
||||
{
|
||||
value: "system-Microsoft YaHei UI",
|
||||
label: "Microsoft YaHei UI",
|
||||
fontFamily: '"Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Microsoft YaHei",
|
||||
label: "Microsoft YaHei",
|
||||
fontFamily: '"Microsoft YaHei", "微软雅黑", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-PingFang SC",
|
||||
label: "PingFang SC",
|
||||
fontFamily: '"PingFang SC", "Hiragino Sans GB", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Noto Sans CJK SC",
|
||||
label: "Noto Sans CJK SC",
|
||||
fontFamily: '"Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Segoe UI",
|
||||
label: "Segoe UI",
|
||||
fontFamily: '"Segoe UI", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Arial",
|
||||
label: "Arial",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
searchText: "系统默认 system default",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -272,13 +265,17 @@ export const Settings: React.FC<{
|
||||
const remoteOptions = res.data
|
||||
.map((name: string) => String(name || "").trim())
|
||||
.filter((name: string) => Boolean(name))
|
||||
.map(
|
||||
(name: string) =>
|
||||
({
|
||||
value: `system-${name}`,
|
||||
label: name,
|
||||
fontFamily: `"${name}", ${SYSTEM_FONT_STACK}`,
|
||||
}) satisfies FontOption
|
||||
.map((name: string) => {
|
||||
const label = formatFontDisplayName(name)
|
||||
return {
|
||||
value: buildSystemFontValue(name),
|
||||
label,
|
||||
fontFamily: buildSystemFontFamily(name),
|
||||
searchText: buildFontSearchText(name, label),
|
||||
} satisfies FontOption
|
||||
})
|
||||
.sort((a: FontOption, b: FontOption) =>
|
||||
a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin")
|
||||
)
|
||||
|
||||
const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions])
|
||||
@@ -829,10 +826,13 @@ export const Settings: React.FC<{
|
||||
options={fontOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
searchText: opt.searchText,
|
||||
}))}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
String(option?.searchText ?? option?.label ?? "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"leaderboard": "Leaderboard",
|
||||
"settlements": "Settlements",
|
||||
"reasons": "Reasons",
|
||||
"autoScore": "Auto Score",
|
||||
"autoScore": "Automation",
|
||||
"rewardExchange": "Reward Exchange",
|
||||
"rewardSettings": "Reward Settings",
|
||||
"settings": "Settings",
|
||||
@@ -758,7 +758,7 @@
|
||||
"name": "Name"
|
||||
},
|
||||
"autoScore": {
|
||||
"title": "Auto Score Management",
|
||||
"title": "Automation Management",
|
||||
"name": "Automation Name",
|
||||
"namePlaceholder": "e.g. Daily Check-in Points",
|
||||
"nameRequired": "Please enter automation name",
|
||||
@@ -814,10 +814,29 @@
|
||||
"tagNamesPlaceholder": "e.g. excellent_student,class_monitor",
|
||||
"triggerIntervalTime": "Interval Time",
|
||||
"triggerStudentTag": "Student Tag",
|
||||
"triggerStudentSql": "Student SQL",
|
||||
"triggerStudentSql": "Custom SQL Condition",
|
||||
"triggerStudentSqlPlaceholder": "Enter student SQL or a WHERE condition",
|
||||
"actionAddScore": "Add Score",
|
||||
"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",
|
||||
"intervalUnitMonth": "month(s) later",
|
||||
"intervalUnitDay": "day(s) later",
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"leaderboard": "排行榜",
|
||||
"settlements": "结算历史",
|
||||
"reasons": "理由管理",
|
||||
"autoScore": "自动加分",
|
||||
"autoScore": "自动化",
|
||||
"rewardExchange": "奖励兑换",
|
||||
"rewardSettings": "奖励设置",
|
||||
"settings": "系统设置",
|
||||
@@ -758,7 +758,7 @@
|
||||
"name": "姓名"
|
||||
},
|
||||
"autoScore": {
|
||||
"title": "自动化加分管理",
|
||||
"title": "自动化管理",
|
||||
"name": "自动化名称",
|
||||
"namePlaceholder": "例如:每日签到加分",
|
||||
"nameRequired": "请输入自动化名称",
|
||||
@@ -812,14 +812,33 @@
|
||||
"operationNoteLabel": "操作说明(可选)",
|
||||
"triggerIntervalTime": "间隔时间",
|
||||
"triggerStudentTag": "学生标签",
|
||||
"triggerStudentSql": "学生 SQL 条件",
|
||||
"triggerStudentSql": "自定义 SQL 条件",
|
||||
"triggerStudentSqlPlaceholder": "输入筛选学生的 SQL 或 WHERE 条件",
|
||||
"actionAddScore": "加分",
|
||||
"actionAddTag": "添加标签",
|
||||
"intervalAmountPlaceholder": "请输入间隔时间数值",
|
||||
"intervalUnitMonth": "个月后",
|
||||
"intervalUnitDay": "天后",
|
||||
"intervalUnitMinute": "分钟后",
|
||||
"actionSettleScore": "结算分数",
|
||||
"actionSettleScoreHint": "无需参数",
|
||||
"cooldownMinutes": "冷却时间(分钟)",
|
||||
"maxStudentsPerRun": "单次最多学生数",
|
||||
"maxRunsPerDay": "每日最多执行次数",
|
||||
"maxScoreDeltaPerDay": "每日最多积分变动",
|
||||
"filterGroups": "按分组筛选",
|
||||
"filterGrades": "按年级筛选",
|
||||
"filterMinScore": "最低当前积分",
|
||||
"filterMaxScore": "最高当前积分",
|
||||
"filterRecentEventDays": "最近事件天数",
|
||||
"filterMinRecentEventCount": "最近事件最少次数",
|
||||
"batchLogs": "执行日志",
|
||||
"batchId": "批次",
|
||||
"batchRollback": "回滚",
|
||||
"batchRolledBack": "已回滚",
|
||||
"batchRollbackConfirm": "确认回滚这个执行批次?",
|
||||
"batchRollbackSuccess": "批次回滚成功",
|
||||
"batchRollbackFailed": "批次回滚失败",
|
||||
"intervalAmountPlaceholder": "请输入间隔时间",
|
||||
"intervalUnitMonth": "个月",
|
||||
"intervalUnitDay": "天",
|
||||
"intervalUnitMinute": "分 钟",
|
||||
"minutesPlaceholder": "请输入分钟数",
|
||||
"minutes": "分钟",
|
||||
"tagsPlaceholder": "请输入标签名称",
|
||||
|
||||
@@ -32,6 +32,27 @@ export interface autoScoreAction {
|
||||
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 {
|
||||
id: number
|
||||
name: string
|
||||
@@ -39,6 +60,7 @@ export interface autoScoreRule {
|
||||
studentNames: string[]
|
||||
triggers: autoScoreTrigger[]
|
||||
actions: autoScoreAction[]
|
||||
execution?: autoScoreExecutionConfig
|
||||
lastExecuted?: string | null
|
||||
}
|
||||
|
||||
@@ -52,6 +74,7 @@ export type settingsKey =
|
||||
| "themes_custom"
|
||||
| "auto_score_enabled"
|
||||
| "auto_score_rules"
|
||||
| "auto_score_batches"
|
||||
| "current_theme_id"
|
||||
| "dashboards_config"
|
||||
| "pg_connection_string"
|
||||
@@ -68,6 +91,7 @@ export interface settingsSpec {
|
||||
themes_custom: themeConfig[]
|
||||
auto_score_enabled: boolean
|
||||
auto_score_rules: autoScoreRule[]
|
||||
auto_score_batches: autoScoreExecutionBatch[]
|
||||
current_theme_id: string
|
||||
dashboards_config: any[]
|
||||
pg_connection_string: string
|
||||
@@ -275,6 +299,7 @@ const api = {
|
||||
studentNames: string[]
|
||||
triggers: autoScoreTrigger[]
|
||||
actions: autoScoreAction[]
|
||||
execution?: autoScoreExecutionConfig
|
||||
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||
invoke("auto_score_add_rule", { rule }),
|
||||
autoScoreUpdateRule: (rule: {
|
||||
@@ -284,6 +309,7 @@ const api = {
|
||||
studentNames: string[]
|
||||
triggers: autoScoreTrigger[]
|
||||
actions: autoScoreAction[]
|
||||
execution?: autoScoreExecutionConfig
|
||||
}): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
invoke("auto_score_update_rule", { rule }),
|
||||
autoScoreDeleteRule: (
|
||||
@@ -306,6 +332,15 @@ const api = {
|
||||
ruleIds: number[]
|
||||
): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
invoke("auto_score_sort_rules", { ruleIds }),
|
||||
autoScoreQueryBatches: (): Promise<{
|
||||
success: boolean
|
||||
data?: autoScoreExecutionBatch[]
|
||||
message?: string
|
||||
}> => invoke("auto_score_query_batches"),
|
||||
autoScoreRollbackBatch: (params: {
|
||||
batchId: string
|
||||
}): Promise<{ success: boolean; data?: autoScoreExecutionBatch; message?: string }> =>
|
||||
invoke("auto_score_rollback_batch", { params }),
|
||||
|
||||
// Settings & Sync
|
||||
getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user