import React, { useEffect, useMemo, useState } from "react" import { Alert, Button, Card, DatePicker, Form, Input, InputNumber, Modal, Pagination, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, message, } from "antd" import { InfoCircleOutlined } from "@ant-design/icons" import { type ImmutableTree } from "@react-awesome-query-builder/antd" import type { ColumnsType } from "antd/es/table" import dayjs from "dayjs" import { useTranslation } from "react-i18next" import { fetchAllTags } from "./TagEditorDialog" import { ActionEditor } from "./AutoScore/ActionEditor" import { parseIntervalTriggerValue } from "./AutoScore/IntervalValueCodec" import { TriggerRuleBuilder } from "./AutoScore/TriggerRuleBuilder" import { actionDraftsToPayload, actionsToDrafts, createDefaultActionDraft, createEmptyTriggerTree, createTriggerQueryConfig, normalizeTriggerTree, queryTreeToJson, normalizeActionDrafts, parseRewardActionValue, queryTreeToTriggers, triggerTreeJsonToQueryTree, type ActionDraft, type AutoScoreExecutionBatch, type AutoScoreExecutionConfig, type AutoScoreRewardOption, type AutoScoreRule, type AutoScoreTagOption, } from "./AutoScore/AutoScoreUtils" interface StudentItem { id: number name: string } interface TagItem { id: number name: string } interface RewardItem { id: number name: string cost_points: number } interface RuleFormValues { name?: string studentNames?: string[] execution?: AutoScoreExecutionConfig } interface AutoScoreManagerProps { canEdit: boolean } const getRuleFileRelativePath = (ruleId: number) => `auto-score/rule-${ruleId}.json` const AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE = 500 interface BackfillPlanItem { ruleId: number runs: number ruleName: string } const buildOfflineBackfillPlan = ( rules: AutoScoreRule[] ): { items: BackfillPlanItem[] totalRuns: number from: dayjs.Dayjs | null to: dayjs.Dayjs truncatedRules: number } => { const now = dayjs() const items: BackfillPlanItem[] = [] let from: dayjs.Dayjs | null = null let truncatedRules = 0 for (const rule of rules) { if (!rule.enabled) continue const intervalTrigger = rule.triggers.find( (trigger) => trigger.event === "interval_time_passed" ) if (!intervalTrigger) continue const intervalValue = parseIntervalTriggerValue(intervalTrigger.value) if (!intervalValue) continue const intervalMinutes = intervalValue.days * 24 * 60 + intervalValue.hours * 60 + intervalValue.minutes if (intervalMinutes <= 0) continue const startAt = rule.execution?.startAt ? dayjs(rule.execution.startAt) : null const lastExecuted = rule.lastExecuted ? dayjs(rule.lastExecuted) : null const validStartAt = startAt && startAt.isValid() ? startAt : null const validLastExecuted = lastExecuted && lastExecuted.isValid() ? lastExecuted : null const baseTime = validStartAt && validLastExecuted ? validStartAt.isAfter(validLastExecuted) ? validStartAt : validLastExecuted : validStartAt || validLastExecuted if (!baseTime) continue if (!now.isAfter(baseTime)) continue const elapsedMinutes = now.diff(baseTime, "minute") if (elapsedMinutes < intervalMinutes) continue const rawRuns = Math.floor(elapsedMinutes / intervalMinutes) if (rawRuns <= 0) continue const runs = Math.min(rawRuns, AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE) if (runs < rawRuns) { truncatedRules += 1 } items.push({ ruleId: rule.id, ruleName: rule.name, runs, }) if (!from || baseTime.isBefore(from)) { from = baseTime } } return { items, totalRuns: items.reduce((sum, item) => sum + item.runs, 0), from, to: now, truncatedRules, } } function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { const { t, i18n } = useTranslation() const [form] = Form.useForm() const [messageApi, contextHolder] = message.useMessage() const [tags, setTags] = useState([]) const [rewards, setRewards] = useState([]) const tagOptions = useMemo( () => tags.map((tag) => ({ label: tag.name, value: tag.name, })), [tags] ) const rewardOptions = useMemo( () => rewards.map((reward) => ({ label: reward.name, value: String(reward.id), })), [rewards] ) const rewardLabelById = useMemo( () => new Map(rewards.map((reward) => [reward.id, reward.name] as const)), [rewards] ) const triggerConfig = useMemo( () => createTriggerQueryConfig(t, tagOptions), [i18n.resolvedLanguage, i18n.language, tagOptions] ) const [triggerTree, setTriggerTree] = useState(() => createEmptyTriggerTree(triggerConfig) ) const [actionDrafts, setActionDrafts] = useState([createDefaultActionDraft()]) const [students, setStudents] = useState([]) const [rules, setRules] = useState([]) const [batches, setBatches] = useState([]) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [rollingBackBatchId, setRollingBackBatchId] = useState(null) 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) const [executionStartAt, setExecutionStartAt] = useState(null) const [backfillPrompted, setBackfillPrompted] = useState(false) useEffect(() => { const maxPage = Math.max(1, Math.ceil(rules.length / pageSize)) if (currentPage > maxPage) { setCurrentPage(maxPage) } }, [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 intervalElapsedHint = useMemo(() => { if (!executionStartAt) { return t("autoScore.startAtHint") } const startAt = dayjs(executionStartAt) if (!startAt.isValid()) { return t("autoScore.startAtHint") } const intervalTrigger = queryTreeToTriggers(triggerTree, triggerConfig).find( (trigger) => trigger.event === "interval_time_passed" ) if (!intervalTrigger) { return t("autoScore.startAtHint") } const intervalValue = parseIntervalTriggerValue(intervalTrigger.value) if (!intervalValue) { return t("autoScore.startAtHint") } const intervalMinutes = intervalValue.days * 24 * 60 + intervalValue.hours * 60 + intervalValue.minutes if (intervalMinutes <= 0) { return t("autoScore.startAtHint") } const now = dayjs() if (now.isBefore(startAt)) { return t("autoScore.startAtWaitHint", { minutes: startAt.diff(now, "minute") }) } const elapsedMinutes = now.diff(startAt, "minute") const remainder = elapsedMinutes % intervalMinutes const remainMinutes = elapsedMinutes < intervalMinutes ? intervalMinutes - elapsedMinutes : remainder === 0 ? 0 : intervalMinutes - remainder return t("autoScore.startAtElapsedHint", { elapsed: elapsedMinutes, remain: remainMinutes, }) }, [executionStartAt, t, triggerConfig, triggerTree]) const resetEditor = () => { setEditingRuleId(null) form.setFieldsValue({ name: "", studentNames: [], execution: {} }) setTriggerTree(createEmptyTriggerTree(triggerConfig)) setActionDrafts([createDefaultActionDraft()]) setExecutionStartAt(null) } const emitDataUpdated = () => { window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } })) } const fetchStudents = async () => { const api = (window as any).api if (!api || !canEdit) return try { const res = await api.queryStudents({}) if (res.success && Array.isArray(res.data)) { setStudents(res.data) } } catch { void 0 } } const fetchTags = async () => { if (!canEdit) return try { const tagList = await fetchAllTags() if (Array.isArray(tagList)) { setTags(tagList) } } catch { void 0 } } const fetchRewards = async () => { const api = (window as any).api if (!api || !canEdit) return try { const res = await api.rewardSettingQuery() if (res.success && Array.isArray(res.data)) { setRewards(res.data) } } catch { void 0 } } const fetchRules = async () => { const api = (window as any).api if (!api || !canEdit) return setLoading(true) try { const res = await api.autoScoreGetRules() if (res.success && Array.isArray(res.data)) { setRules(res.data) } else { messageApi.error(res.message || t("autoScore.fetchFailed")) } } catch { messageApi.error(t("autoScore.fetchFailed")) } finally { setLoading(false) } } 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) fetchRewards().catch(() => void 0) fetchStudents().catch(() => void 0) fetchRules().catch(() => void 0) fetchBatches().catch(() => void 0) }, [canEdit]) useEffect(() => { const api = (window as any).api if (!canEdit || !api || backfillPrompted || rules.length === 0) return const plan = buildOfflineBackfillPlan(rules) setBackfillPrompted(true) if (plan.items.length === 0 || plan.totalRuns <= 0) return const fromText = plan.from ? plan.from.format("YYYY-MM-DD HH:mm:ss") : "-" const toText = plan.to.format("YYYY-MM-DD HH:mm:ss") const previewLines = plan.items .slice(0, 5) .map((item) => t("autoScore.backfillPreviewLine", { name: item.ruleName, runs: item.runs })) .join("\n") Modal.confirm({ title: t("autoScore.backfillConfirmTitle"), content: (
{t("autoScore.backfillConfirmSummary", { from: fromText, to: toText, runs: plan.totalRuns, rules: plan.items.length, })} {previewLines ? `\n${previewLines}` : ""} {plan.items.length > 5 ? `\n${t("autoScore.backfillPreviewMore", { count: plan.items.length - 5 })}` : ""} {plan.truncatedRules > 0 ? `\n${t("autoScore.backfillPreviewTruncated", { count: plan.truncatedRules, max: AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE, })}` : ""}
), onOk: async () => { try { const res = await api.autoScoreApplyBackfill({ items: plan.items.map((item) => ({ ruleId: item.ruleId, runs: item.runs })), }) if (!res?.success) { messageApi.error(res?.message || t("autoScore.backfillApplyFailed")) return } const result = res.data messageApi.success( t("autoScore.backfillApplySuccess", { runs: result?.appliedRuns ?? 0, events: result?.createdEvents ?? 0, }) ) await fetchRules() await fetchBatches() emitDataUpdated() } catch { messageApi.error(t("autoScore.backfillApplyFailed")) } }, }) }, [backfillPrompted, canEdit, messageApi, rules, t]) const handleSubmit = async () => { const api = (window as any).api if (!api) return if (!canEdit) { messageApi.error(t("common.readOnly")) return } const values = await form.validateFields() const name = String(values.name || "").trim() const studentNames = Array.isArray(values.studentNames) ? values.studentNames : [] const executionFromForm = values.execution || {} const execution: AutoScoreExecutionConfig = { ...executionFromForm, startAt: executionStartAt && dayjs(executionStartAt).isValid() ? dayjs(executionStartAt).toISOString() : null, } if (!name) { messageApi.warning(t("autoScore.nameRequired")) 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) if (actionDrafts.length === 0) { setActionDrafts(normalizedActionDrafts) } if (actionPayload.error === "action_required") { messageApi.warning(t("autoScore.actionRequired")) return } if (actionPayload.error === "score_required") { messageApi.warning(t("autoScore.scorePlaceholder")) return } if (actionPayload.error === "tag_required") { messageApi.warning(t("autoScore.tagNamePlaceholder")) return } if (actionPayload.error === "reward_required") { messageApi.warning(t("autoScore.rewardRequired")) return } if (actionPayload.actions.length === 0) { messageApi.warning(t("autoScore.actionRequired")) return } setSaving(true) try { const payload = { name, enabled: true, studentNames, triggers, triggerTree: triggerTreeJson, actions: actionPayload.actions, execution, } const res = editingRuleId === null ? await api.autoScoreAddRule(payload) : await api.autoScoreUpdateRule({ ...payload, id: editingRuleId }) if (res.success) { messageApi.success( editingRuleId === null ? t("autoScore.createSuccess") : t("autoScore.updateSuccess") ) resetEditor() await fetchRules() await fetchBatches() emitDataUpdated() } else { messageApi.error( res.message || (editingRuleId === null ? t("autoScore.createFailed") : t("autoScore.updateFailed")) ) } } catch { messageApi.error( editingRuleId === null ? t("autoScore.createFailed") : t("autoScore.updateFailed") ) } finally { setSaving(false) } } const handleEdit = (rule: AutoScoreRule) => { const { startAt, ...executionWithoutStartAt } = rule.execution || {} setEditingRuleId(rule.id) form.setFieldsValue({ name: rule.name, studentNames: rule.studentNames || [], execution: executionWithoutStartAt, }) setExecutionStartAt(startAt ?? null) setTriggerTree(triggerTreeJsonToQueryTree(triggerConfig, rule.triggerTree, rule.triggers || [])) setActionDrafts(actionsToDrafts(rule.actions || [])) } const handleDelete = async (ruleId: number) => { const api = (window as any).api if (!api) return if (!canEdit) { messageApi.error(t("common.readOnly")) return } 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")) } } const handleToggle = async (ruleId: number, enabled: boolean) => { const api = (window as any).api if (!api) return if (!canEdit) { messageApi.error(t("common.readOnly")) return } const res = await api.autoScoreToggleRule({ ruleId, enabled }) if (res.success) { setRules((prevRules) => prevRules.map((rule) => (rule.id === ruleId ? { ...rule, enabled } : rule)) ) messageApi.success(enabled ? t("autoScore.enabled") : t("autoScore.disabled")) emitDataUpdated() } else { messageApi.error( res.message || (enabled ? t("autoScore.enableFailed") : t("autoScore.disableFailed")) ) } } 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) if (Number.isNaN(date.getTime())) return t("autoScore.invalidTime") 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")}` } if (action.event === "reward_exchange") { const rewardValue = parseRewardActionValue(action.value) const rewardLabel = rewardValue ? rewardLabelById.get(rewardValue.rewardId) || rewardValue.rewardName || `#${rewardValue.rewardId}` : String(action.value || "").trim() || "-" return `${t("rewardExchange.title")}: ${rewardLabel}` } 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 = [ { title: t("autoScore.name"), dataIndex: "name", key: "name", ellipsis: true, }, { title: t("autoScore.status"), key: "status", width: 96, render: (_, row) => ( { handleToggle(row.id, checked).catch(() => void 0) }} /> ), }, { title: t("autoScore.applicableStudents"), key: "studentNames", width: 150, ellipsis: true, render: (_, row) => row.studentNames.length > 0 ? ( {t("autoScore.studentCount", { count: row.studentNames.length })} ) : ( {t("autoScore.allStudents")} ), }, { title: t("autoScore.triggers"), key: "triggers", width: 120, render: (_, row) => ( {t("autoScore.triggerCount", { count: row.triggers.length })} ), }, { title: t("autoScore.actions"), key: "actions", width: 120, render: (_, row) => ( {t("autoScore.actionCount", { count: row.actions.length })} ), }, { title: t("autoScore.lastExecuted"), dataIndex: "lastExecuted", key: "lastExecuted", width: 160, ellipsis: true, render: (value: string | null | undefined) => formatLastExecuted(value), }, { title: t("common.operation"), key: "operation", width: 220, render: (_, row) => ( { handleDelete(row.id).catch(() => void 0) }} > ), }, ] const batchColumns: ColumnsType = [ { 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 ? ( {t("autoScore.batchRolledBack")} ) : ( ), }, ] const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize) const pagedBatches = batches.slice( (batchCurrentPage - 1) * batchPageSize, batchCurrentPage * batchPageSize ) return (
{contextHolder}

{t("autoScore.title")}

{!canEdit && ( )}