Files
ojnext/src/oj/problem/components/ProblemContent.vue
2026-07-03 01:26:16 -06:00

551 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { DIFFICULTY } from "utils/constants"
import type { Problem, 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 = Problem["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?.sql_config)
const sqlDisplay = computed(() => problem.value?.sql_display ?? 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<any[]>([])
const similarLoaded = ref(false)
async function loadSimilarProblems() {
if (similarLoaded.value || !problem.value) return
try {
const res = await getSimilarProblems(problem.value._id)
similarProblems.value = res.data || []
} catch {
similarProblems.value = []
}
similarLoaded.value = true
}
// 切换题目时重置相似推荐状态
watch(
() => problem.value?._id,
() => {
similarProblems.value = []
similarLoaded.value = false
},
)
// AC 或失败次数 >= 3 时加载推荐
watch(
() => [problem.value?._id, problem.value?.my_status, problemStore.failCount],
([, status, failCount]) => {
if (status === 0 || (failCount as number) >= 3) {
loadSimilarProblems()
}
},
{ immediate: true },
)
const hasTriedButNotPassed = computed(() => {
return (
problem.value?.my_status !== undefined &&
problem.value?.my_status !== null &&
problem.value?.my_status !== 0
)
})
const samples = ref<Sample[]>(
problem.value!.samples.map((sample, index) => ({
...sample,
id: index,
msg: "",
status: "not_test",
loading: false,
})),
)
const NODE_TARGET_LABELS: Record<string, string> = {
for_loop: "for 循环",
while_loop: "while 循环",
if_statement: "if 条件",
else_clause: "else 子句",
function_definition: "函数定义",
return: "return 语句",
break: "break 语句",
continue: "continue 语句",
list_comprehension: "列表推导式",
list_literal: "列表",
dict_literal: "字典",
set_literal: "集合",
f_string: "f-string",
try_except: "try-except",
class_definition: "类定义",
}
type AstRule = {
engine: string
target?: string
label?: string
exact?: number
min?: number
max?: number
message: string
}
function ruleDescription(rule: AstRule): string {
if (rule.message) return rule.message
const target = rule.target || ""
const targetLabel = rule.label || NODE_TARGET_LABELS[target] || target
const countDesc = () => {
if (rule.exact !== undefined) return `出现 ${rule.exact}`
if (rule.min !== undefined && rule.max !== undefined)
return `出现 ${rule.min}${rule.max}`
if (rule.min !== undefined) return `至少出现 ${rule.min}`
if (rule.max !== undefined) return `至多出现 ${rule.max}`
return ""
}
const callDesc = () => {
if (rule.exact !== undefined) return `调用 ${rule.exact}`
if (rule.min !== undefined && rule.max !== undefined)
return `调用 ${rule.min}${rule.max}`
if (rule.min !== undefined) return `至少调用 ${rule.min}`
if (rule.max !== undefined) return `至多调用 ${rule.max}`
return ""
}
switch (rule.engine) {
case "must_exist_node":
return `必须使用 ${targetLabel}`
case "must_not_exist_node":
return `不能使用 ${targetLabel}`
case "count_node":
return `${targetLabel} ${countDesc()}`
case "must_call_function":
return `必须调用 ${target}()`
case "must_not_call_function":
return `不能调用 ${target}()`
case "count_function_call":
return `${target}() ${callDesc()}`
case "must_call_method":
return `必须调用 .${target}()`
case "must_not_call_method":
return `不能调用 .${target}()`
case "must_use_operator":
return `必须使用 ${target} 运算符`
default:
return rule.engine
}
}
function ruleTagType(engine: string): "error" | "success" | "info" {
if (engine.startsWith("must_not")) return "error"
if (engine.startsWith("must")) return "success"
return "info"
}
const astRulesForDisplay = computed(() => {
if (!problem.value?.ast_rules) return []
return Object.entries(problem.value.ast_rules).filter(
([, rules]) => rules.length > 0,
)
})
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.my_status === 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.input_description"
: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.output_description"
: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.map((name) => ({ name }))"
:rows="sqlExpectedQuery.rows"
:total-rows="sqlExpectedQuery.total_rows"
:truncated="sqlExpectedQuery.truncated"
/>
<p v-if="!problem.sql_config?.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="astRulesForDisplay.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 astRulesForDisplay" :key="lang">
<p v-if="astRulesForDisplay.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-flex align="center">
<n-tag :type="ruleTagType(rule.engine)">
{{ ruleDescription(rule) }}
</n-tag>
<span v-if="rule.message" class="rule-message">{{
rule.message
}}</span>
</n-flex>
</n-list-item>
</n-list>
</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>
<n-tag
size="small"
:type="
sp.difficulty === 'Low'
? 'success'
: sp.difficulty === 'High'
? 'error'
: 'warning'
"
>
{{
DIFFICULTY[sp.difficulty as keyof typeof 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;
}
.rule-message {
font-size: 13px;
opacity: 0.65;
}
.sqlTableName {
font-weight: 600;
margin: 8px 0 4px;
font-family: "Monaco";
}
.sqlNote {
font-size: 13px;
opacity: 0.65;
margin: 0 0 8px;
}
</style>