- 工具栏「运行例子」:一键用全部例子试跑(Judge0,不算提交),结果进「结果 › 运行例子」, 每个例子 ✓ / ✕,没对上的沿用答案错误那套中文说明;比赛里只摆输出、不给提示句 (后端在比赛里故意不做例子对比);SQL 题不给 - 「结果」页签分成提交结果 / 运行例子 / 自己输入三段;「自己输入」顶替顶栏「自测」模式 那个单独的编辑器 - 试跑出错(编译、运行、超时)给中文说明,Judge0 的英文原文折叠起来 - 例子比对两边都去掉末尾空白:原来只 trim 了运行输出,样例末尾带空格的题永远「不通过」 - 去掉题面里每个例子旁的「测试」、顶栏的「双栏 / 自测 / 题目」切换、EditorForTest 和 screenMode store Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f4db7dc81c
commit
45505e8384
16 files changed
+552
-364
No files matched your search
+1
-1
@@ -58,7 +58,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
|
|||||||
跨端的(登录、资料、标签、验证码)在 `shared/api.ts`。
|
跨端的(登录、资料、标签、验证码)在 `shared/api.ts`。
|
||||||
|
|
||||||
Shared logic lives in `shared/`:
|
Shared logic lives in `shared/`:
|
||||||
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (流程图弹窗的 mermaid 源码)
|
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (流程图弹窗的 mermaid 源码)
|
||||||
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (统计面板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
|
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (统计面板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
|
||||||
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
||||||
- `api.ts` — shared API calls (auth, profile, tags, captcha)
|
- `api.ts` — shared API calls (auth, profile, tags, captcha)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from "pinia"
|
||||||
|
import { useCodeStore } from "oj/store/code"
|
||||||
|
import { useProblemStore } from "oj/store/problem"
|
||||||
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
|
import { SubmissionStatus } from "utils/constants"
|
||||||
|
import TrialErrorNote from "./TrialErrorNote.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「结果」页签里「自己输入」那一段:自己编一组输入跑一下,看看输出。不判对错、不算提交。
|
||||||
|
* 顶替原来顶栏「自测」模式那个单独的编辑器(EditorForTest)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const submissionStore = useSubmissionStore()
|
||||||
|
const { customInput, customOutput, customResult, customRunning } = submissionStore.trial
|
||||||
|
const { problem } = storeToRefs(useProblemStore())
|
||||||
|
const codeStore = useCodeStore()
|
||||||
|
|
||||||
|
function fillSample(input: string) {
|
||||||
|
customInput.value = input
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-flex vertical :size="10">
|
||||||
|
<n-flex align="center" justify="space-between">
|
||||||
|
<span class="label">输入</span>
|
||||||
|
<n-text depth="3" class="hint">自己编数据试试,这里不判对错、不算提交</n-text>
|
||||||
|
</n-flex>
|
||||||
|
<n-input
|
||||||
|
v-model:value="customInput"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 4, maxRows: 10 }"
|
||||||
|
placeholder="在这里写输入,和题目里的「输入」格式一样"
|
||||||
|
class="mono"
|
||||||
|
/>
|
||||||
|
<n-flex align="center" :size="8">
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
secondary
|
||||||
|
:loading="customRunning"
|
||||||
|
:disabled="!codeStore.code.value.trim()"
|
||||||
|
@click="submissionStore.runCustom()"
|
||||||
|
>
|
||||||
|
用这组数据运行
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-for="(sample, index) in problem?.samples ?? []"
|
||||||
|
:key="index"
|
||||||
|
quaternary
|
||||||
|
size="small"
|
||||||
|
@click="fillSample(sample.input)"
|
||||||
|
>
|
||||||
|
填入例子 {{ index + 1 }}
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<template v-if="customResult !== null">
|
||||||
|
<span class="label">输出</span>
|
||||||
|
<TrialErrorNote
|
||||||
|
v-if="customResult !== SubmissionStatus.accepted"
|
||||||
|
:result="customResult"
|
||||||
|
:output="customOutput"
|
||||||
|
:language="codeStore.code.language"
|
||||||
|
/>
|
||||||
|
<pre v-else-if="customOutput" class="output">{{ customOutput }}</pre>
|
||||||
|
<n-text v-else depth="3">(什么都没有输出)</n-text>
|
||||||
|
</template>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono :deep(textarea) {
|
||||||
|
font-family: Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgba(128, 128, 128, 0.08);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: Monaco, Consolas, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { storeToRefs } from "pinia"
|
|
||||||
import { useCodeStore } from "oj/store/code"
|
|
||||||
import { useProblemStore } from "oj/store/problem"
|
|
||||||
import { SOURCES } from "utils/constants"
|
|
||||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
|
||||||
import storage from "utils/storage"
|
|
||||||
import { createTestSubmission } from "utils/judge"
|
|
||||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
|
||||||
import type { DropdownOption } from "naive-ui"
|
|
||||||
import { copyToClipboard } from "utils/functions"
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
const route = useRoute()
|
|
||||||
const contestID = route.params.contestID ? route.params.contestID : null
|
|
||||||
|
|
||||||
const codeStore = useCodeStore()
|
|
||||||
const problemStore = useProblemStore()
|
|
||||||
const { input, output } = storeToRefs(codeStore)
|
|
||||||
const { problem } = storeToRefs(problemStore)
|
|
||||||
|
|
||||||
const storageKey = computed(
|
|
||||||
() => `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (storage.get(storageKey.value)) {
|
|
||||||
codeStore.setCode(storage.get(storageKey.value))
|
|
||||||
} else {
|
|
||||||
codeStore.setCode(
|
|
||||||
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function changeCode(v: string) {
|
|
||||||
storage.set(storageKey.value, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeLanguage(v: string) {
|
|
||||||
if (storage.get(storageKey.value) && storageKey.value.split("_").pop() === v) {
|
|
||||||
codeStore.setCode(storage.get(storageKey.value))
|
|
||||||
} else {
|
|
||||||
codeStore.setCode(
|
|
||||||
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const copy = async () => {
|
|
||||||
const success = await copyToClipboard(codeStore.code.value)
|
|
||||||
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
codeStore.setCode(
|
|
||||||
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
|
||||||
)
|
|
||||||
storage.remove(storageKey.value)
|
|
||||||
message.success("代码重置成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
const runCode = async () => {
|
|
||||||
const res = await createTestSubmission(codeStore.code, input.value)
|
|
||||||
output.value = res.output
|
|
||||||
}
|
|
||||||
|
|
||||||
const languageOptions: DropdownOption[] = problem.value!.languages.map((it) => ({
|
|
||||||
label: () => LANGUAGE_SHOW_VALUE[it],
|
|
||||||
value: it,
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<n-flex vertical style="height: calc(100vh - 92px)">
|
|
||||||
<n-split direction="horizontal" :min="1 / 3" :max="4 / 5">
|
|
||||||
<template #1>
|
|
||||||
<n-flex vertical>
|
|
||||||
<n-flex align="center">
|
|
||||||
<n-select
|
|
||||||
v-model:value="codeStore.code.language"
|
|
||||||
style="width: 120px"
|
|
||||||
:options="languageOptions"
|
|
||||||
@update:value="changeLanguage"
|
|
||||||
/>
|
|
||||||
<n-button @click="copy">复制代码</n-button>
|
|
||||||
<n-button @click="reset">重置代码</n-button>
|
|
||||||
<n-button type="primary" secondary @click="runCode"> 运行代码 </n-button>
|
|
||||||
</n-flex>
|
|
||||||
<CodeEditor
|
|
||||||
v-model:value="codeStore.code.value"
|
|
||||||
@update:model-value="changeCode"
|
|
||||||
:language="codeStore.code.language"
|
|
||||||
/>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<template #2>
|
|
||||||
<n-split direction="vertical" :default-size="1 / 3" :min="1 / 5" :max="3 / 5">
|
|
||||||
<template #1>
|
|
||||||
<div class="title">输入框</div>
|
|
||||||
<n-input
|
|
||||||
v-model:value="input"
|
|
||||||
type="textarea"
|
|
||||||
:bordered="false"
|
|
||||||
:resizable="false"
|
|
||||||
class="box"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #2>
|
|
||||||
<div class="title">输出框</div>
|
|
||||||
<n-input
|
|
||||||
class="box output"
|
|
||||||
v-model:value="output"
|
|
||||||
placeholder=""
|
|
||||||
type="textarea"
|
|
||||||
:bordered="false"
|
|
||||||
:resizable="false"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</n-split>
|
|
||||||
</template>
|
|
||||||
</n-split>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.title {
|
|
||||||
height: 40px;
|
|
||||||
line-height: 40px;
|
|
||||||
padding-left: 20px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.box {
|
|
||||||
padding-left: 10px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: calc(100% - 40px);
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.output {
|
|
||||||
font-family: "Monaco";
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -4,6 +4,7 @@ import { copyToClipboard, compressToBase64 } from "utils/functions"
|
|||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
import {
|
import {
|
||||||
ICON_SET,
|
ICON_SET,
|
||||||
LANGUAGE_FORMAT_VALUE,
|
LANGUAGE_FORMAT_VALUE,
|
||||||
@@ -48,6 +49,20 @@ const { isDesktop } = useBreakpoints()
|
|||||||
|
|
||||||
const statisticPanel = ref(false)
|
const statisticPanel = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「运行例子」:用题目里的例子试跑(Judge0),结果在左栏「结果」页签。顶替原来题面里
|
||||||
|
* 每个例子旁的「测试」按钮 —— 那个在左栏、离编辑器远,只给通过 / 不通过、2 秒后复位。
|
||||||
|
* Judge0 跑不了 SQL,流程图也没得跑;没有例子的题不给。
|
||||||
|
*/
|
||||||
|
const submissionStore = useSubmissionStore()
|
||||||
|
const { samplesRunning } = submissionStore.trial
|
||||||
|
const canRunSamples = computed(
|
||||||
|
() =>
|
||||||
|
!!problem.value?.samples.length &&
|
||||||
|
codeStore.code.language !== "Flowchart" &&
|
||||||
|
codeStore.code.language !== "SQL",
|
||||||
|
)
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const isContestMode = computed(() => route.name === "contest problem")
|
const isContestMode = computed(() => route.name === "contest problem")
|
||||||
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
|
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
|
||||||
@@ -265,6 +280,16 @@ const goEdit = () => {
|
|||||||
@update:value="changeLanguage"
|
@update:value="changeLanguage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<n-button
|
||||||
|
v-if="canRunSamples"
|
||||||
|
:size="buttonSize"
|
||||||
|
:loading="samplesRunning"
|
||||||
|
:disabled="!codeStore.code.value.trim()"
|
||||||
|
@click="submissionStore.runSamples()"
|
||||||
|
>
|
||||||
|
运行例子
|
||||||
|
</n-button>
|
||||||
|
|
||||||
<SubmitFlowchart v-if="codeStore.code.language === 'Flowchart'" />
|
<SubmitFlowchart v-if="codeStore.code.language === 'Flowchart'" />
|
||||||
|
|
||||||
<SubmitCode v-else />
|
<SubmitCode v-else />
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import { useCodeStore } from "oj/store/code"
|
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { DIFFICULTY } from "utils/constants"
|
import { DIFFICULTY } from "utils/constants"
|
||||||
import { getTagColor } from "utils/functions"
|
import { getTagColor } from "utils/functions"
|
||||||
import { createTestSubmission } from "utils/judge"
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
import type { ProblemDetail, ProblemRow, ProblemStatus } from "utils/types"
|
import type { ProblemRow } from "utils/types"
|
||||||
import Copy from "shared/components/Copy.vue"
|
import Copy from "shared/components/Copy.vue"
|
||||||
import { useDark } from "@vueuse/core"
|
import { useDark } from "@vueuse/core"
|
||||||
import { MdPreview } from "md-editor-v3"
|
import { MdPreview } from "md-editor-v3"
|
||||||
@@ -14,16 +13,8 @@ import "md-editor-v3/lib/preview.css"
|
|||||||
import { getSimilarProblems } from "oj/api"
|
import { getSimilarProblems } from "oj/api"
|
||||||
import SQLDataTable from "./SQLDataTable.vue"
|
import SQLDataTable from "./SQLDataTable.vue"
|
||||||
|
|
||||||
type Sample = ProblemDetail["samples"][number] & {
|
|
||||||
id: number
|
|
||||||
msg: string
|
|
||||||
status: ProblemStatus
|
|
||||||
loading: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const codeStore = useCodeStore()
|
|
||||||
const problemStore = useProblemStore()
|
const problemStore = useProblemStore()
|
||||||
const { problem } = storeToRefs(problemStore)
|
const { problem } = storeToRefs(problemStore)
|
||||||
|
|
||||||
@@ -88,26 +79,10 @@ const hasTriedButNotPassed = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function freshSamples(): Sample[] {
|
// 例子只是摆出来。原来每个例子旁有个「测试」按钮(结果只给通过 / 不通过、2 秒后复位),
|
||||||
return (problem.value?.samples ?? []).map((sample, index) => ({
|
// 现在试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
|
||||||
...sample,
|
const samples = computed(() => problem.value?.samples ?? [])
|
||||||
id: index,
|
const submissionStore = useSubmissionStore()
|
||||||
msg: "",
|
|
||||||
status: "not_test",
|
|
||||||
loading: false,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const samples = ref<Sample[]>(freshSamples())
|
|
||||||
|
|
||||||
// 题目页换题是同一个组件复用(顶栏题号直达、「下一题」、相似题都是只换路由参数),
|
|
||||||
// 不跟着重建的话,新题下面摆的还是上一道题的例子,「测试」也拿旧例子去比
|
|
||||||
watch(
|
|
||||||
() => problem.value?._id,
|
|
||||||
() => {
|
|
||||||
samples.value = freshSamples()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
|
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
|
||||||
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
|
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
|
||||||
@@ -119,66 +94,6 @@ const KIND_TAG_TYPE = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {}))
|
const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {}))
|
||||||
|
|
||||||
async function test(sample: Sample, index: number) {
|
|
||||||
samples.value = samples.value.map((sample) => {
|
|
||||||
if (sample.id === index) {
|
|
||||||
sample.loading = true
|
|
||||||
}
|
|
||||||
return sample
|
|
||||||
})
|
|
||||||
const problemId = problem.value?._id
|
|
||||||
const res = await createTestSubmission(codeStore.code, sample.input)
|
|
||||||
// 跑的这一会儿换了题:结果是上一道题的例子的,按下标写进去就串到新题上了
|
|
||||||
if (problem.value?._id !== problemId) return
|
|
||||||
samples.value = samples.value.map((sample) => {
|
|
||||||
if (sample.id === index) {
|
|
||||||
const status = res.status === 3 && res.output.trim() === sample.output ? "passed" : "failed"
|
|
||||||
return {
|
|
||||||
...sample,
|
|
||||||
msg: res.output,
|
|
||||||
status: status,
|
|
||||||
loading: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return sample
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const id = setTimeout(() => {
|
|
||||||
clearTimeout(id)
|
|
||||||
if (problem.value?._id !== problemId) return
|
|
||||||
samples.value = samples.value.map((sample) => {
|
|
||||||
if (sample.id === index) {
|
|
||||||
return {
|
|
||||||
...sample,
|
|
||||||
msg: res.output,
|
|
||||||
status: "not_test",
|
|
||||||
loading: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return sample
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function label(status: ProblemStatus, loading: boolean) {
|
|
||||||
if (loading) return "测试中"
|
|
||||||
return {
|
|
||||||
not_test: "测试",
|
|
||||||
failed: "不通过",
|
|
||||||
passed: "通过",
|
|
||||||
}[status]
|
|
||||||
}
|
|
||||||
|
|
||||||
function type(status: ProblemStatus) {
|
|
||||||
return {
|
|
||||||
not_test: "",
|
|
||||||
failed: "error",
|
|
||||||
passed: "success",
|
|
||||||
}[status] as "warning" | "error" | "success"
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -280,14 +195,15 @@ function type(status: ProblemStatus) {
|
|||||||
<Icon icon="streamline-emojis:microscope"></Icon>
|
<Icon icon="streamline-emojis:microscope"></Icon>
|
||||||
例子 {{ index + 1 }}
|
例子 {{ index + 1 }}
|
||||||
</h3>
|
</h3>
|
||||||
|
<!-- 只在第一个例子旁放一个入口:每个都放就成了一排一样的链接 -->
|
||||||
<n-button
|
<n-button
|
||||||
|
v-if="index === 0"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
secondary
|
@click="submissionStore.revealResult('custom')"
|
||||||
:type="type(sample.status) || 'default'"
|
|
||||||
:loading="sample.loading"
|
|
||||||
@click="test(sample, index)"
|
|
||||||
>
|
>
|
||||||
{{ label(sample.status, sample.loading) }}
|
自己输入数据试试 ›
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<div class="sample-grid">
|
<div class="sample-grid">
|
||||||
@@ -306,10 +222,6 @@ function type(status: ProblemStatus) {
|
|||||||
<pre class="testcase">{{ sample.output }}</pre>
|
<pre class="testcase">{{ sample.output }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="sample.msg" class="sample-box sample-result">
|
|
||||||
<div class="sample-label">运行结果</div>
|
|
||||||
<pre class="testcase">{{ sample.msg }}</pre>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -521,10 +433,6 @@ function type(status: ProblemStatus) {
|
|||||||
padding: 6px 10px 10px;
|
padding: 6px 10px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sample-result {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sample-label {
|
.sample-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
@@ -1,24 +1,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useSubmissionStore } from "oj/store/submission"
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 左栏「结果」页签的内容:提交前语法检查没过的说明、这一次提交的判题结果,或者空状态。
|
* 左栏「结果」页签:三段 —— 提交结果 / 运行例子 / 自己输入。
|
||||||
*
|
*
|
||||||
* 原来是提交按钮下面的一个浮层(n-popover),点一下外面就收起、最高 600px、盖住编辑器,
|
* 原来提交结果是提交按钮下面的一个浮层(n-popover),点一下外面就收起、最高 600px、
|
||||||
* 学生没法一边看「正确输出 / 你的输出」一边改代码。放进左栏页签之后编辑器整列都在,
|
* 盖住编辑器;运行例子是题面里每个例子旁的「测试」按钮,只给通过 / 不通过、2 秒后复位;
|
||||||
* 改代码时结果就摆在旁边(见 docs/specs/2026-09-28-problem-page-redesign-design.md)。
|
* 自己输入是顶栏「自测」模式里单独的一个编辑器。现在三样都在这一个页签里,
|
||||||
|
* 编辑器整列都在,改代码时结果就摆在旁边(见 docs/specs/2026-09-28-problem-page-redesign-design.md)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 第一次切到「结果」时才加载:它带着 DataTable 和 Markdown 渲染,只看题不提交的人不用付
|
// 第一次切到「结果」时才加载:它带着 DataTable 和 Markdown 渲染,只看题不提交的人不用付
|
||||||
const SubmissionResult = defineAsyncComponent(() => import("./SubmissionResult.vue"))
|
const SubmissionResult = defineAsyncComponent(() => import("./SubmissionResult.vue"))
|
||||||
const PythonErrorExplain = defineAsyncComponent(() => import("./PythonErrorExplain.vue"))
|
const PythonErrorExplain = defineAsyncComponent(() => import("./PythonErrorExplain.vue"))
|
||||||
|
const SampleRunResult = defineAsyncComponent(() => import("./SampleRunResult.vue"))
|
||||||
|
const CustomRun = defineAsyncComponent(() => import("./CustomRun.vue"))
|
||||||
|
|
||||||
const { submission, syntaxErrorInfo } = storeToRefs(useSubmissionStore())
|
const submissionStore = useSubmissionStore()
|
||||||
|
const { submission, syntaxErrorInfo, resultSegment } = storeToRefs(submissionStore)
|
||||||
|
const { problem } = storeToRefs(useProblemStore())
|
||||||
|
|
||||||
|
// 试跑走 Judge0,它跑不了 SQL;流程图也没得跑。这两种只留「提交结果」
|
||||||
|
const canTrial = computed(() => {
|
||||||
|
const languages = problem.value?.languages ?? []
|
||||||
|
return !languages.includes("SQL")
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="result-pane">
|
<div class="result-pane">
|
||||||
|
<n-radio-group v-if="canTrial" v-model:value="resultSegment" size="small" class="segments">
|
||||||
|
<n-radio-button value="submit">提交结果</n-radio-button>
|
||||||
|
<n-radio-button value="samples">运行例子</n-radio-button>
|
||||||
|
<n-radio-button value="custom">自己输入</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
|
||||||
|
<!-- 提交结果用 v-show:切去看运行例子时不卸载,挂着的错误说明在编辑器里标着红 -->
|
||||||
|
<div v-show="resultSegment === 'submit' || !canTrial">
|
||||||
<n-flex v-if="syntaxErrorInfo" vertical>
|
<n-flex v-if="syntaxErrorInfo" vertical>
|
||||||
<n-alert type="warning" title="代码有语法错误,还没有提交" />
|
<n-alert type="warning" title="代码有语法错误,还没有提交" />
|
||||||
<PythonErrorExplain :err-info="syntaxErrorInfo" />
|
<PythonErrorExplain :err-info="syntaxErrorInfo" />
|
||||||
@@ -30,6 +50,11 @@ const { submission, syntaxErrorInfo } = storeToRefs(useSubmissionStore())
|
|||||||
description="还没有提交过。写完代码按「提交代码」,结果会出现在这里"
|
description="还没有提交过。写完代码按「提交代码」,结果会出现在这里"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="canTrial">
|
||||||
|
<SampleRunResult v-if="resultSegment === 'samples'" />
|
||||||
|
<CustomRun v-else-if="resultSegment === 'custom'" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -37,6 +62,10 @@ const { submission, syntaxErrorInfo } = storeToRefs(useSubmissionStore())
|
|||||||
padding: 4px 0 16px;
|
padding: 4px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.segments {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
margin-top: 48px;
|
margin-top: 48px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
|
import { SubmissionStatus } from "utils/constants"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
|
import TrialErrorNote from "./TrialErrorNote.vue"
|
||||||
|
import WrongAnswerExplain from "./WrongAnswerExplain.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「结果」页签里「运行例子」那一段:每个例子对没对上,点一个看细节。
|
||||||
|
* 没对上的说明复用判完之后那一套(WrongAnswerExplain);比赛里只摆输出、不给提示句。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sampleRuns, samplesRunning, samplesRunAt, samplesCode } = useSubmissionStore().trial
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const route = useRoute()
|
||||||
|
const inContest = computed(() => !!route.params.contestID)
|
||||||
|
|
||||||
|
const failedCount = computed(
|
||||||
|
() => sampleRuns.value.filter((run) => run.result !== SubmissionStatus.accepted).length,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 看的是哪一个例子:默认第一个没对上的 */
|
||||||
|
const selected = ref(0)
|
||||||
|
watch(sampleRuns, (runs) => {
|
||||||
|
const firstFailed = runs.findIndex((run) => run.result !== SubmissionStatus.accepted)
|
||||||
|
selected.value = firstFailed >= 0 ? firstFailed : 0
|
||||||
|
})
|
||||||
|
const current = computed(() => sampleRuns.value[selected.value] ?? null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-flex v-if="samplesRunning" align="center" class="state">
|
||||||
|
<n-spin size="small" />
|
||||||
|
<span>正在用例子试跑……</span>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-empty
|
||||||
|
v-else-if="!sampleRuns.length"
|
||||||
|
class="state"
|
||||||
|
description="还没运行过。点工具栏的「运行例子」,用题目里的例子试跑一遍,不算提交"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<n-flex v-else vertical :size="12">
|
||||||
|
<n-flex align="center" :size="10">
|
||||||
|
<n-icon :size="22" :color="failedCount ? theme.errorColor : theme.successColor">
|
||||||
|
<Icon :icon="failedCount ? 'ph:x-circle-fill' : 'ph:check-circle-fill'" />
|
||||||
|
</n-icon>
|
||||||
|
<span class="title" :style="{ color: failedCount ? theme.errorColor : theme.successColor }">
|
||||||
|
{{ failedCount ? `试跑:${failedCount} 个例子没对上` : "试跑:例子都对上了" }}
|
||||||
|
</span>
|
||||||
|
<n-text depth="3"
|
||||||
|
>(不算提交{{ samplesRunAt ? ` · ${parseTime(samplesRunAt, "HH:mm")}` : "" }})</n-text
|
||||||
|
>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-flex :size="6">
|
||||||
|
<n-button
|
||||||
|
v-for="run in sampleRuns"
|
||||||
|
:key="run.index"
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
:type="run.result === SubmissionStatus.accepted ? 'success' : 'error'"
|
||||||
|
:secondary="selected !== run.index"
|
||||||
|
@click="selected = run.index"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon :icon="run.result === SubmissionStatus.accepted ? 'ph:check-bold' : 'ph:x-bold'" />
|
||||||
|
</template>
|
||||||
|
例子 {{ run.index + 1 }}
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<template v-if="current">
|
||||||
|
<!-- 对上了:三栏摆出来就行 -->
|
||||||
|
<n-card v-if="current.result === SubmissionStatus.accepted" embedded size="small">
|
||||||
|
<div class="blocks">
|
||||||
|
<div class="block">
|
||||||
|
<div class="label">输入</div>
|
||||||
|
<pre>{{ current.input }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="block">
|
||||||
|
<div class="label">正确输出</div>
|
||||||
|
<pre>{{ current.expected }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="block">
|
||||||
|
<div class="label">你的输出</div>
|
||||||
|
<pre>{{ current.output }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
<!-- 跑完了、输出对不上:和判完之后同一套说明 -->
|
||||||
|
<WrongAnswerExplain
|
||||||
|
v-else-if="current.result === SubmissionStatus.wrong_answer"
|
||||||
|
:check="{
|
||||||
|
passed: false,
|
||||||
|
index: current.index,
|
||||||
|
input: current.input,
|
||||||
|
expected: current.expected,
|
||||||
|
output: current.output,
|
||||||
|
result: SubmissionStatus.wrong_answer,
|
||||||
|
}"
|
||||||
|
:code="samplesCode?.value ?? ''"
|
||||||
|
:language="samplesCode?.language ?? ''"
|
||||||
|
:plain="inContest"
|
||||||
|
/>
|
||||||
|
<!-- 没跑完:编译不过、运行出错、超时 -->
|
||||||
|
<TrialErrorNote
|
||||||
|
v-else
|
||||||
|
:result="current.result"
|
||||||
|
:output="current.output"
|
||||||
|
:language="samplesCode?.language ?? ''"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-text depth="3" class="note">
|
||||||
|
例子都对上了再按「提交代码」。例子对了也可能在别的测试点上错,提交才算数。
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.state {
|
||||||
|
margin-top: 32px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocks {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: Monaco, Consolas, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SubmissionStatus } from "utils/constants"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 试跑(Judge0)没跑完时给学生看的中文说明。Judge0 回来的是英文原文(Traceback、
|
||||||
|
* gcc 的报错),学生读不懂,原文收进折叠区。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
result: SubmissionStatus
|
||||||
|
output: string
|
||||||
|
language: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const message = computed(() => {
|
||||||
|
switch (props.result) {
|
||||||
|
case SubmissionStatus.compile_error:
|
||||||
|
return props.language === "Python"
|
||||||
|
? "代码没能跑起来,写法有错误。按「提交代码」会先检查一遍,告诉你错在哪一行。"
|
||||||
|
: "代码没能编译通过,写法有错误。看看是不是漏了分号、括号没配对、变量没声明。"
|
||||||
|
case SubmissionStatus.runtime_error:
|
||||||
|
return "程序运行到一半出错,停下来了。检查一下输入是怎么读的、下标有没有越界、除数是不是 0。"
|
||||||
|
case SubmissionStatus.real_time_limit_exceeded:
|
||||||
|
return "运行超时了:程序跑了太久还没结束。看看是不是有死循环,或者在等一个例子里没有的输入。"
|
||||||
|
default:
|
||||||
|
return "试跑的服务暂时连不上,稍后再试一次。"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-flex vertical :size="8">
|
||||||
|
<n-alert type="error" :show-icon="false">{{ message }}</n-alert>
|
||||||
|
<n-collapse v-if="output">
|
||||||
|
<n-collapse-item title="原始输出(英文)" name="raw">
|
||||||
|
<pre class="raw">{{ output }}</pre>
|
||||||
|
</n-collapse-item>
|
||||||
|
</n-collapse>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.raw {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: Monaco, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,6 +15,12 @@ const props = defineProps<{
|
|||||||
check: SampleCheck
|
check: SampleCheck
|
||||||
code: string
|
code: string
|
||||||
language: string
|
language: string
|
||||||
|
/**
|
||||||
|
* 只摆输入、正确输出、你的输出,不给中文提示句。比赛里的「运行例子」用:后端在比赛里
|
||||||
|
* 故意不做例子对比(judge/run.ts:比赛里有期末考试,格式提示在那里不该给),
|
||||||
|
* 前端试跑也不能绕过去
|
||||||
|
*/
|
||||||
|
plain?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/** 后端每段截到 2000 字,到了上限就是被截过 */
|
/** 后端每段截到 2000 字,到了上限就是被截过 */
|
||||||
@@ -61,7 +67,8 @@ function lines(text: string) {
|
|||||||
</div>
|
</div>
|
||||||
<n-flex v-else vertical :size="12">
|
<n-flex v-else vertical :size="12">
|
||||||
<div class="explain">
|
<div class="explain">
|
||||||
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过:</b>{{ hint }}
|
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? "。" : ":" }}</b
|
||||||
|
><template v-if="!plain">{{ hint }}</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="blocks">
|
<div class="blocks">
|
||||||
<div class="block input">
|
<div class="block input">
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { createTestSubmission } from "utils/judge"
|
||||||
|
import { SubmissionStatus } from "utils/constants"
|
||||||
|
import type { Code } from "utils/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 试跑:「运行例子」和「自己输入」。走 Judge0(utils/judge.ts),不经过判题机、不算提交。
|
||||||
|
*
|
||||||
|
* 编译器版本和判题机不一样(Judge0 的 C 是 GCC 9,判题机是 gcc-14 加宽松参数),
|
||||||
|
* 所以这里的结果偶尔会和提交不一致 —— 这是拍板过的取舍(设计文档第 3 节决定 3)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Judge0 的状态码(见 Judge0 的 /statuses)。同步调用(wait=true)只会拿到终态 */
|
||||||
|
const JUDGE0_ACCEPTED = 3
|
||||||
|
const JUDGE0_TIME_LIMIT = 5
|
||||||
|
const JUDGE0_COMPILE_ERROR = 6
|
||||||
|
const isJudge0RuntimeError = (status: number) => status >= 7 && status <= 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 Judge0 的状态换成判题机那套码,好直接交给 WrongAnswerExplain 和 JUDGE_STATUS。
|
||||||
|
* 跑完、只是输出对不上的,按答案错误算。
|
||||||
|
*/
|
||||||
|
export function trialStatus(status: number | null, passed: boolean) {
|
||||||
|
if (status === JUDGE0_ACCEPTED) {
|
||||||
|
return passed ? SubmissionStatus.accepted : SubmissionStatus.wrong_answer
|
||||||
|
}
|
||||||
|
if (status === JUDGE0_TIME_LIMIT) return SubmissionStatus.real_time_limit_exceeded
|
||||||
|
if (status === JUDGE0_COMPILE_ERROR) return SubmissionStatus.compile_error
|
||||||
|
if (status !== null && isJudge0RuntimeError(status)) return SubmissionStatus.runtime_error
|
||||||
|
return SubmissionStatus.system_error
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判题机比的是去掉末尾空白之后的输出(JudgeServer 的 stripped_output_md5),
|
||||||
|
* 这里两边都去。原来题目页的「测试」按钮只 trim 了运行输出,样例输出末尾带个
|
||||||
|
* 空格或换行,就永远显示「不通过」。
|
||||||
|
*/
|
||||||
|
const sameOutput = (output: string, expected: string) => output.trimEnd() === expected.trimEnd()
|
||||||
|
|
||||||
|
export interface SampleRun {
|
||||||
|
/** 第几个例子,0 起 */
|
||||||
|
index: number
|
||||||
|
input: string
|
||||||
|
expected: string
|
||||||
|
output: string
|
||||||
|
/** 换算成判题机那套码之后的结果,见 trialStatus */
|
||||||
|
result: SubmissionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrialRun() {
|
||||||
|
const sampleRuns = ref<SampleRun[]>([])
|
||||||
|
const samplesRunning = ref(false)
|
||||||
|
/** 最近一次运行例子是什么时候(ISO),显示在结果页签里 */
|
||||||
|
const samplesRunAt = ref<string | null>(null)
|
||||||
|
/** 跑例子时用的那份代码:错因说明要看代码(比如有没有 input("提示文字")),编辑器里可能已经改了 */
|
||||||
|
const samplesCode = ref<Code | null>(null)
|
||||||
|
|
||||||
|
const customInput = ref("")
|
||||||
|
const customOutput = ref("")
|
||||||
|
const customResult = ref<SubmissionStatus | null>(null)
|
||||||
|
const customRunning = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每次开跑 +1。跑的这一会儿换了题、或者又点了一次,旧的那批回来就扔掉。
|
||||||
|
* 两边各记各的:一边在跑的时候点另一边,不能把这边的结果也扔了、转圈转个没完
|
||||||
|
*/
|
||||||
|
let samplesGeneration = 0
|
||||||
|
let customGeneration = 0
|
||||||
|
|
||||||
|
async function runOne(code: Code, input: string) {
|
||||||
|
try {
|
||||||
|
return await createTestSubmission(code, input)
|
||||||
|
} catch {
|
||||||
|
return { status: null, output: "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSamples(code: Code, samples: { input: string; output: string }[]) {
|
||||||
|
const mine = ++samplesGeneration
|
||||||
|
samplesRunning.value = true
|
||||||
|
// 例子一般两三个,一起发
|
||||||
|
const outputs = await Promise.all(samples.map((sample) => runOne(code, sample.input)))
|
||||||
|
if (mine !== samplesGeneration) return
|
||||||
|
sampleRuns.value = samples.map((sample, index) => {
|
||||||
|
const run = outputs[index]!
|
||||||
|
const passed = run.status === JUDGE0_ACCEPTED && sameOutput(run.output, sample.output)
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
input: sample.input,
|
||||||
|
expected: sample.output,
|
||||||
|
output: run.output,
|
||||||
|
result: trialStatus(run.status, passed),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
samplesRunAt.value = new Date().toISOString()
|
||||||
|
samplesCode.value = code
|
||||||
|
samplesRunning.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCustom(code: Code) {
|
||||||
|
const mine = ++customGeneration
|
||||||
|
customRunning.value = true
|
||||||
|
const run = await runOne(code, customInput.value)
|
||||||
|
if (mine !== customGeneration) return
|
||||||
|
customOutput.value = run.output
|
||||||
|
// 自己输入的没有标准答案,跑完就算「对」,只看有没有出错
|
||||||
|
customResult.value = trialStatus(run.status, true)
|
||||||
|
customRunning.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 换题:两边都清掉。输入框也清 —— 那是给上一道题编的数据 */
|
||||||
|
function resetTrial() {
|
||||||
|
samplesGeneration++
|
||||||
|
customGeneration++
|
||||||
|
sampleRuns.value = []
|
||||||
|
samplesRunning.value = false
|
||||||
|
samplesRunAt.value = null
|
||||||
|
samplesCode.value = null
|
||||||
|
customInput.value = ""
|
||||||
|
customOutput.value = ""
|
||||||
|
customResult.value = null
|
||||||
|
customRunning.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sampleRuns,
|
||||||
|
samplesRunning,
|
||||||
|
samplesRunAt,
|
||||||
|
samplesCode,
|
||||||
|
customInput,
|
||||||
|
customOutput,
|
||||||
|
customResult,
|
||||||
|
customRunning,
|
||||||
|
runSamples,
|
||||||
|
runCustom,
|
||||||
|
resetTrial,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { getProblem } from "oj/api"
|
|||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useScreenModeStore } from "shared/store/screenMode"
|
|
||||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { useSubmissionStore } from "oj/store/submission"
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
@@ -19,7 +18,6 @@ import ResultTabLabel from "./components/ResultTabLabel.vue"
|
|||||||
// 点了没反应(见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 10 节)
|
// 点了没反应(见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 10 节)
|
||||||
const loadProblemEditor = () => import("./components/ProblemEditor.vue")
|
const loadProblemEditor = () => import("./components/ProblemEditor.vue")
|
||||||
const ProblemEditor = defineAsyncComponent(loadProblemEditor)
|
const ProblemEditor = defineAsyncComponent(loadProblemEditor)
|
||||||
const EditorForTest = defineAsyncComponent(() => import("./components/EditorForTest.vue"))
|
|
||||||
const ProblemContent = defineAsyncComponent(() => import("./components/ProblemContent.vue"))
|
const ProblemContent = defineAsyncComponent(() => import("./components/ProblemContent.vue"))
|
||||||
const ProblemInfo = defineAsyncComponent(() => import("./components/ProblemInfo.vue"))
|
const ProblemInfo = defineAsyncComponent(() => import("./components/ProblemInfo.vue"))
|
||||||
const ProblemSubmission = defineAsyncComponent(() => import("./components/ProblemSubmission.vue"))
|
const ProblemSubmission = defineAsyncComponent(() => import("./components/ProblemSubmission.vue"))
|
||||||
@@ -41,10 +39,8 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const problemStore = useProblemStore()
|
const problemStore = useProblemStore()
|
||||||
const screenModeStore = useScreenModeStore()
|
|
||||||
const myFlowchartStore = useMyFlowchartStore()
|
const myFlowchartStore = useMyFlowchartStore()
|
||||||
const { problem } = storeToRefs(problemStore)
|
const { problem } = storeToRefs(problemStore)
|
||||||
const { shouldShowProblem } = storeToRefs(screenModeStore)
|
|
||||||
|
|
||||||
const { isMobile, isDesktop } = useBreakpoints()
|
const { isMobile, isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
@@ -117,7 +113,6 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
screenModeStore.resetScreenMode()
|
|
||||||
// 「我的流程图」是上一道题的。这道题也画到了 A/S 的话,SubmitFlowchart 查完会再亮出来
|
// 「我的流程图」是上一道题的。这道题也画到了 A/S 的话,SubmitFlowchart 查完会再亮出来
|
||||||
myFlowchartStore.hide()
|
myFlowchartStore.hide()
|
||||||
// 并行预取右侧编辑器 chunk(CodeMirror ~370K+),
|
// 并行预取右侧编辑器 chunk(CodeMirror ~370K+),
|
||||||
@@ -138,7 +133,7 @@ watch(() => problemID, init)
|
|||||||
|
|
||||||
// 题目详情里的 myStatus / myFailedCount 是按当前用户算的,而登录不重新挂载这个页面 ——
|
// 题目详情里的 myStatus / myFailedCount 是按当前用户算的,而登录不重新挂载这个页面 ——
|
||||||
// 会话过期后直接在题目页登录的(机房里最常见的那条路)不补拉一次,AI 提示的解锁进度
|
// 会话过期后直接在题目页登录的(机房里最常见的那条路)不补拉一次,AI 提示的解锁进度
|
||||||
// 就还是匿名时的 0,等于白改。只换 problem,不走 init:那里还会重置分栏模式。
|
// 就还是匿名时的 0,等于白改。只换 problem,不走 init:那里会把整页重来一遍。
|
||||||
watch(
|
watch(
|
||||||
() => useUserStore().isAuthed,
|
() => useUserStore().isAuthed,
|
||||||
async (authed) => {
|
async (authed) => {
|
||||||
@@ -158,30 +153,15 @@ onBeforeUnmount(() => {
|
|||||||
useFlowchartStore().reset()
|
useFlowchartStore().reset()
|
||||||
problem.value = null
|
problem.value = null
|
||||||
errMsg.value = "无数据"
|
errMsg.value = "无数据"
|
||||||
screenModeStore.resetScreenMode()
|
|
||||||
myFlowchartStore.hide()
|
myFlowchartStore.hide()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(isMobile, (value) => {
|
|
||||||
if (value) screenModeStore.resetScreenMode()
|
|
||||||
})
|
|
||||||
|
|
||||||
// SQL 题不支持"自测"模式(外部代码运行器无法执行 SQL),切到该屏时自动跳到下一模式
|
|
||||||
watch(
|
|
||||||
() => screenModeStore.isCodeOnlyMode,
|
|
||||||
(codeOnly) => {
|
|
||||||
if (codeOnly && problem.value?.languages.includes("SQL")) {
|
|
||||||
screenModeStore.switchScreenMode()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="problem">
|
<template v-if="problem">
|
||||||
<SubmissionEffects />
|
<SubmissionEffects />
|
||||||
<n-split
|
<n-split
|
||||||
v-if="isDesktop && screenModeStore.isBothMode"
|
v-if="isDesktop"
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
:default-size="0.43"
|
:default-size="0.43"
|
||||||
:min="0.2"
|
:min="0.2"
|
||||||
@@ -221,40 +201,6 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</n-split>
|
</n-split>
|
||||||
|
|
||||||
<!-- Desktop: code only mode -->
|
|
||||||
<template v-else-if="isDesktop && screenModeStore.isCodeOnlyMode">
|
|
||||||
<EditorForTest />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Desktop: problem only mode -->
|
|
||||||
<template v-else-if="isDesktop && shouldShowProblem">
|
|
||||||
<n-scrollbar style="max-height: calc(100vh - 92px)">
|
|
||||||
<n-tabs v-model:value="currentTab" type="segment">
|
|
||||||
<n-tab-pane name="content" tab="题目">
|
|
||||||
<ProblemContent />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="result" :tab="resultTab" display-directive="show:lazy">
|
|
||||||
<ResultPane />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程图表">
|
|
||||||
<ProblemFlowchart />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
|
|
||||||
<ProblemInfo />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane v-if="!contestID" name="comment" tab="题目点评" :disabled="!!problemSetId">
|
|
||||||
<ProblemReaction />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane v-if="myFlowchartStore.showing" name="my-flowchart" tab="我的流程图">
|
|
||||||
<MyFlowchartTab />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="submission" tab="我的提交" :disabled="!!problemSetId">
|
|
||||||
<ProblemSubmission />
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</n-scrollbar>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Mobile -->
|
<!-- Mobile -->
|
||||||
<n-tabs v-else v-model:value="currentTab" type="segment">
|
<n-tabs v-else v-model:value="currentTab" type="segment">
|
||||||
<n-tab-pane name="content" tab="题目">
|
<n-tab-pane name="content" tab="题目">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCodeStore } from "oj/store/code"
|
|||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useSubmissionMonitor } from "oj/problem/composables/useSubmissionMonitor"
|
import { useSubmissionMonitor } from "oj/problem/composables/useSubmissionMonitor"
|
||||||
import { useSubmissionHint } from "oj/problem/composables/useSubmissionHint"
|
import { useSubmissionHint } from "oj/problem/composables/useSubmissionHint"
|
||||||
|
import { useTrialRun } from "oj/problem/composables/useTrialRun"
|
||||||
import { restartEditTrace, snapshotEditTrace } from "oj/problem/utils/editTrace"
|
import { restartEditTrace, snapshotEditTrace } from "oj/problem/utils/editTrace"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
import { LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
import { LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
||||||
@@ -22,6 +23,8 @@ import type { SubmitCodePayload } from "utils/types"
|
|||||||
* 判完之后「该发生什么」(失败计数、标成已解决、烟花、点评、回题单)不在这里,
|
* 判完之后「该发生什么」(失败计数、标成已解决、烟花、点评、回题单)不在这里,
|
||||||
* 在页面这一层的 SubmissionEffects —— 它们要路由守卫和弹窗,得挂在一直在的组件上。
|
* 在页面这一层的 SubmissionEffects —— 它们要路由守卫和弹窗,得挂在一直在的组件上。
|
||||||
*/
|
*/
|
||||||
|
export type ResultSegment = "submit" | "samples" | "custom"
|
||||||
|
|
||||||
export const useSubmissionStore = defineStore("submission", () => {
|
export const useSubmissionStore = defineStore("submission", () => {
|
||||||
const problemStore = useProblemStore()
|
const problemStore = useProblemStore()
|
||||||
const codeStore = useCodeStore()
|
const codeStore = useCodeStore()
|
||||||
@@ -29,15 +32,33 @@ export const useSubmissionStore = defineStore("submission", () => {
|
|||||||
|
|
||||||
const monitor = useSubmissionMonitor()
|
const monitor = useSubmissionMonitor()
|
||||||
const hint = useSubmissionHint()
|
const hint = useSubmissionHint()
|
||||||
|
const trial = useTrialRun()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 有新结果要给学生看了(交上去、或者语法检查没过)就 +1。题目页看着它把左栏切到
|
* 有新结果要给学生看了(交上去、或者语法检查没过)就 +1。题目页看着它把左栏切到
|
||||||
* 「结果」页签 —— store 不管页签,只说「该看结果了」。
|
* 「结果」页签 —— store 不管页签,只说「该看结果了」。
|
||||||
*/
|
*/
|
||||||
const resultSeq = ref(0)
|
const resultSeq = ref(0)
|
||||||
function revealResult() {
|
/** 「结果」页签里看哪一段:提交结果 / 运行例子 / 自己输入 */
|
||||||
|
const resultSegment = ref<ResultSegment>("submit")
|
||||||
|
function revealResult(segment: ResultSegment = "submit") {
|
||||||
|
resultSegment.value = segment
|
||||||
resultSeq.value++
|
resultSeq.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 用题目里的例子试跑(Judge0),不算提交 */
|
||||||
|
function runSamples() {
|
||||||
|
const problem = problemStore.problem
|
||||||
|
if (!problem?.samples.length) return
|
||||||
|
revealResult("samples")
|
||||||
|
trial.runSamples({ ...codeStore.code }, problem.samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用「自己输入」框里的数据跑一次 */
|
||||||
|
function runCustom() {
|
||||||
|
revealResult("custom")
|
||||||
|
trial.runCustom({ ...codeStore.code })
|
||||||
|
}
|
||||||
const isFormatting = ref(false)
|
const isFormatting = ref(false)
|
||||||
const isSubmittingRequest = ref(false)
|
const isSubmittingRequest = ref(false)
|
||||||
|
|
||||||
@@ -130,8 +151,10 @@ export const useSubmissionStore = defineStore("submission", () => {
|
|||||||
*/
|
*/
|
||||||
function reset() {
|
function reset() {
|
||||||
syntaxErrorInfo.value = ""
|
syntaxErrorInfo.value = ""
|
||||||
|
resultSegment.value = "submit"
|
||||||
monitor.reset()
|
monitor.reset()
|
||||||
hint.clearHint()
|
hint.clearHint()
|
||||||
|
trial.resetTrial()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 题目页换题是同一个组件复用(顶栏题号直达、「下一题」都只换路由参数)
|
// 题目页换题是同一个组件复用(顶栏题号直达、「下一题」都只换路由参数)
|
||||||
@@ -153,7 +176,10 @@ export const useSubmissionStore = defineStore("submission", () => {
|
|||||||
pending: monitor.pending,
|
pending: monitor.pending,
|
||||||
submitting: monitor.submitting,
|
submitting: monitor.submitting,
|
||||||
resultSeq,
|
resultSeq,
|
||||||
|
resultSegment,
|
||||||
revealResult,
|
revealResult,
|
||||||
|
runSamples,
|
||||||
|
runCustom,
|
||||||
isFormatting,
|
isFormatting,
|
||||||
isSubmittingRequest,
|
isSubmittingRequest,
|
||||||
syntaxErrorInfo,
|
syntaxErrorInfo,
|
||||||
@@ -162,5 +188,6 @@ export const useSubmissionStore = defineStore("submission", () => {
|
|||||||
reset,
|
reset,
|
||||||
// markRaw:不让 store 把它包成 reactive,组件解构出来的还是 ref
|
// markRaw:不让 store 把它包成 reactive,组件解构出来的还是 ref
|
||||||
hint: markRaw(hint),
|
hint: markRaw(hint),
|
||||||
|
trial: markRaw(trial),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -7,7 +7,6 @@ import { useLearnProgress } from "shared/composables/learnProgress"
|
|||||||
import { useProblemJump } from "shared/composables/problemJump"
|
import { useProblemJump } from "shared/composables/problemJump"
|
||||||
import { useAuthModalStore } from "shared/store/authModal"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
import { useScreenModeStore } from "shared/store/screenMode"
|
|
||||||
import { useConfigStore } from "../store/config"
|
import { useConfigStore } from "../store/config"
|
||||||
import { useUserStore } from "../store/user"
|
import { useUserStore } from "../store/user"
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ const userStore = useUserStore()
|
|||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const collabStore = useCollabStore()
|
const collabStore = useCollabStore()
|
||||||
const authStore = useAuthModalStore()
|
const authStore = useAuthModalStore()
|
||||||
const screenModeStore = useScreenModeStore()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -31,7 +29,6 @@ const { isDark, toggleDark } = useDarkTransition()
|
|||||||
const pendingHelpCount = computed(() => (collabStore.isTeacher ? collabStore.pendingCount : 0))
|
const pendingHelpCount = computed(() => (collabStore.isTeacher ? collabStore.pendingCount : 0))
|
||||||
|
|
||||||
// 从 store 中获取屏幕模式状态
|
// 从 store 中获取屏幕模式状态
|
||||||
const { screenMode } = storeToRefs(screenModeStore)
|
|
||||||
|
|
||||||
const names = [
|
const names = [
|
||||||
"man-with-chinese-cap-1",
|
"man-with-chinese-cap-1",
|
||||||
@@ -297,12 +294,6 @@ function goHome() {
|
|||||||
<Icon icon="ph:magnifying-glass" />
|
<Icon icon="ph:magnifying-glass" />
|
||||||
</template>
|
</template>
|
||||||
</n-input>
|
</n-input>
|
||||||
<n-button
|
|
||||||
v-if="isDesktop && (route.name === 'problem' || route.name === 'contest problem')"
|
|
||||||
@click="() => screenModeStore.switchScreenMode()"
|
|
||||||
>
|
|
||||||
{{ screenMode }}
|
|
||||||
</n-button>
|
|
||||||
<div v-if="userStore.isFinished">
|
<div v-if="userStore.isFinished">
|
||||||
<n-dropdown v-if="userStore.isAuthed" :options="options" size="large">
|
<n-dropdown v-if="userStore.isAuthed" :options="options" size="large">
|
||||||
<n-badge :value="pendingHelpCount" :max="99">
|
<n-badge :value="pendingHelpCount" :max="99">
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
import { useScreenModeStore } from "shared/store/screenMode"
|
|
||||||
|
|
||||||
/** 由顶栏的姓名下拉菜单打开 */
|
/** 由顶栏的姓名下拉菜单打开 */
|
||||||
const show = defineModel<boolean>("show", { default: false })
|
const show = defineModel<boolean>("show", { default: false })
|
||||||
|
|
||||||
const collabStore = useCollabStore()
|
const collabStore = useCollabStore()
|
||||||
const screenModeStore = useScreenModeStore()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -69,11 +67,6 @@ const handleAccept = (studentId: number, problemId: string, status: string) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
collabStore.accept(studentId)
|
collabStore.accept(studentId)
|
||||||
// 协作用的是右侧那个编辑器,而「题目」「自测」两种分屏模式下它根本没挂出来 ——
|
|
||||||
// 落进那样一个页面就是:学生显示「老师正在帮你」,老师这边什么都没有。
|
|
||||||
// 跳到另一道题时 detail.vue 的 init() 会重置分屏模式,**停在同一道题上接单
|
|
||||||
// 不会**(路由没变、组件不重建),所以这里显式重置一次。
|
|
||||||
screenModeStore.resetScreenMode()
|
|
||||||
show.value = false
|
show.value = false
|
||||||
// 已经在这道题的页面上就不跳了(老师正投影着这道题时就是这种)。
|
// 已经在这道题的页面上就不跳了(老师正投影着这道题时就是这种)。
|
||||||
// 重复导航在 vue-router 里是一个 rejected promise,不拦一下控制台会报
|
// 重复导航在 vue-router 里是一个 rejected promise,不拦一下控制台会报
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { defineStore } from "pinia"
|
|
||||||
import { ScreenMode } from "utils/constants"
|
|
||||||
|
|
||||||
export const useScreenModeStore = defineStore("screenMode", () => {
|
|
||||||
const { state: screenMode, next: switchScreenMode } = useCycleList(Object.values(ScreenMode), {
|
|
||||||
initialValue: ScreenMode.both,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const isBothMode = computed(() => screenMode.value === ScreenMode.both)
|
|
||||||
const isCodeOnlyMode = computed(() => screenMode.value === ScreenMode.code)
|
|
||||||
|
|
||||||
const shouldShowProblem = computed(
|
|
||||||
() => screenMode.value === ScreenMode.both || screenMode.value === ScreenMode.problem,
|
|
||||||
)
|
|
||||||
|
|
||||||
function resetScreenMode() {
|
|
||||||
screenMode.value = ScreenMode.both
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
screenMode,
|
|
||||||
isBothMode,
|
|
||||||
isCodeOnlyMode,
|
|
||||||
shouldShowProblem,
|
|
||||||
switchScreenMode,
|
|
||||||
resetScreenMode,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -252,12 +252,6 @@ export const CODE_TEMPLATES = {
|
|||||||
SQL: blankTemplate,
|
SQL: blankTemplate,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export enum ScreenMode {
|
|
||||||
both = "双栏",
|
|
||||||
code = "自测",
|
|
||||||
problem = "题目",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ChartType {
|
export enum ChartType {
|
||||||
Rank,
|
Rank,
|
||||||
Activity,
|
Activity,
|
||||||
|
|||||||
Reference in new issue
Block a user