From f2c062f904ccc3ff79f112206644b5c1d527f21b Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Fri, 10 Apr 2026 22:35:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E5=8C=96=E5=8A=A0?= =?UTF-8?q?=E4=B8=8A=E5=AD=A6=E7=94=9F=E5=88=86=E6=95=B0=E5=A4=A7=E4=BA=8E?= =?UTF-8?q?=E6=88=96=E5=B0=8F=E4=BA=8E=E7=9A=84trigger=20feat:=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8C=96=E8=A1=A8=E6=A0=BCtyle=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/data.sql | Bin 126976 -> 151552 bytes src-tauri/src/services/auto_score.rs | 65 +++++++++++++++++++++ src/components/AutoScore/AutoScoreUtils.ts | 55 ++++++++++++++++- src/components/AutoScoreManager.tsx | 28 ++++++++- src/components/ScoreManager.tsx | 33 +++++++++-- src/i18n/locales/en-US.json | 2 + src/i18n/locales/zh-CN.json | 2 + 7 files changed, 177 insertions(+), 8 deletions(-) diff --git a/src-tauri/data.sql b/src-tauri/data.sql index 0612fe9a9cecd4ce724715372ff05c745201c9fd..5fb6752e990a5e7bdbc7877d8e012220995c76f8 100644 GIT binary patch delta 8588 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 delta 465 zcmZozz}fJCeS#Dp!y^U;26-Ts17gOB8fHukk2WSOS{ZB|rJ;h$_E zFT!N*IQfFS7OR1tp_#?xQh7HPXGSiD&HLr01sDxB-_}=E;Nu1A(`5L=z`u~+iSOZL zz5w6JLIMl7|GB`(!XL#2lH@F4;1lJ(&ehKo#CeE+J>O2AYF>FR5grD80Y4K%qhp1HTg}Zvl}q#ZBH~{Oy!%bATVpXi9O>5W=+1G)7c#u?Y9>>Fiz(M zir?A3(~r?NQG$Viw~;rKH-gucSAv&;=Q+<6o{c;+*ci6UZD5RK+N>bZ$;{qq&ds7b zDR}z-ZH!*qPi$f|U}4YCE6Ipas@>et)z8Feuw7y=<69e>lj<&ceRM7byIq8bvsmlLr_uphy6N?fRz}A2LthaFVfL`zmRs iFqY{K;!IrI`5rL3v9rd4lx)wHV4BPh7mnj#j0FJQVS-Hn 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)}>