diff --git a/src-tauri/data.sql b/src-tauri/data.sql deleted file mode 100644 index 5fb6752..0000000 Binary files a/src-tauri/data.sql and /dev/null differ diff --git a/src-tauri/src/commands/auto_score.rs b/src-tauri/src/commands/auto_score.rs index 2d6ad22..da5e1b2 100644 --- a/src-tauri/src/commands/auto_score.rs +++ b/src-tauri/src/commands/auto_score.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, State}; 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, AutoScoreTrigger, PermissionLevel, SettingsKey, SettingsValue, }; @@ -67,6 +68,11 @@ pub struct ToggleRuleParams { pub enabled: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplyBackfillParams { + pub items: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AutoScoreStatus { 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?; Ok(IpcResponse::success(batch)) } + +#[tauri::command] +pub async fn auto_score_apply_backfill( + params: ApplyBackfillParams, + sender_id: Option, + app_handle: AppHandle, + state: State<'_, Arc>>, +) -> Result, String> { + { + let state_guard = state.read(); + let mut permissions = state_guard.permissions.write(); + if !check_admin_permission(&mut permissions, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + } + + let 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)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4094bab..76f16d9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -98,6 +98,7 @@ pub fn run() { auto_score_sort_rules, auto_score_query_batches, auto_score_rollback_batch, + auto_score_apply_backfill, board_get_configs, board_save_configs, board_query_sql, diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index d44326d..cb6664f 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -18,6 +18,7 @@ const DEFAULT_INTERVAL_MINUTES: i64 = 30; const AUTO_SCORE_TICK_SECONDS: u64 = 15; const AUTO_SCORE_SQL_LIMIT: u64 = 5000; const AUTO_SCORE_REASON_PREFIX: &str = "自动化"; +const AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE: i64 = 500; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AutoScoreTrigger { @@ -37,6 +38,7 @@ pub struct AutoScoreExecutionConfig { pub cooldown_minutes: Option, pub max_runs_per_day: Option, pub max_score_delta_per_day: Option, + pub start_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -67,6 +69,23 @@ pub struct AutoScoreExecutionBatch { pub rollback_at: Option, } +#[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)] #[serde(rename_all = "lowercase")] enum IntervalUnit { @@ -107,6 +126,12 @@ enum PlannedAction { SettleScore, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExecutionMode { + Normal, + Backfill, +} + #[derive(Debug, Default, Clone)] struct StudentRefs { ids: HashSet, @@ -347,7 +372,7 @@ impl AutoScoreService { continue; } - match execute_rule(&conn, rule, &execution_batches).await { + match execute_rule(&conn, rule, &execution_batches, ExecutionMode::Normal).await { Ok(stats) => { if stats.affected_students == 0 { 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.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.start_at = normalize_datetime_rfc3339(config.start_at); Ok(config) } @@ -985,13 +1011,17 @@ fn normalize_integer_string(value: Option<&str>) -> Option { Some(parsed.to_string()) } -fn normalize_last_executed(value: Option) -> Option { +fn normalize_datetime_rfc3339(value: Option) -> Option { value .as_deref() .and_then(|raw_value| DateTime::parse_from_rfc3339(raw_value).ok()) .map(|parsed| parsed.with_timezone(&Utc).to_rfc3339()) } +fn normalize_last_executed(value: Option) -> Option { + normalize_datetime_rfc3339(value) +} + fn parse_positive_i64(value: &JsonValue) -> Option { match value { 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 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); }; @@ -1419,10 +1463,184 @@ pub async fn rollback_execution_batch( Ok(batch) } +fn interval_base_time(rule: &AutoScoreRule) -> Option> { + 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 { + 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) -> 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 { + if items.is_empty() { + return Ok(AutoScoreBackfillResult::default()); + } + + let mut requested_runs_by_rule: HashMap = 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( conn: &DatabaseConnection, rule: &AutoScoreRule, execution_batches: &[AutoScoreExecutionBatch], + mode: ExecutionMode, ) -> Result { let mut target_students = resolve_target_students(conn, rule).await?; @@ -1441,13 +1659,15 @@ async fn execute_rule( return Ok(RuleExecutionStats::default()); } - if let Some(max_runs) = rule.execution.max_runs_per_day { - let today_runs = execution_batches - .iter() - .filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at)) - .count() as i64; - if today_runs >= max_runs { - return Ok(RuleExecutionStats::default()); + if mode == ExecutionMode::Normal { + 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()); + } } } @@ -1459,11 +1679,15 @@ async fn execute_rule( .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 mut daily_score_delta_used: i64 = if mode == ExecutionMode::Normal { + 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() + } else { + 0 + }; let txn = conn.begin().await.map_err(|e| e.to_string())?; let mut stats = RuleExecutionStats::default(); @@ -1483,27 +1707,31 @@ async fn execute_rule( } for mut student in target_students { - if !is_student_pass_cooldown( - conn, - execution_batches, - rule, - student.id, - student.name.as_str(), - ) - .await? - { - continue; + if mode == ExecutionMode::Normal { + if !is_student_pass_cooldown( + conn, + execution_batches, + rule, + student.id, + student.name.as_str(), + ) + .await? + { + continue; + } } let mut touched = false; for action in &planned_actions { match action { PlannedAction::AddScore(delta) => { - if let Some(max_delta) = rule.execution.max_score_delta_per_day { - let next = daily_score_delta_used + (*delta as i64).abs(); - if next > max_delta { - continue; + if mode == ExecutionMode::Normal { + 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; } - daily_score_delta_used = next; } let now = now_iso(); let val_prev = student.score; diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 7a7ea77..facd7c7 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -9,7 +9,8 @@ pub mod theme; pub use auth::AuthService; 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, AutoScoreTrigger, }; diff --git a/src/components/AutoScore/AutoScoreUtils.ts b/src/components/AutoScore/AutoScoreUtils.ts index efc45be..a72d239 100644 --- a/src/components/AutoScore/AutoScoreUtils.ts +++ b/src/components/AutoScore/AutoScoreUtils.ts @@ -28,6 +28,7 @@ export interface AutoScoreExecutionConfig { cooldownMinutes?: number | null maxRunsPerDay?: number | null maxScoreDeltaPerDay?: number | null + startAt?: string | null } export interface AutoScoreExecutionBatch { diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx index 76b81d3..3faf201 100644 --- a/src/components/AutoScoreManager.tsx +++ b/src/components/AutoScoreManager.tsx @@ -3,6 +3,7 @@ import { Alert, Button, Card, + DatePicker, Form, Input, InputNumber, @@ -19,6 +20,7 @@ import { } from "antd" import { type ImmutableTree } from "@react-awesome-query-builder/antd" import type { ColumnsType } from "antd/es/table" +import dayjs from "dayjs" import { useTranslation } from "react-i18next" import { fetchAllTags } from "./TagEditorDialog" import { ActionEditor } from "./AutoScore/ActionEditor" @@ -63,6 +65,81 @@ interface AutoScoreManagerProps { } 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 { const { t, i18n } = useTranslation() @@ -98,6 +175,8 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element const [pageSize, setPageSize] = useState(10) const [batchCurrentPage, setBatchCurrentPage] = useState(1) const [batchPageSize, setBatchPageSize] = useState(10) + const [executionStartAt, setExecutionStartAt] = useState(null) + const [backfillPrompted, setBackfillPrompted] = useState(false) useEffect(() => { 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)) }, [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 = () => { setEditingRuleId(null) form.setFieldsValue({ name: "", studentNames: [], execution: {} }) setTriggerTree(createEmptyTriggerTree(triggerConfig)) setActionDrafts([createDefaultActionDraft()]) + setExecutionStartAt(null) } const emitDataUpdated = () => { @@ -195,6 +321,70 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element fetchBatches().catch(() => void 0) }, [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: ( +
+ {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, + })}` + : ""} +
+ ), + 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 api = (window as any).api if (!api) return @@ -206,7 +396,14 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element const values = await form.validateFields() const name = String(values.name || "").trim() const studentNames = Array.isArray(values.studentNames) ? values.studentNames : [] - const execution = values.execution || {} + const executionFromForm = values.execution || {} + const execution: AutoScoreExecutionConfig = { + ...executionFromForm, + startAt: + executionStartAt && dayjs(executionStartAt).isValid() + ? dayjs(executionStartAt).toISOString() + : null, + } if (!name) { messageApi.warning(t("autoScore.nameRequired")) @@ -286,12 +483,14 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } const handleEdit = (rule: AutoScoreRule) => { + const { startAt, ...executionWithoutStartAt } = rule.execution || {} setEditingRuleId(rule.id) form.setFieldsValue({ name: rule.name, studentNames: rule.studentNames || [], - execution: rule.execution || {}, + execution: executionWithoutStartAt, }) + setExecutionStartAt(startAt ?? null) setTriggerTree(triggerTreeJsonToQueryTree(triggerConfig, rule.triggerTree, rule.triggers || [])) setActionDrafts(actionsToDrafts(rule.actions || [])) } @@ -649,6 +848,24 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element > + + { + setExecutionStartAt(value ? value.toISOString() : null) + }} + /> + diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 197b836..59eb6b5 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -824,6 +824,11 @@ "actionAddTag": "Add Tag", "actionSettleScore": "Settle Score", "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)", "maxStudentsPerRun": "Max Students Per Run", "maxRunsPerDay": "Max Runs Per Day", @@ -859,7 +864,14 @@ "operatorContains": "Contains", "relationNot": "Not", "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": { "studentTag": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 4790c44..4b2bb9d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -822,6 +822,11 @@ "actionAddTag": "添加标签", "actionSettleScore": "结算分数", "actionSettleScoreHint": "无需参数", + "startAt": "开始时间", + "startAtPlaceholder": "请选择开始时间(留空立即开始)", + "startAtHint": "留空表示立即开始;设置后将以该时间为基准按间隔触发", + "startAtWaitHint": "距离开始还有 {{minutes}} 分钟", + "startAtElapsedHint": "自开始已过去 {{elapsed}} 分钟,距离下一次触发约 {{remain}} 分钟", "cooldownMinutes": "冷却时间(分钟)", "maxStudentsPerRun": "单次最多学生数", "maxRunsPerDay": "每日最多执行次数", @@ -857,7 +862,14 @@ "operatorContains": "包含", "relationNot": "非", "openFile": "打开文件", - "openFileFailed": "打开自动化文件失败" + "openFileFailed": "打开自动化文件失败", + "backfillConfirmTitle": "检测到关闭期间可补回的自动化", + "backfillConfirmSummary": "在 {{from}} 到 {{to}} 期间,预计有 {{runs}} 次自动化执行(共 {{rules}} 条规则)可补回,是否现在补回?", + "backfillPreviewLine": "{{name}}:{{runs}} 次", + "backfillPreviewMore": "其余 {{count}} 条规则未展开", + "backfillPreviewTruncated": "{{count}} 条规则补回次数已截断到最多 {{max}} 次", + "backfillApplySuccess": "补回完成:执行 {{runs}} 次,新增 {{events}} 条积分记录", + "backfillApplyFailed": "补回自动加分失败" }, "triggers": { "studentTag": { diff --git a/src/preload/types.ts b/src/preload/types.ts index 2455fe5..97cb791 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -36,6 +36,7 @@ export interface autoScoreExecutionConfig { cooldownMinutes?: number | null maxRunsPerDay?: number | null maxScoreDeltaPerDay?: number | null + startAt?: string | null } export interface autoScoreExecutionBatch { @@ -53,6 +54,19 @@ export interface autoScoreExecutionBatch { 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 { id: number name: string @@ -361,6 +375,10 @@ const api = { batchId: string }): Promise<{ success: boolean; data?: autoScoreExecutionBatch; message?: string }> => 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 getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> =>