mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 18:19:03 +08:00
feat: 自动化更新
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user