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, createEmptyTriggerTree,
createTriggerQueryConfig, createTriggerQueryConfig,
hasUnsupportedTriggerLogic, hasUnsupportedTriggerLogic,
normalizeActionDrafts,
queryTreeToTriggers, queryTreeToTriggers,
triggersToQueryTree, triggersToQueryTree,
type ActionDraft, type ActionDraft,
@@ -183,7 +184,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
return 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") { if (actionPayload.error === "score_required") {
messageApi.warning(t("autoScore.scorePlaceholder")) messageApi.warning(t("autoScore.scorePlaceholder"))
return return
@@ -423,7 +432,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
value={actionDrafts} value={actionDrafts}
tagOptions={tagOptions} tagOptions={tagOptions}
canEdit={canEdit} canEdit={canEdit}
onChange={setActionDrafts} onChange={(nextDrafts) => setActionDrafts(normalizeActionDrafts(nextDrafts))}
/> />
<div style={{ marginBottom: "24px", display: "flex", gap: "12px" }}> <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 { Button, Card, InputNumber, Select, Space } from "antd"
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons" import { DeleteOutlined, PlusOutlined } from "@ant-design/icons"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@@ -19,6 +19,14 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
onChange, onChange,
}) => { }) => {
const { t } = useTranslation() 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( const actionOptions = useMemo(
() => [ () => [
@@ -31,7 +39,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
const mergedTagOptions = useMemo(() => { const mergedTagOptions = useMemo(() => {
const optionMap = new Map(tagOptions.map((option) => [option.value, option])) 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 if (action.event !== "add_tag") continue
const tagValues = Array.isArray(action.value) const tagValues = Array.isArray(action.value)
? action.value ? action.value
@@ -47,19 +55,19 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
} }
return Array.from(optionMap.values()) return Array.from(optionMap.values())
}, [tagOptions, value]) }, [safeValue, tagOptions])
const updateAction = (id: string, patch: Partial<Omit<ActionDraft, "id">>) => { 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 = () => { const handleAddAction = () => {
onChange([...value, createDefaultActionDraft()]) onChange([...safeValue, createDefaultActionDraft()])
} }
const handleRemoveAction = (id: string) => { const handleRemoveAction = (id: string) => {
if (value.length <= 1) return if (safeValue.length <= 1) return
onChange(value.filter((item) => item.id !== id)) onChange(safeValue.filter((item) => item.id !== id))
} }
return ( return (
@@ -68,7 +76,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
title={t("autoScore.triggeredActions")} title={t("autoScore.triggeredActions")}
> >
<Space direction="vertical" style={{ width: "100%" }} size={12}> <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" }}> <div key={action.id} style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<Select <Select
style={{ minWidth: 180 }} style={{ minWidth: 180 }}
@@ -108,7 +116,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
<Button <Button
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
disabled={!canEdit || value.length <= 1} disabled={!canEdit || safeValue.length <= 1}
onClick={() => handleRemoveAction(action.id)} onClick={() => handleRemoveAction(action.id)}
/> />
</div> </div>
@@ -43,7 +43,7 @@ export interface ActionDraft {
value: string | string[] 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_INTERVAL = "interval_minutes"
const TRIGGER_FIELD_TAG = "student_tag" const TRIGGER_FIELD_TAG = "student_tag"
@@ -312,6 +312,32 @@ export const createDefaultActionDraft = (): ActionDraft => ({
value: "1", 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[] => { export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
const mapped = actions const mapped = actions
.map((action) => { .map((action) => {
@@ -325,14 +351,19 @@ export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
}) })
.filter((item): item is ActionDraft => Boolean(item)) .filter((item): item is ActionDraft => Boolean(item))
return mapped.length > 0 ? mapped : [createDefaultActionDraft()] return normalizeActionDrafts(mapped)
} }
export const actionDraftsToPayload = ( export const actionDraftsToPayload = (
drafts: ActionDraft[] drafts: ActionDraft[]
): { actions: AutoScoreAction[]; error: ActionDraftError } => { ): { actions: AutoScoreAction[]; error: ActionDraftError } => {
const normalizedDrafts = normalizeActionDrafts(drafts)
if (normalizedDrafts.length === 0) {
return { actions: [], error: "action_required" }
}
const actions: AutoScoreAction[] = [] const actions: AutoScoreAction[] = []
for (const draft of drafts) { for (const draft of normalizedDrafts) {
if (draft.event === "add_score") { if (draft.event === "add_score") {
const score = Array.isArray(draft.value) ? null : toFiniteNumber(draft.value) const score = Array.isArray(draft.value) ? null : toFiniteNumber(draft.value)
if (!score || score === 0) { if (!score || score === 0) {
+95
View File
@@ -30,6 +30,18 @@ type appSettings = {
window_zoom?: string window_zoom?: string
search_keyboard_layout?: "t9" | "qwerty26" search_keyboard_layout?: "t9" | "qwerty26"
disable_search_keyboard?: boolean 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 ( const withTimeout = async (
promise: Promise<any>, promise: Promise<any>,
@@ -64,6 +76,8 @@ export const Settings: React.FC<{
search_keyboard_layout: "qwerty26", search_keyboard_layout: "qwerty26",
disable_search_keyboard: false, disable_search_keyboard: false,
}) })
const [fontOptions, setFontOptions] = useState<FontOption[]>([])
const [isLoadingFonts, setIsLoadingFonts] = useState(true)
const [securityStatus, setSecurityStatus] = useState<{ const [securityStatus, setSecurityStatus] = useState<{
permission: permissionLevel 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 () => { const loadAll = async () => {
if (!(window as any).api) return if (!(window as any).api) return
const res = await (window as any).api.getAllSettings() const res = await (window as any).api.getAllSettings()
@@ -180,10 +224,17 @@ export const Settings: React.FC<{
setSettings(res.data) setSettings(res.data)
setPgConnectionString(res.data.pg_connection_string || "") setPgConnectionString(res.data.pg_connection_string || "")
setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: "sqlite" }) 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() const authRes = await (window as any).api.authGetStatus()
if (authRes.success && authRes.data) setSecurityStatus(authRes.data) if (authRes.success && authRes.data) setSecurityStatus(authRes.data)
await loadMcpStatus() await loadMcpStatus()
await loadSystemFonts()
} }
const loadAboutContent = async () => { const loadAboutContent = async () => {
@@ -220,6 +271,13 @@ export const Settings: React.FC<{
return { ...prev, disable_search_keyboard: Boolean(change.value) } return { ...prev, disable_search_keyboard: Boolean(change.value) }
if (change?.key === "auto_score_enabled") if (change?.key === "auto_score_enabled")
return { ...prev, auto_score_enabled: change.value } 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 return prev
}) })
}) })
@@ -664,6 +722,43 @@ export const Settings: React.FC<{
<Divider /> <Divider />
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}> <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 && ( {!isMobile && (
<> <>
<Form.Item label={t("settings.searchKeyboard.title")}> <Form.Item label={t("settings.searchKeyboard.title")}>
+2
View File
@@ -135,6 +135,8 @@
}, },
"language": "Language", "language": "Language",
"languageHint": "Select interface display language", "languageHint": "Select interface display language",
"fontFamily": "Global Font",
"fontFamilyHint": "Select the application interface font (page refresh required for full effect)",
"theme": "Theme", "theme": "Theme",
"password": "Set Password", "password": "Set Password",
"themeMode": "Mode", "themeMode": "Mode",
+2
View File
@@ -135,6 +135,8 @@
}, },
"language": "语言", "language": "语言",
"languageHint": "选择界面显示语言", "languageHint": "选择界面显示语言",
"fontFamily": "全局字体",
"fontFamilyHint": "选择应用界面的字体(需要刷新页面以完全生效)",
"theme": "主题", "theme": "主题",
"password": "设置密码", "password": "设置密码",
"themeMode": "模式", "themeMode": "模式",