Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f6b4102c9 | ||
|
|
f919d41199 |
+98
-35
@@ -1,3 +1,10 @@
|
|||||||
|
import {
|
||||||
|
astRuleSchema,
|
||||||
|
AST_NODE_TARGET_LABELS,
|
||||||
|
type AstRequirement,
|
||||||
|
type AstRequirements,
|
||||||
|
type AstRule,
|
||||||
|
} from "@oj2/contract"
|
||||||
import { Language, Parser, type Node } from "web-tree-sitter"
|
import { Language, Parser, type Node } from "web-tree-sitter"
|
||||||
// 语法 wasm 内嵌成资源。原来是 `Bun.resolveSync(pkg + "/" + name, import.meta.dir)`,
|
// 语法 wasm 内嵌成资源。原来是 `Bun.resolveSync(pkg + "/" + name, import.meta.dir)`,
|
||||||
// 编译成单二进制后 import.meta.dir 是 /$bunfs/root,解析不到 node_modules。见 vendor/jieba.ts
|
// 编译成单二进制后 import.meta.dir 是 /$bunfs/root,解析不到 node_modules。见 vendor/jieba.ts
|
||||||
@@ -6,17 +13,9 @@ import pythonWasmPath from "tree-sitter-python/tree-sitter-python.wasm" with { t
|
|||||||
// web-tree-sitter 自己的运行时 wasm,Parser.init() 要用
|
// web-tree-sitter 自己的运行时 wasm,Parser.init() 要用
|
||||||
import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" }
|
import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" }
|
||||||
|
|
||||||
export interface AstRule {
|
// AstRule 的形状在契约里(astRuleSchema)—— 原来这份和前端两份各写各的。
|
||||||
engine?: string
|
// 这里 re-export,判题机的调用方不用再去 import 契约。
|
||||||
target?: string
|
export type { AstRule } from "@oj2/contract"
|
||||||
outer?: string
|
|
||||||
inner?: string
|
|
||||||
label?: string
|
|
||||||
message?: string
|
|
||||||
exact?: number
|
|
||||||
min?: number
|
|
||||||
max?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AstResult {
|
export interface AstResult {
|
||||||
description: string
|
description: string
|
||||||
@@ -97,16 +96,87 @@ function hasNode(root: Node, type: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function targetName(rule: AstRule) {
|
function targetName(rule: AstRule) {
|
||||||
return rule.label || rule.target || "指定语法"
|
const target = rule.target ?? ""
|
||||||
|
return rule.label || AST_NODE_TARGET_LABELS[target] || target || "指定语法"
|
||||||
}
|
}
|
||||||
|
|
||||||
function rangeDescription(subject: string, rule: AstRule) {
|
function countPhrase(verb: string, rule: AstRule) {
|
||||||
|
if (rule.exact !== undefined) return `${verb} ${rule.exact} 次`
|
||||||
|
if (rule.min !== undefined && rule.max !== undefined)
|
||||||
|
return `${verb} ${rule.min}~${rule.max} 次`
|
||||||
|
if (rule.min !== undefined) return `至少${verb} ${rule.min} 次`
|
||||||
|
if (rule.max !== undefined) return `至多${verb} ${rule.max} 次`
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条规则的中文描述。判题结果(statistic_info.ast_results)和题目页的「要求」
|
||||||
|
* 用的是同一份 —— 原来前端 ProblemContent.vue 里另有一份几乎一样的实现,
|
||||||
|
* 只有 min/max 同时给出时的措辞不一样(生产库里没有这种规则)。
|
||||||
|
*/
|
||||||
|
export function describeAstRule(rule: AstRule): string {
|
||||||
if (rule.message) return rule.message
|
if (rule.message) return rule.message
|
||||||
if (rule.exact !== undefined) return `${subject} 出现 ${rule.exact} 次`
|
const name = targetName(rule)
|
||||||
const parts: string[] = []
|
const target = rule.target ?? ""
|
||||||
if (rule.min !== undefined) parts.push(`至少 ${rule.min} 次`)
|
switch (rule.engine) {
|
||||||
if (rule.max !== undefined) parts.push(`至多 ${rule.max} 次`)
|
case "must_exist_node":
|
||||||
return `${subject} ${parts.join("、")}`.trim()
|
return `必须使用 ${name}`
|
||||||
|
case "must_not_exist_node":
|
||||||
|
return `不能使用 ${name}`
|
||||||
|
case "count_node":
|
||||||
|
return `${name} ${countPhrase("出现", rule)}`.trim()
|
||||||
|
case "must_call_function":
|
||||||
|
return `必须调用 ${target}()`
|
||||||
|
case "must_not_call_function":
|
||||||
|
return `不能调用 ${target}()`
|
||||||
|
case "count_function_call":
|
||||||
|
return `${target}() ${countPhrase("调用", rule)}`.trim()
|
||||||
|
case "must_call_method":
|
||||||
|
return `必须调用 .${target}()`
|
||||||
|
case "must_not_call_method":
|
||||||
|
return `不能调用 .${target}()`
|
||||||
|
case "must_use_operator":
|
||||||
|
return `必须使用 ${target} 运算符`
|
||||||
|
case "must_have_nesting": {
|
||||||
|
const outer = rule.outer ?? ""
|
||||||
|
const inner = rule.inner ?? ""
|
||||||
|
return outer === inner
|
||||||
|
? `必须使用 ${outer} 嵌套`
|
||||||
|
: `必须在 ${outer} 中嵌套使用 ${inner}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标签配色用的粗分类,见契约 astRequirementSchema */
|
||||||
|
function requirementKind(engine: AstRule["engine"]): AstRequirement["kind"] {
|
||||||
|
if (engine.startsWith("must_not")) return "forbid"
|
||||||
|
if (engine.startsWith("count")) return "count"
|
||||||
|
return "require"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把规则原文投影成下发给学生的「代码要求」。规则里的 engine / target 不出现在
|
||||||
|
* 响应里 —— 阶段 3 泄露评审收掉 ast_rules 时要的就是这个,见契约的注释。
|
||||||
|
*/
|
||||||
|
export function astRequirements(value: unknown): AstRequirements | null {
|
||||||
|
const grouped = value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
if (!grouped) return null
|
||||||
|
const out: AstRequirements = {}
|
||||||
|
for (const [language, rules] of Object.entries(grouped)) {
|
||||||
|
if (!Array.isArray(rules)) continue
|
||||||
|
const items = rules.flatMap((rule) => {
|
||||||
|
const parsed = astRuleSchema.safeParse(rule)
|
||||||
|
if (!parsed.success) return []
|
||||||
|
return [{
|
||||||
|
description: describeAstRule(parsed.data),
|
||||||
|
kind: requirementKind(parsed.data.engine),
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
if (items.length > 0) out[language] = items
|
||||||
|
}
|
||||||
|
return Object.keys(out).length > 0 ? out : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function rangePassed(count: number, rule: AstRule) {
|
function rangePassed(count: number, rule: AstRule) {
|
||||||
@@ -147,51 +217,51 @@ function evaluateRule(
|
|||||||
switch (rule.engine) {
|
switch (rule.engine) {
|
||||||
case "must_exist_node":
|
case "must_exist_node":
|
||||||
return {
|
return {
|
||||||
description: rule.message || `必须使用 ${targetName(rule)}`,
|
description: describeAstRule(rule),
|
||||||
passed: hasNode(root, nodeType),
|
passed: hasNode(root, nodeType),
|
||||||
}
|
}
|
||||||
case "must_not_exist_node":
|
case "must_not_exist_node":
|
||||||
return {
|
return {
|
||||||
description: rule.message || `不能使用 ${targetName(rule)}`,
|
description: describeAstRule(rule),
|
||||||
passed: !hasNode(root, nodeType),
|
passed: !hasNode(root, nodeType),
|
||||||
}
|
}
|
||||||
case "count_node": {
|
case "count_node": {
|
||||||
const count = collectNodes(root, nodeType).length
|
const count = collectNodes(root, nodeType).length
|
||||||
return {
|
return {
|
||||||
description: rangeDescription(targetName(rule), rule),
|
description: describeAstRule(rule),
|
||||||
passed: rangePassed(count, rule),
|
passed: rangePassed(count, rule),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "must_call_function":
|
case "must_call_function":
|
||||||
return {
|
return {
|
||||||
description: rule.message || `必须调用 ${target}()`,
|
description: describeAstRule(rule),
|
||||||
passed: functionCalls(root, target, language).length > 0,
|
passed: functionCalls(root, target, language).length > 0,
|
||||||
}
|
}
|
||||||
case "must_not_call_function":
|
case "must_not_call_function":
|
||||||
return {
|
return {
|
||||||
description: rule.message || `不能调用 ${target}()`,
|
description: describeAstRule(rule),
|
||||||
passed: functionCalls(root, target, language).length === 0,
|
passed: functionCalls(root, target, language).length === 0,
|
||||||
}
|
}
|
||||||
case "count_function_call": {
|
case "count_function_call": {
|
||||||
const count = functionCalls(root, target, language).length
|
const count = functionCalls(root, target, language).length
|
||||||
return {
|
return {
|
||||||
description: rangeDescription(`${target}()`, rule),
|
description: describeAstRule(rule),
|
||||||
passed: rangePassed(count, rule),
|
passed: rangePassed(count, rule),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "must_call_method":
|
case "must_call_method":
|
||||||
return {
|
return {
|
||||||
description: rule.message || `必须调用 .${target}()`,
|
description: describeAstRule(rule),
|
||||||
passed: methodCalls(root, target, language).length > 0,
|
passed: methodCalls(root, target, language).length > 0,
|
||||||
}
|
}
|
||||||
case "must_not_call_method":
|
case "must_not_call_method":
|
||||||
return {
|
return {
|
||||||
description: rule.message || `不能调用 .${target}()`,
|
description: describeAstRule(rule),
|
||||||
passed: methodCalls(root, target, language).length === 0,
|
passed: methodCalls(root, target, language).length === 0,
|
||||||
}
|
}
|
||||||
case "must_use_operator":
|
case "must_use_operator":
|
||||||
return {
|
return {
|
||||||
description: rule.message || `必须使用 ${target} 运算符`,
|
description: describeAstRule(rule),
|
||||||
passed: hasNode(root, nodeType),
|
passed: hasNode(root, nodeType),
|
||||||
}
|
}
|
||||||
case "must_have_nesting": {
|
case "must_have_nesting": {
|
||||||
@@ -202,14 +272,7 @@ function evaluateRule(
|
|||||||
const passed = collectNodes(root, outerType).some((node) =>
|
const passed = collectNodes(root, outerType).some((node) =>
|
||||||
node.children.some((child) => hasNode(child, innerType)),
|
node.children.some((child) => hasNode(child, innerType)),
|
||||||
)
|
)
|
||||||
return {
|
return { description: describeAstRule(rule), passed }
|
||||||
description:
|
|
||||||
rule.message ||
|
|
||||||
(outer === inner
|
|
||||||
? `必须使用 ${outer} 嵌套`
|
|
||||||
: `必须在 ${outer} 中嵌套使用 ${inner}`),
|
|
||||||
passed,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
|
import { astRuleSchema } from "@oj2/contract"
|
||||||
import { and, eq, inArray } from "drizzle-orm"
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
@@ -54,9 +55,19 @@ function templateForLanguage(value: unknown, language: string) {
|
|||||||
return typeof template === "string" ? template : null
|
return typeof template === "string" ? template : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取某个语言的 AST 规则。逐条过 astRuleSchema 而不是整片 `as AstRule[]` 硬转 ——
|
||||||
|
* 这是从库里读出来的 JSONB,形状不对(比如 engine 是个 evaluateRule 不认识的值)
|
||||||
|
* 时丢掉那一条,行为和 evaluateRule 的 `default: return null` 一致,只是提前到了
|
||||||
|
* 这里、并且不再骗类型系统。
|
||||||
|
*/
|
||||||
function astRulesForLanguage(value: unknown, language: string): AstRule[] {
|
function astRulesForLanguage(value: unknown, language: string): AstRule[] {
|
||||||
const rules = objectValue(value)[language]
|
const rules = objectValue(value)[language]
|
||||||
return Array.isArray(rules) ? (rules as AstRule[]) : []
|
if (!Array.isArray(rules)) return []
|
||||||
|
return rules.flatMap((rule) => {
|
||||||
|
const parsed = astRuleSchema.safeParse(rule)
|
||||||
|
return parsed.success ? [parsed.data] : []
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJudge(
|
async function requestJudge(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Hono } from "hono"
|
|||||||
import { optionalAuth, requireAuth } from "../auth/middleware"
|
import { optionalAuth, requireAuth } from "../auth/middleware"
|
||||||
import { setContestPassword } from "../auth/session"
|
import { setContestPassword } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
|
import { astRequirements } from "../judge/ast"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
import {
|
import {
|
||||||
canAccessContest,
|
canAccessContest,
|
||||||
@@ -180,6 +181,8 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
|
|||||||
flowchartHint: row.problem.flowchartHint,
|
flowchartHint: row.problem.flowchartHint,
|
||||||
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
|
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
|
||||||
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
|
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
|
||||||
|
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||||
|
astRequirements: astRequirements(row.problem.astRules),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { Hono } from "hono"
|
|||||||
|
|
||||||
import { optionalAuth, type AppEnv } from "../auth/middleware"
|
import { optionalAuth, type AppEnv } from "../auth/middleware"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
|
import { astRequirements } from "../judge/ast"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { objectValue as toObject, queryInteger, sampleUser } from "./helpers"
|
import { objectValue as toObject, queryInteger, sampleUser } from "./helpers"
|
||||||
@@ -319,6 +320,8 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
|
|||||||
flowchartHint: row.problem.flowchartHint,
|
flowchartHint: row.problem.flowchartHint,
|
||||||
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
|
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
|
||||||
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
|
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
|
||||||
|
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||||
|
astRequirements: astRequirements(row.problem.astRules),
|
||||||
})
|
})
|
||||||
|
|
||||||
return success(c, data)
|
return success(c, data)
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { LANGUAGE } from "utils/types"
|
import { AST_NODE_TARGET_LABELS } from "@oj2/contract"
|
||||||
|
import type { AstRule, AstRules, LANGUAGE } from "utils/types"
|
||||||
interface AstRule {
|
|
||||||
engine: string
|
|
||||||
target?: string
|
|
||||||
label?: string
|
|
||||||
exact?: number
|
|
||||||
min?: number
|
|
||||||
max?: number
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: { [key: string]: AstRule[] } | null
|
modelValue: AstRules | null
|
||||||
languages: LANGUAGE[]
|
languages: LANGUAGE[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "update:modelValue", value: { [key: string]: AstRule[] } | null): void
|
(e: "update:modelValue", value: AstRules | null): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const activeTab = ref(props.languages[0] || "Python3")
|
const activeTab = ref(props.languages[0] || "Python3")
|
||||||
@@ -61,23 +52,11 @@ const ENGINE_OPTIONS: SelectOption[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const NODE_TARGET_OPTIONS: SelectOption[] = [
|
// 选项从契约的 AST_NODE_TARGET_LABELS 生成 —— 这 15 条原来在这里和
|
||||||
{ label: "for 循环", value: "for_loop" },
|
// ProblemContent.vue 各手抄一份
|
||||||
{ label: "while 循环", value: "while_loop" },
|
const NODE_TARGET_OPTIONS: SelectOption[] = Object.entries(
|
||||||
{ label: "if 条件", value: "if_statement" },
|
AST_NODE_TARGET_LABELS,
|
||||||
{ label: "else 子句", value: "else_clause" },
|
).map(([value, label]) => ({ label, value }))
|
||||||
{ 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[] = [
|
const OPERATOR_TARGET_OPTIONS: SelectOption[] = [
|
||||||
{ label: "+", value: "+" },
|
{ label: "+", value: "+" },
|
||||||
|
|||||||
@@ -97,90 +97,18 @@ const samples = ref<Sample[]>(
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const NODE_TARGET_LABELS: Record<string, string> = {
|
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
|
||||||
for_loop: "for 循环",
|
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
|
||||||
while_loop: "while 循环",
|
// astRequirementSchema。规则原文(engine / target)不下发给学生。
|
||||||
if_statement: "if 条件",
|
const KIND_TAG_TYPE = {
|
||||||
else_clause: "else 子句",
|
require: "success",
|
||||||
function_definition: "函数定义",
|
forbid: "error",
|
||||||
return: "return 语句",
|
count: "info",
|
||||||
break: "break 语句",
|
} as const
|
||||||
continue: "continue 语句",
|
|
||||||
list_comprehension: "列表推导式",
|
|
||||||
list_literal: "列表",
|
|
||||||
dict_literal: "字典",
|
|
||||||
set_literal: "集合",
|
|
||||||
f_string: "f-string",
|
|
||||||
try_except: "try-except",
|
|
||||||
class_definition: "类定义",
|
|
||||||
}
|
|
||||||
|
|
||||||
type AstRule = {
|
const astRequirements = computed(() =>
|
||||||
engine: string
|
Object.entries(problem.value?.astRequirements ?? {}),
|
||||||
target?: string
|
)
|
||||||
label?: string
|
|
||||||
exact?: number
|
|
||||||
min?: number
|
|
||||||
max?: number
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function ruleDescription(rule: AstRule): string {
|
|
||||||
if (rule.message) return rule.message
|
|
||||||
const target = rule.target || ""
|
|
||||||
const targetLabel = rule.label || NODE_TARGET_LABELS[target] || target
|
|
||||||
const countDesc = () => {
|
|
||||||
if (rule.exact !== undefined) return `出现 ${rule.exact} 次`
|
|
||||||
if (rule.min !== undefined && rule.max !== undefined)
|
|
||||||
return `出现 ${rule.min}~${rule.max} 次`
|
|
||||||
if (rule.min !== undefined) return `至少出现 ${rule.min} 次`
|
|
||||||
if (rule.max !== undefined) return `至多出现 ${rule.max} 次`
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
const callDesc = () => {
|
|
||||||
if (rule.exact !== undefined) return `调用 ${rule.exact} 次`
|
|
||||||
if (rule.min !== undefined && rule.max !== undefined)
|
|
||||||
return `调用 ${rule.min}~${rule.max} 次`
|
|
||||||
if (rule.min !== undefined) return `至少调用 ${rule.min} 次`
|
|
||||||
if (rule.max !== undefined) return `至多调用 ${rule.max} 次`
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
switch (rule.engine) {
|
|
||||||
case "must_exist_node":
|
|
||||||
return `必须使用 ${targetLabel}`
|
|
||||||
case "must_not_exist_node":
|
|
||||||
return `不能使用 ${targetLabel}`
|
|
||||||
case "count_node":
|
|
||||||
return `${targetLabel} ${countDesc()}`
|
|
||||||
case "must_call_function":
|
|
||||||
return `必须调用 ${target}()`
|
|
||||||
case "must_not_call_function":
|
|
||||||
return `不能调用 ${target}()`
|
|
||||||
case "count_function_call":
|
|
||||||
return `${target}() ${callDesc()}`
|
|
||||||
case "must_call_method":
|
|
||||||
return `必须调用 .${target}()`
|
|
||||||
case "must_not_call_method":
|
|
||||||
return `不能调用 .${target}()`
|
|
||||||
case "must_use_operator":
|
|
||||||
return `必须使用 ${target} 运算符`
|
|
||||||
default:
|
|
||||||
return 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?.astRules) return []
|
|
||||||
return Object.entries(problem.value.astRules).filter(
|
|
||||||
([, rules]) => rules.length > 0,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function test(sample: Sample, index: number) {
|
async function test(sample: Sample, index: number) {
|
||||||
samples.value = samples.value.map((sample) => {
|
samples.value = samples.value.map((sample) => {
|
||||||
@@ -370,27 +298,20 @@ function type(status: ProblemStatus) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 代码要求(AST 规则) -->
|
<!-- 代码要求(AST 规则) -->
|
||||||
<div v-if="astRulesForDisplay.length > 0">
|
<div v-if="astRequirements.length > 0">
|
||||||
<p class="title" :style="style">
|
<p class="title" :style="style">
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<Icon icon="streamline-ultimate-color:check-button"></Icon>
|
<Icon icon="streamline-ultimate-color:check-button"></Icon>
|
||||||
要求
|
要求
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</p>
|
</p>
|
||||||
<div v-for="[lang, rules] in astRulesForDisplay" :key="lang">
|
<div v-for="[lang, rules] in astRequirements" :key="lang">
|
||||||
<p v-if="astRulesForDisplay.length > 1" class="lang-label">
|
<p v-if="astRequirements.length > 1" class="lang-label">
|
||||||
{{ lang }}
|
{{ lang }}
|
||||||
</p>
|
</p>
|
||||||
<n-list bordered style="margin-bottom: 8px">
|
<n-list bordered style="margin-bottom: 8px">
|
||||||
<n-list-item v-for="(rule, i) in rules" :key="i">
|
<n-list-item v-for="(rule, i) in rules" :key="i">
|
||||||
<n-flex align="center">
|
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{ rule.description }}</n-tag>
|
||||||
<n-tag :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-item>
|
||||||
</n-list>
|
</n-list>
|
||||||
</div>
|
</div>
|
||||||
@@ -530,11 +451,6 @@ function type(status: ProblemStatus) {
|
|||||||
margin: 8px 0 4px;
|
margin: 8px 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-message {
|
|
||||||
font-size: 13px;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sqlTableName {
|
.sqlTableName {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 8px 0 4px;
|
margin: 8px 0 4px;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
ProblemDetail,
|
ProblemDetail,
|
||||||
ProblemDifficulty,
|
ProblemDifficulty,
|
||||||
JudgeStatus,
|
JudgeStatus,
|
||||||
|
AstRules,
|
||||||
CreateAnnouncementRequest,
|
CreateAnnouncementRequest,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
|
|
||||||
@@ -111,26 +112,21 @@ export type { ProblemTestCaseScore as Testcase } from "@oj2/contract"
|
|||||||
/**
|
/**
|
||||||
* 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化:
|
* 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化:
|
||||||
* - `languages` / `template` 的键窄化成 LANGUAGE,组件按语言查模板要靠它
|
* - `languages` / `template` 的键窄化成 LANGUAGE,组件按语言查模板要靠它
|
||||||
* - `astRules` 契约里是 unknown,这里给出组件实际读的形状
|
|
||||||
*/
|
*/
|
||||||
export type Problem = Omit<ProblemDetail, "languages" | "template"> & {
|
export type Problem = Omit<ProblemDetail, "languages" | "template"> & {
|
||||||
languages: LANGUAGE[]
|
languages: LANGUAGE[]
|
||||||
template: { [key in LANGUAGE]?: string }
|
template: { [key in LANGUAGE]?: string }
|
||||||
astRules?: AstRules | null
|
// oj 侧不下发 astRules 原文,只有 astRequirements(契约里就有)
|
||||||
hasAstRules?: boolean
|
hasAstRules?: boolean
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
answers?: { language: LANGUAGE; code: string }[]
|
answers?: { language: LANGUAGE; code: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AstRules = {
|
/**
|
||||||
[key: string]: {
|
* AST 代码要求。原来这里手抄的那份少了 label / exact / outer / inner ——
|
||||||
engine: string
|
* 编辑器写得出 label 和 exact,判题机也认,只有这个类型不认。形状在契约里。
|
||||||
target?: string
|
*/
|
||||||
min?: number
|
export type { AstRule, AstRules, AstRuleEngine } from "@oj2/contract"
|
||||||
max?: number
|
|
||||||
message: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ProblemDetail,
|
ProblemDetail,
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { achievementRaritySchema } from "./achievement"
|
|||||||
import { rankProfileSchema } from "./account"
|
import { rankProfileSchema } from "./account"
|
||||||
import { paginatedSchema, sampleUserSchema } from "./common"
|
import { paginatedSchema, sampleUserSchema } from "./common"
|
||||||
import { reactionKeySchema } from "./content"
|
import { reactionKeySchema } from "./content"
|
||||||
import { problemDifficultySchema, sqlConfigSchema, sqlDisplaySchema } from "./problem"
|
import {
|
||||||
|
astRulesSchema,
|
||||||
|
problemDifficultySchema,
|
||||||
|
sqlConfigSchema,
|
||||||
|
sqlDisplaySchema,
|
||||||
|
} from "./problem"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台侧的契约。与 oj 侧分开放:同一张表在两侧下发的字段集通常不同
|
* 后台侧的契约。与 oj 侧分开放:同一张表在两侧下发的字段集通常不同
|
||||||
@@ -566,6 +571,7 @@ export const problemTestCaseScoreSchema = z.object({
|
|||||||
score: z.coerce.number().int().min(0),
|
score: z.coerce.number().int().min(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
/** 后台题目详情:包含 oj 侧永不下发的 answers / testCase* / astRules */
|
/** 后台题目详情:包含 oj 侧永不下发的 answers / testCase* / astRules */
|
||||||
export const adminProblemSchema = z.object({
|
export const adminProblemSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
@@ -599,7 +605,7 @@ export const adminProblemSchema = z.object({
|
|||||||
showFlowchart: z.boolean(),
|
showFlowchart: z.boolean(),
|
||||||
mermaidCode: z.string().nullable(),
|
mermaidCode: z.string().nullable(),
|
||||||
flowchartHint: z.string().nullable(),
|
flowchartHint: z.string().nullable(),
|
||||||
astRules: z.unknown(),
|
astRules: astRulesSchema.nullable(),
|
||||||
answers: z.array(problemAnswerSchema),
|
answers: z.array(problemAnswerSchema),
|
||||||
prompt: z.string().nullable(),
|
prompt: z.string().nullable(),
|
||||||
sqlConfig: sqlConfigSchema.nullable(),
|
sqlConfig: sqlConfigSchema.nullable(),
|
||||||
@@ -631,7 +637,7 @@ export const createProblemRequestSchema = z.object({
|
|||||||
showFlowchart: z.boolean().default(false),
|
showFlowchart: z.boolean().default(false),
|
||||||
mermaidCode: z.string().nullable().default(null),
|
mermaidCode: z.string().nullable().default(null),
|
||||||
flowchartHint: z.string().nullable().default(null),
|
flowchartHint: z.string().nullable().default(null),
|
||||||
astRules: z.unknown().default(null),
|
astRules: astRulesSchema.nullable().default(null),
|
||||||
// order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的
|
// order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的
|
||||||
// `BooleanField(default=False)` 一致
|
// `BooleanField(default=False)` 一致
|
||||||
sqlConfig: sqlConfigSchema
|
sqlConfig: sqlConfigSchema
|
||||||
|
|||||||
@@ -55,6 +55,94 @@ export const sqlDisplaySchema = z.object({
|
|||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AST 代码要求。同一个形状原来在**三个地方**各写了一份,三份都不一样:
|
||||||
|
* apps/api/src/judge/ast.ts 的 AstRule(判题机真读的那份,九个字段)、
|
||||||
|
* apps/web/src/utils/types.ts 的 AstRules(少了 label / exact / outer / inner)、
|
||||||
|
* AstRulesEditor.vue 里的本地 AstRule(少了 outer / inner)。
|
||||||
|
* 编辑器写得出 label / exact,题目类型却描述不了它们。现在以这里为准。
|
||||||
|
*
|
||||||
|
* 除 engine 外全部可选:判题机每条规则只读自己那几个字段
|
||||||
|
* (见 ast.ts 的 evaluateRule),缺了就走默认文案。
|
||||||
|
*/
|
||||||
|
export const astRuleEngineSchema = z.enum([
|
||||||
|
"must_exist_node",
|
||||||
|
"must_not_exist_node",
|
||||||
|
"count_node",
|
||||||
|
"must_call_function",
|
||||||
|
"must_not_call_function",
|
||||||
|
"count_function_call",
|
||||||
|
"must_call_method",
|
||||||
|
"must_not_call_method",
|
||||||
|
"must_use_operator",
|
||||||
|
// 判题机实现了,但后台编辑器还没有对应的选项,目前只能手工造数据用上
|
||||||
|
"must_have_nesting",
|
||||||
|
])
|
||||||
|
|
||||||
|
export const astRuleSchema = z.object({
|
||||||
|
engine: astRuleEngineSchema,
|
||||||
|
/** 检查目标:节点类型 / 函数名 / 方法名 / 运算符,按 engine 而定 */
|
||||||
|
target: z.string().optional(),
|
||||||
|
/** must_have_nesting 专用:外层、内层节点 */
|
||||||
|
outer: z.string().optional(),
|
||||||
|
inner: z.string().optional(),
|
||||||
|
/** 展示用的中文名,缺省回落到 target */
|
||||||
|
label: z.string().optional(),
|
||||||
|
/** 自定义提示。生产库里存的是空串而不是缺键,判题机按 `||` 回落到默认文案 */
|
||||||
|
message: z.string().optional(),
|
||||||
|
/** count_* 引擎的次数约束 */
|
||||||
|
exact: z.number().int().optional(),
|
||||||
|
min: z.number().int().optional(),
|
||||||
|
max: z.number().int().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 按语言分组:`{ Python3: [...], C: [...] }`,键是 languages 里的语言名 */
|
||||||
|
export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点类型的中文名。后台编辑器的下拉选项、后端生成要求文案都要用它 ——
|
||||||
|
* 原来在 AstRulesEditor.vue(下拉 options)和 ProblemContent.vue
|
||||||
|
* (NODE_TARGET_LABELS)各存了一份同样的 15 条。
|
||||||
|
*/
|
||||||
|
export const AST_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: "类定义",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下发给**学生**的代码要求。只有渲染要用的两个字段 —— 文案由后端生成,
|
||||||
|
* engine / target 这些内部字段不出现在响应里。
|
||||||
|
*
|
||||||
|
* 旧后端的 ProblemSerializer 没排掉 ast_rules,学生拿到的是规则原文;阶段 3
|
||||||
|
* 泄露评审刻意收掉了它,同时写明「前端要读具体内容的话得补个专门的字段」——
|
||||||
|
* 就是这个。收紧保留,展示恢复。
|
||||||
|
*/
|
||||||
|
export const astRequirementSchema = z.object({
|
||||||
|
/** 已经渲染好的中文文案,例如「if 条件 出现 2 次」 */
|
||||||
|
description: z.string(),
|
||||||
|
/** 标签配色:必须做 / 不能做 / 次数约束 */
|
||||||
|
kind: z.enum(["require", "forbid", "count"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 按语言分组,与 astRulesSchema 同一套键 */
|
||||||
|
export const astRequirementsSchema = z.record(
|
||||||
|
z.string(),
|
||||||
|
z.array(astRequirementSchema),
|
||||||
|
)
|
||||||
|
|
||||||
export const problemDetailSchema = z.object({
|
export const problemDetailSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
_id: z.string(),
|
_id: z.string(),
|
||||||
@@ -98,6 +186,8 @@ export const problemDetailSchema = z.object({
|
|||||||
flowchartHint: z.string().nullable(),
|
flowchartHint: z.string().nullable(),
|
||||||
sqlConfig: sqlConfigSchema.nullable(),
|
sqlConfig: sqlConfigSchema.nullable(),
|
||||||
sqlDisplay: sqlDisplaySchema.nullable(),
|
sqlDisplay: sqlDisplaySchema.nullable(),
|
||||||
|
// 代码要求(AST 规则的展示投影)。规则原文不下发给学生,见 astRequirementSchema
|
||||||
|
astRequirements: astRequirementsSchema.nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ProblemDetail = z.infer<typeof problemDetailSchema>
|
export type ProblemDetail = z.infer<typeof problemDetailSchema>
|
||||||
@@ -138,6 +228,11 @@ export const yearlyAcSchema = z.object({
|
|||||||
acRate: z.number(),
|
acRate: z.number(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export type AstRuleEngine = z.infer<typeof astRuleEngineSchema>
|
||||||
|
export type AstRule = z.infer<typeof astRuleSchema>
|
||||||
|
export type AstRules = z.infer<typeof astRulesSchema>
|
||||||
|
export type AstRequirement = z.infer<typeof astRequirementSchema>
|
||||||
|
export type AstRequirements = z.infer<typeof astRequirementsSchema>
|
||||||
export type ProblemDifficulty = z.infer<typeof problemDifficultySchema>
|
export type ProblemDifficulty = z.infer<typeof problemDifficultySchema>
|
||||||
export type ProblemListItem = z.infer<typeof problemListItemSchema>
|
export type ProblemListItem = z.infer<typeof problemListItemSchema>
|
||||||
export type ProblemList = z.infer<typeof problemListSchema>
|
export type ProblemList = z.infer<typeof problemListSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user