mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat(设置): 添加全局字体选择功能
fix: 自动化不能保存的问题
This commit is contained in:
Binary file not shown.
@@ -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) {
|
||||||
|
|||||||
@@ -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")}>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -135,6 +135,8 @@
|
|||||||
},
|
},
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
"languageHint": "选择界面显示语言",
|
"languageHint": "选择界面显示语言",
|
||||||
|
"fontFamily": "全局字体",
|
||||||
|
"fontFamilyHint": "选择应用界面的字体(需要刷新页面以完全生效)",
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
"password": "设置密码",
|
"password": "设置密码",
|
||||||
"themeMode": "模式",
|
"themeMode": "模式",
|
||||||
|
|||||||
Reference in New Issue
Block a user