diff --git a/src-tauri/data.sql b/src-tauri/data.sql index 0612fe9..5fb6752 100644 Binary files a/src-tauri/data.sql and b/src-tauri/data.sql differ diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index ad4bc4a..d44326d 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -563,6 +563,20 @@ fn build_trigger_rule_node(trigger: &AutoScoreTrigger) -> JsonValue { trigger.value.clone().unwrap_or_default(), )]), ), + "student_score_gt" => ( + "student_score", + "greater", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), + "student_score_lt" => ( + "student_score", + "less", + JsonValue::Array(vec![JsonValue::String( + trigger.value.clone().unwrap_or_default(), + )]), + ), _ => ( "student_sql", "equal", @@ -742,6 +756,11 @@ fn trigger_from_rule_node(node: &JsonValue) -> Result .and_then(|value| value.get("field")) .and_then(JsonValue::as_str) .unwrap_or_default(); + let operator = node + .get("properties") + .and_then(|value| value.get("operator")) + .and_then(JsonValue::as_str) + .unwrap_or_default(); let first_value = node .get("properties") .and_then(|value| value.get("value")) @@ -785,6 +804,23 @@ fn trigger_from_rule_node(node: &JsonValue) -> Result }; normalize_trigger(trigger) } + "student_score" | "student_score_gt" | "student_score_lt" => { + let score = match first_value { + Some(JsonValue::Number(value)) => value.to_string(), + Some(JsonValue::String(value)) => value, + _ => String::new(), + }; + let event = if operator.eq_ignore_ascii_case("less") || field == "student_score_lt" { + "student_score_lt" + } else { + "student_score_gt" + }; + let trigger = AutoScoreTrigger { + event: event.to_string(), + value: if score.trim().is_empty() { None } else { Some(score) }, + }; + normalize_trigger(trigger) + } _ => Err(format!("Unsupported trigger tree field: {}", field)), } } @@ -829,6 +865,14 @@ fn normalize_trigger(trigger: AutoScoreTrigger) -> Result { + let threshold = normalize_integer_string(trigger.value.as_deref()) + .ok_or_else(|| "Student score trigger requires an integer value".to_string())?; + Ok(AutoScoreTrigger { + event, + value: Some(threshold), + }) + } _ => Err(format!("Unsupported trigger event: {}", event)), } } @@ -932,6 +976,15 @@ fn normalize_non_zero_integer_string(value: Option<&str>) -> Option { Some(parsed.to_string()) } +fn normalize_integer_string(value: Option<&str>) -> Option { + let normalized = value?.trim(); + if normalized.is_empty() { + return None; + } + let parsed = normalized.parse::().ok()?; + Some(parsed.to_string()) +} + fn normalize_last_executed(value: Option) -> Option { value .as_deref() @@ -1816,6 +1869,18 @@ fn evaluate_trigger_tree_for_student( .map(|refs| refs.ids.contains(&student.id) || refs.names.contains(&student.name)) .unwrap_or(false) } + "student_score_gt" => trigger + .value + .as_deref() + .and_then(|value| value.trim().parse::().ok()) + .map(|threshold| student.score > threshold) + .unwrap_or(false), + "student_score_lt" => trigger + .value + .as_deref() + .and_then(|value| value.trim().parse::().ok()) + .map(|threshold| student.score < threshold) + .unwrap_or(false), _ => false, }; Ok(matched) diff --git a/src/components/AutoScore/AutoScoreUtils.ts b/src/components/AutoScore/AutoScoreUtils.ts index d1188f1..efc45be 100644 --- a/src/components/AutoScore/AutoScoreUtils.ts +++ b/src/components/AutoScore/AutoScoreUtils.ts @@ -75,9 +75,13 @@ export type ActionDraftError = "action_required" | "score_required" | "tag_requi const TRIGGER_FIELD_INTERVAL = "interval_minutes" const TRIGGER_FIELD_TAG = "student_tag" const TRIGGER_FIELD_SQL = "student_sql" +const TRIGGER_FIELD_SCORE = "student_score" +const TRIGGER_FIELD_SCORE_GT = "student_score_gt" const TRIGGER_TYPE_INTERVAL = "interval_duration" const TRIGGER_WIDGET_INTERVAL = "interval_duration" const OP_EQUAL = "equal" +const OP_GREATER = "greater" +const OP_LESS = "less" const OP_MULTISELECT_CONTAINS = "multiselect_contains" const buildEmptyGroup = (): JsonGroup => ({ @@ -188,11 +192,26 @@ const ruleFromTrigger = (trigger: AutoScoreTrigger): JsonRule | null => { } } + if (trigger.event === "student_score_gt" || trigger.event === "student_score_lt") { + const score = toFiniteNumber(trigger.value) + if (score === null) return null + return { + id: QbUtils.uuid(), + type: "rule", + properties: { + field: TRIGGER_FIELD_SCORE, + operator: trigger.event === "student_score_lt" ? OP_LESS : OP_GREATER, + value: [score], + }, + } + } + return null } const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => { const field = typeof rule.properties?.field === "string" ? rule.properties.field : "" + const operator = typeof rule.properties?.operator === "string" ? rule.properties.operator : "" const value = Array.isArray(rule.properties?.value) ? rule.properties.value[0] : undefined if (field === TRIGGER_FIELD_INTERVAL) { @@ -226,6 +245,15 @@ const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => { } } + if (field === TRIGGER_FIELD_SCORE || field === TRIGGER_FIELD_SCORE_GT) { + const score = toFiniteNumber(value) + if (score === null) return null + return { + event: operator === OP_LESS ? "student_score_lt" : "student_score_gt", + value: String(score), + } + } + return null } @@ -261,6 +289,16 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO }, operators: { ...AntdConfig.operators, + [OP_GREATER]: { + ...AntdConfig.operators[OP_GREATER], + label: ">", + labelForFormat: ">", + }, + [OP_LESS]: { + ...AntdConfig.operators[OP_LESS], + label: "<", + labelForFormat: "<", + }, [OP_MULTISELECT_CONTAINS]: { ...AntdConfig.operators[OP_MULTISELECT_CONTAINS], label: t("autoScore.operatorContains"), @@ -329,6 +367,13 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO placeholder: t("autoScore.triggerStudentSqlPlaceholder"), }, }, + [TRIGGER_FIELD_SCORE]: { + label: t("autoScore.triggerStudentScore"), + type: "number", + defaultOperator: OP_GREATER, + operators: [OP_GREATER, OP_LESS], + valueSources: ["value"], + }, }, settings: { ...AntdConfig.settings, @@ -341,6 +386,7 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO canLeaveEmptyGroup: false, canReorder: true, canRegroup: true, + setOpOnChangeField: ["default"], }, }) as Config @@ -401,7 +447,14 @@ const hydrateTriggerTreeNode = ( fallbackIndex.current += 1 const parsed = triggerFromRule(node) if (parsed) { - return node + const normalizedRule = ruleFromTrigger(parsed) + if (!normalizedRule) { + return node + } + return { + ...node, + properties: normalizedRule.properties, + } } if (!fallbackTrigger) { diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx index 5b2d303..76b81d3 100644 --- a/src/components/AutoScoreManager.tsx +++ b/src/components/AutoScoreManager.tsx @@ -96,6 +96,8 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element const [editingRuleId, setEditingRuleId] = useState(null) const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(10) + const [batchCurrentPage, setBatchCurrentPage] = useState(1) + const [batchPageSize, setBatchPageSize] = useState(10) useEffect(() => { const maxPage = Math.max(1, Math.ceil(rules.length / pageSize)) @@ -104,6 +106,13 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } }, [currentPage, pageSize, rules.length]) + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(batches.length / batchPageSize)) + if (batchCurrentPage > maxPage) { + setBatchCurrentPage(maxPage) + } + }, [batchCurrentPage, batchPageSize, batches.length]) + useEffect(() => { setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig)) }, [triggerConfig]) @@ -584,6 +593,10 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element ] const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize) + const pagedBatches = batches.slice( + (batchCurrentPage - 1) * batchPageSize, + batchCurrentPage * batchPageSize + ) return (
@@ -701,11 +714,24 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element +
+ t("common.total", { count: total })} + onChange={(page, size) => { + setBatchCurrentPage(page) + setBatchPageSize(size) + }} + /> +
) diff --git a/src/components/ScoreManager.tsx b/src/components/ScoreManager.tsx index 647ee60..515ac2b 100644 --- a/src/components/ScoreManager.tsx +++ b/src/components/ScoreManager.tsx @@ -84,12 +84,21 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [students, setStudents] = useState([]) const [reasons, setReasons] = useState([]) const [events, setEvents] = useState([]) + const [recordCurrentPage, setRecordCurrentPage] = useState(1) + const [recordPageSize, setRecordPageSize] = useState(10) const [loading, setLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false) const [form] = Form.useForm() const [messageApi, contextHolder] = message.useMessage() const selectedStudentNames = Form.useWatch("student_name", form) as string[] | undefined + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(events.length / recordPageSize)) + if (recordCurrentPage > maxPage) { + setRecordCurrentPage(maxPage) + } + }, [events.length, recordCurrentPage, recordPageSize]) + const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => { window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) } @@ -102,7 +111,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [stuRes, reaRes, eveRes] = await Promise.all([ (window as any).api.queryStudents({}), (window as any).api.queryReasons(), - (window as any).api.queryEvents({ limit: 10 }), + (window as any).api.queryEvents({ limit: 100 }), ]) if (stuRes.success) setStudents(stuRes.data) @@ -231,12 +240,12 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } const columns: ColumnsType = [ - { title: t("score.student"), dataIndex: "student_name", key: "student_name", width: 100 }, + { title: t("score.student"), dataIndex: "student_name", key: "student_name", width: 120 }, { title: t("score.change"), dataIndex: "delta", key: "delta", - width: 80, + width: 92, render: (delta: number) => ( 0 ? "success" : "error"}>{delta > 0 ? `+${delta}` : delta} ), @@ -245,6 +254,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { title: t("score.reason"), dataIndex: "reason_content", key: "reason_content", + width: 280, ellipsis: true, }, { @@ -257,7 +267,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { { title: t("common.operation"), key: "operation", - width: 80, + width: 96, render: (_, row) => ( handleUndo(row.uuid)}>