From 4304f301e7036202fce87cb01f9e26ee622110fe Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Sat, 11 Apr 2026 13:32:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=8E=B0=E5=9C=A8=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E5=9C=A8=E7=A6=BB=E7=BA=BF=E6=9C=9F=E9=97=B4=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E6=89=93=E5=BC=80=E5=90=8E=E8=A1=A5=E5=8A=A0=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/data.sql | Bin 151552 -> 0 bytes src-tauri/src/commands/auto_score.rs | 30 ++- src-tauri/src/lib.rs | 1 + src-tauri/src/services/auto_score.rs | 292 ++++++++++++++++++--- src-tauri/src/services/mod.rs | 3 +- src/components/AutoScore/AutoScoreUtils.ts | 1 + src/components/AutoScoreManager.tsx | 221 +++++++++++++++- src/i18n/locales/en-US.json | 14 +- src/i18n/locales/zh-CN.json | 14 +- src/preload/types.ts | 18 ++ 10 files changed, 556 insertions(+), 38 deletions(-) delete mode 100644 src-tauri/data.sql diff --git a/src-tauri/data.sql b/src-tauri/data.sql deleted file mode 100644 index 5fb6752e990a5e7bdbc7877d8e012220995c76f8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 151552 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, +} + #[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 }> =>