mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 09:39:03 +08:00
feat: 自动化更新
This commit is contained in:
+2
-13
@@ -33,26 +33,15 @@ import { OAuthLogin } from "./components/OAuth/OAuthLogin"
|
||||
import { OAuthCallback } from "./components/OAuth/OAuthCallback"
|
||||
import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
|
||||
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
|
||||
import { resolveStoredFontFamily } from "./shared/fontFamily"
|
||||
|
||||
const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
|
||||
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(
|
||||
0,
|
||||
4
|
||||
)
|
||||
const SYSTEM_FONT_STACK =
|
||||
'"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'
|
||||
|
||||
const resolveFontFamily = (fontValue?: string): string => {
|
||||
if (!fontValue || fontValue === "system") return SYSTEM_FONT_STACK
|
||||
if (fontValue.startsWith("system-")) {
|
||||
const family = fontValue.slice("system-".length).trim()
|
||||
if (family) return `"${family}", ${SYSTEM_FONT_STACK}`
|
||||
}
|
||||
return SYSTEM_FONT_STACK
|
||||
}
|
||||
|
||||
const applyGlobalFontFamily = (fontValue?: string) => {
|
||||
const fontFamily = resolveFontFamily(fontValue)
|
||||
const fontFamily = resolveStoredFontFamily(fontValue)
|
||||
document.documentElement.style.setProperty("--ss-font-family", fontFamily)
|
||||
document.body.style.fontFamily = fontFamily
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
() => [
|
||||
{ value: "add_score", label: t("autoScore.actionAddScore") },
|
||||
{ value: "add_tag", label: t("autoScore.actionAddTag") },
|
||||
{ value: "settle_score", label: t("autoScore.actionSettleScore") },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
@@ -86,7 +87,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
onChange={(event: ActionEvent) =>
|
||||
updateAction(action.id, {
|
||||
event,
|
||||
value: event === "add_score" ? "1" : [],
|
||||
value: event === "add_score" ? "1" : event === "add_tag" ? [] : "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -100,7 +101,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
updateAction(action.id, { value: nextValue === null ? "" : String(nextValue) })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
) : action.event === "add_tag" ? (
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
@@ -114,6 +115,10 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
options={mergedTagOptions}
|
||||
onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ minWidth: 260, color: "var(--ss-text-secondary)" }}>
|
||||
{t("autoScore.actionSettleScoreHint")}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
danger
|
||||
|
||||
@@ -24,6 +24,27 @@ export interface AutoScoreAction {
|
||||
value?: string | null
|
||||
}
|
||||
|
||||
export interface AutoScoreExecutionConfig {
|
||||
cooldownMinutes?: number | null
|
||||
maxRunsPerDay?: number | null
|
||||
maxScoreDeltaPerDay?: number | null
|
||||
}
|
||||
|
||||
export interface AutoScoreExecutionBatch {
|
||||
id: string
|
||||
ruleId: number
|
||||
ruleName: string
|
||||
runAt: string
|
||||
affectedStudents: number
|
||||
affectedStudentNames: string[]
|
||||
createdEventIds: number[]
|
||||
addedStudentTagIds: number[]
|
||||
scoreDeltaTotal: number
|
||||
settled: boolean
|
||||
rolledBack: boolean
|
||||
rollbackAt?: string | null
|
||||
}
|
||||
|
||||
export interface AutoScoreRule {
|
||||
id: number
|
||||
name: string
|
||||
@@ -31,10 +52,11 @@ export interface AutoScoreRule {
|
||||
studentNames: string[]
|
||||
triggers: AutoScoreTrigger[]
|
||||
actions: AutoScoreAction[]
|
||||
execution?: AutoScoreExecutionConfig
|
||||
lastExecuted?: string | null
|
||||
}
|
||||
|
||||
export type ActionEvent = "add_score" | "add_tag"
|
||||
export type ActionEvent = "add_score" | "add_tag" | "settle_score"
|
||||
|
||||
export interface AutoScoreTagOption {
|
||||
label: string
|
||||
@@ -379,7 +401,7 @@ export const createDefaultActionDraft = (): ActionDraft => ({
|
||||
})
|
||||
|
||||
const isActionEvent = (value: unknown): value is ActionEvent =>
|
||||
value === "add_score" || value === "add_tag"
|
||||
value === "add_score" || value === "add_tag" || value === "settle_score"
|
||||
|
||||
export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined): ActionDraft[] => {
|
||||
if (!Array.isArray(drafts) || drafts.length === 0) {
|
||||
@@ -396,7 +418,9 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
|
||||
value:
|
||||
draft.event === "add_tag"
|
||||
? parseTagValues(draft.value)
|
||||
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
||||
: draft.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
||||
} satisfies ActionDraft
|
||||
})
|
||||
.filter((item): item is ActionDraft => Boolean(item))
|
||||
@@ -407,12 +431,18 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
|
||||
export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
|
||||
const mapped = actions
|
||||
.map((action) => {
|
||||
if (action.event !== "add_score" && action.event !== "add_tag") return null
|
||||
if (action.event !== "add_score" && action.event !== "add_tag" && action.event !== "settle_score") {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: QbUtils.uuid(),
|
||||
event: action.event,
|
||||
value:
|
||||
action.event === "add_tag" ? parseTagValues(action.value) : toStringValue(action.value),
|
||||
action.event === "add_tag"
|
||||
? parseTagValues(action.value)
|
||||
: action.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(action.value),
|
||||
} satisfies ActionDraft
|
||||
})
|
||||
.filter((item): item is ActionDraft => Boolean(item))
|
||||
@@ -439,6 +469,11 @@ export const actionDraftsToPayload = (
|
||||
continue
|
||||
}
|
||||
|
||||
if (draft.event === "settle_score") {
|
||||
actions.push({ event: draft.event })
|
||||
continue
|
||||
}
|
||||
|
||||
const serializedTagValue = stringifyTagValues(parseTagValues(draft.value))
|
||||
if (!serializedTagValue) {
|
||||
return { actions: [], error: "tag_required" }
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Select,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
message,
|
||||
} from "antd"
|
||||
import { type ImmutableTree } from "@react-awesome-query-builder/antd"
|
||||
@@ -32,6 +35,8 @@ import {
|
||||
queryTreeToTriggers,
|
||||
triggersToQueryTree,
|
||||
type ActionDraft,
|
||||
type AutoScoreExecutionBatch,
|
||||
type AutoScoreExecutionConfig,
|
||||
type AutoScoreRule,
|
||||
type AutoScoreTagOption,
|
||||
} from "./AutoScore/AutoScoreUtils"
|
||||
@@ -49,6 +54,7 @@ interface TagItem {
|
||||
interface RuleFormValues {
|
||||
name?: string
|
||||
studentNames?: string[]
|
||||
execution?: AutoScoreExecutionConfig
|
||||
}
|
||||
|
||||
interface AutoScoreManagerProps {
|
||||
@@ -82,8 +88,10 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
const [actionDrafts, setActionDrafts] = useState<ActionDraft[]>([createDefaultActionDraft()])
|
||||
const [students, setStudents] = useState<StudentItem[]>([])
|
||||
const [rules, setRules] = useState<AutoScoreRule[]>([])
|
||||
const [batches, setBatches] = useState<AutoScoreExecutionBatch[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [rollingBackBatchId, setRollingBackBatchId] = useState<string | null>(null)
|
||||
const [editingRuleId, setEditingRuleId] = useState<number | null>(null)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
@@ -101,7 +109,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
|
||||
const resetEditor = () => {
|
||||
setEditingRuleId(null)
|
||||
form.setFieldsValue({ name: "", studentNames: [] })
|
||||
form.setFieldsValue({ name: "", studentNames: [], execution: {} })
|
||||
setTriggerTree(createEmptyTriggerTree(triggerConfig))
|
||||
setActionDrafts([createDefaultActionDraft()])
|
||||
}
|
||||
@@ -156,11 +164,25 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBatches = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
try {
|
||||
const res = await api.autoScoreQueryBatches()
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setBatches(res.data)
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEdit) return
|
||||
fetchTags().catch(() => void 0)
|
||||
fetchStudents().catch(() => void 0)
|
||||
fetchRules().catch(() => void 0)
|
||||
fetchBatches().catch(() => void 0)
|
||||
}, [canEdit])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -174,6 +196,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
const values = await form.validateFields()
|
||||
const name = String(values.name || "").trim()
|
||||
const studentNames = Array.isArray(values.studentNames) ? values.studentNames : []
|
||||
const execution = values.execution || {}
|
||||
|
||||
if (!name) {
|
||||
messageApi.warning(t("autoScore.nameRequired"))
|
||||
@@ -221,6 +244,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
studentNames,
|
||||
triggers,
|
||||
actions: actionPayload.actions,
|
||||
execution,
|
||||
}
|
||||
const res =
|
||||
editingRuleId === null
|
||||
@@ -233,6 +257,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
)
|
||||
resetEditor()
|
||||
await fetchRules()
|
||||
await fetchBatches()
|
||||
emitDataUpdated()
|
||||
} else {
|
||||
messageApi.error(
|
||||
@@ -254,6 +279,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
form.setFieldsValue({
|
||||
name: rule.name,
|
||||
studentNames: rule.studentNames || [],
|
||||
execution: rule.execution || {},
|
||||
})
|
||||
setTriggerTree(triggersToQueryTree(triggerConfig, rule.triggers || []))
|
||||
setActionDrafts(actionsToDrafts(rule.actions || []))
|
||||
@@ -275,6 +301,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
resetEditor()
|
||||
}
|
||||
await fetchRules()
|
||||
await fetchBatches()
|
||||
emitDataUpdated()
|
||||
} else {
|
||||
messageApi.error(res.message || t("autoScore.deleteFailed"))
|
||||
@@ -316,6 +343,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
studentNames: rule.studentNames,
|
||||
triggers: rule.triggers,
|
||||
actions: rule.actions,
|
||||
execution: rule.execution || {},
|
||||
lastExecuted: rule.lastExecuted ?? null,
|
||||
exportedAt: new Date().toISOString(),
|
||||
}
|
||||
@@ -342,6 +370,31 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const handleRollbackBatch = async (batchId: string) => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
Modal.confirm({
|
||||
title: t("autoScore.batchRollbackConfirm"),
|
||||
onOk: async () => {
|
||||
setRollingBackBatchId(batchId)
|
||||
try {
|
||||
const res = await api.autoScoreRollbackBatch({ batchId })
|
||||
if (res.success) {
|
||||
messageApi.success(t("autoScore.batchRollbackSuccess"))
|
||||
await fetchBatches()
|
||||
emitDataUpdated()
|
||||
} else {
|
||||
messageApi.error(res.message || t("autoScore.batchRollbackFailed"))
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t("autoScore.batchRollbackFailed"))
|
||||
} finally {
|
||||
setRollingBackBatchId(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AutoScoreRule> = [
|
||||
{
|
||||
title: t("autoScore.name"),
|
||||
@@ -370,7 +423,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
ellipsis: true,
|
||||
render: (_, row) =>
|
||||
row.studentNames.length > 0 ? (
|
||||
<Tag>{t("autoScore.studentCount", { count: row.studentNames.length })}</Tag>
|
||||
<Tooltip title={row.studentNames.join("、")}>
|
||||
<Tag>{t("autoScore.studentCount", { count: row.studentNames.length })}</Tag>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tag>{t("autoScore.allStudents")}</Tag>
|
||||
),
|
||||
@@ -422,6 +477,63 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
},
|
||||
]
|
||||
|
||||
const batchColumns: ColumnsType<AutoScoreExecutionBatch> = [
|
||||
{
|
||||
title: t("autoScore.batchId"),
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
ellipsis: true,
|
||||
width: 220,
|
||||
render: (value: string) => value.slice(0, 8),
|
||||
},
|
||||
{
|
||||
title: t("autoScore.name"),
|
||||
dataIndex: "ruleName",
|
||||
key: "ruleName",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t("autoScore.lastExecuted"),
|
||||
dataIndex: "runAt",
|
||||
key: "runAt",
|
||||
width: 180,
|
||||
render: (value: string) => formatLastExecuted(value),
|
||||
},
|
||||
{
|
||||
title: t("autoScore.applicableStudents"),
|
||||
dataIndex: "affectedStudents",
|
||||
key: "affectedStudents",
|
||||
width: 100,
|
||||
render: (value: number) => value,
|
||||
},
|
||||
{
|
||||
title: t("autoScore.actionAddScore"),
|
||||
dataIndex: "scoreDeltaTotal",
|
||||
key: "scoreDeltaTotal",
|
||||
width: 120,
|
||||
render: (value: number) => value,
|
||||
},
|
||||
{
|
||||
title: t("common.operation"),
|
||||
key: "operation",
|
||||
width: 180,
|
||||
render: (_, row) =>
|
||||
row.rolledBack ? (
|
||||
<Tag>{t("autoScore.batchRolledBack")}</Tag>
|
||||
) : (
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
loading={rollingBackBatchId === row.id}
|
||||
disabled={row.settled || !canEdit}
|
||||
onClick={() => handleRollbackBatch(row.id).catch(() => void 0)}
|
||||
>
|
||||
{t("autoScore.batchRollback")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
|
||||
return (
|
||||
@@ -461,6 +573,21 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "12px" }}>
|
||||
<Form.Item label={t("autoScore.cooldownMinutes")} name={["execution", "cooldownMinutes"]}>
|
||||
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("autoScore.maxRunsPerDay")} name={["execution", "maxRunsPerDay"]}>
|
||||
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("autoScore.maxScoreDeltaPerDay")}
|
||||
name={["execution", "maxScoreDeltaPerDay"]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
@@ -517,6 +644,20 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t("autoScore.batchLogs")}
|
||||
style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={batchColumns}
|
||||
dataSource={batches.slice(0, 50)}
|
||||
pagination={false}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 860 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+40
-40
@@ -18,8 +18,14 @@ import {
|
||||
import { ThemeQuickSettings } from "./ThemeQuickSettings"
|
||||
import { OAuthLogin } from "./OAuth/OAuthLogin"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { pinyin } from "pinyin-pro"
|
||||
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
|
||||
import { useResponsive } from "../hooks/useResponsive"
|
||||
import {
|
||||
buildSystemFontFamily,
|
||||
buildSystemFontValue,
|
||||
SYSTEM_FONT_STACK,
|
||||
} from "../shared/fontFamily"
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
@@ -37,46 +43,33 @@ interface FontOption {
|
||||
value: string
|
||||
label: string
|
||||
fontFamily: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const SYSTEM_FONT_STACK =
|
||||
'"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'
|
||||
const CHINESE_FONT_CHAR_PATTERN = /[\u3400-\u9fff]/
|
||||
const CHINESE_FONT_SEGMENT_PATTERN = /[\u3400-\u9fff]+/g
|
||||
|
||||
const toPascalCasePinyin = (value: string): string =>
|
||||
pinyin(value, { toneType: "none" })
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
|
||||
.join("")
|
||||
|
||||
const formatFontDisplayName = (name: string): string => {
|
||||
if (!CHINESE_FONT_CHAR_PATTERN.test(name)) return name
|
||||
return name.replace(CHINESE_FONT_SEGMENT_PATTERN, (segment) => toPascalCasePinyin(segment))
|
||||
}
|
||||
|
||||
const buildFontSearchText = (name: string, label: string): string =>
|
||||
`${name} ${label}`.toLowerCase()
|
||||
|
||||
const defaultFontOptions: FontOption[] = [
|
||||
{
|
||||
value: "system",
|
||||
label: "系统默认",
|
||||
fontFamily: SYSTEM_FONT_STACK,
|
||||
},
|
||||
{
|
||||
value: "system-Microsoft YaHei UI",
|
||||
label: "Microsoft YaHei UI",
|
||||
fontFamily: '"Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Microsoft YaHei",
|
||||
label: "Microsoft YaHei",
|
||||
fontFamily: '"Microsoft YaHei", "微软雅黑", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-PingFang SC",
|
||||
label: "PingFang SC",
|
||||
fontFamily: '"PingFang SC", "Hiragino Sans GB", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Noto Sans CJK SC",
|
||||
label: "Noto Sans CJK SC",
|
||||
fontFamily: '"Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Segoe UI",
|
||||
label: "Segoe UI",
|
||||
fontFamily: '"Segoe UI", sans-serif',
|
||||
},
|
||||
{
|
||||
value: "system-Arial",
|
||||
label: "Arial",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
searchText: "系统默认 system default",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -272,13 +265,17 @@ export const Settings: React.FC<{
|
||||
const remoteOptions = res.data
|
||||
.map((name: string) => String(name || "").trim())
|
||||
.filter((name: string) => Boolean(name))
|
||||
.map(
|
||||
(name: string) =>
|
||||
({
|
||||
value: `system-${name}`,
|
||||
label: name,
|
||||
fontFamily: `"${name}", ${SYSTEM_FONT_STACK}`,
|
||||
}) satisfies FontOption
|
||||
.map((name: string) => {
|
||||
const label = formatFontDisplayName(name)
|
||||
return {
|
||||
value: buildSystemFontValue(name),
|
||||
label,
|
||||
fontFamily: buildSystemFontFamily(name),
|
||||
searchText: buildFontSearchText(name, label),
|
||||
} satisfies FontOption
|
||||
})
|
||||
.sort((a: FontOption, b: FontOption) =>
|
||||
a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin")
|
||||
)
|
||||
|
||||
const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions])
|
||||
@@ -829,10 +826,13 @@ export const Settings: React.FC<{
|
||||
options={fontOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
searchText: opt.searchText,
|
||||
}))}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
String(option?.searchText ?? option?.label ?? "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"leaderboard": "Leaderboard",
|
||||
"settlements": "Settlements",
|
||||
"reasons": "Reasons",
|
||||
"autoScore": "Auto Score",
|
||||
"autoScore": "Automation",
|
||||
"rewardExchange": "Reward Exchange",
|
||||
"rewardSettings": "Reward Settings",
|
||||
"settings": "Settings",
|
||||
@@ -758,7 +758,7 @@
|
||||
"name": "Name"
|
||||
},
|
||||
"autoScore": {
|
||||
"title": "Auto Score Management",
|
||||
"title": "Automation Management",
|
||||
"name": "Automation Name",
|
||||
"namePlaceholder": "e.g. Daily Check-in Points",
|
||||
"nameRequired": "Please enter automation name",
|
||||
@@ -814,10 +814,29 @@
|
||||
"tagNamesPlaceholder": "e.g. excellent_student,class_monitor",
|
||||
"triggerIntervalTime": "Interval Time",
|
||||
"triggerStudentTag": "Student Tag",
|
||||
"triggerStudentSql": "Student SQL",
|
||||
"triggerStudentSql": "Custom SQL Condition",
|
||||
"triggerStudentSqlPlaceholder": "Enter student SQL or a WHERE condition",
|
||||
"actionAddScore": "Add Score",
|
||||
"actionAddTag": "Add Tag",
|
||||
"actionSettleScore": "Settle Score",
|
||||
"actionSettleScoreHint": "No parameters required",
|
||||
"cooldownMinutes": "Cooldown (minutes)",
|
||||
"maxStudentsPerRun": "Max Students Per Run",
|
||||
"maxRunsPerDay": "Max Runs Per Day",
|
||||
"maxScoreDeltaPerDay": "Max Score Delta Per Day",
|
||||
"filterGroups": "Filter by Group",
|
||||
"filterGrades": "Filter by Grade",
|
||||
"filterMinScore": "Min Current Score",
|
||||
"filterMaxScore": "Max Current Score",
|
||||
"filterRecentEventDays": "Recent Event Days",
|
||||
"filterMinRecentEventCount": "Min Recent Event Count",
|
||||
"batchLogs": "Execution Logs",
|
||||
"batchId": "Batch",
|
||||
"batchRollback": "Rollback",
|
||||
"batchRolledBack": "Rolled Back",
|
||||
"batchRollbackConfirm": "Confirm rollback this execution batch?",
|
||||
"batchRollbackSuccess": "Batch rollback succeeded",
|
||||
"batchRollbackFailed": "Batch rollback failed",
|
||||
"intervalAmountPlaceholder": "Enter interval time",
|
||||
"intervalUnitMonth": "month(s) later",
|
||||
"intervalUnitDay": "day(s) later",
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"leaderboard": "排行榜",
|
||||
"settlements": "结算历史",
|
||||
"reasons": "理由管理",
|
||||
"autoScore": "自动加分",
|
||||
"autoScore": "自动化",
|
||||
"rewardExchange": "奖励兑换",
|
||||
"rewardSettings": "奖励设置",
|
||||
"settings": "系统设置",
|
||||
@@ -758,7 +758,7 @@
|
||||
"name": "姓名"
|
||||
},
|
||||
"autoScore": {
|
||||
"title": "自动化加分管理",
|
||||
"title": "自动化管理",
|
||||
"name": "自动化名称",
|
||||
"namePlaceholder": "例如:每日签到加分",
|
||||
"nameRequired": "请输入自动化名称",
|
||||
@@ -812,14 +812,33 @@
|
||||
"operationNoteLabel": "操作说明(可选)",
|
||||
"triggerIntervalTime": "间隔时间",
|
||||
"triggerStudentTag": "学生标签",
|
||||
"triggerStudentSql": "学生 SQL 条件",
|
||||
"triggerStudentSql": "自定义 SQL 条件",
|
||||
"triggerStudentSqlPlaceholder": "输入筛选学生的 SQL 或 WHERE 条件",
|
||||
"actionAddScore": "加分",
|
||||
"actionAddTag": "添加标签",
|
||||
"intervalAmountPlaceholder": "请输入间隔时间数值",
|
||||
"intervalUnitMonth": "个月后",
|
||||
"intervalUnitDay": "天后",
|
||||
"intervalUnitMinute": "分钟后",
|
||||
"actionSettleScore": "结算分数",
|
||||
"actionSettleScoreHint": "无需参数",
|
||||
"cooldownMinutes": "冷却时间(分钟)",
|
||||
"maxStudentsPerRun": "单次最多学生数",
|
||||
"maxRunsPerDay": "每日最多执行次数",
|
||||
"maxScoreDeltaPerDay": "每日最多积分变动",
|
||||
"filterGroups": "按分组筛选",
|
||||
"filterGrades": "按年级筛选",
|
||||
"filterMinScore": "最低当前积分",
|
||||
"filterMaxScore": "最高当前积分",
|
||||
"filterRecentEventDays": "最近事件天数",
|
||||
"filterMinRecentEventCount": "最近事件最少次数",
|
||||
"batchLogs": "执行日志",
|
||||
"batchId": "批次",
|
||||
"batchRollback": "回滚",
|
||||
"batchRolledBack": "已回滚",
|
||||
"batchRollbackConfirm": "确认回滚这个执行批次?",
|
||||
"batchRollbackSuccess": "批次回滚成功",
|
||||
"batchRollbackFailed": "批次回滚失败",
|
||||
"intervalAmountPlaceholder": "请输入间隔时间",
|
||||
"intervalUnitMonth": "个月",
|
||||
"intervalUnitDay": "天",
|
||||
"intervalUnitMinute": "分 钟",
|
||||
"minutesPlaceholder": "请输入分钟数",
|
||||
"minutes": "分钟",
|
||||
"tagsPlaceholder": "请输入标签名称",
|
||||
|
||||
@@ -32,6 +32,27 @@ export interface autoScoreAction {
|
||||
value?: string | null
|
||||
}
|
||||
|
||||
export interface autoScoreExecutionConfig {
|
||||
cooldownMinutes?: number | null
|
||||
maxRunsPerDay?: number | null
|
||||
maxScoreDeltaPerDay?: number | null
|
||||
}
|
||||
|
||||
export interface autoScoreExecutionBatch {
|
||||
id: string
|
||||
ruleId: number
|
||||
ruleName: string
|
||||
runAt: string
|
||||
affectedStudents: number
|
||||
affectedStudentNames: string[]
|
||||
createdEventIds: number[]
|
||||
addedStudentTagIds: number[]
|
||||
scoreDeltaTotal: number
|
||||
settled: boolean
|
||||
rolledBack: boolean
|
||||
rollbackAt?: string | null
|
||||
}
|
||||
|
||||
export interface autoScoreRule {
|
||||
id: number
|
||||
name: string
|
||||
@@ -39,6 +60,7 @@ export interface autoScoreRule {
|
||||
studentNames: string[]
|
||||
triggers: autoScoreTrigger[]
|
||||
actions: autoScoreAction[]
|
||||
execution?: autoScoreExecutionConfig
|
||||
lastExecuted?: string | null
|
||||
}
|
||||
|
||||
@@ -52,6 +74,7 @@ export type settingsKey =
|
||||
| "themes_custom"
|
||||
| "auto_score_enabled"
|
||||
| "auto_score_rules"
|
||||
| "auto_score_batches"
|
||||
| "current_theme_id"
|
||||
| "dashboards_config"
|
||||
| "pg_connection_string"
|
||||
@@ -68,6 +91,7 @@ export interface settingsSpec {
|
||||
themes_custom: themeConfig[]
|
||||
auto_score_enabled: boolean
|
||||
auto_score_rules: autoScoreRule[]
|
||||
auto_score_batches: autoScoreExecutionBatch[]
|
||||
current_theme_id: string
|
||||
dashboards_config: any[]
|
||||
pg_connection_string: string
|
||||
@@ -275,6 +299,7 @@ const api = {
|
||||
studentNames: string[]
|
||||
triggers: autoScoreTrigger[]
|
||||
actions: autoScoreAction[]
|
||||
execution?: autoScoreExecutionConfig
|
||||
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||
invoke("auto_score_add_rule", { rule }),
|
||||
autoScoreUpdateRule: (rule: {
|
||||
@@ -284,6 +309,7 @@ const api = {
|
||||
studentNames: string[]
|
||||
triggers: autoScoreTrigger[]
|
||||
actions: autoScoreAction[]
|
||||
execution?: autoScoreExecutionConfig
|
||||
}): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
invoke("auto_score_update_rule", { rule }),
|
||||
autoScoreDeleteRule: (
|
||||
@@ -306,6 +332,15 @@ const api = {
|
||||
ruleIds: number[]
|
||||
): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
invoke("auto_score_sort_rules", { ruleIds }),
|
||||
autoScoreQueryBatches: (): Promise<{
|
||||
success: boolean
|
||||
data?: autoScoreExecutionBatch[]
|
||||
message?: string
|
||||
}> => invoke("auto_score_query_batches"),
|
||||
autoScoreRollbackBatch: (params: {
|
||||
batchId: string
|
||||
}): Promise<{ success: boolean; data?: autoScoreExecutionBatch; message?: string }> =>
|
||||
invoke("auto_score_rollback_batch", { params }),
|
||||
|
||||
// Settings & Sync
|
||||
getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> =>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export const SYSTEM_FONT_STACK =
|
||||
'"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'
|
||||
|
||||
export const normalizeSystemFontName = (name: string): string =>
|
||||
String(name || "")
|
||||
.trim()
|
||||
.replace(/^["']+|["']+$/g, "")
|
||||
|
||||
export const quoteFontFamilyName = (name: string): string =>
|
||||
`"${normalizeSystemFontName(name).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
||||
|
||||
export const buildSystemFontFamily = (name: string): string => {
|
||||
const normalized = normalizeSystemFontName(name)
|
||||
if (!normalized) return SYSTEM_FONT_STACK
|
||||
return `${quoteFontFamilyName(normalized)}, ${SYSTEM_FONT_STACK}`
|
||||
}
|
||||
|
||||
export const buildSystemFontValue = (name: string): string => {
|
||||
const normalized = normalizeSystemFontName(name)
|
||||
if (!normalized) return "system"
|
||||
return `system-${normalized}`
|
||||
}
|
||||
|
||||
export const resolveStoredFontFamily = (fontValue?: string): string => {
|
||||
if (!fontValue || fontValue === "system") return SYSTEM_FONT_STACK
|
||||
if (fontValue.startsWith("system-")) {
|
||||
return buildSystemFontFamily(fontValue.slice("system-".length))
|
||||
}
|
||||
return SYSTEM_FONT_STACK
|
||||
}
|
||||
Reference in New Issue
Block a user