后端: - getUserByToken → authenticateToken:它会删会话、续 TTL、记在线,不是纯读 - todayStart → dayStart(instant):本来就接受任意时刻,不只是「今天」 - objectValue → asRecord,删掉 4 份一模一样的私有拷贝和 `as toObject` 别名 - 报错文案 "does not exists" / "Already be a public problem" 改通顺 前端: - utils/types 里 Contest / User / Problem 三个别名删掉:Contest 原来指后台 比赛、契约里同名的却是学生侧。现在 Contest = 学生侧、AdminContest = 后台, 后台用户表单叫 AdminUserForm,题目详情直接用 ProblemDetail - SubmissionInfo → ContestSubmissionInfo,免得和 submission.info 撞名 - utoa / atou → compressToBase64 / decompressFromBase64(实际是 zlib + base64), encode / decode → base64EncodeUtf8 / base64DecodeUtf8 - filterResult → toProblemRow、toProblemListItem → toAdminProblemRow, 行字段 submission / rate → submissionCount / acRate - 组件:Modal → AddFromLibraryModal、Form → EditorToolbar、 ContestEditor → BasicEditor(题单也在用它)、rank 的 Index → RankMedal、 Name → UserNameCell、Password → PasswordCell、ContestType → ContestAccessTag Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
470 lines
13 KiB
Vue
470 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { Icon } from "@iconify/vue"
|
||
import { useThemeVars } from "naive-ui"
|
||
import { storeToRefs } from "pinia"
|
||
import { useCodeStore } from "oj/store/code"
|
||
import { useProblemStore } from "oj/store/problem"
|
||
import { createTestSubmission } from "utils/judge"
|
||
import type { ProblemDetail, ProblemRow, ProblemStatus } from "utils/types"
|
||
import Copy from "shared/components/Copy.vue"
|
||
import { useDark } from "@vueuse/core"
|
||
import { MdPreview } from "md-editor-v3"
|
||
import "md-editor-v3/lib/preview.css"
|
||
import { getSimilarProblems } from "oj/api"
|
||
import SQLDataTable from "./SQLDataTable.vue"
|
||
|
||
type Sample = ProblemDetail["samples"][number] & {
|
||
id: number
|
||
msg: string
|
||
status: ProblemStatus
|
||
loading: boolean
|
||
}
|
||
|
||
const theme = useThemeVars()
|
||
const style = computed(() => "color: " + theme.value.primaryColor)
|
||
const isDark = useDark()
|
||
const route = useRoute()
|
||
const codeStore = useCodeStore()
|
||
const problemStore = useProblemStore()
|
||
const { problem } = storeToRefs(problemStore)
|
||
|
||
const problemSetId = computed(() => route.params.problemSetId)
|
||
|
||
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
|
||
const isSQL = computed(() => !!problem.value?.sqlConfig)
|
||
const sqlDisplay = computed(() => problem.value?.sqlDisplay ?? null)
|
||
const sqlExpectedQuery = computed(() => {
|
||
const exp = sqlDisplay.value?.expected
|
||
return exp && "columns" in exp ? exp : null
|
||
})
|
||
const sqlChangedTables = computed(() => {
|
||
const exp = sqlDisplay.value?.expected
|
||
return exp && "changed_tables" in exp ? exp.changed_tables : []
|
||
})
|
||
|
||
const router = useRouter()
|
||
|
||
// 相似题目推荐
|
||
const similarProblems = ref<ProblemRow[]>([])
|
||
const similarLoaded = ref(false)
|
||
|
||
async function loadSimilarProblems() {
|
||
if (similarLoaded.value || !problem.value) return
|
||
// 比赛题不推荐:接口按 displayId 在**公开题库**里找,比赛题的编号默认是 1/2/3,
|
||
// 一般白跑一趟 404,撞上同号公开题时反而会在比赛中把题库列给学生。
|
||
if (problem.value.contestId !== null) return
|
||
try {
|
||
similarProblems.value = await getSimilarProblems(problem.value._id)
|
||
} catch {
|
||
similarProblems.value = []
|
||
}
|
||
similarLoaded.value = true
|
||
}
|
||
|
||
// 切换题目时重置相似推荐状态
|
||
watch(
|
||
() => problem.value?._id,
|
||
() => {
|
||
similarProblems.value = []
|
||
similarLoaded.value = false
|
||
},
|
||
)
|
||
|
||
// AC 或失败次数 >= 3 时加载推荐
|
||
watch(
|
||
() => [problem.value?._id, problem.value?.myStatus, problemStore.failCount],
|
||
([, status, failCount]) => {
|
||
if (status === 0 || (failCount as number) >= 3) {
|
||
loadSimilarProblems()
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
const hasTriedButNotPassed = computed(() => {
|
||
return (
|
||
problem.value?.myStatus !== undefined &&
|
||
problem.value?.myStatus !== null &&
|
||
problem.value?.myStatus !== 0
|
||
)
|
||
})
|
||
|
||
const samples = ref<Sample[]>(
|
||
problem.value!.samples.map((sample, index) => ({
|
||
...sample,
|
||
id: index,
|
||
msg: "",
|
||
status: "not_test",
|
||
loading: false,
|
||
})),
|
||
)
|
||
|
||
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
|
||
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
|
||
// astRequirementSchema。规则原文(engine / target)不下发给学生。
|
||
const KIND_TAG_TYPE = {
|
||
require: "success",
|
||
forbid: "error",
|
||
count: "info",
|
||
} as const
|
||
|
||
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 res = await createTestSubmission(codeStore.code, sample.input)
|
||
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)
|
||
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>
|
||
|
||
<template>
|
||
<div v-if="problem">
|
||
<template v-if="!problemSetId">
|
||
<!-- 已通过 -->
|
||
<n-alert
|
||
class="status-alert"
|
||
v-if="problem.myStatus === 0"
|
||
type="success"
|
||
title="🎉 本 题 已 经 被 你 解 决 啦"
|
||
>
|
||
</n-alert>
|
||
|
||
<!-- 尝试过但未通过 -->
|
||
<n-alert
|
||
class="status-alert"
|
||
v-else-if="hasTriedButNotPassed"
|
||
type="warning"
|
||
title="💪 你已经尝试过这道题,但还没有通过"
|
||
>
|
||
不要放弃!仔细检查代码逻辑,或者寻求 AI 的帮助获取灵感。
|
||
</n-alert>
|
||
</template>
|
||
|
||
<n-flex align="center">
|
||
<n-tag>{{ problem._id }}</n-tag>
|
||
<h2 class="problemTitle">{{ problem.title }}</h2>
|
||
</n-flex>
|
||
<p class="title" :style="style">
|
||
<n-flex align="center">
|
||
<Icon icon="streamline-ultimate-color:checklist"></Icon>
|
||
描述
|
||
</n-flex>
|
||
</p>
|
||
<MdPreview
|
||
preview-theme="vuepress"
|
||
:model-value="problem.description"
|
||
:theme="isDark ? 'dark' : 'light'"
|
||
/>
|
||
|
||
<template v-if="!isSQL">
|
||
<p class="title" :style="style">
|
||
<n-flex align="center">
|
||
<Icon icon="streamline-ultimate-color:envelope-back-front"></Icon>
|
||
输入
|
||
</n-flex>
|
||
</p>
|
||
<MdPreview
|
||
preview-theme="vuepress"
|
||
:model-value="problem.inputDescription"
|
||
:theme="isDark ? 'dark' : 'light'"
|
||
/>
|
||
|
||
<p class="title" :style="style">
|
||
<n-flex align="center">
|
||
<Icon icon="streamline-ultimate-color:mailbox-post"></Icon>
|
||
输出
|
||
</n-flex>
|
||
</p>
|
||
<MdPreview
|
||
preview-theme="vuepress"
|
||
:model-value="problem.outputDescription"
|
||
:theme="isDark ? 'dark' : 'light'"
|
||
/>
|
||
</template>
|
||
|
||
<template v-if="isSQL && sqlDisplay">
|
||
<p class="title" :style="style">
|
||
<n-flex align="center">
|
||
<Icon icon="devicon:sqlite"></Icon>
|
||
数据表
|
||
</n-flex>
|
||
</p>
|
||
<div v-for="t in sqlDisplay.tables" :key="t.name">
|
||
<p class="sqlTableName">{{ t.name }}</p>
|
||
<SQLDataTable
|
||
:columns="t.columns"
|
||
:rows="t.rows"
|
||
:total-rows="t.total_rows"
|
||
:truncated="t.truncated"
|
||
/>
|
||
</div>
|
||
|
||
<p class="title" :style="style">
|
||
<n-flex align="center">
|
||
<Icon icon="streamline-ultimate-color:check-button"></Icon>
|
||
期望结果
|
||
</n-flex>
|
||
</p>
|
||
<template v-if="sqlExpectedQuery">
|
||
<SQLDataTable
|
||
:columns="sqlExpectedQuery.columns"
|
||
:rows="sqlExpectedQuery.rows"
|
||
:total-rows="sqlExpectedQuery.total_rows"
|
||
:truncated="sqlExpectedQuery.truncated"
|
||
/>
|
||
<p v-if="!problem.sqlConfig?.order_sensitive" class="sqlNote">
|
||
结果顺序不限
|
||
</p>
|
||
</template>
|
||
<div v-for="t in sqlChangedTables" :key="t.name">
|
||
<p class="sqlTableName">
|
||
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name} 表` }}
|
||
</p>
|
||
<SQLDataTable
|
||
v-if="!t.dropped"
|
||
:columns="t.columns"
|
||
:rows="t.rows"
|
||
:total-rows="t.total_rows"
|
||
:truncated="t.truncated"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-if="problem.hint">
|
||
<p class="title" :style="style">
|
||
<n-flex align="center">
|
||
<Icon icon="streamline-emojis:man-tipping-hand-1"></Icon>
|
||
提示
|
||
</n-flex>
|
||
</p>
|
||
<MdPreview
|
||
preview-theme="preview"
|
||
:model-value="problem.hint"
|
||
:theme="isDark ? 'dark' : 'light'"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 代码要求(AST 规则) -->
|
||
<div v-if="astRequirements.length > 0">
|
||
<p class="title" :style="style">
|
||
<n-flex align="center">
|
||
<Icon icon="streamline-ultimate-color:check-button"></Icon>
|
||
要求
|
||
</n-flex>
|
||
</p>
|
||
<div v-for="[lang, rules] in astRequirements" :key="lang">
|
||
<p v-if="astRequirements.length > 1" class="lang-label">
|
||
{{ lang }}
|
||
</p>
|
||
<n-list bordered style="margin-bottom: 8px">
|
||
<n-list-item v-for="(rule, i) in rules" :key="i">
|
||
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{
|
||
rule.description
|
||
}}</n-tag>
|
||
</n-list-item>
|
||
</n-list>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-if="!isSQL">
|
||
<div v-for="(sample, index) of samples" :key="index">
|
||
<n-flex align="center">
|
||
<p class="title" :style="style">
|
||
<n-flex align="center">
|
||
<Icon icon="streamline-emojis:microscope"></Icon>
|
||
例子 {{ index + 1 }}
|
||
</n-flex>
|
||
</p>
|
||
<n-button
|
||
size="small"
|
||
:type="type(sample.status)"
|
||
@click="test(sample, index)"
|
||
>
|
||
{{ label(sample.status, sample.loading) }}
|
||
</n-button>
|
||
</n-flex>
|
||
<n-descriptions
|
||
bordered
|
||
:column="2"
|
||
label-style="width: 50%; min-width: 100px"
|
||
>
|
||
<n-descriptions-item>
|
||
<template #label>
|
||
<n-flex>
|
||
<span>输入</span>
|
||
<Copy :value="sample.input" />
|
||
</n-flex>
|
||
</template>
|
||
<div class="testcase">{{ sample.input }}</div>
|
||
</n-descriptions-item>
|
||
<n-descriptions-item>
|
||
<template #label>
|
||
<n-flex>
|
||
<span>输出</span>
|
||
<Copy :value="sample.output" />
|
||
</n-flex>
|
||
</template>
|
||
<div class="testcase">{{ sample.output }}</div>
|
||
</n-descriptions-item>
|
||
<n-descriptions-item label="运行结果" v-if="sample.msg">
|
||
<div class="testcase">{{ sample.msg }}</div>
|
||
</n-descriptions-item>
|
||
</n-descriptions>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-if="problem.source">
|
||
<p class="title" :style="style">
|
||
<n-flex align="center">
|
||
<Icon icon="streamline-ultimate-color:book-open-bookmark"></Icon>
|
||
来源
|
||
</n-flex>
|
||
</p>
|
||
<MdPreview
|
||
preview-theme="vuepress"
|
||
:model-value="problem.source"
|
||
:theme="isDark ? 'dark' : 'light'"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 相似题目推荐 -->
|
||
<div v-if="similarProblems.length > 0">
|
||
<n-divider />
|
||
<p class="title" :style="style">
|
||
<n-flex align="center">
|
||
<Icon icon="streamline-ultimate-color:like"></Icon>
|
||
相似题目推荐
|
||
</n-flex>
|
||
</p>
|
||
<n-list bordered>
|
||
<n-list-item v-for="sp in similarProblems" :key="sp._id">
|
||
<n-flex align="center" justify="space-between">
|
||
<n-flex align="center">
|
||
<n-tag size="small">{{ sp._id }}</n-tag>
|
||
<n-button
|
||
text
|
||
type="info"
|
||
@click="
|
||
router.push({
|
||
name: 'problem',
|
||
params: { problemID: sp._id },
|
||
})
|
||
"
|
||
>
|
||
{{ sp.title }}
|
||
</n-button>
|
||
</n-flex>
|
||
<!-- getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High -->
|
||
<n-tag
|
||
v-if="sp.difficulty"
|
||
size="small"
|
||
:type="
|
||
sp.difficulty === '简单'
|
||
? 'success'
|
||
: sp.difficulty === '困难'
|
||
? 'error'
|
||
: 'warning'
|
||
"
|
||
>
|
||
{{ sp.difficulty }}
|
||
</n-tag>
|
||
</n-flex>
|
||
</n-list-item>
|
||
</n-list>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.problemTitle {
|
||
margin: 0;
|
||
}
|
||
|
||
.title {
|
||
font-size: 20px;
|
||
margin: 12px 0;
|
||
}
|
||
|
||
.testcase {
|
||
font-size: 14px;
|
||
white-space: pre;
|
||
font-family: "Monaco";
|
||
}
|
||
|
||
.status-alert {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.lang-label {
|
||
font-weight: 600;
|
||
margin: 8px 0 4px;
|
||
}
|
||
|
||
.sqlTableName {
|
||
font-weight: 600;
|
||
margin: 8px 0 4px;
|
||
font-family: Monaco, Consolas, monospace;
|
||
}
|
||
|
||
.sqlNote {
|
||
font-size: 13px;
|
||
opacity: 0.65;
|
||
margin: 0 0 8px;
|
||
}
|
||
</style>
|