mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat: 现在可以在离线期间重新打开后补加分
This commit is contained in:
Binary file not shown.
@@ -5,7 +5,8 @@ use std::sync::Arc;
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
use crate::services::{
|
use crate::services::{
|
||||||
query_execution_batches, rollback_execution_batch, AutoScoreAction, AutoScoreExecutionBatch,
|
apply_offline_backfill, query_execution_batches, rollback_execution_batch, AutoScoreAction,
|
||||||
|
AutoScoreBackfillItem, AutoScoreBackfillResult, AutoScoreExecutionBatch,
|
||||||
AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService,
|
AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService,
|
||||||
AutoScoreTrigger, PermissionLevel, SettingsKey, SettingsValue,
|
AutoScoreTrigger, PermissionLevel, SettingsKey, SettingsValue,
|
||||||
};
|
};
|
||||||
@@ -67,6 +68,11 @@ pub struct ToggleRuleParams {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApplyBackfillParams {
|
||||||
|
pub items: Vec<AutoScoreBackfillItem>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AutoScoreStatus {
|
pub struct AutoScoreStatus {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -396,3 +402,25 @@ pub async fn auto_score_rollback_batch(
|
|||||||
let batch = rollback_execution_batch(state.inner(), ¶ms.batch_id).await?;
|
let batch = rollback_execution_batch(state.inner(), ¶ms.batch_id).await?;
|
||||||
Ok(IpcResponse::success(batch))
|
Ok(IpcResponse::success(batch))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auto_score_apply_backfill(
|
||||||
|
params: ApplyBackfillParams,
|
||||||
|
sender_id: Option<u32>,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<AutoScoreBackfillResult>, 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 result = apply_offline_backfill(state.inner(), ¶ms.items).await?;
|
||||||
|
let rules = state.read().auto_score.read().get_rules().to_vec();
|
||||||
|
emit_auto_score_status_changed(&app_handle, &rules);
|
||||||
|
emit_rules_changed(&app_handle, state.inner());
|
||||||
|
Ok(IpcResponse::success(result))
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ pub fn run() {
|
|||||||
auto_score_sort_rules,
|
auto_score_sort_rules,
|
||||||
auto_score_query_batches,
|
auto_score_query_batches,
|
||||||
auto_score_rollback_batch,
|
auto_score_rollback_batch,
|
||||||
|
auto_score_apply_backfill,
|
||||||
board_get_configs,
|
board_get_configs,
|
||||||
board_save_configs,
|
board_save_configs,
|
||||||
board_query_sql,
|
board_query_sql,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const DEFAULT_INTERVAL_MINUTES: i64 = 30;
|
|||||||
const AUTO_SCORE_TICK_SECONDS: u64 = 15;
|
const AUTO_SCORE_TICK_SECONDS: u64 = 15;
|
||||||
const AUTO_SCORE_SQL_LIMIT: u64 = 5000;
|
const AUTO_SCORE_SQL_LIMIT: u64 = 5000;
|
||||||
const AUTO_SCORE_REASON_PREFIX: &str = "自动化";
|
const AUTO_SCORE_REASON_PREFIX: &str = "自动化";
|
||||||
|
const AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE: i64 = 500;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct AutoScoreTrigger {
|
pub struct AutoScoreTrigger {
|
||||||
@@ -37,6 +38,7 @@ pub struct AutoScoreExecutionConfig {
|
|||||||
pub cooldown_minutes: Option<i64>,
|
pub cooldown_minutes: Option<i64>,
|
||||||
pub max_runs_per_day: Option<i64>,
|
pub max_runs_per_day: Option<i64>,
|
||||||
pub max_score_delta_per_day: Option<i64>,
|
pub max_score_delta_per_day: Option<i64>,
|
||||||
|
pub start_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
@@ -67,6 +69,23 @@ pub struct AutoScoreExecutionBatch {
|
|||||||
pub rollback_at: Option<String>,
|
pub rollback_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AutoScoreBackfillItem {
|
||||||
|
pub rule_id: i32,
|
||||||
|
pub runs: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AutoScoreBackfillResult {
|
||||||
|
pub applied_rules: usize,
|
||||||
|
pub applied_runs: i64,
|
||||||
|
pub affected_students: usize,
|
||||||
|
pub created_events: usize,
|
||||||
|
pub score_delta_total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[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 {
|
||||||
@@ -107,6 +126,12 @@ enum PlannedAction {
|
|||||||
SettleScore,
|
SettleScore,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ExecutionMode {
|
||||||
|
Normal,
|
||||||
|
Backfill,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
struct StudentRefs {
|
struct StudentRefs {
|
||||||
ids: HashSet<i32>,
|
ids: HashSet<i32>,
|
||||||
@@ -347,7 +372,7 @@ impl AutoScoreService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match execute_rule(&conn, rule, &execution_batches).await {
|
match execute_rule(&conn, rule, &execution_batches, ExecutionMode::Normal).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");
|
||||||
@@ -494,6 +519,7 @@ fn normalize_execution_config(
|
|||||||
config.cooldown_minutes = config.cooldown_minutes.filter(|value| *value > 0);
|
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_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);
|
config.max_score_delta_per_day = config.max_score_delta_per_day.filter(|value| *value > 0);
|
||||||
|
config.start_at = normalize_datetime_rfc3339(config.start_at);
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,13 +1011,17 @@ fn normalize_integer_string(value: Option<&str>) -> Option<String> {
|
|||||||
Some(parsed.to_string())
|
Some(parsed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_last_executed(value: Option<String>) -> Option<String> {
|
fn normalize_datetime_rfc3339(value: Option<String>) -> Option<String> {
|
||||||
value
|
value
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|raw_value| DateTime::parse_from_rfc3339(raw_value).ok())
|
.and_then(|raw_value| DateTime::parse_from_rfc3339(raw_value).ok())
|
||||||
.map(|parsed| parsed.with_timezone(&Utc).to_rfc3339())
|
.map(|parsed| parsed.with_timezone(&Utc).to_rfc3339())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_last_executed(value: Option<String>) -> Option<String> {
|
||||||
|
normalize_datetime_rfc3339(value)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_positive_i64(value: &JsonValue) -> Option<i64> {
|
fn parse_positive_i64(value: &JsonValue) -> Option<i64> {
|
||||||
match value {
|
match value {
|
||||||
JsonValue::Number(number) => number.as_i64().filter(|parsed| *parsed > 0),
|
JsonValue::Number(number) => number.as_i64().filter(|parsed| *parsed > 0),
|
||||||
@@ -1144,12 +1174,26 @@ fn get_interval_delay_ms(rule: &AutoScoreRule, value: Option<&str>) -> Option<i6
|
|||||||
unit: IntervalUnit::Minute,
|
unit: IntervalUnit::Minute,
|
||||||
});
|
});
|
||||||
|
|
||||||
let Some(base_time) = rule
|
let last_executed = rule
|
||||||
.last_executed
|
.last_executed
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|raw| DateTime::parse_from_rfc3339(raw).ok())
|
.and_then(|raw| DateTime::parse_from_rfc3339(raw).ok())
|
||||||
.map(|parsed| parsed.with_timezone(&Utc))
|
.map(|parsed| parsed.with_timezone(&Utc));
|
||||||
else {
|
let start_at = rule
|
||||||
|
.execution
|
||||||
|
.start_at
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|raw| DateTime::parse_from_rfc3339(raw).ok())
|
||||||
|
.map(|parsed| parsed.with_timezone(&Utc));
|
||||||
|
|
||||||
|
let base_time = match (last_executed, start_at) {
|
||||||
|
(Some(last), Some(start)) => Some(if last > start { last } else { start }),
|
||||||
|
(Some(last), None) => Some(last),
|
||||||
|
(None, Some(start)) => Some(start),
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(base_time) = base_time else {
|
||||||
return Some(0);
|
return Some(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1419,10 +1463,184 @@ pub async fn rollback_execution_batch(
|
|||||||
Ok(batch)
|
Ok(batch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn interval_base_time(rule: &AutoScoreRule) -> Option<DateTime<Utc>> {
|
||||||
|
let last_executed = rule
|
||||||
|
.last_executed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|raw| DateTime::parse_from_rfc3339(raw).ok())
|
||||||
|
.map(|parsed| parsed.with_timezone(&Utc));
|
||||||
|
let start_at = rule
|
||||||
|
.execution
|
||||||
|
.start_at
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|raw| DateTime::parse_from_rfc3339(raw).ok())
|
||||||
|
.map(|parsed| parsed.with_timezone(&Utc));
|
||||||
|
|
||||||
|
match (last_executed, start_at) {
|
||||||
|
(Some(last), Some(start)) => Some(if last > start { last } else { start }),
|
||||||
|
(Some(last), None) => Some(last),
|
||||||
|
(None, Some(start)) => Some(start),
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interval_value_for_backfill(rule: &AutoScoreRule) -> Option<IntervalTriggerValue> {
|
||||||
|
let value = rule
|
||||||
|
.triggers
|
||||||
|
.iter()
|
||||||
|
.find(|trigger| trigger.event == "interval_time_passed")
|
||||||
|
.and_then(|trigger| trigger.value.as_deref());
|
||||||
|
|
||||||
|
parse_interval_trigger_value(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_rule_backfill_runs(rule: &AutoScoreRule, now: DateTime<Utc>) -> i64 {
|
||||||
|
if !rule.enabled {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(base_time) = interval_base_time(rule) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
if base_time >= now {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(interval) = interval_value_for_backfill(rule) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mut next) = add_interval_to_time(base_time, &interval) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut runs = 0_i64;
|
||||||
|
while next <= now {
|
||||||
|
runs += 1;
|
||||||
|
if runs >= AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(next_value) = add_interval_to_time(next, &interval) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
next = next_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
runs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn apply_offline_backfill(
|
||||||
|
state: &SafeAppState,
|
||||||
|
items: &[AutoScoreBackfillItem],
|
||||||
|
) -> Result<AutoScoreBackfillResult, String> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return Ok(AutoScoreBackfillResult::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut requested_runs_by_rule: HashMap<i32, i64> = HashMap::new();
|
||||||
|
for item in items {
|
||||||
|
if item.runs <= 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let capped = item.runs.min(AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE);
|
||||||
|
if capped <= 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
requested_runs_by_rule
|
||||||
|
.entry(item.rule_id)
|
||||||
|
.and_modify(|value| *value = (*value + capped).min(AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE))
|
||||||
|
.or_insert(capped);
|
||||||
|
}
|
||||||
|
if requested_runs_by_rule.is_empty() {
|
||||||
|
return Ok(AutoScoreBackfillResult::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
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 execution_batches = load_batches_from_settings(state).await?;
|
||||||
|
let rules_snapshot = {
|
||||||
|
let state_guard = state.read();
|
||||||
|
let auto_score = state_guard.auto_score.read();
|
||||||
|
auto_score.get_rules().to_vec()
|
||||||
|
};
|
||||||
|
let mut next_rules = rules_snapshot.clone();
|
||||||
|
let mut result = AutoScoreBackfillResult::default();
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
for rule in next_rules.iter_mut().filter(|rule| rule.enabled) {
|
||||||
|
let Some(requested_runs) = requested_runs_by_rule.get(&rule.id).copied() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if requested_runs <= 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let available_runs = calculate_rule_backfill_runs(rule, now);
|
||||||
|
let replay_runs = requested_runs.min(available_runs);
|
||||||
|
if replay_runs <= 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.applied_rules += 1;
|
||||||
|
changed = true;
|
||||||
|
|
||||||
|
for _ in 0..replay_runs {
|
||||||
|
let stats = execute_rule(&conn, rule, &execution_batches, ExecutionMode::Backfill).await?;
|
||||||
|
result.applied_runs += 1;
|
||||||
|
result.affected_students += stats.affected_students;
|
||||||
|
result.created_events += stats.created_events;
|
||||||
|
result.score_delta_total += stats.score_delta_total;
|
||||||
|
|
||||||
|
if stats.affected_students == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.last_executed = Some(Utc::now().to_rfc3339());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
persist_rules_to_settings(state, &next_rules).await?;
|
||||||
|
save_batches_to_settings(state, &execution_batches).await?;
|
||||||
|
{
|
||||||
|
let state_guard = state.read();
|
||||||
|
let mut auto_score = state_guard.auto_score.write();
|
||||||
|
auto_score.replace_rules(next_rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute_rule(
|
async fn execute_rule(
|
||||||
conn: &DatabaseConnection,
|
conn: &DatabaseConnection,
|
||||||
rule: &AutoScoreRule,
|
rule: &AutoScoreRule,
|
||||||
execution_batches: &[AutoScoreExecutionBatch],
|
execution_batches: &[AutoScoreExecutionBatch],
|
||||||
|
mode: ExecutionMode,
|
||||||
) -> 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?;
|
||||||
|
|
||||||
@@ -1441,13 +1659,15 @@ async fn execute_rule(
|
|||||||
return Ok(RuleExecutionStats::default());
|
return Ok(RuleExecutionStats::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(max_runs) = rule.execution.max_runs_per_day {
|
if mode == ExecutionMode::Normal {
|
||||||
let today_runs = execution_batches
|
if let Some(max_runs) = rule.execution.max_runs_per_day {
|
||||||
.iter()
|
let today_runs = execution_batches
|
||||||
.filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at))
|
.iter()
|
||||||
.count() as i64;
|
.filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at))
|
||||||
if today_runs >= max_runs {
|
.count() as i64;
|
||||||
return Ok(RuleExecutionStats::default());
|
if today_runs >= max_runs {
|
||||||
|
return Ok(RuleExecutionStats::default());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1459,11 +1679,15 @@ async fn execute_rule(
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|action| matches!(action, PlannedAction::SettleScore));
|
.any(|action| matches!(action, PlannedAction::SettleScore));
|
||||||
|
|
||||||
let mut daily_score_delta_used: i64 = execution_batches
|
let mut daily_score_delta_used: i64 = if mode == ExecutionMode::Normal {
|
||||||
.iter()
|
execution_batches
|
||||||
.filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at))
|
.iter()
|
||||||
.map(|batch| batch.score_delta_total.abs())
|
.filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at))
|
||||||
.sum();
|
.map(|batch| batch.score_delta_total.abs())
|
||||||
|
.sum()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
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::default();
|
let mut stats = RuleExecutionStats::default();
|
||||||
@@ -1483,27 +1707,31 @@ async fn execute_rule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for mut student in target_students {
|
for mut student in target_students {
|
||||||
if !is_student_pass_cooldown(
|
if mode == ExecutionMode::Normal {
|
||||||
conn,
|
if !is_student_pass_cooldown(
|
||||||
execution_batches,
|
conn,
|
||||||
rule,
|
execution_batches,
|
||||||
student.id,
|
rule,
|
||||||
student.name.as_str(),
|
student.id,
|
||||||
)
|
student.name.as_str(),
|
||||||
.await?
|
)
|
||||||
{
|
.await?
|
||||||
continue;
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let mut touched = false;
|
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 {
|
if mode == ExecutionMode::Normal {
|
||||||
let next = daily_score_delta_used + (*delta as i64).abs();
|
if let Some(max_delta) = rule.execution.max_score_delta_per_day {
|
||||||
if next > max_delta {
|
let next = daily_score_delta_used + (*delta as i64).abs();
|
||||||
continue;
|
if next > max_delta {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
daily_score_delta_used = next;
|
||||||
}
|
}
|
||||||
daily_score_delta_used = next;
|
|
||||||
}
|
}
|
||||||
let now = now_iso();
|
let now = now_iso();
|
||||||
let val_prev = student.score;
|
let val_prev = student.score;
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ pub mod theme;
|
|||||||
|
|
||||||
pub use auth::AuthService;
|
pub use auth::AuthService;
|
||||||
pub use auto_score::{
|
pub use auto_score::{
|
||||||
query_execution_batches, rollback_execution_batch, AutoScoreAction, AutoScoreExecutionBatch,
|
apply_offline_backfill, query_execution_batches, rollback_execution_batch, AutoScoreAction,
|
||||||
|
AutoScoreBackfillItem, AutoScoreBackfillResult, AutoScoreExecutionBatch,
|
||||||
AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService,
|
AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService,
|
||||||
AutoScoreTrigger,
|
AutoScoreTrigger,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface AutoScoreExecutionConfig {
|
|||||||
cooldownMinutes?: number | null
|
cooldownMinutes?: number | null
|
||||||
maxRunsPerDay?: number | null
|
maxRunsPerDay?: number | null
|
||||||
maxScoreDeltaPerDay?: number | null
|
maxScoreDeltaPerDay?: number | null
|
||||||
|
startAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoScoreExecutionBatch {
|
export interface AutoScoreExecutionBatch {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
DatePicker,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
} from "antd"
|
} from "antd"
|
||||||
import { type ImmutableTree } from "@react-awesome-query-builder/antd"
|
import { type ImmutableTree } from "@react-awesome-query-builder/antd"
|
||||||
import type { ColumnsType } from "antd/es/table"
|
import type { ColumnsType } from "antd/es/table"
|
||||||
|
import dayjs from "dayjs"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { fetchAllTags } from "./TagEditorDialog"
|
import { fetchAllTags } from "./TagEditorDialog"
|
||||||
import { ActionEditor } from "./AutoScore/ActionEditor"
|
import { ActionEditor } from "./AutoScore/ActionEditor"
|
||||||
@@ -63,6 +65,81 @@ interface AutoScoreManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getRuleFileRelativePath = (ruleId: number) => `auto-score/rule-${ruleId}.json`
|
const getRuleFileRelativePath = (ruleId: number) => `auto-score/rule-${ruleId}.json`
|
||||||
|
const AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE = 500
|
||||||
|
|
||||||
|
interface BackfillPlanItem {
|
||||||
|
ruleId: number
|
||||||
|
runs: number
|
||||||
|
ruleName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildOfflineBackfillPlan = (rules: AutoScoreRule[]): {
|
||||||
|
items: BackfillPlanItem[]
|
||||||
|
totalRuns: number
|
||||||
|
from: dayjs.Dayjs | null
|
||||||
|
to: dayjs.Dayjs
|
||||||
|
truncatedRules: number
|
||||||
|
} => {
|
||||||
|
const now = dayjs()
|
||||||
|
const items: BackfillPlanItem[] = []
|
||||||
|
let from: dayjs.Dayjs | null = null
|
||||||
|
let truncatedRules = 0
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (!rule.enabled) continue
|
||||||
|
const intervalTrigger = rule.triggers.find((trigger) => trigger.event === "interval_time_passed")
|
||||||
|
if (!intervalTrigger) continue
|
||||||
|
|
||||||
|
const intervalValue = parseIntervalTriggerValue(intervalTrigger.value)
|
||||||
|
if (!intervalValue) continue
|
||||||
|
const intervalMinutes = intervalValue.days * 24 * 60 + intervalValue.hours * 60 + intervalValue.minutes
|
||||||
|
if (intervalMinutes <= 0) continue
|
||||||
|
|
||||||
|
const startAt = rule.execution?.startAt ? dayjs(rule.execution.startAt) : null
|
||||||
|
const lastExecuted = rule.lastExecuted ? dayjs(rule.lastExecuted) : null
|
||||||
|
const validStartAt = startAt && startAt.isValid() ? startAt : null
|
||||||
|
const validLastExecuted = lastExecuted && lastExecuted.isValid() ? lastExecuted : null
|
||||||
|
|
||||||
|
const baseTime =
|
||||||
|
validStartAt && validLastExecuted
|
||||||
|
? validStartAt.isAfter(validLastExecuted)
|
||||||
|
? validStartAt
|
||||||
|
: validLastExecuted
|
||||||
|
: validStartAt || validLastExecuted
|
||||||
|
|
||||||
|
if (!baseTime) continue
|
||||||
|
if (!now.isAfter(baseTime)) continue
|
||||||
|
|
||||||
|
const elapsedMinutes = now.diff(baseTime, "minute")
|
||||||
|
if (elapsedMinutes < intervalMinutes) continue
|
||||||
|
|
||||||
|
const rawRuns = Math.floor(elapsedMinutes / intervalMinutes)
|
||||||
|
if (rawRuns <= 0) continue
|
||||||
|
|
||||||
|
const runs = Math.min(rawRuns, AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE)
|
||||||
|
if (runs < rawRuns) {
|
||||||
|
truncatedRules += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
ruleId: rule.id,
|
||||||
|
ruleName: rule.name,
|
||||||
|
runs,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!from || baseTime.isBefore(from)) {
|
||||||
|
from = baseTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
totalRuns: items.reduce((sum, item) => sum + item.runs, 0),
|
||||||
|
from,
|
||||||
|
to: now,
|
||||||
|
truncatedRules,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element {
|
function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
@@ -98,6 +175,8 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
const [pageSize, setPageSize] = useState(10)
|
const [pageSize, setPageSize] = useState(10)
|
||||||
const [batchCurrentPage, setBatchCurrentPage] = useState(1)
|
const [batchCurrentPage, setBatchCurrentPage] = useState(1)
|
||||||
const [batchPageSize, setBatchPageSize] = useState(10)
|
const [batchPageSize, setBatchPageSize] = useState(10)
|
||||||
|
const [executionStartAt, setExecutionStartAt] = useState<string | null>(null)
|
||||||
|
const [backfillPrompted, setBackfillPrompted] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const maxPage = Math.max(1, Math.ceil(rules.length / pageSize))
|
const maxPage = Math.max(1, Math.ceil(rules.length / pageSize))
|
||||||
@@ -117,11 +196,58 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig))
|
setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig))
|
||||||
}, [triggerConfig])
|
}, [triggerConfig])
|
||||||
|
|
||||||
|
const intervalElapsedHint = useMemo(() => {
|
||||||
|
if (!executionStartAt) {
|
||||||
|
return t("autoScore.startAtHint")
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAt = dayjs(executionStartAt)
|
||||||
|
if (!startAt.isValid()) {
|
||||||
|
return t("autoScore.startAtHint")
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalTrigger = queryTreeToTriggers(triggerTree, triggerConfig).find(
|
||||||
|
(trigger) => trigger.event === "interval_time_passed"
|
||||||
|
)
|
||||||
|
if (!intervalTrigger) {
|
||||||
|
return t("autoScore.startAtHint")
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalValue = parseIntervalTriggerValue(intervalTrigger.value)
|
||||||
|
if (!intervalValue) {
|
||||||
|
return t("autoScore.startAtHint")
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalMinutes = intervalValue.days * 24 * 60 + intervalValue.hours * 60 + intervalValue.minutes
|
||||||
|
if (intervalMinutes <= 0) {
|
||||||
|
return t("autoScore.startAtHint")
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = dayjs()
|
||||||
|
if (now.isBefore(startAt)) {
|
||||||
|
return t("autoScore.startAtWaitHint", { minutes: startAt.diff(now, "minute") })
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMinutes = now.diff(startAt, "minute")
|
||||||
|
const remainder = elapsedMinutes % intervalMinutes
|
||||||
|
const remainMinutes =
|
||||||
|
elapsedMinutes < intervalMinutes
|
||||||
|
? intervalMinutes - elapsedMinutes
|
||||||
|
: remainder === 0
|
||||||
|
? 0
|
||||||
|
: intervalMinutes - remainder
|
||||||
|
return t("autoScore.startAtElapsedHint", {
|
||||||
|
elapsed: elapsedMinutes,
|
||||||
|
remain: remainMinutes,
|
||||||
|
})
|
||||||
|
}, [executionStartAt, t, triggerConfig, triggerTree])
|
||||||
|
|
||||||
const resetEditor = () => {
|
const resetEditor = () => {
|
||||||
setEditingRuleId(null)
|
setEditingRuleId(null)
|
||||||
form.setFieldsValue({ name: "", studentNames: [], execution: {} })
|
form.setFieldsValue({ name: "", studentNames: [], execution: {} })
|
||||||
setTriggerTree(createEmptyTriggerTree(triggerConfig))
|
setTriggerTree(createEmptyTriggerTree(triggerConfig))
|
||||||
setActionDrafts([createDefaultActionDraft()])
|
setActionDrafts([createDefaultActionDraft()])
|
||||||
|
setExecutionStartAt(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emitDataUpdated = () => {
|
const emitDataUpdated = () => {
|
||||||
@@ -195,6 +321,70 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
fetchBatches().catch(() => void 0)
|
fetchBatches().catch(() => void 0)
|
||||||
}, [canEdit])
|
}, [canEdit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const api = (window as any).api
|
||||||
|
if (!canEdit || !api || backfillPrompted || rules.length === 0) return
|
||||||
|
|
||||||
|
const plan = buildOfflineBackfillPlan(rules)
|
||||||
|
setBackfillPrompted(true)
|
||||||
|
if (plan.items.length === 0 || plan.totalRuns <= 0) return
|
||||||
|
|
||||||
|
const fromText = plan.from ? plan.from.format("YYYY-MM-DD HH:mm:ss") : "-"
|
||||||
|
const toText = plan.to.format("YYYY-MM-DD HH:mm:ss")
|
||||||
|
const previewLines = plan.items
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((item) => t("autoScore.backfillPreviewLine", { name: item.ruleName, runs: item.runs }))
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: t("autoScore.backfillConfirmTitle"),
|
||||||
|
content: (
|
||||||
|
<div style={{ whiteSpace: "pre-wrap", lineHeight: 1.6 }}>
|
||||||
|
{t("autoScore.backfillConfirmSummary", {
|
||||||
|
from: fromText,
|
||||||
|
to: toText,
|
||||||
|
runs: plan.totalRuns,
|
||||||
|
rules: plan.items.length,
|
||||||
|
})}
|
||||||
|
{previewLines ? `\n${previewLines}` : ""}
|
||||||
|
{plan.items.length > 5
|
||||||
|
? `\n${t("autoScore.backfillPreviewMore", { count: plan.items.length - 5 })}`
|
||||||
|
: ""}
|
||||||
|
{plan.truncatedRules > 0
|
||||||
|
? `\n${t("autoScore.backfillPreviewTruncated", {
|
||||||
|
count: plan.truncatedRules,
|
||||||
|
max: AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE,
|
||||||
|
})}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.autoScoreApplyBackfill({
|
||||||
|
items: plan.items.map((item) => ({ ruleId: item.ruleId, runs: item.runs })),
|
||||||
|
})
|
||||||
|
if (!res?.success) {
|
||||||
|
messageApi.error(res?.message || t("autoScore.backfillApplyFailed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = res.data
|
||||||
|
messageApi.success(
|
||||||
|
t("autoScore.backfillApplySuccess", {
|
||||||
|
runs: result?.appliedRuns ?? 0,
|
||||||
|
events: result?.createdEvents ?? 0,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await fetchRules()
|
||||||
|
await fetchBatches()
|
||||||
|
emitDataUpdated()
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t("autoScore.backfillApplyFailed"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [backfillPrompted, canEdit, messageApi, rules, t])
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const api = (window as any).api
|
const api = (window as any).api
|
||||||
if (!api) return
|
if (!api) return
|
||||||
@@ -206,7 +396,14 @@ 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 || {}
|
const executionFromForm = values.execution || {}
|
||||||
|
const execution: AutoScoreExecutionConfig = {
|
||||||
|
...executionFromForm,
|
||||||
|
startAt:
|
||||||
|
executionStartAt && dayjs(executionStartAt).isValid()
|
||||||
|
? dayjs(executionStartAt).toISOString()
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
messageApi.warning(t("autoScore.nameRequired"))
|
messageApi.warning(t("autoScore.nameRequired"))
|
||||||
@@ -286,12 +483,14 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (rule: AutoScoreRule) => {
|
const handleEdit = (rule: AutoScoreRule) => {
|
||||||
|
const { startAt, ...executionWithoutStartAt } = rule.execution || {}
|
||||||
setEditingRuleId(rule.id)
|
setEditingRuleId(rule.id)
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: rule.name,
|
name: rule.name,
|
||||||
studentNames: rule.studentNames || [],
|
studentNames: rule.studentNames || [],
|
||||||
execution: rule.execution || {},
|
execution: executionWithoutStartAt,
|
||||||
})
|
})
|
||||||
|
setExecutionStartAt(startAt ?? null)
|
||||||
setTriggerTree(triggerTreeJsonToQueryTree(triggerConfig, rule.triggerTree, rule.triggers || []))
|
setTriggerTree(triggerTreeJsonToQueryTree(triggerConfig, rule.triggerTree, rule.triggers || []))
|
||||||
setActionDrafts(actionsToDrafts(rule.actions || []))
|
setActionDrafts(actionsToDrafts(rule.actions || []))
|
||||||
}
|
}
|
||||||
@@ -649,6 +848,24 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
>
|
>
|
||||||
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label={t("autoScore.startAt")} extra={intervalElapsedHint}>
|
||||||
|
<DatePicker
|
||||||
|
showTime
|
||||||
|
allowClear
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
placeholder={t("autoScore.startAtPlaceholder")}
|
||||||
|
disabled={!canEdit}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
value={
|
||||||
|
executionStartAt && dayjs(executionStartAt).isValid()
|
||||||
|
? dayjs(executionStartAt)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onChange={(value) => {
|
||||||
|
setExecutionStartAt(value ? value.toISOString() : null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -824,6 +824,11 @@
|
|||||||
"actionAddTag": "Add Tag",
|
"actionAddTag": "Add Tag",
|
||||||
"actionSettleScore": "Settle Score",
|
"actionSettleScore": "Settle Score",
|
||||||
"actionSettleScoreHint": "No parameters required",
|
"actionSettleScoreHint": "No parameters required",
|
||||||
|
"startAt": "Start Time",
|
||||||
|
"startAtPlaceholder": "Select start time (empty means start now)",
|
||||||
|
"startAtHint": "Empty means start immediately; when set, interval will use this as base time",
|
||||||
|
"startAtWaitHint": "{{minutes}} minutes until start",
|
||||||
|
"startAtElapsedHint": "{{elapsed}} minutes elapsed since start, about {{remain}} minutes to next run",
|
||||||
"cooldownMinutes": "Cooldown (minutes)",
|
"cooldownMinutes": "Cooldown (minutes)",
|
||||||
"maxStudentsPerRun": "Max Students Per Run",
|
"maxStudentsPerRun": "Max Students Per Run",
|
||||||
"maxRunsPerDay": "Max Runs Per Day",
|
"maxRunsPerDay": "Max Runs Per Day",
|
||||||
@@ -859,7 +864,14 @@
|
|||||||
"operatorContains": "Contains",
|
"operatorContains": "Contains",
|
||||||
"relationNot": "Not",
|
"relationNot": "Not",
|
||||||
"openFile": "Open File",
|
"openFile": "Open File",
|
||||||
"openFileFailed": "Failed to open automation file"
|
"openFileFailed": "Failed to open automation file",
|
||||||
|
"backfillConfirmTitle": "Offline automation replay available",
|
||||||
|
"backfillConfirmSummary": "From {{from}} to {{to}}, about {{runs}} automation runs ({{rules}} rules) can be replayed. Replay now?",
|
||||||
|
"backfillPreviewLine": "{{name}}: {{runs}} run(s)",
|
||||||
|
"backfillPreviewMore": "{{count}} more rules not shown",
|
||||||
|
"backfillPreviewTruncated": "{{count}} rules are capped to at most {{max}} replay runs",
|
||||||
|
"backfillApplySuccess": "Replay completed: {{runs}} runs applied, {{events}} score records created",
|
||||||
|
"backfillApplyFailed": "Failed to replay offline auto score runs"
|
||||||
},
|
},
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"studentTag": {
|
"studentTag": {
|
||||||
|
|||||||
@@ -822,6 +822,11 @@
|
|||||||
"actionAddTag": "添加标签",
|
"actionAddTag": "添加标签",
|
||||||
"actionSettleScore": "结算分数",
|
"actionSettleScore": "结算分数",
|
||||||
"actionSettleScoreHint": "无需参数",
|
"actionSettleScoreHint": "无需参数",
|
||||||
|
"startAt": "开始时间",
|
||||||
|
"startAtPlaceholder": "请选择开始时间(留空立即开始)",
|
||||||
|
"startAtHint": "留空表示立即开始;设置后将以该时间为基准按间隔触发",
|
||||||
|
"startAtWaitHint": "距离开始还有 {{minutes}} 分钟",
|
||||||
|
"startAtElapsedHint": "自开始已过去 {{elapsed}} 分钟,距离下一次触发约 {{remain}} 分钟",
|
||||||
"cooldownMinutes": "冷却时间(分钟)",
|
"cooldownMinutes": "冷却时间(分钟)",
|
||||||
"maxStudentsPerRun": "单次最多学生数",
|
"maxStudentsPerRun": "单次最多学生数",
|
||||||
"maxRunsPerDay": "每日最多执行次数",
|
"maxRunsPerDay": "每日最多执行次数",
|
||||||
@@ -857,7 +862,14 @@
|
|||||||
"operatorContains": "包含",
|
"operatorContains": "包含",
|
||||||
"relationNot": "非",
|
"relationNot": "非",
|
||||||
"openFile": "打开文件",
|
"openFile": "打开文件",
|
||||||
"openFileFailed": "打开自动化文件失败"
|
"openFileFailed": "打开自动化文件失败",
|
||||||
|
"backfillConfirmTitle": "检测到关闭期间可补回的自动化",
|
||||||
|
"backfillConfirmSummary": "在 {{from}} 到 {{to}} 期间,预计有 {{runs}} 次自动化执行(共 {{rules}} 条规则)可补回,是否现在补回?",
|
||||||
|
"backfillPreviewLine": "{{name}}:{{runs}} 次",
|
||||||
|
"backfillPreviewMore": "其余 {{count}} 条规则未展开",
|
||||||
|
"backfillPreviewTruncated": "{{count}} 条规则补回次数已截断到最多 {{max}} 次",
|
||||||
|
"backfillApplySuccess": "补回完成:执行 {{runs}} 次,新增 {{events}} 条积分记录",
|
||||||
|
"backfillApplyFailed": "补回自动加分失败"
|
||||||
},
|
},
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"studentTag": {
|
"studentTag": {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface autoScoreExecutionConfig {
|
|||||||
cooldownMinutes?: number | null
|
cooldownMinutes?: number | null
|
||||||
maxRunsPerDay?: number | null
|
maxRunsPerDay?: number | null
|
||||||
maxScoreDeltaPerDay?: number | null
|
maxScoreDeltaPerDay?: number | null
|
||||||
|
startAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface autoScoreExecutionBatch {
|
export interface autoScoreExecutionBatch {
|
||||||
@@ -53,6 +54,19 @@ export interface autoScoreExecutionBatch {
|
|||||||
rollbackAt?: string | null
|
rollbackAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface autoScoreBackfillItem {
|
||||||
|
ruleId: number
|
||||||
|
runs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface autoScoreBackfillResult {
|
||||||
|
appliedRules: number
|
||||||
|
appliedRuns: number
|
||||||
|
affectedStudents: number
|
||||||
|
createdEvents: number
|
||||||
|
scoreDeltaTotal: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface autoScoreRule {
|
export interface autoScoreRule {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -361,6 +375,10 @@ const api = {
|
|||||||
batchId: string
|
batchId: string
|
||||||
}): Promise<{ success: boolean; data?: autoScoreExecutionBatch; message?: string }> =>
|
}): Promise<{ success: boolean; data?: autoScoreExecutionBatch; message?: string }> =>
|
||||||
invoke("auto_score_rollback_batch", { params }),
|
invoke("auto_score_rollback_batch", { params }),
|
||||||
|
autoScoreApplyBackfill: (params: {
|
||||||
|
items: autoScoreBackfillItem[]
|
||||||
|
}): Promise<{ success: boolean; data?: autoScoreBackfillResult; message?: string }> =>
|
||||||
|
invoke("auto_score_apply_backfill", { params }),
|
||||||
|
|
||||||
// Settings & Sync
|
// Settings & Sync
|
||||||
getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> =>
|
getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> =>
|
||||||
|
|||||||
Reference in New Issue
Block a user