做完这节课(或题库里自己做对)之后列出还能挑战的题;去挑战后编辑器顶上写着要求, 没做到标出是哪几行,做到了写本班第几个、全站第几个。课堂看板做到的格子贴一枚紫色筹码、 在挑战的加一圈紫边,题卡片写挑战成功几人。出题页多一块「挑战」,能从做对的代码里找候选。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0faf78ad8f
commit
1a173f75d7
19 files changed
+1243
-24
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { toAdminType, toProblemPermission } from "@oj2/contract"
|
||||
import type {
|
||||
AddContestProblemRequest,
|
||||
ChallengeSuggestionsResponse,
|
||||
AddProblemsToSetRequest,
|
||||
BatchProblemTagRequest,
|
||||
BatchProblemVisibleRequest,
|
||||
@@ -353,6 +354,16 @@ export function checkAstRules(data: AstCheckRequest) {
|
||||
return api.post<AstCheckResponse>("admin/ast-check", data satisfies AstCheckRequest)
|
||||
}
|
||||
|
||||
// 出题页「挑战」的候选:拿这道题历史上做对的代码挖
|
||||
export function getChallengeSuggestions(problemId: number, language: string) {
|
||||
return api.post<ChallengeSuggestionsResponse>(
|
||||
`admin/problems/${problemId}/challenge-suggestions`,
|
||||
{
|
||||
language,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// AI 按题面和标准答案想几组测试输入(输出由标准答案跑)
|
||||
export function generateTestInputs(data: GenerateTestInputsRequest) {
|
||||
return api.post<GenerateTestInputsResponse>(
|
||||
|
||||
@@ -15,6 +15,11 @@ interface Props {
|
||||
languages: LANGUAGE[]
|
||||
/** 各语言的标准答案(学生那段代码,不套模板):拿来自测规则 */
|
||||
answers: Partial<Record<LANGUAGE, string>>
|
||||
/**
|
||||
* 挑战的判定规则(ChallengeEditor 里一条挑战一个)。和语法要求是同一套规则,但口径不同:
|
||||
* 不满足不算错、说法由挑战自己写、标准答案不这么写是常事(挑战就是另一种写法)
|
||||
*/
|
||||
challenge?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -294,8 +299,13 @@ watch(supportedLanguages, (langs) => {
|
||||
<template>
|
||||
<div class="astRules">
|
||||
<div class="head">
|
||||
<span class="label">语法要求</span>
|
||||
<div v-if="supportedLanguages.length" class="langs" role="tablist" aria-label="语言">
|
||||
<span class="label">{{ challenge ? "判定" : "语法要求" }}</span>
|
||||
<div
|
||||
v-if="supportedLanguages.length && !challenge"
|
||||
class="langs"
|
||||
role="tablist"
|
||||
aria-label="语言"
|
||||
>
|
||||
<button
|
||||
v-for="lang in supportedLanguages"
|
||||
:key="lang"
|
||||
@@ -310,7 +320,8 @@ watch(supportedLanguages, (langs) => {
|
||||
</button>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<n-text depth="3" class="note">提交时逐条检查,不满足算错</n-text>
|
||||
<n-text v-if="!challenge" depth="3" class="note">提交时逐条检查,不满足算错</n-text>
|
||||
<n-text v-else depth="3" class="note">几条都满足才算做到</n-text>
|
||||
</div>
|
||||
<n-text v-if="unsupportedLanguages.length" depth="3" class="note">
|
||||
{{ unsupportedLanguages.join("、") }} 检查不了语法要求(判题机只认
|
||||
@@ -402,6 +413,7 @@ watch(supportedLanguages, (langs) => {
|
||||
</div>
|
||||
<div class="ruleRow sub">
|
||||
<n-input
|
||||
v-if="!challenge"
|
||||
:value="rule.message"
|
||||
size="tiny"
|
||||
class="message"
|
||||
@@ -412,7 +424,19 @@ watch(supportedLanguages, (langs) => {
|
||||
"
|
||||
@update:value="(v: string) => updateRule(lang, index, 'message', v)"
|
||||
/>
|
||||
<span v-if="checkOf(lang, index)?.passed === true" class="ok">✓ 标准答案过了</span>
|
||||
<template v-if="challenge">
|
||||
<n-text v-if="checkOf(lang, index)?.passed === true" depth="3" class="note">
|
||||
标准答案这样写也满足
|
||||
</n-text>
|
||||
<n-text v-else-if="checkOf(lang, index)?.passed === false" depth="3" class="note">
|
||||
标准答案不是这样写的{{
|
||||
checkOf(lang, index)!.actual !== undefined
|
||||
? `(数到 ${checkOf(lang, index)!.actual} 次)`
|
||||
: ""
|
||||
}}
|
||||
</n-text>
|
||||
</template>
|
||||
<span v-else-if="checkOf(lang, index)?.passed === true" class="ok">✓ 标准答案过了</span>
|
||||
<span v-else-if="checkOf(lang, index)?.passed === false" class="bad">
|
||||
✗ 标准答案自己没过{{
|
||||
checkOf(lang, index)!.actual !== undefined
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AST_SUPPORTED_LANGUAGES,
|
||||
CHALLENGE_RULES_MAX,
|
||||
CHALLENGE_WORDING_MAX,
|
||||
CHALLENGES_PER_LANGUAGE_MAX,
|
||||
type AstRules,
|
||||
type ChallengeInput,
|
||||
type ChallengeSuggestionsResponse,
|
||||
} from "@oj2/contract"
|
||||
import { errorMessage } from "utils/api"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
import { getChallengeSuggestions } from "../../api"
|
||||
import AstRulesEditor from "./AstRulesEditor.vue"
|
||||
|
||||
/**
|
||||
* 出题页的「挑战」:做对之后,同一道题加一条写法上的限制再做一遍(设计稿 StkAdmin)。
|
||||
* 判定就是语法要求那套规则,所以每条挑战底下直接摆一个规则编辑器(只开这一种语言)。
|
||||
*
|
||||
* 「从做对的代码里找」把这道题历史上做对的代码挖一遍,列出「一批人用另一种写法也做对了」
|
||||
* 的地方。机械挖出来的会有坏的(拆掉 elif 换成一串 if 也算「不用 elif」),所以每条带一份
|
||||
* 做到的人的真实代码,好不好老师看了再加。新建的题还没人做过,不给这个按钮。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modelValue: Record<string, ChallengeInput[]> | null
|
||||
languages: LANGUAGE[]
|
||||
answers: Partial<Record<LANGUAGE, string>>
|
||||
/** 编辑已有的题才有;新建的题没有做对的代码可挖 */
|
||||
problemId: number | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: Record<string, ChallengeInput[]> | null): void
|
||||
}>()
|
||||
|
||||
const languages = computed(() =>
|
||||
props.languages.filter((lang) => AST_SUPPORTED_LANGUAGES.includes(lang)),
|
||||
)
|
||||
const tab = ref<string>(languages.value[0] ?? "Python")
|
||||
watch(languages, (list) => {
|
||||
if (!list.includes(tab.value as LANGUAGE)) tab.value = list[0] ?? "Python"
|
||||
})
|
||||
|
||||
const list = computed(() => props.modelValue?.[tab.value] ?? [])
|
||||
|
||||
function update(next: ChallengeInput[]) {
|
||||
const value = { ...props.modelValue }
|
||||
if (next.length) value[tab.value] = next
|
||||
else delete value[tab.value]
|
||||
emit("update:modelValue", Object.keys(value).length ? value : null)
|
||||
}
|
||||
|
||||
function patch(index: number, change: Partial<ChallengeInput>) {
|
||||
update(list.value.map((item, i) => (i === index ? { ...item, ...change } : item)))
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
update(list.value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function add(item: ChallengeInput = { wording: "", rules: [] }) {
|
||||
update([...list.value, item])
|
||||
}
|
||||
|
||||
/** AstRulesEditor 按语言分组收发,这里只给它一种语言 */
|
||||
function rulesModel(item: ChallengeInput): AstRules {
|
||||
return { [tab.value]: item.rules }
|
||||
}
|
||||
function onRules(index: number, value: AstRules | null) {
|
||||
patch(index, { rules: (value?.[tab.value] ?? []).slice(0, CHALLENGE_RULES_MAX) })
|
||||
}
|
||||
|
||||
const full = computed(() => list.value.length >= CHALLENGES_PER_LANGUAGE_MAX)
|
||||
|
||||
// ---------------------------------------------------------------- 候选
|
||||
|
||||
const suggestions = ref<Record<string, ChallengeSuggestionsResponse>>({})
|
||||
const loading = ref(false)
|
||||
const error = ref("")
|
||||
|
||||
async function findSuggestions() {
|
||||
if (!props.problemId) return
|
||||
loading.value = true
|
||||
error.value = ""
|
||||
try {
|
||||
suggestions.value = {
|
||||
...suggestions.value,
|
||||
[tab.value]: await getChallengeSuggestions(props.problemId, tab.value),
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = errorMessage(err, "没找成")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const current = computed(() => suggestions.value[tab.value] ?? null)
|
||||
|
||||
/** 已经加上的不再列 */
|
||||
const shown = computed(() => {
|
||||
const taken = list.value.map((item) => JSON.stringify(item.rules))
|
||||
return (current.value?.suggestions ?? []).filter(
|
||||
(item) => !taken.includes(JSON.stringify(item.rules)),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="challenge-editor">
|
||||
<n-radio-group v-if="languages.length > 1" v-model:value="tab" size="small">
|
||||
<n-radio-button v-for="lang in languages" :key="lang" :value="lang">
|
||||
{{ lang }}{{ modelValue?.[lang]?.length ? ` ${modelValue[lang]!.length} 条` : "" }}
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
|
||||
<div v-for="(item, index) in list" :key="`${tab}-${item.id ?? index}`" class="item">
|
||||
<div class="itemHead">
|
||||
<span class="index">{{ index + 1 }}</span>
|
||||
<n-input
|
||||
:value="item.wording"
|
||||
:maxlength="CHALLENGE_WORDING_MAX"
|
||||
placeholder="给学生看的说法,例如「整首歌只用 1 次 print」"
|
||||
:status="item.wording.trim() ? undefined : 'warning'"
|
||||
@update:value="(value: string) => patch(index, { wording: value })"
|
||||
/>
|
||||
<n-button size="small" quaternary @click="remove(index)">删掉</n-button>
|
||||
</div>
|
||||
<AstRulesEditor
|
||||
:model-value="rulesModel(item)"
|
||||
:languages="[tab as LANGUAGE]"
|
||||
:answers="answers"
|
||||
challenge
|
||||
@update:model-value="(value) => onRules(index, value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-text v-if="!languages.length" depth="3" class="note">
|
||||
只有 Python、C、C++ 能配挑战,先在「学生怎么写」里勾上其中一种。
|
||||
</n-text>
|
||||
<template v-else>
|
||||
<div class="actions">
|
||||
<n-button size="small" :disabled="full" @click="add()">+ 自己写一条</n-button>
|
||||
<n-button
|
||||
v-if="problemId"
|
||||
size="small"
|
||||
:loading="loading"
|
||||
:disabled="full"
|
||||
@click="findSuggestions"
|
||||
>
|
||||
从做对的代码里找
|
||||
</n-button>
|
||||
</div>
|
||||
<n-text v-if="error" type="error" class="note">{{ error }}</n-text>
|
||||
|
||||
<div v-if="current" class="suggestions">
|
||||
<n-text depth="3" class="note">
|
||||
{{ current.acCount }} 个人用 {{ tab }} 做对过,下面是有一批人换了种写法也做对的地方
|
||||
</n-text>
|
||||
<n-text v-if="!shown.length" depth="3" class="note">没找到别的写法</n-text>
|
||||
<div v-for="item in shown" :key="JSON.stringify(item.rules)" class="suggestion">
|
||||
<div class="suggestionHead">
|
||||
<div class="suggestionText">
|
||||
<b>{{ item.wording }}</b>
|
||||
<n-text depth="3" class="note">
|
||||
{{ item.did }} 人这样做对<template v-if="item.firstShare !== null"
|
||||
>,第一次做对时 {{ item.firstShare }}% 这样写</template
|
||||
>
|
||||
</n-text>
|
||||
</div>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="full"
|
||||
@click="add({ wording: item.wording, rules: item.rules })"
|
||||
>
|
||||
加上
|
||||
</n-button>
|
||||
</div>
|
||||
<pre class="example">{{ item.example }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.challenge-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(91, 52, 196, 0.3);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.itemHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.index {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
background-color: rgba(91, 52, 196, 0.12);
|
||||
color: #5b34c4;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.suggestionHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.suggestionText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.example {
|
||||
margin: 6px 0 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
max-height: 180px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { TEST_CASE_MAX_CASES, type RunnableLanguage } from "@oj2/contract"
|
||||
import { TEST_CASE_MAX_CASES, type ChallengeInput, type RunnableLanguage } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { getProblemTagList } from "shared/api"
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
uploadTestcases,
|
||||
} from "../api"
|
||||
import AstRulesEditor from "./components/AstRulesEditor.vue"
|
||||
import ChallengeEditor from "./components/ChallengeEditor.vue"
|
||||
import CaseTable from "./components/CaseTable.vue"
|
||||
import CodePreview from "./components/CodePreview.vue"
|
||||
import PreviewStrip from "./components/PreviewStrip.vue"
|
||||
@@ -108,6 +109,8 @@ interface CodeForm {
|
||||
answers: Record<RunnableLanguage, string>
|
||||
templates: Record<RunnableLanguage, TemplateParts>
|
||||
astRules: AstRules | null
|
||||
/** 做对之后的挑战,按语言分组(见契约 challengeSchema) */
|
||||
challenges: Record<string, ChallengeInput[]> | null
|
||||
flowchartMode: FlowchartMode
|
||||
mermaidCode: string
|
||||
/** 画布画的参考流程图;null 是老题(只有手写的 Mermaid)或者还没画 */
|
||||
@@ -135,6 +138,7 @@ const blankForm = (): CodeForm => ({
|
||||
answers: { Python: "", C: "", "C++": "" },
|
||||
templates: { Python: blankParts(), C: blankParts(), "C++": blankParts() },
|
||||
astRules: null,
|
||||
challenges: null,
|
||||
flowchartMode: "none",
|
||||
mermaidCode: "",
|
||||
flowchartDoc: null,
|
||||
@@ -428,6 +432,14 @@ watch(
|
||||
const showHidden = ref(false)
|
||||
const astChecks = ref<AstCheckState>({})
|
||||
|
||||
const challengeSummary = computed(() => {
|
||||
const parts = form.languages
|
||||
.map((lang) => [lang, form.challenges?.[lang]?.length ?? 0] as const)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([lang, n]) => `${lang} ${n} 条`)
|
||||
return parts.length ? parts.join(" · ") : "没有"
|
||||
})
|
||||
|
||||
const answersForAst = computed(() => ({ ...form.answers }) as Partial<Record<LANGUAGE, string>>)
|
||||
const templatesForPreview = computed(
|
||||
() => ({ ...form.templates }) as Partial<Record<LANGUAGE, TemplateParts>>,
|
||||
@@ -493,6 +505,7 @@ const open = reactive({
|
||||
answer: true,
|
||||
cases: true,
|
||||
write: false,
|
||||
challenge: false,
|
||||
flowchart: false,
|
||||
more: false,
|
||||
})
|
||||
@@ -612,6 +625,15 @@ async function loadProblem(data: AdminProblem) {
|
||||
answers,
|
||||
templates,
|
||||
astRules: data.astRules ?? null,
|
||||
// scannedAt 是后端记账用的,表单里不带;id 带着,规则没改就沿用(做到记录跟着 id 走)
|
||||
challenges: data.challenges
|
||||
? Object.fromEntries(
|
||||
Object.entries(data.challenges).map(([lang, list]) => [
|
||||
lang,
|
||||
list.map(({ id, wording, rules }) => ({ id, wording, rules })),
|
||||
]),
|
||||
)
|
||||
: null,
|
||||
flowchartMode: data.allowFlowchart ? "draw" : data.showFlowchart ? "show" : "none",
|
||||
mermaidCode: data.mermaidCode ?? "",
|
||||
flowchartDoc: parseDoc(data.flowchartData),
|
||||
@@ -719,6 +741,15 @@ const blockers = computed<Blocker[]>(() => {
|
||||
})
|
||||
})
|
||||
}
|
||||
for (const lang of form.languages) {
|
||||
form.challenges?.[lang]?.forEach((item, i) => {
|
||||
if (!item.wording.trim() || !item.rules.length)
|
||||
list.push({
|
||||
text: `把挑战 ${lang} 第 ${i + 1} 条${item.wording.trim() ? "的规则" : "的说法"}写上`,
|
||||
section: "challenge",
|
||||
})
|
||||
})
|
||||
}
|
||||
if (form.flowchartMode === "show") {
|
||||
if (legacyMermaid.value) {
|
||||
if (!mermaidOk.value)
|
||||
@@ -804,6 +835,7 @@ async function save() {
|
||||
: null,
|
||||
flowchartHint: form.flowchartHint.trim(),
|
||||
astRules: form.astRules,
|
||||
challenges: form.challenges,
|
||||
sqlConfig: null,
|
||||
sqlDisplay: null,
|
||||
contestId: props.contestID ? Number(props.contestID) : null,
|
||||
@@ -1197,6 +1229,24 @@ onMounted(async () => {
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- 挑战 -->
|
||||
<section id="section-challenge" class="card">
|
||||
<button type="button" class="cardHead" @click="open.challenge = !open.challenge">
|
||||
<h3>挑战</h3>
|
||||
<span v-if="!open.challenge" class="summary">{{ challengeSummary }}</span>
|
||||
<n-text v-else depth="3" class="note">选填,学生做对以后才看得到</n-text>
|
||||
<span class="grow"></span>
|
||||
<span class="caret">{{ open.challenge ? "⌃" : "›" }}</span>
|
||||
</button>
|
||||
<ChallengeEditor
|
||||
v-if="open.challenge"
|
||||
v-model="form.challenges"
|
||||
:languages="form.languages"
|
||||
:answers="answersForAst"
|
||||
:problem-id="props.initial?.id ?? null"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- 流程图 -->
|
||||
<section id="section-flowchart" class="card">
|
||||
<button type="button" class="cardHead" @click="open.flowchart = !open.flowchart">
|
||||
|
||||
Reference in new issue
Block a user