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
+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