Compare commits

...

3 Commits

Author SHA1 Message Date
11e447d4b7 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-25 21:56:07 -06:00
7547f896f6 feat: add admin UI for AST rule configuration 2026-05-25 20:50:40 -06:00
18fc65f2ce feat: add AST_CHECK_FAILED status and update result display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:47:46 -06:00
7 changed files with 358 additions and 4 deletions

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import type { LANGUAGE } from "utils/types"
interface AstRule {
engine: string
target?: string
min?: number
max?: number
message: string
}
interface Props {
modelValue: { [key: string]: AstRule[] } | null
languages: LANGUAGE[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "update:modelValue", value: { [key: string]: AstRule[] } | 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) }
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 addRule(lang: string) {
const rules = [...getRulesForLang(lang)]
rules.push({ engine: "must_exist_node", target: "for_loop", 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"
else if (isOperatorEngine(value)) rule.target = "+"
else rule.target = ""
delete rule.min
delete rule.max
} else if (field === "target") {
rule.target = value
} 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"
/>
<n-input-number
v-if="isCountEngine(rule.engine)"
:value="rule.min ?? null"
@update:value="(v: number | null) => updateRule(lang, index, 'min', v)"
placeholder="最少"
style="width: 150px"
size="small"
:min="0"
clearable
/>
<n-input-number
v-if="isCountEngine(rule.engine)"
:value="rule.max ?? null"
@update:value="(v: number | null) => updateRule(lang, index, 'max', v)"
placeholder="最多"
style="width: 150px"
size="small"
:min="0"
clearable
/>
<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>

View File

@@ -2,6 +2,7 @@
import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue"
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
import AstRulesEditor from "./components/AstRulesEditor.vue"
import {
CODE_TEMPLATES,
LANGUAGE_SHOW_VALUE,
@@ -87,6 +88,7 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
flowchart_data: {},
flowchart_hint: "",
show_flowchart: false,
ast_rules: null as { [key: string]: any[] } | null,
})
// 从服务器来的tag列表
@@ -176,6 +178,7 @@ async function getProblemDetail() {
problem.value.mermaid_code = data.mermaid_code ?? ""
problem.value.flowchart_hint = data.flowchart_hint ?? ""
problem.value.flowchart_data = data.flowchart_data
problem.value.ast_rules = data.ast_rules ?? null
if (data.answers && data.answers.length) {
problem.value.answers = data.answers
} else {
@@ -643,6 +646,15 @@ watch(
</n-gi>
</n-grid>
<n-grid :cols="2">
<n-gi :span="1">
<AstRulesEditor
v-model="problem.ast_rules!"
:languages="problem.languages"
/>
</n-gi>
</n-grid>
<n-divider />
<h2 class="title">测试用例区域</h2>

View File

@@ -6,7 +6,7 @@ import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { createTestSubmission } from "utils/judge"
import { DIFFICULTY } from "utils/constants"
import { Problem, ProblemStatus } from "utils/types"
import type { Problem, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue"
import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3"
@@ -85,6 +85,60 @@ const samples = ref<Sample[]>(
})),
)
const NODE_TARGET_LABELS: Record<string, string> = {
for_loop: "for 循环",
while_loop: "while 循环",
if_statement: "if 条件",
else_clause: "else 子句",
function_definition: "函数定义",
return: "return 语句",
break: "break 语句",
continue: "continue 语句",
list_comprehension: "列表推导式",
list_literal: "列表",
dict_literal: "字典",
set_literal: "集合",
f_string: "f-string",
try_except: "try-except",
class_definition: "类定义",
}
type AstRule = { engine: string; target?: string; min?: number; max?: number; message: string }
function ruleDescription(rule: AstRule): string {
const target = rule.target || ""
const targetLabel = NODE_TARGET_LABELS[target] || target
const range = (min?: number, max?: number) => {
if (min !== undefined && max !== undefined) return `${min}${max}`
if (min !== undefined) return `至少 ${min}`
if (max !== undefined) return `至多 ${max}`
return ""
}
switch (rule.engine) {
case "must_exist_node": return `必须使用 ${targetLabel}`
case "must_not_exist_node": return `不能使用 ${targetLabel}`
case "count_node": return `${targetLabel} 出现次数 ${range(rule.min, rule.max)}`
case "must_call_function": return `必须调用函数 ${target}`
case "must_not_call_function": return `不能调用函数 ${target}`
case "count_function_call": return `函数 ${target} 调用次数 ${range(rule.min, rule.max)}`
case "must_call_method": return `必须调用方法 ${target}`
case "must_not_call_method": return `不能调用方法 ${target}`
case "must_use_operator": return `必须使用运算符 ${target}`
default: return rule.message || rule.engine
}
}
function ruleTagType(engine: string): "error" | "success" | "info" {
if (engine.startsWith("must_not")) return "error"
if (engine.startsWith("must")) return "success"
return "info"
}
const astRulesForDisplay = computed(() => {
if (!problem.value?.ast_rules) return []
return Object.entries(problem.value.ast_rules).filter(([, rules]) => rules.length > 0)
})
async function test(sample: Sample, index: number) {
samples.value = samples.value.map((sample) => {
if (sample.id === index) {
@@ -222,6 +276,29 @@ function type(status: ProblemStatus) {
/>
</div>
<!-- 代码要求AST 规则 -->
<div v-if="astRulesForDisplay.length > 0">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-emojis:open-book"></Icon>
要求
</n-flex>
</p>
<div v-for="[lang, rules] in astRulesForDisplay" :key="lang">
<p v-if="astRulesForDisplay.length > 1" class="lang-label">{{ lang }}</p>
<n-list bordered style="margin-bottom: 8px">
<n-list-item v-for="(rule, i) in rules" :key="i">
<n-flex align="center">
<n-tag size="small" :type="ruleTagType(rule.engine)">
{{ ruleDescription(rule) }}
</n-tag>
<span v-if="rule.message" class="rule-message">{{ rule.message }}</span>
</n-flex>
</n-list-item>
</n-list>
</div>
</div>
<div v-for="(sample, index) of samples" :key="index">
<n-flex align="center">
<p class="title" :style="style">例子 {{ index + 1 }}</p>
@@ -338,4 +415,14 @@ function type(status: ProblemStatus) {
.status-alert {
margin-bottom: 16px;
}
.lang-label {
font-weight: 600;
margin: 8px 0 4px;
}
.rule-message {
font-size: 13px;
opacity: 0.65;
}
</style>

View File

@@ -40,6 +40,10 @@ const msg = computed(() => {
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
}
if (result === SubmissionStatus.ast_check_failed) {
msg += "你的答案是正确的,但是代码结构不符合要求:\n\n"
}
if (props.submission.statistic_info?.err_info) {
msg += props.submission.statistic_info.err_info
}
@@ -53,6 +57,7 @@ const showAIHint = computed(() => {
return (
problemStore.failCount >= 3 &&
props.submission.result !== SubmissionStatus.accepted &&
props.submission.result !== SubmissionStatus.ast_check_failed &&
props.submission.result !== SubmissionStatus.pending &&
props.submission.result !== SubmissionStatus.judging &&
props.submission.result !== SubmissionStatus.submitting
@@ -108,6 +113,7 @@ const infoTable = computed(() => {
// AC、编译错误、运行时错误不显示测试用例表格
if (
result === SubmissionStatus.accepted ||
result === SubmissionStatus.ast_check_failed ||
result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error
) {

View File

@@ -149,7 +149,7 @@ watch(
result === SubmissionStatus.submitting
)
return
if (result !== SubmissionStatus.accepted) {
if (result !== SubmissionStatus.accepted && result !== SubmissionStatus.ast_check_failed) {
problemStore.incrementFailCount()
}
},
@@ -159,7 +159,7 @@ watch(
watch(
() => submission.value?.result,
async (result) => {
if (result !== SubmissionStatus.accepted) return
if (result !== SubmissionStatus.accepted && result !== SubmissionStatus.ast_check_failed) return
// 1. 刷新题目状态
problem.value!.my_status = 0
@@ -173,6 +173,8 @@ watch(
)
}
if (result !== SubmissionStatus.accepted) return
// 3. 放烟花
celebrate()

View File

@@ -12,6 +12,7 @@ export enum SubmissionStatus {
judging = 7,
partial_accepted = 8,
submitting = 9,
ast_check_failed = 10,
}
export enum ContestStatus {
@@ -80,6 +81,10 @@ export const JUDGE_STATUS: {
name: "正在提交",
type: "info",
},
"10": {
name: "代码检查未通过",
type: "warning",
},
}
export const CONTEST_STATUS: {

View File

@@ -65,7 +65,7 @@ export type LANGUAGE =
export type LANGUAGE_SHOW_LABEL =
(typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE]
export type SUBMISSION_RESULT = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
export type SUBMISSION_RESULT = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
export type ProblemStatus = "passed" | "failed" | "not_test"
@@ -137,6 +137,7 @@ export interface Problem {
flowchart_data?: Record<string, any>
flowchart_hint?: string
show_flowchart?: boolean
ast_rules?: { [key: string]: { engine: string; target?: string; min?: number; max?: number; message: string }[] } | null
}
export type AdminProblem = Problem & AlterProblem