feat: 自动化更新

This commit is contained in:
NanGua-QWQ
2026-04-06 19:40:19 +08:00
parent ed4891210d
commit 3caea0afe5
18 changed files with 948 additions and 152 deletions
+2 -13
View File
@@ -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
}
+7 -2
View File
@@ -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
+40 -5
View File
@@ -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" }
+143 -2
View File
@@ -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
View File
@@ -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
+22 -3
View File
@@ -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",
+26 -7
View File
@@ -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": "请输入标签名称",
+35
View File
@@ -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 }> =>
+30
View File
@@ -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
}