mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 22:39:02 +08:00
Merge branch 'main' of https://github.com/SECTL/SecScore
This commit is contained in:
@@ -32,6 +32,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
() => [
|
||||
{ value: "add_score", label: t("autoScore.actionAddScore") },
|
||||
{ value: "add_tag", label: t("autoScore.actionAddTag") },
|
||||
{ value: "settle_score", label: t("autoScore.actionSettleScore") },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
@@ -86,7 +87,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
onChange={(event: ActionEvent) =>
|
||||
updateAction(action.id, {
|
||||
event,
|
||||
value: event === "add_score" ? "1" : [],
|
||||
value: event === "add_score" ? "1" : event === "add_tag" ? [] : "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -100,7 +101,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
updateAction(action.id, { value: nextValue === null ? "" : String(nextValue) })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
) : action.event === "add_tag" ? (
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
@@ -114,6 +115,10 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
options={mergedTagOptions}
|
||||
onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ minWidth: 260, color: "var(--ss-text-secondary)" }}>
|
||||
{t("autoScore.actionSettleScoreHint")}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
danger
|
||||
|
||||
@@ -24,17 +24,40 @@ export interface AutoScoreAction {
|
||||
value?: string | null
|
||||
}
|
||||
|
||||
export interface AutoScoreExecutionConfig {
|
||||
cooldownMinutes?: number | null
|
||||
maxRunsPerDay?: number | null
|
||||
maxScoreDeltaPerDay?: number | null
|
||||
}
|
||||
|
||||
export interface AutoScoreExecutionBatch {
|
||||
id: string
|
||||
ruleId: number
|
||||
ruleName: string
|
||||
runAt: string
|
||||
affectedStudents: number
|
||||
affectedStudentNames: string[]
|
||||
createdEventIds: number[]
|
||||
addedStudentTagIds: number[]
|
||||
scoreDeltaTotal: number
|
||||
settled: boolean
|
||||
rolledBack: boolean
|
||||
rollbackAt?: string | null
|
||||
}
|
||||
|
||||
export interface AutoScoreRule {
|
||||
id: number
|
||||
name: string
|
||||
enabled: boolean
|
||||
studentNames: string[]
|
||||
triggers: AutoScoreTrigger[]
|
||||
triggerTree?: JsonGroup | null
|
||||
actions: AutoScoreAction[]
|
||||
execution?: AutoScoreExecutionConfig
|
||||
lastExecuted?: string | null
|
||||
}
|
||||
|
||||
export type ActionEvent = "add_score" | "add_tag"
|
||||
export type ActionEvent = "add_score" | "add_tag" | "settle_score"
|
||||
|
||||
export interface AutoScoreTagOption {
|
||||
label: string
|
||||
@@ -51,9 +74,14 @@ 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 => ({
|
||||
@@ -146,11 +174,44 @@ const ruleFromTrigger = (trigger: AutoScoreTrigger): JsonRule | null => {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
trigger.event === "query_sql" ||
|
||||
trigger.event === "student_query_sql" ||
|
||||
trigger.event === "student_sql"
|
||||
) {
|
||||
const sql = toStringValue(trigger.value).trim()
|
||||
if (!sql) return null
|
||||
return {
|
||||
id: QbUtils.uuid(),
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: TRIGGER_FIELD_SQL,
|
||||
operator: OP_EQUAL,
|
||||
value: [sql],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -175,6 +236,24 @@ const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => {
|
||||
}
|
||||
}
|
||||
|
||||
if (field === TRIGGER_FIELD_SQL) {
|
||||
const sql = toStringValue(value).trim()
|
||||
if (!sql) return null
|
||||
return {
|
||||
event: "query_sql",
|
||||
value: sql,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -197,6 +276,35 @@ const collectTriggersFromItems = (items: JsonItem[] | undefined): AutoScoreTrigg
|
||||
export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagOption[]): Config =>
|
||||
({
|
||||
...AntdConfig,
|
||||
conjunctions: {
|
||||
...AntdConfig.conjunctions,
|
||||
AND: {
|
||||
...AntdConfig.conjunctions.AND,
|
||||
label: t("autoScore.relationAnd"),
|
||||
},
|
||||
OR: {
|
||||
...AntdConfig.conjunctions.OR,
|
||||
label: t("autoScore.relationOr"),
|
||||
},
|
||||
},
|
||||
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"),
|
||||
labelForFormat: t("autoScore.operatorContains"),
|
||||
},
|
||||
},
|
||||
widgets: {
|
||||
...AntdConfig.widgets,
|
||||
[TRIGGER_WIDGET_INTERVAL]: {
|
||||
@@ -250,6 +358,22 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
|
||||
showSearch: true,
|
||||
},
|
||||
},
|
||||
[TRIGGER_FIELD_SQL]: {
|
||||
label: t("autoScore.triggerStudentSql"),
|
||||
type: "text",
|
||||
operators: [OP_EQUAL],
|
||||
valueSources: ["value"],
|
||||
fieldSettings: {
|
||||
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,
|
||||
@@ -257,16 +381,21 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
|
||||
compactMode: false,
|
||||
renderSize: "medium",
|
||||
showNot: true,
|
||||
notLabel: t("autoScore.relationNot"),
|
||||
forceShowConj: true,
|
||||
canLeaveEmptyGroup: false,
|
||||
canReorder: true,
|
||||
canRegroup: true,
|
||||
setOpOnChangeField: ["default"],
|
||||
},
|
||||
}) as Config
|
||||
|
||||
export const createEmptyTriggerTree = (config: Config): ImmutableTree =>
|
||||
QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config)
|
||||
|
||||
export const normalizeTriggerTree = (tree: ImmutableTree, config: Config): ImmutableTree =>
|
||||
QbUtils.checkTree(tree, config)
|
||||
|
||||
export const triggersToQueryTree = (
|
||||
config: Config,
|
||||
triggers: AutoScoreTrigger[]
|
||||
@@ -286,6 +415,77 @@ export const queryTreeToTriggers = (tree: ImmutableTree, config: Config): AutoSc
|
||||
return collectTriggersFromItems(jsonTree.children1)
|
||||
}
|
||||
|
||||
export const queryTreeToJson = (tree: ImmutableTree, config: Config): JsonGroup | null => {
|
||||
const checkedTree = QbUtils.checkTree(tree, config)
|
||||
const jsonTree = QbUtils.getTree(checkedTree, false, true)
|
||||
if (!jsonTree || jsonTree.type !== "group") return null
|
||||
return jsonTree as JsonGroup
|
||||
}
|
||||
|
||||
const hydrateTriggerTreeNode = (
|
||||
node: JsonItem,
|
||||
fallbackTriggers: AutoScoreTrigger[],
|
||||
fallbackIndex: { current: number }
|
||||
): JsonItem | null => {
|
||||
if (node.type === "group") {
|
||||
const children = Array.isArray(node.children1)
|
||||
? node.children1
|
||||
.map((child) => hydrateTriggerTreeNode(child, fallbackTriggers, fallbackIndex))
|
||||
.filter((child): child is JsonItem => Boolean(child))
|
||||
: []
|
||||
return {
|
||||
...node,
|
||||
children1: children,
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type !== "rule") {
|
||||
return node
|
||||
}
|
||||
|
||||
const fallbackTrigger = fallbackTriggers[fallbackIndex.current]
|
||||
fallbackIndex.current += 1
|
||||
const parsed = triggerFromRule(node)
|
||||
if (parsed) {
|
||||
const normalizedRule = ruleFromTrigger(parsed)
|
||||
if (!normalizedRule) {
|
||||
return node
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
properties: normalizedRule.properties,
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackTrigger) {
|
||||
return node
|
||||
}
|
||||
|
||||
const fallbackRule = ruleFromTrigger(fallbackTrigger)
|
||||
if (!fallbackRule) {
|
||||
return node
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
properties: fallbackRule.properties,
|
||||
}
|
||||
}
|
||||
|
||||
export const triggerTreeJsonToQueryTree = (
|
||||
config: Config,
|
||||
triggerTree?: JsonGroup | null,
|
||||
fallbackTriggers: AutoScoreTrigger[] = []
|
||||
): ImmutableTree => {
|
||||
if (triggerTree && triggerTree.type === "group") {
|
||||
const hydratedTree = hydrateTriggerTreeNode(triggerTree, fallbackTriggers, { current: 0 })
|
||||
if (hydratedTree && hydratedTree.type === "group") {
|
||||
return QbUtils.checkTree(QbUtils.loadTree(hydratedTree), config)
|
||||
}
|
||||
}
|
||||
return triggersToQueryTree(config, fallbackTriggers)
|
||||
}
|
||||
|
||||
const hasUnsupportedLogicInGroup = (group: JsonGroup): boolean => {
|
||||
const conjunction =
|
||||
typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND"
|
||||
@@ -319,7 +519,7 @@ export const createDefaultActionDraft = (): ActionDraft => ({
|
||||
})
|
||||
|
||||
const isActionEvent = (value: unknown): value is ActionEvent =>
|
||||
value === "add_score" || value === "add_tag"
|
||||
value === "add_score" || value === "add_tag" || value === "settle_score"
|
||||
|
||||
export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined): ActionDraft[] => {
|
||||
if (!Array.isArray(drafts) || drafts.length === 0) {
|
||||
@@ -336,7 +536,9 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
|
||||
value:
|
||||
draft.event === "add_tag"
|
||||
? parseTagValues(draft.value)
|
||||
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
||||
: draft.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
||||
} satisfies ActionDraft
|
||||
})
|
||||
.filter((item): item is ActionDraft => Boolean(item))
|
||||
@@ -347,12 +549,18 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
|
||||
export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
|
||||
const mapped = actions
|
||||
.map((action) => {
|
||||
if (action.event !== "add_score" && action.event !== "add_tag") return null
|
||||
if (action.event !== "add_score" && action.event !== "add_tag" && action.event !== "settle_score") {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: QbUtils.uuid(),
|
||||
event: action.event,
|
||||
value:
|
||||
action.event === "add_tag" ? parseTagValues(action.value) : toStringValue(action.value),
|
||||
action.event === "add_tag"
|
||||
? parseTagValues(action.value)
|
||||
: action.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(action.value),
|
||||
} satisfies ActionDraft
|
||||
})
|
||||
.filter((item): item is ActionDraft => Boolean(item))
|
||||
@@ -379,6 +587,11 @@ export const actionDraftsToPayload = (
|
||||
continue
|
||||
}
|
||||
|
||||
if (draft.event === "settle_score") {
|
||||
actions.push({ event: draft.event })
|
||||
continue
|
||||
}
|
||||
|
||||
const serializedTagValue = stringifyTagValues(parseTagValues(draft.value))
|
||||
if (!serializedTagValue) {
|
||||
return { actions: [], error: "tag_required" }
|
||||
|
||||
@@ -1,44 +1,87 @@
|
||||
export type IntervalUnit = "minute" | "day" | "month"
|
||||
|
||||
export interface IntervalValue {
|
||||
days: number
|
||||
hours: number
|
||||
minutes: number
|
||||
}
|
||||
|
||||
interface LegacyIntervalValue {
|
||||
amount: number
|
||||
unit: IntervalUnit
|
||||
}
|
||||
|
||||
export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day"
|
||||
|
||||
const normalizePositiveInteger = (value: unknown): number | null => {
|
||||
const normalizeNonNegativeInteger = (value: unknown): number | null => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const normalized = Math.floor(value)
|
||||
return normalized > 0 ? normalized : null
|
||||
return normalized >= 0 ? normalized : null
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) {
|
||||
const normalized = Math.floor(parsed)
|
||||
return normalized > 0 ? normalized : null
|
||||
return normalized >= 0 ? normalized : null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizePositiveInteger = (value: unknown): number | null => {
|
||||
const normalized = normalizeNonNegativeInteger(value)
|
||||
if (normalized === null || normalized <= 0) return null
|
||||
return normalized
|
||||
}
|
||||
|
||||
const isIntervalUnit = (value: unknown): value is IntervalUnit =>
|
||||
value === "minute" || value === "day" || value === "month"
|
||||
|
||||
export const getDefaultIntervalValue = (): IntervalValue => ({
|
||||
amount: 1,
|
||||
unit: DEFAULT_INTERVAL_UNIT,
|
||||
days: 1,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
})
|
||||
|
||||
const fromTotalMinutes = (totalMinutes: number): IntervalValue => {
|
||||
const normalized = Math.max(0, Math.floor(totalMinutes))
|
||||
const days = Math.floor(normalized / (24 * 60))
|
||||
const hours = Math.floor((normalized % (24 * 60)) / 60)
|
||||
const minutes = normalized % 60
|
||||
return { days, hours, minutes }
|
||||
}
|
||||
|
||||
const toTotalMinutes = (value: IntervalValue): number =>
|
||||
value.days * 24 * 60 + value.hours * 60 + value.minutes
|
||||
|
||||
const normalizeCompositeValue = (value: Partial<IntervalValue>): IntervalValue | null => {
|
||||
const days = normalizeNonNegativeInteger(value.days) ?? 0
|
||||
const hours = normalizeNonNegativeInteger(value.hours) ?? 0
|
||||
const minutes = normalizeNonNegativeInteger(value.minutes) ?? 0
|
||||
|
||||
const totalMinutes = days * 24 * 60 + hours * 60 + minutes
|
||||
if (totalMinutes <= 0) return null
|
||||
return fromTotalMinutes(totalMinutes)
|
||||
}
|
||||
|
||||
const legacyToComposite = (value: Partial<LegacyIntervalValue>): IntervalValue | null => {
|
||||
const amount = normalizePositiveInteger(value.amount)
|
||||
if (!amount || !isIntervalUnit(value.unit)) return null
|
||||
|
||||
if (value.unit === "minute") return fromTotalMinutes(amount)
|
||||
if (value.unit === "day") return fromTotalMinutes(amount * 24 * 60)
|
||||
|
||||
// Legacy month values are approximated as 30 days in the editor.
|
||||
return fromTotalMinutes(amount * 30 * 24 * 60)
|
||||
}
|
||||
|
||||
export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const amount = normalizePositiveInteger((value as IntervalValue).amount)
|
||||
const unit = (value as IntervalValue).unit
|
||||
if (amount && isIntervalUnit(unit)) {
|
||||
return { amount, unit }
|
||||
}
|
||||
const composite = normalizeCompositeValue(value as Partial<IntervalValue>)
|
||||
if (composite) return composite
|
||||
|
||||
const legacy = legacyToComposite(value as Partial<LegacyIntervalValue>)
|
||||
if (legacy) return legacy
|
||||
}
|
||||
|
||||
const text = value === null || value === undefined ? "" : String(value).trim()
|
||||
@@ -46,15 +89,16 @@ export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null
|
||||
|
||||
const legacyMinutes = normalizePositiveInteger(text)
|
||||
if (legacyMinutes !== null && !text.startsWith("{")) {
|
||||
return { amount: legacyMinutes, unit: "minute" }
|
||||
return fromTotalMinutes(legacyMinutes)
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Partial<IntervalValue>
|
||||
const amount = normalizePositiveInteger(parsed.amount)
|
||||
if (amount && isIntervalUnit(parsed.unit)) {
|
||||
return { amount, unit: parsed.unit }
|
||||
}
|
||||
const parsed = JSON.parse(text) as Partial<IntervalValue & LegacyIntervalValue>
|
||||
const composite = normalizeCompositeValue(parsed)
|
||||
if (composite) return composite
|
||||
|
||||
const legacy = legacyToComposite(parsed)
|
||||
if (legacy) return legacy
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
@@ -63,17 +107,15 @@ export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null
|
||||
}
|
||||
|
||||
export const stringifyIntervalTriggerValue = (value: IntervalValue): string | null => {
|
||||
const amount = normalizePositiveInteger(value.amount)
|
||||
if (!amount || !isIntervalUnit(value.unit)) {
|
||||
return null
|
||||
}
|
||||
const normalized = normalizeCompositeValue(value)
|
||||
if (!normalized) return null
|
||||
|
||||
if (value.unit === "minute") {
|
||||
return String(amount)
|
||||
}
|
||||
const totalMinutes = toTotalMinutes(normalized)
|
||||
if (totalMinutes <= 0) return null
|
||||
|
||||
return JSON.stringify({
|
||||
amount,
|
||||
unit: value.unit,
|
||||
days: normalized.days,
|
||||
hours: normalized.hours,
|
||||
minutes: normalized.minutes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,116 +1,87 @@
|
||||
import React, { useMemo } from "react"
|
||||
import { InputNumber, Select } from "antd"
|
||||
import React from "react"
|
||||
import { InputNumber } from "antd"
|
||||
import type { WidgetProps } from "@react-awesome-query-builder/antd"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import {
|
||||
getDefaultIntervalValue,
|
||||
parseIntervalTriggerValue,
|
||||
stringifyIntervalTriggerValue,
|
||||
type IntervalValue,
|
||||
} from "./IntervalValueCodec"
|
||||
|
||||
export type IntervalUnit = "month" | "day" | "minute"
|
||||
export { parseIntervalTriggerValue, stringifyIntervalTriggerValue } from "./IntervalValueCodec"
|
||||
export type { IntervalValue, IntervalUnit } from "./IntervalValueCodec"
|
||||
|
||||
export interface IntervalValue {
|
||||
amount: number
|
||||
unit: IntervalUnit
|
||||
}
|
||||
|
||||
export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day"
|
||||
|
||||
export const getDefaultIntervalValue = (): IntervalValue => ({
|
||||
amount: 1,
|
||||
unit: DEFAULT_INTERVAL_UNIT,
|
||||
})
|
||||
|
||||
export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => {
|
||||
if (value === null || value === undefined) return null
|
||||
|
||||
const str = String(value).trim()
|
||||
if (!str) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(str)
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
typeof parsed.amount === "number" &&
|
||||
typeof parsed.unit === "string"
|
||||
) {
|
||||
const validUnits: IntervalUnit[] = ["month", "day", "minute"]
|
||||
if (validUnits.includes(parsed.unit as IntervalUnit)) {
|
||||
return {
|
||||
amount: Math.max(1, Math.floor(parsed.amount)),
|
||||
unit: parsed.unit as IntervalUnit,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
const buildNextIntervalValue = (
|
||||
currentValue: IntervalValue | null,
|
||||
patch: Partial<IntervalValue>
|
||||
): IntervalValue => {
|
||||
const base = currentValue ?? getDefaultIntervalValue()
|
||||
return {
|
||||
days: patch.days ?? base.days,
|
||||
hours: patch.hours ?? base.hours,
|
||||
minutes: patch.minutes ?? base.minutes,
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const stringifyIntervalTriggerValue = (value: IntervalValue): string => {
|
||||
return JSON.stringify({
|
||||
amount: value.amount,
|
||||
unit: value.unit,
|
||||
})
|
||||
}
|
||||
const getDisplayValue = (value: number | undefined, fallback = 0) =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : fallback
|
||||
|
||||
export const IntervalValueWidget: React.FC<WidgetProps> = ({
|
||||
value,
|
||||
setValue,
|
||||
readonly,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const parsedValue = parseIntervalTriggerValue(value)
|
||||
|
||||
const unitOptions = useMemo(
|
||||
() => [
|
||||
{ label: t("autoScore.intervalUnitMonth"), value: "month" },
|
||||
{ label: t("autoScore.intervalUnitDay"), value: "day" },
|
||||
{ label: t("autoScore.intervalUnitMinute"), value: "minute" },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const handleAmountChange = (nextAmount: number | null) => {
|
||||
if (nextAmount === null) {
|
||||
setValue(null)
|
||||
return
|
||||
}
|
||||
|
||||
const serializedValue = stringifyIntervalTriggerValue({
|
||||
amount: nextAmount,
|
||||
unit: parsedValue?.unit ?? DEFAULT_INTERVAL_UNIT,
|
||||
})
|
||||
setValue(serializedValue)
|
||||
}
|
||||
|
||||
const handleUnitChange = (nextUnit: IntervalUnit) => {
|
||||
const baseValue = parsedValue ?? getDefaultIntervalValue()
|
||||
const serializedValue = stringifyIntervalTriggerValue({
|
||||
amount: baseValue.amount,
|
||||
unit: nextUnit,
|
||||
})
|
||||
setValue(serializedValue)
|
||||
const updateValue = (patch: Partial<IntervalValue>) => {
|
||||
const nextValue = buildNextIntervalValue(parsedValue, patch)
|
||||
const serialized = stringifyIntervalTriggerValue(nextValue)
|
||||
setValue(serialized)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, minWidth: 280 }}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
disabled={readonly}
|
||||
value={parsedValue?.amount}
|
||||
placeholder={placeholder || t("autoScore.intervalAmountPlaceholder")}
|
||||
onChange={handleAmountChange}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<Select
|
||||
disabled={readonly}
|
||||
value={parsedValue?.unit ?? DEFAULT_INTERVAL_UNIT}
|
||||
options={unitOptions}
|
||||
onChange={handleUnitChange}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 12, minWidth: 360 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||
{t("autoScore.intervalPartDay")}
|
||||
</span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={readonly}
|
||||
value={getDisplayValue(parsedValue?.days)}
|
||||
onChange={(next) => updateValue({ days: Math.max(0, Number(next ?? 0)) })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||
{t("autoScore.intervalPartHour")}
|
||||
</span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={readonly}
|
||||
value={getDisplayValue(parsedValue?.hours)}
|
||||
onChange={(next) => updateValue({ hours: Math.max(0, Number(next ?? 0)) })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||
{t("autoScore.intervalPartMinute")}
|
||||
</span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={readonly}
|
||||
value={getDisplayValue(parsedValue?.minutes)}
|
||||
onChange={(next) => updateValue({ minutes: Math.max(0, Number(next ?? 0)) })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo } from "react"
|
||||
import { Card } from "antd"
|
||||
import { Alert, Card } from "antd"
|
||||
import {
|
||||
Builder,
|
||||
Query,
|
||||
@@ -46,6 +46,12 @@ export const TriggerRuleBuilder: React.FC<TriggerRuleBuilderProps> = ({
|
||||
style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}
|
||||
title={t("autoScore.whenTriggered")}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: "16px" }}
|
||||
title={t("autoScore.triggerScheduleHint")}
|
||||
/>
|
||||
<div
|
||||
className="query-builder-container"
|
||||
style={{
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Select,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
message,
|
||||
} from "antd"
|
||||
import { type ImmutableTree } from "@react-awesome-query-builder/antd"
|
||||
@@ -19,6 +22,7 @@ import type { ColumnsType } from "antd/es/table"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { fetchAllTags } from "./TagEditorDialog"
|
||||
import { ActionEditor } from "./AutoScore/ActionEditor"
|
||||
import { parseIntervalTriggerValue } from "./AutoScore/IntervalValueCodec"
|
||||
import { TriggerRuleBuilder } from "./AutoScore/TriggerRuleBuilder"
|
||||
import {
|
||||
actionDraftsToPayload,
|
||||
@@ -26,11 +30,14 @@ import {
|
||||
createDefaultActionDraft,
|
||||
createEmptyTriggerTree,
|
||||
createTriggerQueryConfig,
|
||||
hasUnsupportedTriggerLogic,
|
||||
normalizeTriggerTree,
|
||||
queryTreeToJson,
|
||||
normalizeActionDrafts,
|
||||
queryTreeToTriggers,
|
||||
triggersToQueryTree,
|
||||
triggerTreeJsonToQueryTree,
|
||||
type ActionDraft,
|
||||
type AutoScoreExecutionBatch,
|
||||
type AutoScoreExecutionConfig,
|
||||
type AutoScoreRule,
|
||||
type AutoScoreTagOption,
|
||||
} from "./AutoScore/AutoScoreUtils"
|
||||
@@ -48,12 +55,15 @@ interface TagItem {
|
||||
interface RuleFormValues {
|
||||
name?: string
|
||||
studentNames?: string[]
|
||||
execution?: AutoScoreExecutionConfig
|
||||
}
|
||||
|
||||
interface AutoScoreManagerProps {
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
const getRuleFileRelativePath = (ruleId: number) => `auto-score/rule-${ruleId}.json`
|
||||
|
||||
function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [form] = Form.useForm<RuleFormValues>()
|
||||
@@ -79,11 +89,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
const [actionDrafts, setActionDrafts] = useState<ActionDraft[]>([createDefaultActionDraft()])
|
||||
const [students, setStudents] = useState<StudentItem[]>([])
|
||||
const [rules, setRules] = useState<AutoScoreRule[]>([])
|
||||
const [batches, setBatches] = useState<AutoScoreExecutionBatch[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [rollingBackBatchId, setRollingBackBatchId] = useState<string | null>(null)
|
||||
const [editingRuleId, setEditingRuleId] = useState<number | null>(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))
|
||||
@@ -92,9 +106,20 @@ 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])
|
||||
|
||||
const resetEditor = () => {
|
||||
setEditingRuleId(null)
|
||||
form.setFieldsValue({ name: "", studentNames: [] })
|
||||
form.setFieldsValue({ name: "", studentNames: [], execution: {} })
|
||||
setTriggerTree(createEmptyTriggerTree(triggerConfig))
|
||||
setActionDrafts([createDefaultActionDraft()])
|
||||
}
|
||||
@@ -149,11 +174,25 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBatches = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
try {
|
||||
const res = await api.autoScoreQueryBatches()
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setBatches(res.data)
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEdit) return
|
||||
fetchTags().catch(() => void 0)
|
||||
fetchStudents().catch(() => void 0)
|
||||
fetchRules().catch(() => void 0)
|
||||
fetchBatches().catch(() => void 0)
|
||||
}, [canEdit])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -167,22 +206,23 @@ 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 || {}
|
||||
|
||||
if (!name) {
|
||||
messageApi.warning(t("autoScore.nameRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
if (hasUnsupportedTriggerLogic(triggerTree, triggerConfig)) {
|
||||
messageApi.warning(t("autoScore.unsupportedLogic"))
|
||||
return
|
||||
}
|
||||
|
||||
const triggers = queryTreeToTriggers(triggerTree, triggerConfig)
|
||||
const triggerTreeJson = queryTreeToJson(triggerTree, triggerConfig)
|
||||
if (triggers.length === 0) {
|
||||
messageApi.warning(t("autoScore.triggerRequired"))
|
||||
return
|
||||
}
|
||||
if (!triggers.some((trigger) => trigger.event === "interval_time_passed")) {
|
||||
messageApi.warning(t("autoScore.intervalTriggerRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedActionDrafts = normalizeActionDrafts(actionDrafts)
|
||||
const actionPayload = actionDraftsToPayload(normalizedActionDrafts)
|
||||
@@ -213,7 +253,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
enabled: true,
|
||||
studentNames,
|
||||
triggers,
|
||||
triggerTree: triggerTreeJson,
|
||||
actions: actionPayload.actions,
|
||||
execution,
|
||||
}
|
||||
const res =
|
||||
editingRuleId === null
|
||||
@@ -226,6 +268,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
)
|
||||
resetEditor()
|
||||
await fetchRules()
|
||||
await fetchBatches()
|
||||
emitDataUpdated()
|
||||
} else {
|
||||
messageApi.error(
|
||||
@@ -247,8 +290,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
form.setFieldsValue({
|
||||
name: rule.name,
|
||||
studentNames: rule.studentNames || [],
|
||||
execution: rule.execution || {},
|
||||
})
|
||||
setTriggerTree(triggersToQueryTree(triggerConfig, rule.triggers || []))
|
||||
setTriggerTree(triggerTreeJsonToQueryTree(triggerConfig, rule.triggerTree, rule.triggers || []))
|
||||
setActionDrafts(actionsToDrafts(rule.actions || []))
|
||||
}
|
||||
|
||||
@@ -262,11 +306,13 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
|
||||
const res = await api.autoScoreDeleteRule(ruleId)
|
||||
if (res.success) {
|
||||
await api.fsDeleteFile(getRuleFileRelativePath(ruleId), "automatic").catch(() => void 0)
|
||||
messageApi.success(t("autoScore.deleteSuccess"))
|
||||
if (editingRuleId === ruleId) {
|
||||
resetEditor()
|
||||
}
|
||||
await fetchRules()
|
||||
await fetchBatches()
|
||||
emitDataUpdated()
|
||||
} else {
|
||||
messageApi.error(res.message || t("autoScore.deleteFailed"))
|
||||
@@ -295,6 +341,40 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenRuleFile = async (rule: AutoScoreRule) => {
|
||||
const api = (window as any).api
|
||||
if (!api) return
|
||||
|
||||
try {
|
||||
const relativePath = getRuleFileRelativePath(rule.id)
|
||||
const fileContent = {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
enabled: rule.enabled,
|
||||
studentNames: rule.studentNames,
|
||||
triggers: rule.triggers,
|
||||
triggerTree: rule.triggerTree ?? null,
|
||||
actions: rule.actions,
|
||||
execution: rule.execution || {},
|
||||
lastExecuted: rule.lastExecuted ?? null,
|
||||
exportedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const writeRes = await api.fsWriteJson(relativePath, fileContent, "automatic")
|
||||
|
||||
if (!writeRes?.success) {
|
||||
throw new Error("prepare rule file failed")
|
||||
}
|
||||
|
||||
const openRes = await api.fsOpenPath(relativePath, "automatic")
|
||||
if (!openRes?.success) {
|
||||
throw new Error(openRes?.message || "open rule file failed")
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t("autoScore.openFileFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const formatLastExecuted = (value?: string | null) => {
|
||||
if (!value) return t("autoScore.notExecuted")
|
||||
const date = new Date(value)
|
||||
@@ -302,6 +382,69 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const formatTriggerSummary = (trigger: AutoScoreRule["triggers"][number]) => {
|
||||
if (trigger.event === "interval_time_passed") {
|
||||
const intervalValue = parseIntervalTriggerValue(trigger.value)
|
||||
if (!intervalValue) {
|
||||
return `${t("autoScore.triggerIntervalTime")}: -`
|
||||
}
|
||||
return `${t("autoScore.triggerIntervalTime")}: ${intervalValue.days}${t(
|
||||
"autoScore.intervalPartDay"
|
||||
)} ${intervalValue.hours}${t("autoScore.intervalPartHour")} ${intervalValue.minutes}${t(
|
||||
"autoScore.intervalPartMinute"
|
||||
)}`
|
||||
}
|
||||
if (trigger.event === "student_has_tag") {
|
||||
return `${t("autoScore.triggerStudentTag")}: ${String(trigger.value || "").trim() || "-"}`
|
||||
}
|
||||
if (
|
||||
trigger.event === "query_sql" ||
|
||||
trigger.event === "student_query_sql" ||
|
||||
trigger.event === "student_sql"
|
||||
) {
|
||||
return `${t("autoScore.triggerStudentSql")}: ${String(trigger.value || "").trim() || "-"}`
|
||||
}
|
||||
return `${trigger.event}: ${String(trigger.value || "").trim() || "-"}`
|
||||
}
|
||||
|
||||
const formatActionSummary = (action: AutoScoreRule["actions"][number]) => {
|
||||
if (action.event === "add_score") {
|
||||
return `${t("autoScore.actionAddScore")}: ${String(action.value || "").trim() || "-"}`
|
||||
}
|
||||
if (action.event === "add_tag") {
|
||||
return `${t("autoScore.actionAddTag")}: ${String(action.value || "").trim() || "-"}`
|
||||
}
|
||||
if (action.event === "settle_score") {
|
||||
return `${t("autoScore.actionSettleScore")}: ${t("autoScore.actionSettleScoreHint")}`
|
||||
}
|
||||
return `${action.event}: ${String(action.value || "").trim() || "-"}`
|
||||
}
|
||||
|
||||
const handleRollbackBatch = async (batchId: string) => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
Modal.confirm({
|
||||
title: t("autoScore.batchRollbackConfirm"),
|
||||
onOk: async () => {
|
||||
setRollingBackBatchId(batchId)
|
||||
try {
|
||||
const res = await api.autoScoreRollbackBatch({ batchId })
|
||||
if (res.success) {
|
||||
messageApi.success(t("autoScore.batchRollbackSuccess"))
|
||||
await fetchBatches()
|
||||
emitDataUpdated()
|
||||
} else {
|
||||
messageApi.error(res.message || t("autoScore.batchRollbackFailed"))
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t("autoScore.batchRollbackFailed"))
|
||||
} finally {
|
||||
setRollingBackBatchId(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AutoScoreRule> = [
|
||||
{
|
||||
title: t("autoScore.name"),
|
||||
@@ -330,7 +473,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
ellipsis: true,
|
||||
render: (_, row) =>
|
||||
row.studentNames.length > 0 ? (
|
||||
<Tag>{t("autoScore.studentCount", { count: row.studentNames.length })}</Tag>
|
||||
<Tooltip title={row.studentNames.join("、")}>
|
||||
<Tag>{t("autoScore.studentCount", { count: row.studentNames.length })}</Tag>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tag>{t("autoScore.allStudents")}</Tag>
|
||||
),
|
||||
@@ -339,13 +484,21 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
title: t("autoScore.triggers"),
|
||||
key: "triggers",
|
||||
width: 120,
|
||||
render: (_, row) => <Tag>{t("autoScore.triggerCount", { count: row.triggers.length })}</Tag>,
|
||||
render: (_, row) => (
|
||||
<Tooltip title={row.triggers.map(formatTriggerSummary).join("\n") || "-"}>
|
||||
<Tag>{t("autoScore.triggerCount", { count: row.triggers.length })}</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("autoScore.actions"),
|
||||
key: "actions",
|
||||
width: 120,
|
||||
render: (_, row) => <Tag>{t("autoScore.actionCount", { count: row.actions.length })}</Tag>,
|
||||
render: (_, row) => (
|
||||
<Tooltip title={row.actions.map(formatActionSummary).join("\n") || "-"}>
|
||||
<Tag>{t("autoScore.actionCount", { count: row.actions.length })}</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("autoScore.lastExecuted"),
|
||||
@@ -358,12 +511,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
{
|
||||
title: t("common.operation"),
|
||||
key: "operation",
|
||||
width: 140,
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" disabled={!canEdit} onClick={() => handleEdit(row)}>
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
<Button type="link" onClick={() => handleOpenRuleFile(row).catch(() => void 0)}>
|
||||
{t("autoScore.openFile")}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t("autoScore.deleteConfirm")}
|
||||
onConfirm={() => {
|
||||
@@ -379,7 +535,68 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
},
|
||||
]
|
||||
|
||||
const batchColumns: ColumnsType<AutoScoreExecutionBatch> = [
|
||||
{
|
||||
title: t("autoScore.batchId"),
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
ellipsis: true,
|
||||
width: 220,
|
||||
render: (value: string) => value.slice(0, 8),
|
||||
},
|
||||
{
|
||||
title: t("autoScore.name"),
|
||||
dataIndex: "ruleName",
|
||||
key: "ruleName",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t("autoScore.lastExecuted"),
|
||||
dataIndex: "runAt",
|
||||
key: "runAt",
|
||||
width: 180,
|
||||
render: (value: string) => formatLastExecuted(value),
|
||||
},
|
||||
{
|
||||
title: t("autoScore.applicableStudents"),
|
||||
dataIndex: "affectedStudents",
|
||||
key: "affectedStudents",
|
||||
width: 100,
|
||||
render: (value: number) => value,
|
||||
},
|
||||
{
|
||||
title: t("autoScore.actionAddScore"),
|
||||
dataIndex: "scoreDeltaTotal",
|
||||
key: "scoreDeltaTotal",
|
||||
width: 120,
|
||||
render: (value: number) => value,
|
||||
},
|
||||
{
|
||||
title: t("common.operation"),
|
||||
key: "operation",
|
||||
width: 180,
|
||||
render: (_, row) =>
|
||||
row.rolledBack ? (
|
||||
<Tag>{t("autoScore.batchRolledBack")}</Tag>
|
||||
) : (
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
loading={rollingBackBatchId === row.id}
|
||||
disabled={row.settled || !canEdit}
|
||||
onClick={() => handleRollbackBatch(row.id).catch(() => void 0)}
|
||||
>
|
||||
{t("autoScore.batchRollback")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
const pagedBatches = batches.slice(
|
||||
(batchCurrentPage - 1) * batchPageSize,
|
||||
batchCurrentPage * batchPageSize
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px" }}>
|
||||
@@ -391,7 +608,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: "16px" }}
|
||||
message={t("autoScore.adminRequired")}
|
||||
title={t("autoScore.adminRequired")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -403,7 +620,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
name="name"
|
||||
rules={[{ required: true, message: t("autoScore.nameRequired") }]}
|
||||
>
|
||||
<Input placeholder={t("autoScore.namePlaceholder")} disabled={!canEdit} />
|
||||
<Input placeholder={t("autoScore.namePlaceholder")} disabled={!canEdit} autoComplete="off"/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("autoScore.applicableStudents")} name="studentNames">
|
||||
<Select
|
||||
@@ -418,6 +635,21 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "12px" }}>
|
||||
<Form.Item label={t("autoScore.cooldownMinutes")} name={["execution", "cooldownMinutes"]}>
|
||||
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("autoScore.maxRunsPerDay")} name={["execution", "maxRunsPerDay"]}>
|
||||
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("autoScore.maxScoreDeltaPerDay")}
|
||||
name={["execution", "maxScoreDeltaPerDay"]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
@@ -474,6 +706,33 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t("autoScore.batchLogs")}
|
||||
style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={batchColumns}
|
||||
dataSource={pagedBatches}
|
||||
pagination={false}
|
||||
tableLayout="fixed"
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,12 +84,21 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [students, setStudents] = useState<student[]>([])
|
||||
const [reasons, setReasons] = useState<reason[]>([])
|
||||
const [events, setEvents] = useState<scoreEvent[]>([])
|
||||
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<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"),
|
||||
dataIndex: "delta",
|
||||
key: "delta",
|
||||
width: 80,
|
||||
width: 92,
|
||||
render: (delta: number) => (
|
||||
<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"),
|
||||
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) => (
|
||||
<Popconfirm title={t("score.undoConfirm")} onConfirm={() => handleUndo(row.uuid)}>
|
||||
<Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}>
|
||||
@@ -274,17 +284,19 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<h2 style={{ marginBottom: "24px", color: "var(--ss-text-main)" }}>{t("score.title")}</h2>
|
||||
|
||||
<Card style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ type: "add" }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ type: "add", student_name: [] }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "24px" }}>
|
||||
<Form.Item label={t("score.student")} name="student_name">
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
placeholder={t("score.pleaseSelectStudent")}
|
||||
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
|
||||
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
||||
/>
|
||||
<Form.Item label={t("score.student")}>
|
||||
<Space orientation="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Form.Item name="student_name" noStyle>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
placeholder={t("score.pleaseSelectStudent")}
|
||||
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
|
||||
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -386,8 +398,19 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
columns={columns}
|
||||
rowKey="uuid"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }}
|
||||
pagination={{
|
||||
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)" }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
+40
-40
@@ -18,8 +18,14 @@ import {
|
||||
import { ThemeQuickSettings } from "./ThemeQuickSettings"
|
||||
import { OAuthLogin } from "./OAuth/OAuthLogin"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { pinyin } from "pinyin-pro"
|
||||
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
|
||||
import { useResponsive } from "../hooks/useResponsive"
|
||||
import {
|
||||
buildSystemFontFamily,
|
||||
buildSystemFontValue,
|
||||
SYSTEM_FONT_STACK,
|
||||
} from "../shared/fontFamily"
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
@@ -37,46 +43,33 @@ interface FontOption {
|
||||
value: string
|
||||
label: string
|
||||
fontFamily: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const SYSTEM_FONT_STACK =
|
||||
'"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'
|
||||
const CHINESE_FONT_CHAR_PATTERN = /[\u3400-\u9fff]/
|
||||
const CHINESE_FONT_SEGMENT_PATTERN = /[\u3400-\u9fff]+/g
|
||||
|
||||
const toPascalCasePinyin = (value: string): string =>
|
||||
pinyin(value, { toneType: "none" })
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
|
||||
.join("")
|
||||
|
||||
const formatFontDisplayName = (name: string): string => {
|
||||
if (!CHINESE_FONT_CHAR_PATTERN.test(name)) return name
|
||||
return name.replace(CHINESE_FONT_SEGMENT_PATTERN, (segment) => toPascalCasePinyin(segment))
|
||||
}
|
||||
|
||||
const buildFontSearchText = (name: string, label: string): string =>
|
||||
`${name} ${label}`.toLowerCase()
|
||||
|
||||
const defaultFontOptions: FontOption[] = [
|
||||
{
|
||||
value: "system",
|
||||
label: "系统默认",
|
||||
fontFamily: SYSTEM_FONT_STACK,
|
||||
},
|
||||
{
|
||||
value: "system-Microsoft YaHei UI",
|
||||
label: "Microsoft YaHei UI",
|
||||
fontFamily: '"Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Microsoft YaHei",
|
||||
label: "Microsoft YaHei",
|
||||
fontFamily: '"Microsoft YaHei", "微软雅黑", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-PingFang SC",
|
||||
label: "PingFang SC",
|
||||
fontFamily: '"PingFang SC", "Hiragino Sans GB", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Noto Sans CJK SC",
|
||||
label: "Noto Sans CJK SC",
|
||||
fontFamily: '"Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Segoe UI",
|
||||
label: "Segoe UI",
|
||||
fontFamily: '"Segoe UI", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Arial",
|
||||
label: "Arial",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
searchText: "系统默认 system default",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -272,13 +265,17 @@ export const Settings: React.FC<{
|
||||
const remoteOptions = res.data
|
||||
.map((name: string) => String(name || "").trim())
|
||||
.filter((name: string) => Boolean(name))
|
||||
.map(
|
||||
(name: string) =>
|
||||
({
|
||||
value: `system-${name}`,
|
||||
label: name,
|
||||
fontFamily: `"${name}", ${SYSTEM_FONT_STACK}`,
|
||||
}) satisfies FontOption
|
||||
.map((name: string) => {
|
||||
const label = formatFontDisplayName(name)
|
||||
return {
|
||||
value: buildSystemFontValue(name),
|
||||
label,
|
||||
fontFamily: buildSystemFontFamily(name),
|
||||
searchText: buildFontSearchText(name, label),
|
||||
} satisfies FontOption
|
||||
})
|
||||
.sort((a: FontOption, b: FontOption) =>
|
||||
a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin")
|
||||
)
|
||||
|
||||
const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions])
|
||||
@@ -829,10 +826,13 @@ export const Settings: React.FC<{
|
||||
options={fontOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
searchText: opt.searchText,
|
||||
}))}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
String(option?.searchText ?? option?.label ?? "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user