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",
|
"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"
|
||||||
|
|||||||
@@ -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.
@@ -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(), ¶ms.batch_id).await?;
|
||||||
|
Ok(IpcResponse::success(batch))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "请输入标签名称",
|
||||||
|
|||||||
@@ -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 }> =>
|
||||||
|
|||||||
@@ -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