做完这节课(或题库里自己做对)之后列出还能挑战的题;去挑战后编辑器顶上写着要求, 没做到标出是哪几行,做到了写本班第几个、全站第几个。课堂看板做到的格子贴一枚紫色筹码、 在挑战的加一圈紫边,题卡片写挑战成功几人。出题页多一块「挑战」,能从做对的代码里找候选。 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 { toAdminType, toProblemPermission } from "@oj2/contract"
|
||||||
import type {
|
import type {
|
||||||
AddContestProblemRequest,
|
AddContestProblemRequest,
|
||||||
|
ChallengeSuggestionsResponse,
|
||||||
AddProblemsToSetRequest,
|
AddProblemsToSetRequest,
|
||||||
BatchProblemTagRequest,
|
BatchProblemTagRequest,
|
||||||
BatchProblemVisibleRequest,
|
BatchProblemVisibleRequest,
|
||||||
@@ -353,6 +354,16 @@ export function checkAstRules(data: AstCheckRequest) {
|
|||||||
return api.post<AstCheckResponse>("admin/ast-check", data satisfies 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 按题面和标准答案想几组测试输入(输出由标准答案跑)
|
// AI 按题面和标准答案想几组测试输入(输出由标准答案跑)
|
||||||
export function generateTestInputs(data: GenerateTestInputsRequest) {
|
export function generateTestInputs(data: GenerateTestInputsRequest) {
|
||||||
return api.post<GenerateTestInputsResponse>(
|
return api.post<GenerateTestInputsResponse>(
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ interface Props {
|
|||||||
languages: LANGUAGE[]
|
languages: LANGUAGE[]
|
||||||
/** 各语言的标准答案(学生那段代码,不套模板):拿来自测规则 */
|
/** 各语言的标准答案(学生那段代码,不套模板):拿来自测规则 */
|
||||||
answers: Partial<Record<LANGUAGE, string>>
|
answers: Partial<Record<LANGUAGE, string>>
|
||||||
|
/**
|
||||||
|
* 挑战的判定规则(ChallengeEditor 里一条挑战一个)。和语法要求是同一套规则,但口径不同:
|
||||||
|
* 不满足不算错、说法由挑战自己写、标准答案不这么写是常事(挑战就是另一种写法)
|
||||||
|
*/
|
||||||
|
challenge?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -294,8 +299,13 @@ watch(supportedLanguages, (langs) => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="astRules">
|
<div class="astRules">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="label">语法要求</span>
|
<span class="label">{{ challenge ? "判定" : "语法要求" }}</span>
|
||||||
<div v-if="supportedLanguages.length" class="langs" role="tablist" aria-label="语言">
|
<div
|
||||||
|
v-if="supportedLanguages.length && !challenge"
|
||||||
|
class="langs"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="语言"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="lang in supportedLanguages"
|
v-for="lang in supportedLanguages"
|
||||||
:key="lang"
|
:key="lang"
|
||||||
@@ -310,7 +320,8 @@ watch(supportedLanguages, (langs) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow"></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>
|
</div>
|
||||||
<n-text v-if="unsupportedLanguages.length" depth="3" class="note">
|
<n-text v-if="unsupportedLanguages.length" depth="3" class="note">
|
||||||
{{ unsupportedLanguages.join("、") }} 检查不了语法要求(判题机只认
|
{{ unsupportedLanguages.join("、") }} 检查不了语法要求(判题机只认
|
||||||
@@ -402,6 +413,7 @@ watch(supportedLanguages, (langs) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="ruleRow sub">
|
<div class="ruleRow sub">
|
||||||
<n-input
|
<n-input
|
||||||
|
v-if="!challenge"
|
||||||
:value="rule.message"
|
:value="rule.message"
|
||||||
size="tiny"
|
size="tiny"
|
||||||
class="message"
|
class="message"
|
||||||
@@ -412,7 +424,19 @@ watch(supportedLanguages, (langs) => {
|
|||||||
"
|
"
|
||||||
@update:value="(v: string) => updateRule(lang, index, 'message', v)"
|
@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">
|
<span v-else-if="checkOf(lang, index)?.passed === false" class="bad">
|
||||||
✗ 标准答案自己没过{{
|
✗ 标准答案自己没过{{
|
||||||
checkOf(lang, index)!.actual !== undefined
|
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">
|
<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 { useThemeVars } from "naive-ui"
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
import PageHeader from "admin/components/PageHeader.vue"
|
||||||
import { getProblemTagList } from "shared/api"
|
import { getProblemTagList } from "shared/api"
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
uploadTestcases,
|
uploadTestcases,
|
||||||
} from "../api"
|
} from "../api"
|
||||||
import AstRulesEditor from "./components/AstRulesEditor.vue"
|
import AstRulesEditor from "./components/AstRulesEditor.vue"
|
||||||
|
import ChallengeEditor from "./components/ChallengeEditor.vue"
|
||||||
import CaseTable from "./components/CaseTable.vue"
|
import CaseTable from "./components/CaseTable.vue"
|
||||||
import CodePreview from "./components/CodePreview.vue"
|
import CodePreview from "./components/CodePreview.vue"
|
||||||
import PreviewStrip from "./components/PreviewStrip.vue"
|
import PreviewStrip from "./components/PreviewStrip.vue"
|
||||||
@@ -108,6 +109,8 @@ interface CodeForm {
|
|||||||
answers: Record<RunnableLanguage, string>
|
answers: Record<RunnableLanguage, string>
|
||||||
templates: Record<RunnableLanguage, TemplateParts>
|
templates: Record<RunnableLanguage, TemplateParts>
|
||||||
astRules: AstRules | null
|
astRules: AstRules | null
|
||||||
|
/** 做对之后的挑战,按语言分组(见契约 challengeSchema) */
|
||||||
|
challenges: Record<string, ChallengeInput[]> | null
|
||||||
flowchartMode: FlowchartMode
|
flowchartMode: FlowchartMode
|
||||||
mermaidCode: string
|
mermaidCode: string
|
||||||
/** 画布画的参考流程图;null 是老题(只有手写的 Mermaid)或者还没画 */
|
/** 画布画的参考流程图;null 是老题(只有手写的 Mermaid)或者还没画 */
|
||||||
@@ -135,6 +138,7 @@ const blankForm = (): CodeForm => ({
|
|||||||
answers: { Python: "", C: "", "C++": "" },
|
answers: { Python: "", C: "", "C++": "" },
|
||||||
templates: { Python: blankParts(), C: blankParts(), "C++": blankParts() },
|
templates: { Python: blankParts(), C: blankParts(), "C++": blankParts() },
|
||||||
astRules: null,
|
astRules: null,
|
||||||
|
challenges: null,
|
||||||
flowchartMode: "none",
|
flowchartMode: "none",
|
||||||
mermaidCode: "",
|
mermaidCode: "",
|
||||||
flowchartDoc: null,
|
flowchartDoc: null,
|
||||||
@@ -428,6 +432,14 @@ watch(
|
|||||||
const showHidden = ref(false)
|
const showHidden = ref(false)
|
||||||
const astChecks = ref<AstCheckState>({})
|
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 answersForAst = computed(() => ({ ...form.answers }) as Partial<Record<LANGUAGE, string>>)
|
||||||
const templatesForPreview = computed(
|
const templatesForPreview = computed(
|
||||||
() => ({ ...form.templates }) as Partial<Record<LANGUAGE, TemplateParts>>,
|
() => ({ ...form.templates }) as Partial<Record<LANGUAGE, TemplateParts>>,
|
||||||
@@ -493,6 +505,7 @@ const open = reactive({
|
|||||||
answer: true,
|
answer: true,
|
||||||
cases: true,
|
cases: true,
|
||||||
write: false,
|
write: false,
|
||||||
|
challenge: false,
|
||||||
flowchart: false,
|
flowchart: false,
|
||||||
more: false,
|
more: false,
|
||||||
})
|
})
|
||||||
@@ -612,6 +625,15 @@ async function loadProblem(data: AdminProblem) {
|
|||||||
answers,
|
answers,
|
||||||
templates,
|
templates,
|
||||||
astRules: data.astRules ?? null,
|
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",
|
flowchartMode: data.allowFlowchart ? "draw" : data.showFlowchart ? "show" : "none",
|
||||||
mermaidCode: data.mermaidCode ?? "",
|
mermaidCode: data.mermaidCode ?? "",
|
||||||
flowchartDoc: parseDoc(data.flowchartData),
|
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 (form.flowchartMode === "show") {
|
||||||
if (legacyMermaid.value) {
|
if (legacyMermaid.value) {
|
||||||
if (!mermaidOk.value)
|
if (!mermaidOk.value)
|
||||||
@@ -804,6 +835,7 @@ async function save() {
|
|||||||
: null,
|
: null,
|
||||||
flowchartHint: form.flowchartHint.trim(),
|
flowchartHint: form.flowchartHint.trim(),
|
||||||
astRules: form.astRules,
|
astRules: form.astRules,
|
||||||
|
challenges: form.challenges,
|
||||||
sqlConfig: null,
|
sqlConfig: null,
|
||||||
sqlDisplay: null,
|
sqlDisplay: null,
|
||||||
contestId: props.contestID ? Number(props.contestID) : null,
|
contestId: props.contestID ? Number(props.contestID) : null,
|
||||||
@@ -1197,6 +1229,24 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</section>
|
</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">
|
<section id="section-flowchart" class="card">
|
||||||
<button type="button" class="cardHead" @click="open.flowchart = !open.flowchart">
|
<button type="button" class="cardHead" @click="open.flowchart = !open.flowchart">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ChallengeList,
|
||||||
ClassLessonRequest,
|
ClassLessonRequest,
|
||||||
ContestPasswordRequest,
|
ContestPasswordRequest,
|
||||||
CreateFlowchartRequest,
|
CreateFlowchartRequest,
|
||||||
@@ -431,6 +432,15 @@ export function getSimilarProblems(problemId: string) {
|
|||||||
.then((response) => response.map(toProblemRow))
|
.then((response) => response.map(toProblemRow))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 挑战 ====================
|
||||||
|
|
||||||
|
/** 几道题、某种语言下的挑战(说法、几人做到、我做到没有),见契约 problemChallengeGroupSchema */
|
||||||
|
export function getChallenges(problemIds: string[], language: string) {
|
||||||
|
return api.get<ChallengeList>("challenges", {
|
||||||
|
params: { problems: problemIds.join(","), language },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 流程图相关API ====================
|
// ==================== 流程图相关API ====================
|
||||||
|
|
||||||
export function submitFlowchart(data: {
|
export function submitFlowchart(data: {
|
||||||
|
|||||||
@@ -256,6 +256,17 @@ const groupCount = computed(() => {
|
|||||||
for (const student of students.value) count[groupOf(student)]++
|
for (const student of students.value) count[groupOf(student)]++
|
||||||
return count
|
return count
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 classBoardCellSchema.challenge)。
|
||||||
|
* 做完了又没在挑战的,就是空着的
|
||||||
|
*/
|
||||||
|
const challengingCount = computed(
|
||||||
|
() =>
|
||||||
|
students.value.filter(
|
||||||
|
(student) =>
|
||||||
|
groupOf(student) === "done" && student.cells.some((cell) => cell.challenge === "doing"),
|
||||||
|
).length,
|
||||||
|
)
|
||||||
/** 每组第一个人,在他上面插一行组名 */
|
/** 每组第一个人,在他上面插一行组名 */
|
||||||
const groupStarts = computed(() => {
|
const groupStarts = computed(() => {
|
||||||
const starts = new Set<number>()
|
const starts = new Set<number>()
|
||||||
@@ -298,7 +309,11 @@ const problemStats = computed(() =>
|
|||||||
const done = present.value.filter((student) => student.cells[i]?.status === "accepted").length
|
const done = present.value.filter((student) => student.cells[i]?.status === "accepted").length
|
||||||
const wrong = present.value.filter((student) => student.cells[i]?.status === "tried").length
|
const wrong = present.value.filter((student) => student.cells[i]?.status === "tried").length
|
||||||
const total = present.value.length
|
const total = present.value.length
|
||||||
return { problem, done, wrong, none: total - done - wrong, total }
|
// 挑战成功几人:只有配了挑战的题才写
|
||||||
|
const challenged = problem.hasChallenges
|
||||||
|
? present.value.filter((student) => student.cells[i]?.challenge === "done").length
|
||||||
|
: null
|
||||||
|
return { problem, done, wrong, none: total - done - wrong, total, challenged }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -532,6 +547,9 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
<span class="card-foot">
|
<span class="card-foot">
|
||||||
<span :class="stat.wrong ? 'wrong' : 'muted'">错着 {{ stat.wrong }}</span>
|
<span :class="stat.wrong ? 'wrong' : 'muted'">错着 {{ stat.wrong }}</span>
|
||||||
<span class="muted">没做 {{ stat.none }}</span>
|
<span class="muted">没做 {{ stat.none }}</span>
|
||||||
|
<span v-if="stat.challenged !== null" class="challenged">
|
||||||
|
挑战成功 {{ stat.challenged }}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -651,6 +669,9 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
<span class="group-label">
|
<span class="group-label">
|
||||||
{{ GROUP_LABEL[groupOf(student)] }}
|
{{ GROUP_LABEL[groupOf(student)] }}
|
||||||
<span class="muted">{{ groupCount[groupOf(student)] }}</span>
|
<span class="muted">{{ groupCount[groupOf(student)] }}</span>
|
||||||
|
<span v-if="groupOf(student) === 'done' && challengingCount" class="challenged">
|
||||||
|
在挑战 {{ challengingCount }}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -709,6 +730,7 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
v-if="cell.status === 'tried' && cell.latest"
|
v-if="cell.status === 'tried' && cell.latest"
|
||||||
type="button"
|
type="button"
|
||||||
class="cell"
|
class="cell"
|
||||||
|
:data-challenge="cell.challenge ?? undefined"
|
||||||
:style="cellStyle(cell)"
|
:style="cellStyle(cell)"
|
||||||
:title="cellTitle(cell, student)"
|
:title="cellTitle(cell, student)"
|
||||||
@click="openExplain(i, student.userId)"
|
@click="openExplain(i, student.userId)"
|
||||||
@@ -727,6 +749,7 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cell"
|
class="cell"
|
||||||
|
:data-challenge="cell.challenge ?? undefined"
|
||||||
:class="{ picked: trailKey === `${student.userId}:${i}` }"
|
:class="{ picked: trailKey === `${student.userId}:${i}` }"
|
||||||
:style="cellStyle(cell)"
|
:style="cellStyle(cell)"
|
||||||
:title="cellTitle(cell, student)"
|
:title="cellTitle(cell, student)"
|
||||||
@@ -747,6 +770,7 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
<a
|
<a
|
||||||
v-else-if="cell.attempts"
|
v-else-if="cell.attempts"
|
||||||
class="cell"
|
class="cell"
|
||||||
|
:data-challenge="cell.challenge ?? undefined"
|
||||||
:style="cellStyle(cell)"
|
:style="cellStyle(cell)"
|
||||||
:href="submissionsHref(student, board.problems[i], !cell.codeAttempts)"
|
:href="submissionsHref(student, board.problems[i], !cell.codeAttempts)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -757,6 +781,7 @@ function lastText(student: ClassBoardStudent) {
|
|||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="cell"
|
class="cell"
|
||||||
|
:data-challenge="cell.challenge ?? undefined"
|
||||||
:style="cellStyle(cell)"
|
:style="cellStyle(cell)"
|
||||||
:title="cellTitle(cell, student)"
|
:title="cellTitle(cell, student)"
|
||||||
>
|
>
|
||||||
@@ -1220,6 +1245,36 @@ a.cell {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 挑战:做到了贴一枚紫色筹码,在挑战加一圈紫边(设计稿 StkBoard) */
|
||||||
|
.cell[data-challenge="done"] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell[data-challenge="done"]::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -6px;
|
||||||
|
top: -6px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #5b34c4;
|
||||||
|
border: 3px solid #b49cf5;
|
||||||
|
box-shadow: 0 0 0 2px v-bind("theme.cardColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell[data-challenge="doing"] {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px v-bind("theme.cardColor"),
|
||||||
|
0 0 0 4px #8f72e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenged {
|
||||||
|
color: #6a45d6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.cell.picked {
|
.cell.picked {
|
||||||
box-shadow: 0 0 0 2px v-bind("theme.textColor1");
|
box-shadow: 0 0 0 2px v-bind("theme.textColor1");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,12 @@ export function cellTitle(
|
|||||||
day: string | undefined,
|
day: string | undefined,
|
||||||
) {
|
) {
|
||||||
if (cell.status === "accepted") {
|
if (cell.status === "accepted") {
|
||||||
return acceptedToday(cell, day)
|
const when = acceptedToday(cell, day)
|
||||||
? `${parseTime(cell.acceptedAt!, "HH:mm")} 做对`
|
? `${parseTime(cell.acceptedAt!, "HH:mm")} 做对`
|
||||||
: "以前就做对了"
|
: "以前就做对了"
|
||||||
|
if (cell.challenge === "done") return `${when},挑战也做到了`
|
||||||
|
if (cell.challenge === "doing") return `${when},在挑战`
|
||||||
|
return when
|
||||||
}
|
}
|
||||||
if (cell.status === "tried") {
|
if (cell.status === "tried") {
|
||||||
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
|
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useChallengeStore } from "oj/store/challenge"
|
||||||
|
import { useChallengeTone } from "../composables/challengeTone"
|
||||||
|
import ChallengeIcon from "./ChallengeIcon.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挑战中:编辑器顶上一条紫色的,写着要求和全站几人做到,随时「不挑战了」(设计稿 StkDoing)。
|
||||||
|
* 做到了换成「挑战成功」,留着让学生看一眼自己是照什么要求写的,再「回到普通模式」
|
||||||
|
*/
|
||||||
|
const challengeStore = useChallengeStore()
|
||||||
|
const tone = useChallengeTone()
|
||||||
|
const active = computed(() => challengeStore.active)
|
||||||
|
const done = computed(() => !!active.value?.mine)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="active" class="challenge-bar">
|
||||||
|
<span class="pill">
|
||||||
|
<svg
|
||||||
|
v-if="done"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||||
|
</svg>
|
||||||
|
<ChallengeIcon v-else />
|
||||||
|
{{ done ? "挑战成功" : "挑战" }}
|
||||||
|
</span>
|
||||||
|
<b class="wording">{{ active.wording }}</b>
|
||||||
|
<span class="spacer" />
|
||||||
|
<span v-if="!done" class="meta">{{ active.doneCount }} 人做到</span>
|
||||||
|
<button type="button" class="quit" @click="challengeStore.stop()">
|
||||||
|
{{ done ? "回到普通模式" : "不挑战了" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.challenge-bar {
|
||||||
|
height: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background-color: v-bind("tone.soft");
|
||||||
|
border-bottom: 1px solid v-bind("tone.border");
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: v-bind("tone.solid");
|
||||||
|
color: v-bind("tone.onSolid");
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wording {
|
||||||
|
font-size: 15px;
|
||||||
|
color: v-bind("tone.text");
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quit {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid v-bind("tone.border");
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("tone.text");
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 手机上一行放不下:说法最要紧,让它折行,人数不写了 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.challenge-bar {
|
||||||
|
height: auto;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wording {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer,
|
||||||
|
.meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quit:focus-visible {
|
||||||
|
outline: 2px solid v-bind("tone.solid");
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** 挑战的标记:一枚筹码(借的是小丑牌的赌注筹码),设计稿「做对后再挑战」 */
|
||||||
|
withDefaults(defineProps<{ size?: number }>(), { size: 14 })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 3v3M12 18v3M3 12h3M18 12h3" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChallengeList } from "@oj2/contract"
|
||||||
|
import { getChallenges } from "oj/api"
|
||||||
|
import { useChallengeStore } from "oj/store/challenge"
|
||||||
|
import { useChallengeTone } from "../composables/challengeTone"
|
||||||
|
import ChallengeIcon from "./ChallengeIcon.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「还能挑战」:这几道题里我做对了、还没做到的挑战,一条一行,「去挑战」(设计稿 StkDone)。
|
||||||
|
* 一条都没有就什么都不显示 —— 课堂题多是一两行,能出挑战的只是一部分。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
problemIds: string[]
|
||||||
|
language: string
|
||||||
|
/** 变了就重拉:刚做对 / 刚做到一条挑战,列表里要去掉它 */
|
||||||
|
refreshKey?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const challengeStore = useChallengeStore()
|
||||||
|
const tone = useChallengeTone()
|
||||||
|
|
||||||
|
const groups = ref<ChallengeList>([])
|
||||||
|
let sequence = 0
|
||||||
|
watch(
|
||||||
|
() => [props.problemIds.join(","), props.language, props.refreshKey] as const,
|
||||||
|
async () => {
|
||||||
|
const mine = ++sequence
|
||||||
|
if (!props.problemIds.length || !props.language) {
|
||||||
|
groups.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const list = await getChallenges(props.problemIds, props.language)
|
||||||
|
if (mine === sequence) groups.value = list
|
||||||
|
} catch {
|
||||||
|
if (mine === sequence) groups.value = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const rows = computed(() =>
|
||||||
|
groups.value.flatMap((group) =>
|
||||||
|
// 没做对的题不给接挑战:挑战是做对之后的事
|
||||||
|
group.solved
|
||||||
|
? group.challenges
|
||||||
|
.filter((challenge) => !challenge.mine)
|
||||||
|
.map((challenge) => ({ group, challenge }))
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-if="rows.length" class="challenge-list" aria-label="还能挑战">
|
||||||
|
<div class="head">
|
||||||
|
<span class="pill"><ChallengeIcon />挑战</span>
|
||||||
|
<b>还能挑战</b>
|
||||||
|
</div>
|
||||||
|
<div v-for="({ group, challenge }, i) in rows" :key="challenge.id" class="row">
|
||||||
|
<div class="text">
|
||||||
|
<div class="problem">
|
||||||
|
<b>{{ group.problemId }}</b> <span class="title">{{ group.title }}</span>
|
||||||
|
</div>
|
||||||
|
<b class="wording">{{ challenge.wording }}</b>
|
||||||
|
<span class="stat">
|
||||||
|
{{ group.acCount }} 个做对的人里,{{ challenge.doneCount }} 人做到
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="go"
|
||||||
|
:class="{ ghost: i > 0 }"
|
||||||
|
@click="challengeStore.start(group.problemId, challenge.id)"
|
||||||
|
>
|
||||||
|
去挑战
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.challenge-list {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: v-bind("tone.solid");
|
||||||
|
color: v-bind("tone.onSolid");
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid v-bind("tone.border");
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title,
|
||||||
|
.stat {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wording {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 24px;
|
||||||
|
color: v-bind("tone.text");
|
||||||
|
}
|
||||||
|
|
||||||
|
.go {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid v-bind("tone.solid");
|
||||||
|
background-color: v-bind("tone.solid");
|
||||||
|
color: v-bind("tone.onSolid");
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.go.ghost {
|
||||||
|
background-color: transparent;
|
||||||
|
color: v-bind("tone.text");
|
||||||
|
border-color: v-bind("tone.border");
|
||||||
|
}
|
||||||
|
|
||||||
|
.go:focus-visible {
|
||||||
|
outline: 2px solid v-bind("tone.solid");
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 手机上按钮另起一行占满,好按 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.row {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.go {
|
||||||
|
justify-content: center;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AstResultInfo } from "@oj2/contract"
|
||||||
|
import { useChallengeStore } from "oj/store/challenge"
|
||||||
|
import { clearChallengeLines, showChallengeLines } from "oj/problem/utils/errorMark"
|
||||||
|
import { useChallengeTone } from "../composables/challengeTone"
|
||||||
|
import ChallengeIcon from "./ChallengeIcon.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挑战中交的这一次,挑战成没成(设计稿 StkFail / StkPass)。答案本身是对的 —— 没做到挑战
|
||||||
|
* 也照样算做对,所以没做到时大字写「答案对了,挑战还差一点」,不是错误的红色。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
passed: boolean
|
||||||
|
results: AstResultInfo[]
|
||||||
|
wording: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const challengeStore = useChallengeStore()
|
||||||
|
const tone = useChallengeTone()
|
||||||
|
|
||||||
|
const failed = computed(() => props.results.filter((result) => !result.passed))
|
||||||
|
|
||||||
|
/** 「print() 至多调用 1 次,现在 3 次」 */
|
||||||
|
const reason = computed(() =>
|
||||||
|
failed.value
|
||||||
|
.map((result) =>
|
||||||
|
result.actual !== undefined
|
||||||
|
? `${result.description},现在 ${result.actual} 次`
|
||||||
|
: result.description,
|
||||||
|
)
|
||||||
|
.join(";"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const lines = computed(() => [...new Set(failed.value.flatMap((result) => result.lines ?? []))])
|
||||||
|
|
||||||
|
// 编辑器里把那几行标紫。做到了、换了一次提交就清掉
|
||||||
|
watch(
|
||||||
|
() => (props.passed ? [] : lines.value),
|
||||||
|
(value) => (value.length ? showChallengeLines(value) : clearChallengeLines()),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
onUnmounted(clearChallengeLines)
|
||||||
|
|
||||||
|
/** 做到了:本班、全站第几个。刚判完那一刻列表还没重拉,先不显示 */
|
||||||
|
const mine = computed(() => (props.passed ? (challengeStore.active?.mine ?? null) : null))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="challenge-outcome">
|
||||||
|
<div class="line">
|
||||||
|
<span class="badge" :class="{ outline: !passed }" aria-hidden="true">
|
||||||
|
<ChallengeIcon :size="18" />
|
||||||
|
</span>
|
||||||
|
<div class="text">
|
||||||
|
<b class="title">{{ passed ? "挑战成功" : "答案对了,挑战还差一点" }}</b>
|
||||||
|
<span class="sub">{{ passed ? wording : reason }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!passed" class="chips">
|
||||||
|
<span class="chip ok">
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||||
|
</svg>
|
||||||
|
测试点全对
|
||||||
|
</span>
|
||||||
|
<span v-if="lines.length" class="chip mark">第 {{ lines.join("、") }} 行</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="mine" class="tiles">
|
||||||
|
<div v-if="mine.classRank !== null" class="tile">
|
||||||
|
<b>第 {{ mine.classRank }} 个</b>
|
||||||
|
<span>本班做到的</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<b>第 {{ mine.siteRank }} 个</b>
|
||||||
|
<span>全站做到的</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.challenge-outcome {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: v-bind("tone.solid");
|
||||||
|
color: v-bind("tone.onSolid");
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.outline {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 2px solid v-bind("tone.solid");
|
||||||
|
color: v-bind("tone.solid");
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 28px;
|
||||||
|
color: v-bind("tone.text");
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 13px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.ok {
|
||||||
|
background-color: rgba(24, 160, 88, 0.12);
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.mark {
|
||||||
|
background-color: v-bind("tone.soft");
|
||||||
|
color: v-bind("tone.text");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiles {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid v-bind("tone.border");
|
||||||
|
background-color: v-bind("tone.soft");
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile b {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 30px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: v-bind("tone.text");
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile span {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { useLessonStore } from "shared/store/lesson"
|
import { useLessonStore } from "shared/store/lesson"
|
||||||
|
import ChallengeList from "./ChallengeList.vue"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过之后告诉学生「这节课的下一题是哪道」。
|
* 通过之后告诉学生「这节课的下一题是哪道」。
|
||||||
@@ -16,6 +17,12 @@ import { useLessonStore } from "shared/store/lesson"
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 当前这道题的展示题号 */
|
/** 当前这道题的展示题号 */
|
||||||
problemDisplayId: string
|
problemDisplayId: string
|
||||||
|
/** 刚做对用的语言:挑战按语言分 */
|
||||||
|
language: string
|
||||||
|
/** 刚做对的那次提交,换了就重拉「还能挑战」 */
|
||||||
|
refreshKey: string
|
||||||
|
/** 这个入口给不给挑战(见 problemPageContext) */
|
||||||
|
challenges: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const lessonStore = useLessonStore()
|
const lessonStore = useLessonStore()
|
||||||
@@ -31,6 +38,11 @@ const lesson = computed(() => lessonStore.nextAfter(props.problemDisplayId))
|
|||||||
|
|
||||||
// 设计稿和上下文条都叫「这节课」,不区分是老师布置的还是从同班提交推断的
|
// 设计稿和上下文条都叫「这节课」,不区分是老师布置的还是从同班提交推断的
|
||||||
const label = "这节课"
|
const label = "这节课"
|
||||||
|
|
||||||
|
/** 这节课的几道题:都做完了以后,这几道里还能挑战的列出来(设计稿 StkDone) */
|
||||||
|
const lessonProblemIds = computed(
|
||||||
|
() => lessonStore.activity?.problems.map((item) => item.problemDisplayId) ?? [],
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -66,13 +78,18 @@ const label = "这节课"
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
|
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
|
||||||
<span class="detail">想接着练,可以去题单里挑一个</span>
|
<span v-if="!challenges" class="detail">想接着练,可以去题单里挑一个</span>
|
||||||
</div>
|
</div>
|
||||||
<router-link to="/problemset" class="next secondary">
|
<router-link v-if="!challenges" to="/problemset" class="next secondary">
|
||||||
<span class="next-label">去题单继续练</span>
|
<span class="next-label">去题单继续练</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 做完了:先给这节课里还能挑战的,再给题单(设计稿 StkDone)。挑战一条都没有时只剩题单那句 -->
|
||||||
|
<template v-if="lesson && !lesson.next && challenges">
|
||||||
|
<ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" />
|
||||||
|
<router-link to="/problemset" class="more">去题单继续练</router-link>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -129,6 +146,17 @@ const label = "这节课"
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.more:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.next.secondary {
|
.next.secondary {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid v-bind("theme.primaryColor");
|
border: 1px solid v-bind("theme.primaryColor");
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { errorMarkExtensions } from "oj/problem/utils/errorMark"
|
|||||||
import { editorFocusTracker } from "oj/problem/utils/symbolKeys"
|
import { editorFocusTracker } from "oj/problem/utils/symbolKeys"
|
||||||
import { hangWrapExtensions } from "shared/extensions/hangIndent"
|
import { hangWrapExtensions } from "shared/extensions/hangIndent"
|
||||||
import EditorToolbar from "./EditorToolbar.vue"
|
import EditorToolbar from "./EditorToolbar.vue"
|
||||||
|
import ChallengeBar from "./ChallengeBar.vue"
|
||||||
|
import { useChallengeStore } from "oj/store/challenge"
|
||||||
import { useDraftKey } from "../composables/draftKey"
|
import { useDraftKey } from "../composables/draftKey"
|
||||||
import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab"
|
import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab"
|
||||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||||
@@ -99,9 +101,14 @@ const storageKey = useDraftKey()
|
|||||||
|
|
||||||
// 桌面:顶栏 56 + 工具栏 48,协作条再占 40(两栏顶满内容区,见 detail.vue 的 problem-split)。
|
// 桌面:顶栏 56 + 工具栏 48,协作条再占 40(两栏顶满内容区,见 detail.vue 的 problem-split)。
|
||||||
// 手机:顶栏两行 + 课堂条 + 页签 + 工具栏约 236px,底部还有「运行例子 / 提交」那条约 64px
|
// 手机:顶栏两行 + 课堂条 + 页签 + 工具栏约 236px,底部还有「运行例子 / 提交」那条约 64px
|
||||||
|
// 挑战中编辑器顶上多一条 44 的挑战条(协作中的老师不显示,见模板)
|
||||||
|
const challengeStore = useChallengeStore()
|
||||||
|
const showChallengeBar = computed(
|
||||||
|
() => ctx.value.challenges && !teacherCollab.value && !!challengeStore.active,
|
||||||
|
)
|
||||||
const editorHeight = computed(() => {
|
const editorHeight = computed(() => {
|
||||||
const base = isDesktop.value ? 104 : 300
|
const base = isDesktop.value ? 104 : 300
|
||||||
return `calc(100vh - ${base + (teacherCollab.value ? 40 : 0)}px)`
|
return `calc(100vh - ${base + (teacherCollab.value ? 40 : 0) + (showChallengeBar.value ? 44 : 0)}px)`
|
||||||
})
|
})
|
||||||
|
|
||||||
function loadCode() {
|
function loadCode() {
|
||||||
@@ -220,6 +227,7 @@ onBeforeUnmount(() => flowchartStore.attachEditor(null))
|
|||||||
<template>
|
<template>
|
||||||
<div class="problem-editor">
|
<div class="problem-editor">
|
||||||
<CollabBar v-if="teacherCollab" />
|
<CollabBar v-if="teacherCollab" />
|
||||||
|
<ChallengeBar v-if="showChallengeBar" />
|
||||||
<EditorToolbar @change-language="changeLanguage" />
|
<EditorToolbar @change-language="changeLanguage" />
|
||||||
<!--
|
<!--
|
||||||
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
|
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import WrongAnswerExplain from "./WrongAnswerExplain.vue"
|
|||||||
import LessonNext from "./LessonNext.vue"
|
import LessonNext from "./LessonNext.vue"
|
||||||
import ResultHeader from "./ResultHeader.vue"
|
import ResultHeader from "./ResultHeader.vue"
|
||||||
import SimilarProblems from "./SimilarProblems.vue"
|
import SimilarProblems from "./SimilarProblems.vue"
|
||||||
|
import ChallengeList from "./ChallengeList.vue"
|
||||||
|
import ChallengeOutcome from "./ChallengeOutcome.vue"
|
||||||
|
import { useChallengeStore } from "oj/store/challenge"
|
||||||
import { useLessonStore } from "shared/store/lesson"
|
import { useLessonStore } from "shared/store/lesson"
|
||||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||||
import Markdown from "shared/components/Markdown.vue"
|
import Markdown from "shared/components/Markdown.vue"
|
||||||
@@ -109,6 +112,40 @@ const showSimilar = computed(
|
|||||||
!lessonStore.includes(problemStore.problem._id),
|
!lessonStore.includes(problemStore.problem._id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挑战中交的这一次:正在挑战的那条判出来的结果(设计稿 StkFail / StkPass)。只有做对的提交
|
||||||
|
* 才判挑战;进挑战之前那次做对的不算(见 challenge store 的 baselineSubmissionId)
|
||||||
|
*/
|
||||||
|
const challengeStore = useChallengeStore()
|
||||||
|
const challengeVerdict = computed(() => {
|
||||||
|
const submission = props.submission
|
||||||
|
if (!submission || props.peer || !ctx.value.challenges) return null
|
||||||
|
if (submission.result !== SubmissionStatus.accepted) return null
|
||||||
|
const id = challengeStore.activeId
|
||||||
|
if (!id || submission.id === challengeStore.baselineSubmissionId) return null
|
||||||
|
return submission.statisticInfo?.challenges?.find((verdict) => verdict.id === id) ?? null
|
||||||
|
})
|
||||||
|
// 做到了:重拉一次,挑战条换成「挑战成功」、拿到本班 / 全站第几个
|
||||||
|
watch(
|
||||||
|
() => (challengeVerdict.value?.passed ? props.submission?.id : null),
|
||||||
|
(id) => {
|
||||||
|
if (id) void challengeStore.load()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「还能挑战」看哪几道:这道在这节课里就看这节课的几道(课堂条「这节课都做完了」之后),
|
||||||
|
* 不在就只看这一道
|
||||||
|
*/
|
||||||
|
const challengeProblemIds = computed(() => {
|
||||||
|
const problem = problemStore.problem
|
||||||
|
if (!problem) return []
|
||||||
|
const lesson = lessonStore.activity
|
||||||
|
return lessonStore.includes(problem._id) && lesson
|
||||||
|
? lesson.problems.map((item) => item.problemDisplayId)
|
||||||
|
: [problem._id]
|
||||||
|
})
|
||||||
|
|
||||||
const msg = computed(() => {
|
const msg = computed(() => {
|
||||||
if (!props.submission) return ""
|
if (!props.submission) return ""
|
||||||
// 走 CompileErrorExplain / RuntimeErrorExplain 那两张中文卡片
|
// 走 CompileErrorExplain / RuntimeErrorExplain 那两张中文卡片
|
||||||
@@ -298,18 +335,44 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="submission">
|
<div v-if="submission">
|
||||||
<ResultHeader
|
<!-- 挑战中:答案对了就只说挑战成没成,「下一题」「相似题」都不给,免得挤掉挑战 -->
|
||||||
v-if="header"
|
<template v-if="challengeVerdict && challengeStore.active">
|
||||||
:kind="header.kind"
|
<ChallengeOutcome
|
||||||
:title="header.title"
|
:passed="challengeVerdict.passed"
|
||||||
:sub="header.sub"
|
:results="challengeVerdict.results"
|
||||||
:progress="header.progress"
|
:wording="challengeStore.active.wording"
|
||||||
/>
|
/>
|
||||||
<LessonNext
|
<ChallengeList
|
||||||
v-if="showLessonNext && problemStore.problem"
|
v-if="challengeVerdict.passed"
|
||||||
:problem-display-id="problemStore.problem._id"
|
:problem-ids="challengeProblemIds"
|
||||||
/>
|
:language="submission.language"
|
||||||
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
|
:refresh-key="submission.id"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<ResultHeader
|
||||||
|
v-if="header"
|
||||||
|
:kind="header.kind"
|
||||||
|
:title="header.title"
|
||||||
|
:sub="header.sub"
|
||||||
|
:progress="header.progress"
|
||||||
|
/>
|
||||||
|
<LessonNext
|
||||||
|
v-if="showLessonNext && problemStore.problem"
|
||||||
|
:problem-display-id="problemStore.problem._id"
|
||||||
|
:language="submission.language"
|
||||||
|
:refresh-key="submission.id"
|
||||||
|
:challenges="ctx.challenges"
|
||||||
|
/>
|
||||||
|
<!-- 不在这节课里(题库入口自己做的):做对之后先给这道题的挑战,再给相似题 -->
|
||||||
|
<ChallengeList
|
||||||
|
v-if="showSimilar && ctx.challenges && problemStore.problem"
|
||||||
|
:problem-ids="[problemStore.problem._id]"
|
||||||
|
:language="submission.language"
|
||||||
|
:refresh-key="submission.id"
|
||||||
|
/>
|
||||||
|
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
|
||||||
|
</template>
|
||||||
<n-flex
|
<n-flex
|
||||||
vertical
|
vertical
|
||||||
v-if="
|
v-if="
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 挑战的颜色:紫。课堂条是绿、题单条是蓝、比赛条是黄,挑战不能和它们撞(设计稿「做对后再挑战」)。
|
||||||
|
* Naive 的主题里没有紫,暗色下换浅一档,深紫放在暗底上看不清
|
||||||
|
*/
|
||||||
|
export function useChallengeTone() {
|
||||||
|
const isDark = useDark()
|
||||||
|
return computed(() =>
|
||||||
|
isDark.value
|
||||||
|
? {
|
||||||
|
solid: "#b49cf5",
|
||||||
|
onSolid: "#17112b",
|
||||||
|
text: "#c9b8ff",
|
||||||
|
soft: "rgba(180, 156, 245, 0.14)",
|
||||||
|
border: "rgba(180, 156, 245, 0.35)",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
solid: "#5b34c4",
|
||||||
|
onSolid: "#ffffff",
|
||||||
|
text: "#2c1a66",
|
||||||
|
soft: "#f4f0fe",
|
||||||
|
border: "#ddd3f6",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ interface EntryRules {
|
|||||||
similar: boolean
|
similar: boolean
|
||||||
/** 通过之后给「这节课的下一题」 */
|
/** 通过之后给「这节课的下一题」 */
|
||||||
lessonNext: boolean
|
lessonNext: boolean
|
||||||
|
/**
|
||||||
|
* 做对之后的挑战(编辑器顶上的挑战条、结果里的「还能挑战」)。题单里通过后 1.5 秒就跳回去,
|
||||||
|
* 比赛提交不判挑战
|
||||||
|
*/
|
||||||
|
challenges: boolean
|
||||||
/** 通过之后 1.5 秒弹强制点评 */
|
/** 通过之后 1.5 秒弹强制点评 */
|
||||||
reviewAfterAccepted: boolean
|
reviewAfterAccepted: boolean
|
||||||
/** 通过之后 1.5 秒回题单 */
|
/** 通过之后 1.5 秒回题单 */
|
||||||
@@ -57,6 +62,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
|
|||||||
statusTag: true,
|
statusTag: true,
|
||||||
similar: true,
|
similar: true,
|
||||||
lessonNext: true,
|
lessonNext: true,
|
||||||
|
challenges: true,
|
||||||
reviewAfterAccepted: true,
|
reviewAfterAccepted: true,
|
||||||
backToProblemSet: false,
|
backToProblemSet: false,
|
||||||
help: true,
|
help: true,
|
||||||
@@ -71,6 +77,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
|
|||||||
// 点进相似题就离开了题单
|
// 点进相似题就离开了题单
|
||||||
similar: false,
|
similar: false,
|
||||||
lessonNext: false,
|
lessonNext: false,
|
||||||
|
challenges: false,
|
||||||
// 1.5 秒后要跳回题单页,弹了也会被冲掉
|
// 1.5 秒后要跳回题单页,弹了也会被冲掉
|
||||||
reviewAfterAccepted: false,
|
reviewAfterAccepted: false,
|
||||||
backToProblemSet: true,
|
backToProblemSet: true,
|
||||||
@@ -86,6 +93,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
|
|||||||
// 撞上同号公开题时反而会在比赛中把题库列给学生
|
// 撞上同号公开题时反而会在比赛中把题库列给学生
|
||||||
similar: false,
|
similar: false,
|
||||||
lessonNext: false,
|
lessonNext: false,
|
||||||
|
challenges: false,
|
||||||
reviewAfterAccepted: false,
|
reviewAfterAccepted: false,
|
||||||
backToProblemSet: false,
|
backToProblemSet: false,
|
||||||
help: false,
|
help: false,
|
||||||
|
|||||||
@@ -75,6 +75,30 @@ const markField = StateField.define<DecorationSet>({
|
|||||||
provide: (field) => EditorView.decorations.from(field),
|
provide: (field) => EditorView.decorations.from(field),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挑战没做到时标出是哪几行(「print 最多 1 次」→ 三个 print 所在的三行),紫底,和挑战
|
||||||
|
* 那条的颜色一致。和错误标记分开存:答案对了、挑战没做到时没有错误可标,两种也不会同时出现
|
||||||
|
*/
|
||||||
|
const setChallengeLines = StateEffect.define<number[]>()
|
||||||
|
const challengeLineDeco = Decoration.line({ class: "cm-challenge-line" })
|
||||||
|
|
||||||
|
const challengeField = StateField.define<DecorationSet>({
|
||||||
|
create: () => Decoration.none,
|
||||||
|
update(deco, tr) {
|
||||||
|
deco = deco.map(tr.changes)
|
||||||
|
for (const effect of tr.effects) {
|
||||||
|
if (effect.is(setChallengeLines)) {
|
||||||
|
const lines = effect.value.filter((line) => line >= 1 && line <= tr.state.doc.lines)
|
||||||
|
deco = Decoration.set(
|
||||||
|
lines.map((line) => challengeLineDeco.range(tr.state.doc.line(line).from)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deco
|
||||||
|
},
|
||||||
|
provide: (field) => EditorView.decorations.from(field),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题目页当前那个编辑器。结果面板和编辑器是兄弟组件,隔着好几层,
|
* 题目页当前那个编辑器。结果面板和编辑器是兄弟组件,隔着好几层,
|
||||||
* 与其一路透传 ref,不如让编辑器挂载时自己登记。只挂在题目页主编辑器上
|
* 与其一路透传 ref,不如让编辑器挂载时自己登记。只挂在题目页主编辑器上
|
||||||
@@ -98,6 +122,11 @@ const theme = EditorView.baseTheme({
|
|||||||
textUnderlineOffset: "3px",
|
textUnderlineOffset: "3px",
|
||||||
backgroundColor: "rgba(208, 48, 80, 0.18)",
|
backgroundColor: "rgba(208, 48, 80, 0.18)",
|
||||||
},
|
},
|
||||||
|
// 左边再压一道紫线:光标所在那行的底色会盖住行底色,线不会被盖
|
||||||
|
".cm-challenge-line": {
|
||||||
|
backgroundColor: "rgba(91, 52, 196, 0.12)",
|
||||||
|
boxShadow: "inset 3px 0 0 #8f72e3",
|
||||||
|
},
|
||||||
".cm-punctuation-mark": {
|
".cm-punctuation-mark": {
|
||||||
backgroundColor: "rgba(240, 160, 32, 0.35)",
|
backgroundColor: "rgba(240, 160, 32, 0.35)",
|
||||||
outline: "1px solid rgba(240, 160, 32, 0.8)",
|
outline: "1px solid rgba(240, 160, 32, 0.8)",
|
||||||
@@ -105,7 +134,7 @@ const theme = EditorView.baseTheme({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const errorMarkExtensions = [markField, punctuationField, tracker, theme]
|
export const errorMarkExtensions = [markField, punctuationField, challengeField, tracker, theme]
|
||||||
|
|
||||||
/** 题目页主编辑器(没挂着就是 null)。手机上的符号键往它里面插字 */
|
/** 题目页主编辑器(没挂着就是 null)。手机上的符号键往它里面插字 */
|
||||||
export function currentEditorView(): EditorView | null {
|
export function currentEditorView(): EditorView | null {
|
||||||
@@ -145,6 +174,15 @@ export function showErrorMark(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 标出挑战没做到的那几行(行号来自判题时的 AST,学生之后改过代码的话会对不上,不滚过去) */
|
||||||
|
export function showChallengeLines(lines: number[]) {
|
||||||
|
current?.dispatch({ effects: setChallengeLines.of(lines) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearChallengeLines() {
|
||||||
|
current?.dispatch({ effects: setChallengeLines.of([]) })
|
||||||
|
}
|
||||||
|
|
||||||
export function clearErrorMark() {
|
export function clearErrorMark() {
|
||||||
current?.dispatch({ effects: setMark.of(null) })
|
current?.dispatch({ effects: setMark.of(null) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { defineStore } from "pinia"
|
||||||
|
import type { ProblemChallengeGroup } from "@oj2/contract"
|
||||||
|
import { getChallenges } from "oj/api"
|
||||||
|
import { useCodeStore } from "oj/store/code"
|
||||||
|
import { useProblemStore } from "oj/store/problem"
|
||||||
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挑战:做对之后,同一道题加一条写法上的限制再做一遍(契约 challengeSchema)。
|
||||||
|
*
|
||||||
|
* 正在挑战哪一条记在路由的 `?challenge=<id>` 上:「去挑战」常常是去另一道题(这节课的
|
||||||
|
* 别的题),带着 query 跳过去最直接;刷新、返回也还在挑战里。
|
||||||
|
*
|
||||||
|
* 这里只管当前这道题的挑战(编辑器顶上那条、结果里的成没成)。课堂条「这节课都做完了」
|
||||||
|
* 那几道题的列表由 ChallengeList 自己拉。
|
||||||
|
*/
|
||||||
|
export const useChallengeStore = defineStore("challenge", () => {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const problemStore = useProblemStore()
|
||||||
|
const codeStore = useCodeStore()
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
|
const activeId = computed(() =>
|
||||||
|
typeof route.query.challenge === "string" ? route.query.challenge : "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进挑战那一刻结果页签里挂着的那次提交。它是进挑战之前那次做对的,也判过挑战(没做到),
|
||||||
|
* 不能拿它说「挑战还差一点」—— 只认之后交的。不用时刻比:机房电脑的钟不一定准
|
||||||
|
*/
|
||||||
|
const submissionStore = useSubmissionStore()
|
||||||
|
const baselineSubmissionId = ref<string | null>(null)
|
||||||
|
watch(
|
||||||
|
activeId,
|
||||||
|
(id) => {
|
||||||
|
if (id) baselineSubmissionId.value = submissionStore.submission?.id ?? null
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 当前这道题、编辑器当前语言下的挑战;没配挑战为 null */
|
||||||
|
const current = ref<ProblemChallengeGroup | null>(null)
|
||||||
|
let sequence = 0
|
||||||
|
async function load() {
|
||||||
|
const problemId = problemStore.problem?._id
|
||||||
|
const language = codeStore.code.language
|
||||||
|
const mine = ++sequence
|
||||||
|
if (!problemId || language === "Flowchart" || problemStore.problem?.contestId != null) {
|
||||||
|
current.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [group] = await getChallenges([problemId], language)
|
||||||
|
if (mine === sequence) current.value = group ?? null
|
||||||
|
} catch {
|
||||||
|
// 拉不到就没有挑战,不影响做题
|
||||||
|
if (mine === sequence) current.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch([() => problemStore.problem?._id, () => codeStore.code.language], load, {
|
||||||
|
immediate: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 正在挑战的那条(换了语言、挑战被老师删了就是 null) */
|
||||||
|
const active = computed(
|
||||||
|
() => current.value?.challenges.find((item) => item.id === activeId.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
function start(problemId: string, challengeId: string) {
|
||||||
|
// 进挑战先看题和代码:手机上编辑器是单独一个页签
|
||||||
|
const tab = isMobile.value ? "editor" : "content"
|
||||||
|
void router.push({
|
||||||
|
name: "problem",
|
||||||
|
params: { problemID: problemId },
|
||||||
|
query: { challenge: challengeId, tab },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
const { challenge: _, ...rest } = route.query
|
||||||
|
void router.replace({ query: rest })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activeId, active, current, baselineSubmissionId, load, start, stop }
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
ProblemDifficulty,
|
ProblemDifficulty,
|
||||||
JudgeStatus,
|
JudgeStatus,
|
||||||
AstRules,
|
AstRules,
|
||||||
|
ChallengeInput,
|
||||||
CreateAnnouncementRequest,
|
CreateAnnouncementRequest,
|
||||||
ProblemLanguage,
|
ProblemLanguage,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
@@ -142,8 +143,10 @@ type ExcludeKeys =
|
|||||||
|
|
||||||
export type BlankProblem = Omit<
|
export type BlankProblem = Omit<
|
||||||
AdminProblem,
|
AdminProblem,
|
||||||
ExcludeKeys | "hint" | "mermaidCode" | "flowchartData"
|
ExcludeKeys | "hint" | "mermaidCode" | "flowchartData" | "challenges"
|
||||||
> & {
|
> & {
|
||||||
|
/** 提交的挑战:新加的不带 id,后端发(见契约 challengeInputSchema) */
|
||||||
|
challenges?: Record<string, ChallengeInput[]> | null
|
||||||
id?: number
|
id?: number
|
||||||
// 新建比赛题时由 detail.vue 在提交前写进来
|
// 新建比赛题时由 detail.vue 在提交前写进来
|
||||||
contestId?: number | null
|
contestId?: number | null
|
||||||
|
|||||||
Reference in new issue
Block a user