feat(设置): 添加全局字体选择功能

fix: 自动化不能保存的问题
This commit is contained in:
NanGua-QWQ
2026-03-30 22:26:56 +08:00
parent 927bc59a82
commit 9a022f3b31
7 changed files with 161 additions and 14 deletions
Binary file not shown.
+11 -2
View File
@@ -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))}
/>
<div style={{ marginBottom: "24px", display: "flex", gap: "12px" }}>
@@ -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<ActionEditorProps> = ({
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<ActionEditorProps> = ({
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<ActionEditorProps> = ({
}
return Array.from(optionMap.values())
}, [tagOptions, value])
}, [safeValue, tagOptions])
const updateAction = (id: string, patch: Partial<Omit<ActionDraft, "id">>) => {
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<ActionEditorProps> = ({
title={t("autoScore.triggeredActions")}
>
<Space direction="vertical" style={{ width: "100%" }} size={12}>
{value.map((action) => (
{safeValue.map((action) => (
<div key={action.id} style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<Select
style={{ minWidth: 180 }}
@@ -108,7 +116,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
<Button
danger
icon={<DeleteOutlined />}
disabled={!canEdit || value.length <= 1}
disabled={!canEdit || safeValue.length <= 1}
onClick={() => handleRemoveAction(action.id)}
/>
</div>
@@ -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) {
+95
View File
@@ -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<any>,
@@ -64,6 +76,8 @@ export const Settings: React.FC<{
search_keyboard_layout: "qwerty26",
disable_search_keyboard: false,
})
const [fontOptions, setFontOptions] = useState<FontOption[]>([])
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<{
<Divider />
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
<Form.Item label={t("settings.fontFamily")}>
<Select
value={settings.font_family || "system"}
onChange={async (v) => {
const fontOpt = fontOptions.find((f) => f.value === v)
if (!fontOpt) return
applyFontFamily(fontOpt.fontFamily)
setSettings((prev) => ({ ...prev, font_family: v }))
if ((window as any).api) {
const res = await (window as any).api.setSetting("font_family", v)
if (res.success) {
messageApi.success(t("settings.general.saved"))
} else {
messageApi.error(res.message || t("settings.general.saveFailed"))
}
} else {
messageApi.success(t("settings.general.saved"))
}
}}
style={{ width: "320px" }}
loading={isLoadingFonts}
options={fontOptions.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
showSearch
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
/>
<div
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
>
{t("settings.fontFamilyHint")}
</div>
</Form.Item>
{!isMobile && (
<>
<Form.Item label={t("settings.searchKeyboard.title")}>
+2
View File
@@ -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",
+2
View File
@@ -135,6 +135,8 @@
},
"language": "语言",
"languageHint": "选择界面显示语言",
"fontFamily": "全局字体",
"fontFamilyHint": "选择应用界面的字体(需要刷新页面以完全生效)",
"theme": "主题",
"password": "设置密码",
"themeMode": "模式",