diff --git a/src-tauri/data.sql b/src-tauri/data.sql
index 34d90ab..c8b719d 100644
Binary files a/src-tauri/data.sql and b/src-tauri/data.sql differ
diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx
index b5c8f25..2fd4f22 100644
--- a/src/components/AutoScoreManager.tsx
+++ b/src/components/AutoScoreManager.tsx
@@ -27,6 +27,7 @@ import {
createEmptyTriggerTree,
createTriggerQueryConfig,
hasUnsupportedTriggerLogic,
+ normalizeActionDrafts,
queryTreeToTriggers,
triggersToQueryTree,
type ActionDraft,
@@ -183,7 +184,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
return
}
- const actionPayload = actionDraftsToPayload(actionDrafts)
+ 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
@@ -423,7 +432,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
value={actionDrafts}
tagOptions={tagOptions}
canEdit={canEdit}
- onChange={setActionDrafts}
+ onChange={(nextDrafts) => setActionDrafts(normalizeActionDrafts(nextDrafts))}
/>
diff --git a/src/components/AutoScoreManagerPage/ActionEditor.tsx b/src/components/AutoScoreManagerPage/ActionEditor.tsx
index ab97de4..8508edb 100644
--- a/src/components/AutoScoreManagerPage/ActionEditor.tsx
+++ b/src/components/AutoScoreManagerPage/ActionEditor.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from "react"
+import React, { useEffect, useMemo } from "react"
import { Button, Card, InputNumber, Select, Space } from "antd"
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons"
import { useTranslation } from "react-i18next"
@@ -19,6 +19,14 @@ export const ActionEditor: React.FC
= ({
onChange,
}) => {
const { t } = useTranslation()
+ const fallbackDraft = useMemo(() => createDefaultActionDraft(), [])
+ const safeValue = value.length > 0 ? value : [fallbackDraft]
+
+ useEffect(() => {
+ if (value.length === 0) {
+ onChange([fallbackDraft])
+ }
+ }, [fallbackDraft, onChange, value.length])
const actionOptions = useMemo(
() => [
@@ -31,7 +39,7 @@ export const ActionEditor: React.FC = ({
const mergedTagOptions = useMemo(() => {
const optionMap = new Map(tagOptions.map((option) => [option.value, option]))
- for (const action of value) {
+ for (const action of safeValue) {
if (action.event !== "add_tag") continue
const tagValues = Array.isArray(action.value)
? action.value
@@ -47,19 +55,19 @@ export const ActionEditor: React.FC = ({
}
return Array.from(optionMap.values())
- }, [tagOptions, value])
+ }, [safeValue, tagOptions])
const updateAction = (id: string, patch: Partial>) => {
- onChange(value.map((item) => (item.id === id ? { ...item, ...patch } : item)))
+ onChange(safeValue.map((item) => (item.id === id ? { ...item, ...patch } : item)))
}
const handleAddAction = () => {
- onChange([...value, createDefaultActionDraft()])
+ onChange([...safeValue, createDefaultActionDraft()])
}
const handleRemoveAction = (id: string) => {
- if (value.length <= 1) return
- onChange(value.filter((item) => item.id !== id))
+ if (safeValue.length <= 1) return
+ onChange(safeValue.filter((item) => item.id !== id))
}
return (
@@ -68,7 +76,7 @@ export const ActionEditor: React.FC = ({
title={t("autoScore.triggeredActions")}
>
- {value.map((action) => (
+ {safeValue.map((action) => (
diff --git a/src/components/AutoScoreManagerPage/AutoScoreUtils.ts b/src/components/AutoScoreManagerPage/AutoScoreUtils.ts
index 30813cc..8bc2de7 100644
--- a/src/components/AutoScoreManagerPage/AutoScoreUtils.ts
+++ b/src/components/AutoScoreManagerPage/AutoScoreUtils.ts
@@ -43,7 +43,7 @@ export interface ActionDraft {
value: string | string[]
}
-export type ActionDraftError = "score_required" | "tag_required" | null
+export type ActionDraftError = "action_required" | "score_required" | "tag_required" | null
const TRIGGER_FIELD_INTERVAL = "interval_minutes"
const TRIGGER_FIELD_TAG = "student_tag"
@@ -312,6 +312,32 @@ export const createDefaultActionDraft = (): ActionDraft => ({
value: "1",
})
+const isActionEvent = (value: unknown): value is ActionEvent =>
+ value === "add_score" || value === "add_tag"
+
+export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined): ActionDraft[] => {
+ if (!Array.isArray(drafts) || drafts.length === 0) {
+ return [createDefaultActionDraft()]
+ }
+
+ const normalized = drafts
+ .map((draft) => {
+ if (!draft || !isActionEvent(draft.event)) return null
+
+ return {
+ id: typeof draft.id === "string" && draft.id ? draft.id : QbUtils.uuid(),
+ event: draft.event,
+ value:
+ draft.event === "add_tag"
+ ? parseTagValues(draft.value)
+ : toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
+ } satisfies ActionDraft
+ })
+ .filter((item): item is ActionDraft => Boolean(item))
+
+ return normalized.length > 0 ? normalized : [createDefaultActionDraft()]
+}
+
export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
const mapped = actions
.map((action) => {
@@ -325,14 +351,19 @@ export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
})
.filter((item): item is ActionDraft => Boolean(item))
- return mapped.length > 0 ? mapped : [createDefaultActionDraft()]
+ return normalizeActionDrafts(mapped)
}
export const actionDraftsToPayload = (
drafts: ActionDraft[]
): { actions: AutoScoreAction[]; error: ActionDraftError } => {
+ const normalizedDrafts = normalizeActionDrafts(drafts)
+ if (normalizedDrafts.length === 0) {
+ return { actions: [], error: "action_required" }
+ }
+
const actions: AutoScoreAction[] = []
- for (const draft of drafts) {
+ for (const draft of normalizedDrafts) {
if (draft.event === "add_score") {
const score = Array.isArray(draft.value) ? null : toFiniteNumber(draft.value)
if (!score || score === 0) {
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx
index 3191b36..67b91a7 100644
--- a/src/components/Settings.tsx
+++ b/src/components/Settings.tsx
@@ -30,6 +30,18 @@ type appSettings = {
window_zoom?: string
search_keyboard_layout?: "t9" | "qwerty26"
disable_search_keyboard?: boolean
+ font_family?: string
+}
+
+interface FontOption {
+ value: string
+ label: string
+ fontFamily: string
+}
+
+const applyFontFamily = (fontFamily: string) => {
+ document.documentElement.style.setProperty("--ss-font-family", fontFamily)
+ document.body.style.fontFamily = fontFamily
}
const withTimeout = async (
promise: Promise,
@@ -64,6 +76,8 @@ export const Settings: React.FC<{
search_keyboard_layout: "qwerty26",
disable_search_keyboard: false,
})
+ const [fontOptions, setFontOptions] = useState([])
+ const [isLoadingFonts, setIsLoadingFonts] = useState(true)
const [securityStatus, setSecurityStatus] = useState<{
permission: permissionLevel
@@ -173,6 +187,36 @@ export const Settings: React.FC<{
}
}
+ const loadSystemFonts = async () => {
+ if (!(window as any).api?.getSystemFonts) return
+
+ setIsLoadingFonts(true)
+ try {
+ const res = await (window as any).api.getSystemFonts()
+ if (res.success && Array.isArray(res.data)) {
+ const fontList = res.data
+ .map((name: string) => ({
+ value: `system-${name}`,
+ label: name,
+ fontFamily: `"${name}"`,
+ })) as FontOption[]
+
+ setFontOptions(fontList)
+
+ if (settings.font_family) {
+ const currentFontOpt = fontList.find((f) => f.value === settings.font_family)
+ if (currentFontOpt) {
+ applyFontFamily(currentFontOpt.fontFamily)
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Failed to load system fonts:", error)
+ } finally {
+ setIsLoadingFonts(false)
+ }
+ }
+
const loadAll = async () => {
if (!(window as any).api) return
const res = await (window as any).api.getAllSettings()
@@ -180,10 +224,17 @@ export const Settings: React.FC<{
setSettings(res.data)
setPgConnectionString(res.data.pg_connection_string || "")
setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: "sqlite" })
+ if (res.data.font_family) {
+ const fontOpt = fontOptions.find((f) => f.value === res.data.font_family)
+ if (fontOpt) {
+ applyFontFamily(fontOpt.fontFamily)
+ }
+ }
}
const authRes = await (window as any).api.authGetStatus()
if (authRes.success && authRes.data) setSecurityStatus(authRes.data)
await loadMcpStatus()
+ await loadSystemFonts()
}
const loadAboutContent = async () => {
@@ -220,6 +271,13 @@ export const Settings: React.FC<{
return { ...prev, disable_search_keyboard: Boolean(change.value) }
if (change?.key === "auto_score_enabled")
return { ...prev, auto_score_enabled: change.value }
+ if (change?.key === "font_family") {
+ const fontOpt = fontOptions.find((f) => f.value === change.value)
+ if (fontOpt) {
+ applyFontFamily(fontOpt.fontFamily)
+ }
+ return { ...prev, font_family: change.value }
+ }
return prev
})
})
@@ -664,6 +722,43 @@ export const Settings: React.FC<{
+
+
{!isMobile && (
<>
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index 6947474..75a8700 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -135,6 +135,8 @@
},
"language": "Language",
"languageHint": "Select interface display language",
+ "fontFamily": "Global Font",
+ "fontFamilyHint": "Select the application interface font (page refresh required for full effect)",
"theme": "Theme",
"password": "Set Password",
"themeMode": "Mode",
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 5a7266b..d35a659 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -135,6 +135,8 @@
},
"language": "语言",
"languageHint": "选择界面显示语言",
+ "fontFamily": "全局字体",
+ "fontFamilyHint": "选择应用界面的字体(需要刷新页面以完全生效)",
"theme": "主题",
"password": "设置密码",
"themeMode": "模式",