feat: 自动化加上学生分数大于或小于的trigger

feat: 自动化表格tyle统一
This commit is contained in:
NanGua-QWQ
2026-04-10 22:35:42 +08:00
parent eef6b703f7
commit f2c062f904
7 changed files with 177 additions and 8 deletions
Binary file not shown.
+65
View File
@@ -563,6 +563,20 @@ fn build_trigger_rule_node(trigger: &AutoScoreTrigger) -> JsonValue {
trigger.value.clone().unwrap_or_default(), 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", "student_sql",
"equal", "equal",
@@ -742,6 +756,11 @@ fn trigger_from_rule_node(node: &JsonValue) -> Result<AutoScoreTrigger, String>
.and_then(|value| value.get("field")) .and_then(|value| value.get("field"))
.and_then(JsonValue::as_str) .and_then(JsonValue::as_str)
.unwrap_or_default(); .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 let first_value = node
.get("properties") .get("properties")
.and_then(|value| value.get("value")) .and_then(|value| value.get("value"))
@@ -785,6 +804,23 @@ fn trigger_from_rule_node(node: &JsonValue) -> Result<AutoScoreTrigger, String>
}; };
normalize_trigger(trigger) 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)), _ => Err(format!("Unsupported trigger tree field: {}", field)),
} }
} }
@@ -829,6 +865,14 @@ fn normalize_trigger(trigger: AutoScoreTrigger) -> Result<AutoScoreTrigger, Stri
value: Some(raw_sql), value: Some(raw_sql),
}) })
} }
"student_score_gt" | "student_score_lt" => {
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)), _ => Err(format!("Unsupported trigger event: {}", event)),
} }
} }
@@ -932,6 +976,15 @@ fn normalize_non_zero_integer_string(value: Option<&str>) -> Option<String> {
Some(parsed.to_string()) Some(parsed.to_string())
} }
fn normalize_integer_string(value: Option<&str>) -> Option<String> {
let normalized = value?.trim();
if normalized.is_empty() {
return None;
}
let parsed = normalized.parse::<i32>().ok()?;
Some(parsed.to_string())
}
fn normalize_last_executed(value: Option<String>) -> Option<String> { fn normalize_last_executed(value: Option<String>) -> Option<String> {
value value
.as_deref() .as_deref()
@@ -1816,6 +1869,18 @@ fn evaluate_trigger_tree_for_student(
.map(|refs| refs.ids.contains(&student.id) || refs.names.contains(&student.name)) .map(|refs| refs.ids.contains(&student.id) || refs.names.contains(&student.name))
.unwrap_or(false) .unwrap_or(false)
} }
"student_score_gt" => trigger
.value
.as_deref()
.and_then(|value| value.trim().parse::<i32>().ok())
.map(|threshold| student.score > threshold)
.unwrap_or(false),
"student_score_lt" => trigger
.value
.as_deref()
.and_then(|value| value.trim().parse::<i32>().ok())
.map(|threshold| student.score < threshold)
.unwrap_or(false),
_ => false, _ => false,
}; };
Ok(matched) Ok(matched)
+54 -1
View File
@@ -75,9 +75,13 @@ export type ActionDraftError = "action_required" | "score_required" | "tag_requi
const TRIGGER_FIELD_INTERVAL = "interval_minutes" const TRIGGER_FIELD_INTERVAL = "interval_minutes"
const TRIGGER_FIELD_TAG = "student_tag" const TRIGGER_FIELD_TAG = "student_tag"
const TRIGGER_FIELD_SQL = "student_sql" 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_TYPE_INTERVAL = "interval_duration"
const TRIGGER_WIDGET_INTERVAL = "interval_duration" const TRIGGER_WIDGET_INTERVAL = "interval_duration"
const OP_EQUAL = "equal" const OP_EQUAL = "equal"
const OP_GREATER = "greater"
const OP_LESS = "less"
const OP_MULTISELECT_CONTAINS = "multiselect_contains" const OP_MULTISELECT_CONTAINS = "multiselect_contains"
const buildEmptyGroup = (): JsonGroup => ({ 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 return null
} }
const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => { const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => {
const field = typeof rule.properties?.field === "string" ? rule.properties.field : "" 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 const value = Array.isArray(rule.properties?.value) ? rule.properties.value[0] : undefined
if (field === TRIGGER_FIELD_INTERVAL) { 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 return null
} }
@@ -261,6 +289,16 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
}, },
operators: { operators: {
...AntdConfig.operators, ...AntdConfig.operators,
[OP_GREATER]: {
...AntdConfig.operators[OP_GREATER],
label: ">",
labelForFormat: ">",
},
[OP_LESS]: {
...AntdConfig.operators[OP_LESS],
label: "<",
labelForFormat: "<",
},
[OP_MULTISELECT_CONTAINS]: { [OP_MULTISELECT_CONTAINS]: {
...AntdConfig.operators[OP_MULTISELECT_CONTAINS], ...AntdConfig.operators[OP_MULTISELECT_CONTAINS],
label: t("autoScore.operatorContains"), label: t("autoScore.operatorContains"),
@@ -329,6 +367,13 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
placeholder: t("autoScore.triggerStudentSqlPlaceholder"), placeholder: t("autoScore.triggerStudentSqlPlaceholder"),
}, },
}, },
[TRIGGER_FIELD_SCORE]: {
label: t("autoScore.triggerStudentScore"),
type: "number",
defaultOperator: OP_GREATER,
operators: [OP_GREATER, OP_LESS],
valueSources: ["value"],
},
}, },
settings: { settings: {
...AntdConfig.settings, ...AntdConfig.settings,
@@ -341,6 +386,7 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
canLeaveEmptyGroup: false, canLeaveEmptyGroup: false,
canReorder: true, canReorder: true,
canRegroup: true, canRegroup: true,
setOpOnChangeField: ["default"],
}, },
}) as Config }) as Config
@@ -401,7 +447,14 @@ const hydrateTriggerTreeNode = (
fallbackIndex.current += 1 fallbackIndex.current += 1
const parsed = triggerFromRule(node) const parsed = triggerFromRule(node)
if (parsed) { if (parsed) {
return node const normalizedRule = ruleFromTrigger(parsed)
if (!normalizedRule) {
return node
}
return {
...node,
properties: normalizedRule.properties,
}
} }
if (!fallbackTrigger) { if (!fallbackTrigger) {
+27 -1
View File
@@ -96,6 +96,8 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
const [editingRuleId, setEditingRuleId] = useState<number | null>(null) const [editingRuleId, setEditingRuleId] = useState<number | null>(null)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
const [batchCurrentPage, setBatchCurrentPage] = useState(1)
const [batchPageSize, setBatchPageSize] = useState(10)
useEffect(() => { useEffect(() => {
const maxPage = Math.max(1, Math.ceil(rules.length / pageSize)) 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]) }, [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(() => { useEffect(() => {
setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig)) setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig))
}, [triggerConfig]) }, [triggerConfig])
@@ -584,6 +593,10 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
] ]
const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize) const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)
const pagedBatches = batches.slice(
(batchCurrentPage - 1) * batchPageSize,
batchCurrentPage * batchPageSize
)
return ( return (
<div style={{ padding: "24px" }}> <div style={{ padding: "24px" }}>
@@ -701,11 +714,24 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
<Table <Table
rowKey="id" rowKey="id"
columns={batchColumns} columns={batchColumns}
dataSource={batches.slice(0, 50)} dataSource={pagedBatches}
pagination={false} pagination={false}
tableLayout="fixed" tableLayout="fixed"
scroll={{ x: 860 }} scroll={{ x: 860 }}
/> />
<div style={{ marginTop: 16, textAlign: "right" }}>
<Pagination
current={batchCurrentPage}
pageSize={batchPageSize}
total={batches.length}
showSizeChanger
showTotal={(total) => t("common.total", { count: total })}
onChange={(page, size) => {
setBatchCurrentPage(page)
setBatchPageSize(size)
}}
/>
</div>
</Card> </Card>
</div> </div>
) )
+27 -6
View File
@@ -84,12 +84,21 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [students, setStudents] = useState<student[]>([]) const [students, setStudents] = useState<student[]>([])
const [reasons, setReasons] = useState<reason[]>([]) const [reasons, setReasons] = useState<reason[]>([])
const [events, setEvents] = useState<scoreEvent[]>([]) const [events, setEvents] = useState<scoreEvent[]>([])
const [recordCurrentPage, setRecordCurrentPage] = useState(1)
const [recordPageSize, setRecordPageSize] = useState(10)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [submitLoading, setSubmitLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false)
const [form] = Form.useForm() const [form] = Form.useForm()
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const selectedStudentNames = Form.useWatch("student_name", form) as string[] | undefined 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") => { const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => {
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) 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([ const [stuRes, reaRes, eveRes] = await Promise.all([
(window as any).api.queryStudents({}), (window as any).api.queryStudents({}),
(window as any).api.queryReasons(), (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) if (stuRes.success) setStudents(stuRes.data)
@@ -231,12 +240,12 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
} }
const columns: ColumnsType<scoreEvent> = [ const columns: ColumnsType<scoreEvent> = [
{ 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"), title: t("score.change"),
dataIndex: "delta", dataIndex: "delta",
key: "delta", key: "delta",
width: 80, width: 92,
render: (delta: number) => ( render: (delta: number) => (
<Tag color={delta > 0 ? "success" : "error"}>{delta > 0 ? `+${delta}` : delta}</Tag> <Tag color={delta > 0 ? "success" : "error"}>{delta > 0 ? `+${delta}` : delta}</Tag>
), ),
@@ -245,6 +254,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
title: t("score.reason"), title: t("score.reason"),
dataIndex: "reason_content", dataIndex: "reason_content",
key: "reason_content", key: "reason_content",
width: 280,
ellipsis: true, ellipsis: true,
}, },
{ {
@@ -257,7 +267,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
{ {
title: t("common.operation"), title: t("common.operation"),
key: "operation", key: "operation",
width: 80, width: 96,
render: (_, row) => ( render: (_, row) => (
<Popconfirm title={t("score.undoConfirm")} onConfirm={() => handleUndo(row.uuid)}> <Popconfirm title={t("score.undoConfirm")} onConfirm={() => handleUndo(row.uuid)}>
<Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}> <Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}>
@@ -388,8 +398,19 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
columns={columns} columns={columns}
rowKey="uuid" rowKey="uuid"
loading={loading} loading={loading}
size="small" pagination={{
pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }} current: recordCurrentPage,
pageSize: recordPageSize,
total: events.length,
showSizeChanger: true,
showTotal: (total) => t("common.total", { count: total }),
onChange: (page, size) => {
setRecordCurrentPage(page)
setRecordPageSize(size)
},
}}
tableLayout="fixed"
scroll={{ x: 860 }}
style={{ color: "var(--ss-text-main)" }} style={{ color: "var(--ss-text-main)" }}
/> />
</Card> </Card>
+2
View File
@@ -816,6 +816,8 @@
"tagNamesPlaceholder": "e.g. excellent_student,class_monitor", "tagNamesPlaceholder": "e.g. excellent_student,class_monitor",
"triggerIntervalTime": "Interval Time", "triggerIntervalTime": "Interval Time",
"triggerStudentTag": "Student Tag", "triggerStudentTag": "Student Tag",
"triggerStudentScore": "Student Score",
"triggerStudentScoreGreater": "Student Score Greater Than",
"triggerStudentSql": "Custom SQL Condition", "triggerStudentSql": "Custom SQL Condition",
"triggerStudentSqlPlaceholder": "Enter student SQL or a WHERE condition", "triggerStudentSqlPlaceholder": "Enter student SQL or a WHERE condition",
"actionAddScore": "Add Score", "actionAddScore": "Add Score",
+2
View File
@@ -814,6 +814,8 @@
"operationNoteLabel": "操作说明(可选)", "operationNoteLabel": "操作说明(可选)",
"triggerIntervalTime": "间隔时间", "triggerIntervalTime": "间隔时间",
"triggerStudentTag": "学生标签", "triggerStudentTag": "学生标签",
"triggerStudentScore": "学生分数",
"triggerStudentScoreGreater": "学生分数大于",
"triggerStudentSql": "自定义 SQL 条件", "triggerStudentSql": "自定义 SQL 条件",
"triggerStudentSqlPlaceholder": "输入筛选学生的 SQL 或 WHERE 条件", "triggerStudentSqlPlaceholder": "输入筛选学生的 SQL 或 WHERE 条件",
"actionAddScore": "加分", "actionAddScore": "加分",