Files
OJ2/apps/web/src/admin/problem/components/AstRulesEditor.vue
yuetsh f919d41199 refactor(契约): AST 代码要求收进契约,三份各写各的合成一份
同一个形状原来在三个地方各定义了一份,三份都不一样:

  apps/api/src/judge/ast.ts        engine target outer inner label message exact min max
  apps/web/src/utils/types.ts      engine target                  message       min max
  AstRulesEditor.vue(本地)        engine target       label message exact min max
  ProblemContent.vue(本地)        engine target       label message exact min max

判题机那份是真的(evaluateRule 按 engine 分支读哪几个字段),契约里则压根没有,
`astRules: z.unknown()`。后果是后台编辑器写得出 label 和 exact、判题机也认,
但 utils/types.ts 那个类型描述不了它们 —— 生产库 12 道带 AST 规则的题里,
15 条规则带 label、6 条带 exact,全都在类型之外。

现在契约里一份 astRuleSchema,四处都指向它。engine 收成枚举,列全判题机
实现的十种。**存量数据核过**:12 道题逐条过新 schema,12/12 通过。

顺带三件:

- `astRules` 的响应和请求 schema 从 z.unknown() 换成 astRulesSchema。之前
  engine 写错一个字母能存进去,判题机 evaluateRule 走 `default: return null`
  静默跳过 —— 老师设了规则、规则不生效、没有任何提示。现在保存时就 400,
  错误信息把十个合法值列出来。
- judge/run.ts 的 astRulesForLanguage 原来是整片 `rules as AstRule[]` 硬转,
  改成逐条 safeParse:认不出的丢掉那一条,行为和 evaluateRule 的 default 分支
  一致,只是提前到读取处,也不再骗类型系统。
- 判题机实现了 must_have_nesting,但后台编辑器没有对应选项,目前只能手工造
  数据才用得上。枚举里留着并加了注释,没有顺手去补 UI(那是加功能不是清理)。

## 验证

tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
起服务实打:

- 把生产库那条带 label/exact 的规则种进本地库,后台题目详情 200、字段齐全;
  原样 PUT 回去 200,库里 label 和 exact 都在(旧类型描述不了的那两个)。
- engine 传 "must_do_magic" → 400,错误信息列出十个合法值。
- 直接调 checkAst:三条规则(两条合法 + 一条 engine 认不出)进去,判题机收下
  两条;两个 if/else 的代码 passed=true,一个的 passed=false,描述文案
  「if 条件 出现 2 次」正确用上了 label。

冒烟改动已还原(problem 2 的 ast_rules 复位成 null,测试标签删掉)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:15:52 -06:00

388 lines
12 KiB
Vue

<script setup lang="ts">
import type { AstRule, AstRules, LANGUAGE } from "utils/types"
interface Props {
modelValue: AstRules | null
languages: LANGUAGE[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "update:modelValue", value: AstRules | null): void
}>()
const activeTab = ref(props.languages[0] || "Python3")
const ENGINE_OPTIONS: SelectOption[] = [
{
label: "节点检查",
type: "group",
key: "node_group",
children: [
{ label: "必须存在", value: "must_exist_node" },
{ label: "不能存在", value: "must_not_exist_node" },
{ label: "出现次数", value: "count_node" },
],
},
{
label: "函数调用",
type: "group",
key: "func_group",
children: [
{ label: "必须调用函数", value: "must_call_function" },
{ label: "不能调用函数", value: "must_not_call_function" },
{ label: "函数调用次数", value: "count_function_call" },
],
},
{
label: "方法调用",
type: "group",
key: "method_group",
children: [
{ label: "必须调用方法", value: "must_call_method" },
{ label: "不能调用方法", value: "must_not_call_method" },
],
},
{
label: "运算符",
type: "group",
key: "op_group",
children: [{ label: "必须使用运算符", value: "must_use_operator" }],
},
]
const NODE_TARGET_OPTIONS: SelectOption[] = [
{ label: "for 循环", value: "for_loop" },
{ label: "while 循环", value: "while_loop" },
{ label: "if 条件", value: "if_statement" },
{ label: "else 子句", value: "else_clause" },
{ label: "函数定义", value: "function_definition" },
{ label: "return 语句", value: "return" },
{ label: "break 语句", value: "break" },
{ label: "continue 语句", value: "continue" },
{ label: "列表推导式", value: "list_comprehension" },
{ label: "列表", value: "list_literal" },
{ label: "字典", value: "dict_literal" },
{ label: "集合", value: "set_literal" },
{ label: "f-string", value: "f_string" },
{ label: "try-except", value: "try_except" },
{ label: "类定义", value: "class_definition" },
]
const OPERATOR_TARGET_OPTIONS: SelectOption[] = [
{ label: "+", value: "+" },
{ label: "-", value: "-" },
{ label: "*", value: "*" },
{ label: "/", value: "/" },
{ label: "//", value: "//" },
{ label: "%", value: "%" },
{ label: "**", value: "**" },
{ label: "+=", value: "+=" },
{ label: "-=", value: "-=" },
{ label: "==", value: "==" },
{ label: "!=", value: "!=" },
{ label: ">", value: ">" },
{ label: ">=", value: ">=" },
{ label: "<", value: "<" },
{ label: "<=", value: "<=" },
{ label: "and / &&", value: "and" },
{ label: "or / ||", value: "or" },
{ label: "not / !", value: "not" },
]
const NODE_ENGINES = ["must_exist_node", "must_not_exist_node", "count_node"]
const FUNCTION_ENGINES = [
"must_call_function",
"must_not_call_function",
"count_function_call",
]
const METHOD_ENGINES = ["must_call_method", "must_not_call_method"]
const OPERATOR_ENGINES = ["must_use_operator"]
const COUNT_ENGINES = ["count_node", "count_function_call"]
function isNodeEngine(engine: string) {
return NODE_ENGINES.includes(engine)
}
function isFunctionEngine(engine: string) {
return FUNCTION_ENGINES.includes(engine)
}
function isMethodEngine(engine: string) {
return METHOD_ENGINES.includes(engine)
}
function isOperatorEngine(engine: string) {
return OPERATOR_ENGINES.includes(engine)
}
function isCountEngine(engine: string) {
return COUNT_ENGINES.includes(engine)
}
const COUNT_MODE_OPTIONS: SelectOption[] = [
{ label: "精确", value: "exact" },
{ label: "范围", value: "range" },
]
function getCountMode(rule: AstRule): "exact" | "range" {
return rule.exact !== undefined ? "exact" : "range"
}
function updateCountMode(lang: string, index: number, mode: "exact" | "range") {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (mode === "exact") {
rule.exact = rule.min ?? 1
delete rule.min
delete rule.max
} else {
delete rule.exact
}
rules[index] = rule
updateRules(lang, rules)
}
function updateExactCount(lang: string, index: number, v: number | null) {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (v === null) delete rule.exact
else rule.exact = v
rules[index] = rule
updateRules(lang, rules)
}
function needsTargetDropdown(engine: string) {
return isNodeEngine(engine)
}
function needsTargetInput(engine: string) {
return isFunctionEngine(engine) || isMethodEngine(engine)
}
function needsOperatorDropdown(engine: string) {
return isOperatorEngine(engine)
}
function getRulesForLang(lang: string): AstRule[] {
if (!props.modelValue) return []
return props.modelValue[lang] || []
}
function updateRules(lang: string, rules: AstRule[]) {
const current = { ...(props.modelValue || {}) }
if (rules.length === 0) {
delete current[lang]
} else {
current[lang] = rules
}
emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
}
function getTargetLabel(engine: string, target: string): string | undefined {
if (isNodeEngine(engine))
return (NODE_TARGET_OPTIONS.find((o) => o.value === target) as any)?.label
if (isOperatorEngine(engine))
return (OPERATOR_TARGET_OPTIONS.find((o) => o.value === target) as any)
?.label
return undefined
}
function addRule(lang: string) {
const rules = [...getRulesForLang(lang)]
rules.push({
engine: "must_exist_node",
target: "for_loop",
label: "for 循环",
message: "",
})
updateRules(lang, rules)
}
function removeRule(lang: string, index: number) {
const rules = [...getRulesForLang(lang)]
rules.splice(index, 1)
updateRules(lang, rules)
}
function updateRule(lang: string, index: number, field: string, value: any) {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (field === "engine") {
rule.engine = value
if (isNodeEngine(value)) {
rule.target = "for_loop"
rule.label = "for 循环"
} else if (isOperatorEngine(value)) {
rule.target = "+"
rule.label = "+"
} else {
rule.target = ""
delete rule.label
}
delete rule.min
delete rule.max
delete rule.exact
} else if (field === "target") {
rule.target = value
const lbl = getTargetLabel(rule.engine, value)
if (lbl) rule.label = lbl
else delete rule.label
} else if (field === "min") {
if (value === null || value === undefined) delete rule.min
else rule.min = value
} else if (field === "max") {
if (value === null || value === undefined) delete rule.max
else rule.max = value
} else if (field === "message") {
rule.message = value
}
rules[index] = rule
updateRules(lang, rules)
}
watch(
() => props.languages,
(langs) => {
if (langs.length && !langs.includes(activeTab.value as LANGUAGE)) {
activeTab.value = langs[0]
}
},
)
</script>
<template>
<n-collapse>
<n-collapse-item title="代码规则检查(选填)" name="ast-rules">
<n-tabs v-if="languages.length" type="segment" v-model:value="activeTab">
<n-tab-pane
v-for="lang in languages"
:key="lang"
:name="lang"
:tab="lang"
>
<n-flex vertical>
<div
v-for="(rule, index) in getRulesForLang(lang)"
:key="index"
style="margin-bottom: 8px"
>
<n-flex align="center" :wrap="false">
<n-select
:options="ENGINE_OPTIONS"
:value="rule.engine"
@update:value="
(v: string) => updateRule(lang, index, 'engine', v)
"
style="width: 150px"
size="small"
/>
<n-select
v-if="needsTargetDropdown(rule.engine)"
:options="NODE_TARGET_OPTIONS"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
style="width: 150px"
size="small"
filterable
/>
<n-input
v-if="needsTargetInput(rule.engine)"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
placeholder="函数/方法名"
style="width: 150px"
size="small"
/>
<n-select
v-if="needsOperatorDropdown(rule.engine)"
:options="OPERATOR_TARGET_OPTIONS"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
style="width: 150px"
size="small"
/>
<template v-if="isCountEngine(rule.engine)">
<n-select
:options="COUNT_MODE_OPTIONS"
:value="getCountMode(rule)"
@update:value="
(v: 'exact' | 'range') => updateCountMode(lang, index, v)
"
style="width: 80px"
size="small"
/>
<n-input-number
v-if="getCountMode(rule) === 'exact'"
:value="rule.exact ?? null"
@update:value="
(v: number | null) => updateExactCount(lang, index, v)
"
placeholder="次数"
style="width: 100px"
size="small"
:min="1"
clearable
/>
<template v-else>
<n-input-number
:value="rule.min ?? null"
@update:value="
(v: number | null) => updateRule(lang, index, 'min', v)
"
placeholder="最少"
style="width: 100px"
size="small"
:min="0"
clearable
/>
<n-input-number
:value="rule.max ?? null"
@update:value="
(v: number | null) => updateRule(lang, index, 'max', v)
"
placeholder="最多"
style="width: 100px"
size="small"
:min="0"
clearable
/>
</template>
</template>
<n-input
:value="rule.message"
@update:value="
(v: string) => updateRule(lang, index, 'message', v)
"
placeholder="错误提示(选填)"
style="flex: 1"
size="small"
/>
<n-button
size="small"
tertiary
type="error"
@click="removeRule(lang, index)"
>
删除
</n-button>
</n-flex>
</div>
<n-button
size="small"
tertiary
type="primary"
@click="addRule(lang)"
>
添加规则
</n-button>
</n-flex>
</n-tab-pane>
</n-tabs>
<n-empty v-else description="请先选择编程语言" />
</n-collapse-item>
</n-collapse>
</template>