feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
1 parent
3c975e85ee
commit
ae1fb329b5
258 files changed
+40490
-91
No files matched your search
@@ -0,0 +1,550 @@
|
||||
<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"
|
||||
: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, Consolas, monospace;
|
||||
}
|
||||
|
||||
.sqlNote {
|
||||
font-size: 13px;
|
||||
opacity: 0.65;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user