From 2c8cc775bbbfe94bd7522774a8a84eff3061414b Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Sun, 29 Mar 2026 17:08:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8A=A0=E5=88=86=E8=A7=84=E5=88=99=E7=AE=A1=E7=90=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AutoScoreManager.tsx | 432 ++++++++++++++++++ .../AutoScoreManagerPage/ActionEditor.tsx | 97 ++++ .../AutoScoreManagerPage/AutoScoreUtils.ts | 262 +++++++++++ .../TriggerRuleBuilder.tsx | 66 +++ src/components/ContentArea.tsx | 8 + src/components/Sidebar.tsx | 6 + src/i18n/locales/en-US.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/preload/types.ts | 66 ++- src/shared/mobileNavigation.ts | 2 + 10 files changed, 940 insertions(+), 1 deletion(-) create mode 100644 src/components/AutoScoreManager.tsx create mode 100644 src/components/AutoScoreManagerPage/ActionEditor.tsx create mode 100644 src/components/AutoScoreManagerPage/AutoScoreUtils.ts create mode 100644 src/components/AutoScoreManagerPage/TriggerRuleBuilder.tsx diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx new file mode 100644 index 0000000..5eeda21 --- /dev/null +++ b/src/components/AutoScoreManager.tsx @@ -0,0 +1,432 @@ +import React, { useEffect, useMemo, useState } from "react" +import { + Alert, + Button, + Card, + Form, + Input, + Pagination, + Popconfirm, + Select, + Space, + Switch, + Table, + Tag, + message, +} from "antd" +import { Utils as QbUtils, type ImmutableTree } from "@react-awesome-query-builder/antd" +import type { ColumnsType } from "antd/es/table" +import { useTranslation } from "react-i18next" +import { ActionEditor } from "./AutoScoreManagerPage/ActionEditor" +import { TriggerRuleBuilder } from "./AutoScoreManagerPage/TriggerRuleBuilder" +import { + actionDraftsToPayload, + actionsToDrafts, + createDefaultActionDraft, + createEmptyTriggerTree, + createTriggerQueryConfig, + hasUnsupportedTriggerLogic, + queryTreeToTriggers, + triggersToQueryTree, + type ActionDraft, + type AutoScoreRule, +} from "./AutoScoreManagerPage/AutoScoreUtils" + +interface StudentItem { + id: number + name: string +} + +interface RuleFormValues { + name?: string + studentNames?: string[] +} + +interface AutoScoreManagerProps { + canEdit: boolean +} + +function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { + const { t, i18n } = useTranslation() + const [form] = Form.useForm() + const [messageApi, contextHolder] = message.useMessage() + + const triggerConfig = useMemo( + () => createTriggerQueryConfig(t), + [i18n.resolvedLanguage, i18n.language] + ) + const [triggerTree, setTriggerTree] = useState(() => + createEmptyTriggerTree(triggerConfig) + ) + const [actionDrafts, setActionDrafts] = useState([createDefaultActionDraft()]) + const [students, setStudents] = useState([]) + const [rules, setRules] = useState([]) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [editingRuleId, setEditingRuleId] = useState(null) + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + + useEffect(() => { + setTriggerTree((prevTree) => QbUtils.checkTree(prevTree, triggerConfig)) + }, [triggerConfig]) + + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(rules.length / pageSize)) + if (currentPage > maxPage) { + setCurrentPage(maxPage) + } + }, [currentPage, pageSize, rules.length]) + + const resetEditor = () => { + setEditingRuleId(null) + form.setFieldsValue({ name: "", studentNames: [] }) + setTriggerTree(createEmptyTriggerTree(triggerConfig)) + setActionDrafts([createDefaultActionDraft()]) + } + + 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 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) + } + } + + useEffect(() => { + if (!canEdit) return + fetchStudents().catch(() => void 0) + fetchRules().catch(() => void 0) + }, [canEdit]) + + 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 : [] + + if (!name) { + messageApi.warning(t("autoScore.nameRequired")) + return + } + + if (hasUnsupportedTriggerLogic(triggerTree, triggerConfig)) { + messageApi.warning(t("autoScore.unsupportedLogic")) + return + } + + const triggers = queryTreeToTriggers(triggerTree, triggerConfig) + if (triggers.length === 0) { + messageApi.warning(t("autoScore.triggerRequired")) + return + } + + const actionPayload = actionDraftsToPayload(actionDrafts) + if (actionPayload.error === "score_required") { + messageApi.warning(t("autoScore.scorePlaceholder")) + return + } + if (actionPayload.error === "tag_required") { + messageApi.warning(t("autoScore.tagNamePlaceholder")) + return + } + if (actionPayload.actions.length === 0) { + messageApi.warning(t("autoScore.actionRequired")) + return + } + + setSaving(true) + try { + const payload = { + name, + enabled: true, + studentNames, + triggers, + actions: actionPayload.actions, + } + 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() + 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) => { + setEditingRuleId(rule.id) + form.setFieldsValue({ + name: rule.name, + studentNames: rule.studentNames || [], + }) + setTriggerTree(triggersToQueryTree(triggerConfig, 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) { + messageApi.success(t("autoScore.deleteSuccess")) + if (editingRuleId === ruleId) { + resetEditor() + } + await fetchRules() + 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 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 columns: ColumnsType = [ + { + title: t("autoScore.name"), + dataIndex: "name", + key: "name", + ellipsis: true, + }, + { + title: t("autoScore.status"), + key: "status", + width: 120, + render: (_, row) => ( + { + handleToggle(row.id, checked).catch(() => void 0) + }} + /> + ), + }, + { + title: t("autoScore.applicableStudents"), + key: "studentNames", + width: 220, + render: (_, row) => + row.studentNames.length > 0 ? ( + {t("autoScore.studentCount", { count: row.studentNames.length })} + ) : ( + {t("autoScore.allStudents")} + ), + }, + { + title: t("autoScore.triggers"), + key: "triggers", + width: 150, + render: (_, row) => {t("autoScore.triggerCount", { count: row.triggers.length })}, + }, + { + title: t("autoScore.actions"), + key: "actions", + width: 140, + render: (_, row) => {t("autoScore.actionCount", { count: row.actions.length })}, + }, + { + title: t("autoScore.lastExecuted"), + dataIndex: "lastExecuted", + key: "lastExecuted", + width: 180, + render: (value: string | null | undefined) => formatLastExecuted(value), + }, + { + title: t("common.operation"), + key: "operation", + width: 160, + render: (_, row) => ( + + + { + handleDelete(row.id).catch(() => void 0) + }} + > + + + + ), + }, + ] + + const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize) + + return ( +
+ {contextHolder} +

{t("autoScore.title")}

+ + {!canEdit && ( + + )} + + +
+
+ + + + + + updateAction(action.id, { + event, + value: event === "add_score" ? "1" : "", + }) + } + /> + {action.event === "add_score" ? ( + + updateAction(action.id, { value: nextValue === null ? "" : String(nextValue) }) + } + /> + ) : ( + updateAction(action.id, { value: event.target.value })} + /> + )} +
+ ))} + + + + ) +} diff --git a/src/components/AutoScoreManagerPage/AutoScoreUtils.ts b/src/components/AutoScoreManagerPage/AutoScoreUtils.ts new file mode 100644 index 0000000..f160ffc --- /dev/null +++ b/src/components/AutoScoreManagerPage/AutoScoreUtils.ts @@ -0,0 +1,262 @@ +import { + AntdConfig, + Utils as QbUtils, + type Config, + type ImmutableTree, + type JsonGroup, + type JsonItem, + type JsonRule, +} from "@react-awesome-query-builder/antd" +import type { TFunction } from "i18next" + +export interface AutoScoreTrigger { + event: string + value?: string | null +} + +export interface AutoScoreAction { + event: string + value?: string | null +} + +export interface AutoScoreRule { + id: number + name: string + enabled: boolean + studentNames: string[] + triggers: AutoScoreTrigger[] + actions: AutoScoreAction[] + lastExecuted?: string | null +} + +export type ActionEvent = "add_score" | "add_tag" + +export interface ActionDraft { + id: string + event: ActionEvent + value: string +} + +export type ActionDraftError = "score_required" | "tag_required" | null + +const TRIGGER_FIELD_INTERVAL = "interval_minutes" +const TRIGGER_FIELD_TAG = "student_tag" +const OP_EQUAL = "equal" + +const buildEmptyGroup = (): JsonGroup => ({ + id: QbUtils.uuid(), + type: "group", + properties: { + conjunction: "AND", + }, + children1: [], +}) + +const toFiniteNumber = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string" && value.trim()) { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return null +} + +const toStringValue = (value: unknown): string => { + if (value === null || value === undefined) return "" + return String(value) +} + +const ruleFromTrigger = (trigger: AutoScoreTrigger): JsonRule | null => { + if (trigger.event === "interval_time_passed") { + const minutes = toFiniteNumber(trigger.value) + if (!minutes || minutes <= 0) return null + return { + id: QbUtils.uuid(), + type: "rule", + properties: { + field: TRIGGER_FIELD_INTERVAL, + operator: OP_EQUAL, + value: [Math.floor(minutes)], + }, + } + } + + if (trigger.event === "student_has_tag") { + const tagName = toStringValue(trigger.value).trim() + if (!tagName) return null + return { + id: QbUtils.uuid(), + type: "rule", + properties: { + field: TRIGGER_FIELD_TAG, + operator: OP_EQUAL, + value: [tagName], + }, + } + } + + return null +} + +const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => { + const field = typeof rule.properties?.field === "string" ? rule.properties.field : "" + const value = Array.isArray(rule.properties?.value) ? rule.properties.value[0] : undefined + + if (field === TRIGGER_FIELD_INTERVAL) { + const minutes = toFiniteNumber(value) + if (!minutes || minutes <= 0) return null + return { + event: "interval_time_passed", + value: String(Math.floor(minutes)), + } + } + + if (field === TRIGGER_FIELD_TAG) { + const tagName = toStringValue(value).trim() + if (!tagName) return null + return { + event: "student_has_tag", + value: tagName, + } + } + + return null +} + +const collectTriggersFromItems = (items: JsonItem[] | undefined): AutoScoreTrigger[] => { + if (!Array.isArray(items)) return [] + const collected: AutoScoreTrigger[] = [] + for (const item of items) { + if (item.type === "rule") { + const trigger = triggerFromRule(item) + if (trigger) collected.push(trigger) + continue + } + if (item.type === "group") { + collected.push(...collectTriggersFromItems(item.children1)) + } + } + return collected +} + +export const createTriggerQueryConfig = (t: TFunction): Config => + ({ + ...AntdConfig, + fields: { + [TRIGGER_FIELD_INTERVAL]: { + label: t("autoScore.triggerIntervalTime"), + type: "number", + operators: [OP_EQUAL], + valueSources: ["value"], + preferWidgets: ["number"], + fieldSettings: { min: 1 }, + }, + [TRIGGER_FIELD_TAG]: { + label: t("autoScore.triggerStudentTag"), + type: "text", + operators: [OP_EQUAL], + valueSources: ["value"], + preferWidgets: ["text"], + }, + }, + settings: { + ...AntdConfig.settings, + liteMode: false, + compactMode: false, + renderSize: "medium", + showNot: true, + forceShowConj: true, + canLeaveEmptyGroup: false, + canReorder: true, + canRegroup: true, + }, + }) as Config + +export const createEmptyTriggerTree = (config: Config): ImmutableTree => + QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config) + +export const triggersToQueryTree = (config: Config, triggers: AutoScoreTrigger[]): ImmutableTree => { + const children = triggers.map(ruleFromTrigger).filter((item): item is JsonRule => Boolean(item)) + const group: JsonGroup = { + ...buildEmptyGroup(), + children1: children, + } + return QbUtils.checkTree(QbUtils.loadTree(group), config) +} + +export const queryTreeToTriggers = (tree: ImmutableTree, config: Config): AutoScoreTrigger[] => { + const checkedTree = QbUtils.checkTree(tree, config) + const jsonTree = QbUtils.getTree(checkedTree, false, true) + if (!jsonTree || jsonTree.type !== "group") return [] + return collectTriggersFromItems(jsonTree.children1) +} + +const hasUnsupportedLogicInGroup = (group: JsonGroup): boolean => { + const conjunction = typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND" + const not = Boolean(group.properties?.not) + + if (conjunction !== "AND" || not) { + return true + } + + const children = Array.isArray(group.children1) ? group.children1 : [] + for (const child of children) { + if (child.type === "group" && hasUnsupportedLogicInGroup(child)) { + return true + } + } + + return false +} + +export const hasUnsupportedTriggerLogic = (tree: ImmutableTree, config: Config): boolean => { + const checkedTree = QbUtils.checkTree(tree, config) + const jsonTree = QbUtils.getTree(checkedTree, false, true) + if (!jsonTree || jsonTree.type !== "group") return false + return hasUnsupportedLogicInGroup(jsonTree) +} + +export const createDefaultActionDraft = (): ActionDraft => ({ + id: QbUtils.uuid(), + event: "add_score", + value: "1", +}) + +export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => { + const mapped = actions + .map((action) => { + if (action.event !== "add_score" && action.event !== "add_tag") return null + return { + id: QbUtils.uuid(), + event: action.event, + value: toStringValue(action.value), + } satisfies ActionDraft + }) + .filter((item): item is ActionDraft => Boolean(item)) + + return mapped.length > 0 ? mapped : [createDefaultActionDraft()] +} + +export const actionDraftsToPayload = ( + drafts: ActionDraft[] +): { actions: AutoScoreAction[]; error: ActionDraftError } => { + const actions: AutoScoreAction[] = [] + for (const draft of drafts) { + if (draft.event === "add_score") { + const score = toFiniteNumber(draft.value) + if (!score || score === 0) { + return { actions: [], error: "score_required" } + } + actions.push({ event: draft.event, value: String(score) }) + continue + } + + const tagName = draft.value.trim() + if (!tagName) { + return { actions: [], error: "tag_required" } + } + actions.push({ event: draft.event, value: tagName }) + } + + return { actions, error: null } +} diff --git a/src/components/AutoScoreManagerPage/TriggerRuleBuilder.tsx b/src/components/AutoScoreManagerPage/TriggerRuleBuilder.tsx new file mode 100644 index 0000000..789405e --- /dev/null +++ b/src/components/AutoScoreManagerPage/TriggerRuleBuilder.tsx @@ -0,0 +1,66 @@ +import React, { useMemo } from "react" +import { Card } from "antd" +import { + Builder, + Query, + type BuilderProps, + type Config, + type ImmutableTree, +} from "@react-awesome-query-builder/antd" +import { useTranslation } from "react-i18next" +import "@react-awesome-query-builder/antd/css/styles.css" + +interface TriggerRuleBuilderProps { + config: Config + value: ImmutableTree + canEdit: boolean + onChange: (nextTree: ImmutableTree, config: Config) => void +} + +export const TriggerRuleBuilder: React.FC = ({ + config, + value, + canEdit, + onChange, +}) => { + const { t } = useTranslation() + + const queryConfig = useMemo(() => { + if (canEdit) return config + return { + ...config, + settings: { + ...config.settings, + canReorder: false, + canRegroup: false, + immutableGroupsMode: true, + immutableFieldsMode: true, + immutableOpsMode: true, + immutableValuesMode: true, + }, + } as Config + }, [config, canEdit]) + + return ( + +
+ onChange(immutableTree, config)} + renderBuilder={(props: BuilderProps) => ( +
+ +
+ )} + /> +
+
+ ) +} diff --git a/src/components/ContentArea.tsx b/src/components/ContentArea.tsx index 0557604..2edec49 100644 --- a/src/components/ContentArea.tsx +++ b/src/components/ContentArea.tsx @@ -17,6 +17,7 @@ const loadStudentManager = () => import("./StudentManager") const loadSettings = () => import("./Settings") const loadReasonManager = () => import("./ReasonManager") const loadScoreManager = () => import("./ScoreManager") +const loadAutoScoreManager = () => import("./AutoScoreManager") const loadLeaderboard = () => import("./Leaderboard") const loadSettlementHistory = () => import("./SettlementHistory") const loadRewardSettings = () => import("./RewardSettings") @@ -27,6 +28,7 @@ const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m const Settings = lazy(() => loadSettings().then((m) => ({ default: m.Settings }))) const ReasonManager = lazy(() => loadReasonManager().then((m) => ({ default: m.ReasonManager }))) const ScoreManager = lazy(() => loadScoreManager().then((m) => ({ default: m.ScoreManager }))) +const AutoScoreManager = lazy(loadAutoScoreManager) const Leaderboard = lazy(() => loadLeaderboard().then((m) => ({ default: m.Leaderboard }))) const SettlementHistory = lazy(() => loadSettlementHistory().then((m) => ({ default: m.SettlementHistory })) @@ -41,6 +43,7 @@ const warmupRouteChunks = () => loadSettings(), loadReasonManager(), loadScoreManager(), + loadAutoScoreManager(), loadLeaderboard(), loadSettlementHistory(), loadRewardSettings(), @@ -101,6 +104,7 @@ export function ContentArea({ if (normalizedPath.startsWith("/home")) return t("sidebar.home") if (normalizedPath.startsWith("/students")) return t("sidebar.students") if (normalizedPath.startsWith("/score")) return t("sidebar.score") + if (normalizedPath.startsWith("/auto-score")) return t("sidebar.autoScore") if (normalizedPath.startsWith("/boards")) return t("sidebar.boards") if (normalizedPath.startsWith("/leaderboard")) return t("sidebar.leaderboard") if (normalizedPath.startsWith("/settlements")) return t("sidebar.settlements") @@ -364,6 +368,10 @@ export function ContentArea({ } /> + } + /> } /> } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a1eb329..2622605 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -163,6 +163,12 @@ export function Sidebar({ icon: , label: t("sidebar.score"), }, + { + key: "auto-score", + icon: , + label: t("sidebar.autoScore"), + disabled: permission !== "admin", + }, { key: "reward-settings", icon: , diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 2e63f10..3734b7f 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -770,6 +770,7 @@ "adminDeleteRequired": "Admin permission required to delete auto score automation", "adminToggleRequired": "Admin permission required to enable/disable auto score automation", "fetchFailed": "Failed to get automation", + "unsupportedLogic": "Saving currently supports AND-only conditions. OR / NOT logic is not wired to backend persistence yet", "triggerRequired": "Please add at least one trigger", "actionRequired": "Please add at least one action", "createSuccess": "Automation created successfully", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 398f832..52754aa 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -770,6 +770,7 @@ "adminDeleteRequired": "需要管理员权限以删除自动加分自动化", "adminToggleRequired": "需要管理员权限以启用/禁用自动加分自动化", "fetchFailed": "获取自动化失败", + "unsupportedLogic": "当前版本保存规则时仅支持 AND 条件,OR / NOT 逻辑暂未接入后端存储", "triggerRequired": "请至少添加一个触发器", "actionRequired": "请至少添加一个行动", "createSuccess": "自动化创建成功", diff --git a/src/preload/types.ts b/src/preload/types.ts index 8b2e3dd..b1f6d55 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -22,6 +22,26 @@ export interface dataUpdatedEvent { source?: string } +export interface autoScoreTrigger { + event: string + value?: string | null +} + +export interface autoScoreAction { + event: string + value?: string | null +} + +export interface autoScoreRule { + id: number + name: string + enabled: boolean + studentNames: string[] + triggers: autoScoreTrigger[] + actions: autoScoreAction[] + lastExecuted?: string | null +} + export type settingsKey = | "is_wizard_completed" | "log_level" @@ -29,6 +49,8 @@ export type settingsKey = | "search_keyboard_layout" | "disable_search_keyboard" | "themes_custom" + | "auto_score_enabled" + | "auto_score_rules" | "current_theme_id" | "dashboards_config" | "pg_connection_string" @@ -42,6 +64,8 @@ export interface settingsSpec { search_keyboard_layout: "t9" | "qwerty26" disable_search_keyboard: boolean themes_custom: themeConfig[] + auto_score_enabled: boolean + auto_score_rules: autoScoreRule[] current_theme_id: string dashboards_config: any[] pg_connection_string: string @@ -235,6 +259,46 @@ const api = { settlementId: number }): Promise<{ success: boolean; data: any }> => invoke("db_settlement_leaderboard", { params }), + // Auto Score + autoScoreGetRules: (): Promise<{ + success: boolean + data?: autoScoreRule[] + message?: string + }> => invoke("auto_score_get_rules"), + autoScoreAddRule: (rule: { + name: string + enabled: boolean + studentNames: string[] + triggers: autoScoreTrigger[] + actions: autoScoreAction[] + }): Promise<{ success: boolean; data?: number; message?: string }> => + invoke("auto_score_add_rule", { rule }), + autoScoreUpdateRule: (rule: { + id: number + name: string + enabled: boolean + studentNames: string[] + triggers: autoScoreTrigger[] + actions: autoScoreAction[] + }): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_update_rule", { rule }), + autoScoreDeleteRule: (ruleId: number): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_delete_rule", { ruleId }), + autoScoreToggleRule: (params: { + ruleId: number + enabled: boolean + }): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_toggle_rule", { params }), + autoScoreGetStatus: (): Promise<{ + success: boolean + data?: { + enabled: boolean + } + message?: string + }> => invoke("auto_score_get_status"), + autoScoreSortRules: (ruleIds: number[]): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_sort_rules", { ruleIds }), + // Settings & Sync getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> => invoke("settings_get_all"), @@ -442,7 +506,7 @@ const api = { appRestart: (): Promise => invoke("app_restart"), // Generic invoke wrapper for backward compatibility with callers using `api.invoke` - invoke: async (channel: string, ...args: any[]): Promise => { + invoke: async (channel: string, ..._args: any[]): Promise => { switch (channel) { default: throw new Error(`Unsupported legacy invoke channel: ${channel}`) diff --git a/src/shared/mobileNavigation.ts b/src/shared/mobileNavigation.ts index 63a1abf..0052911 100644 --- a/src/shared/mobileNavigation.ts +++ b/src/shared/mobileNavigation.ts @@ -2,6 +2,7 @@ export const MOBILE_NAV_ALL_KEYS = [ "home", "students", "score", + "auto-score", "reward-settings", "boards", "leaderboard", @@ -23,6 +24,7 @@ export const MOBILE_NAV_ITEMS: MobileNavItemConfig[] = [ { key: "home", path: "/home", labelKey: "sidebar.home" }, { key: "students", path: "/students", labelKey: "sidebar.students", adminOnly: true }, { key: "score", path: "/score", labelKey: "sidebar.score" }, + { key: "auto-score", path: "/auto-score", labelKey: "sidebar.autoScore", adminOnly: true }, { key: "reward-settings", path: "/reward-settings",