|
|
@@ -1,18 +1,30 @@
|
|
|
<script setup lang="ts">
|
|
|
import { getProblemController } from '@/api/problem-controller'
|
|
|
-import type { ProblemDetailVo } from '@/api/models'
|
|
|
+import { getSubmissionController } from '@/api/submission-controller'
|
|
|
+import type { ProblemDetailVo, SubmissionVo } from '@/api/models'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
-import { ArrowLeft } from '@element-plus/icons-vue'
|
|
|
-import { onMounted, ref } from 'vue'
|
|
|
+import { ArrowLeft, Check } from '@element-plus/icons-vue'
|
|
|
+import { computed, onMounted, ref, watch } from 'vue'
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
+import { useLoginUserStore } from '@/store'
|
|
|
+import MonacoEditor from '@/components/MonacoEditor.vue'
|
|
|
+import { MdViewer, mdPlugins } from '@/util/markdown'
|
|
|
+import { getStatusInfo } from '@/util/submission'
|
|
|
|
|
|
const route = useRoute()
|
|
|
const router = useRouter()
|
|
|
const api = getProblemController()
|
|
|
+const submissionApi = getSubmissionController()
|
|
|
+const loginUserStore = useLoginUserStore()
|
|
|
+
|
|
|
+const problemId = computed(() => Number(route.params.id))
|
|
|
+const contestId = computed(() => (route.query.contestId ? Number(route.query.contestId) : undefined))
|
|
|
|
|
|
const loading = ref(false)
|
|
|
const detail = ref<ProblemDetailVo>({})
|
|
|
|
|
|
+const isLogin = computed(() => loginUserStore.loginUser.isLogin)
|
|
|
+
|
|
|
const difficultyTag = (difficulty: ProblemDetailVo['difficulty']) => {
|
|
|
if (difficulty === 'easy') return 'success'
|
|
|
if (difficulty === 'medium') return 'warning'
|
|
|
@@ -27,11 +39,10 @@ const difficultyLabel = (difficulty: ProblemDetailVo['difficulty']) => {
|
|
|
}
|
|
|
|
|
|
const fetchDetail = async () => {
|
|
|
- const id = Number(route.params.id)
|
|
|
- if (!id) return
|
|
|
+ if (!problemId.value) return
|
|
|
loading.value = true
|
|
|
try {
|
|
|
- const res = await api.getDetail(id)
|
|
|
+ const res = await api.getDetail1(problemId.value)
|
|
|
if (res?.code === 200 && res.data) {
|
|
|
detail.value = res.data
|
|
|
} else {
|
|
|
@@ -45,17 +56,82 @@ const fetchDetail = async () => {
|
|
|
}
|
|
|
|
|
|
const goBack = () => {
|
|
|
- router.push('/problems')
|
|
|
+ if (contestId.value) {
|
|
|
+ router.push(`/contests/${contestId.value}`)
|
|
|
+ } else {
|
|
|
+ router.push('/problems')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 代码提交 ----------
|
|
|
+const DEFAULT_CODE = `public class Main {
|
|
|
+ public static void main(String[] args) {
|
|
|
+ // 在此编写你的代码
|
|
|
+
|
|
|
+ }
|
|
|
+}
|
|
|
+`
|
|
|
+
|
|
|
+const storageKey = computed(() => `problem-code-${problemId.value}-${contestId.value ?? 0}`)
|
|
|
+
|
|
|
+const code = ref('')
|
|
|
+const language = ref<'java'>('java')
|
|
|
+const submitting = ref(false)
|
|
|
+const result = ref<SubmissionVo | undefined>(undefined)
|
|
|
+
|
|
|
+const initCode = () => {
|
|
|
+ const saved = localStorage.getItem(storageKey.value)
|
|
|
+ code.value = saved ?? DEFAULT_CODE
|
|
|
}
|
|
|
|
|
|
-onMounted(fetchDetail)
|
|
|
+watch(storageKey, () => {
|
|
|
+ if (problemId.value) initCode()
|
|
|
+})
|
|
|
+
|
|
|
+const handleSubmit = async () => {
|
|
|
+ if (!isLogin.value) {
|
|
|
+ ElMessage.warning('请先登录')
|
|
|
+ router.push({ path: '/login', query: { redirect: route.fullPath } })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!code.value.trim()) {
|
|
|
+ ElMessage.warning('请输入代码')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ submitting.value = true
|
|
|
+ try {
|
|
|
+ const res = await submissionApi.submit({
|
|
|
+ code: code.value,
|
|
|
+ language: language.value,
|
|
|
+ problemId: problemId.value,
|
|
|
+ contestId: contestId.value
|
|
|
+ })
|
|
|
+ if (res?.code === 200 && res.data) {
|
|
|
+ result.value = res.data
|
|
|
+ ElMessage.success('提交成功')
|
|
|
+ } else {
|
|
|
+ ElMessage.error(res?.message || '提交失败')
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ ElMessage.error('提交失败')
|
|
|
+ } finally {
|
|
|
+ submitting.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const resultStatus = computed(() => getStatusInfo(result.value?.status))
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ initCode()
|
|
|
+ fetchDetail()
|
|
|
+})
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
- <div class="problem-detail-view" v-loading="loading">
|
|
|
+ <div class="problem-detail-view">
|
|
|
<div class="detail-header">
|
|
|
<el-button text @click="goBack">
|
|
|
- <el-icon><ArrowLeft /></el-icon>返回列表
|
|
|
+ <el-icon><ArrowLeft /></el-icon>返回{{ contestId ? '比赛' : '列表' }}
|
|
|
</el-button>
|
|
|
<div class="title-row">
|
|
|
<h2 class="title">{{ detail.title }}</h2>
|
|
|
@@ -64,51 +140,106 @@ onMounted(fetchDetail)
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <el-card class="info-card" shadow="never">
|
|
|
- <el-descriptions :column="4" border>
|
|
|
- <el-descriptions-item label="时间限制">{{ detail.timeLimitMs ?? '-' }} ms</el-descriptions-item>
|
|
|
- <el-descriptions-item label="内存限制">{{ detail.memoryLimitMb ?? '-' }} MB</el-descriptions-item>
|
|
|
- <el-descriptions-item label="提交次数">{{ detail.tryCount ?? 0 }}</el-descriptions-item>
|
|
|
- <el-descriptions-item label="通过次数">{{ detail.acceptedCount ?? 0 }}</el-descriptions-item>
|
|
|
- <el-descriptions-item label="创建者">{{ detail.createdByUsername || '-' }}</el-descriptions-item>
|
|
|
- <el-descriptions-item label="分类" :span="3">
|
|
|
- <template v-if="detail.categories?.length">
|
|
|
- <el-tag v-for="c in detail.categories" :key="c.id" class="category-tag" size="small">{{ c.categoryName }}</el-tag>
|
|
|
+ <div class="main-content" v-loading="loading">
|
|
|
+ <div class="left-panel">
|
|
|
+ <el-card class="info-card" shadow="never">
|
|
|
+ <el-descriptions :column="4" border>
|
|
|
+ <el-descriptions-item label="时间限制">{{ detail.timeLimitMs ?? '-' }} ms</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="内存限制">{{ detail.memoryLimitMb ?? '-' }} MB</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="提交次数">{{ detail.tryCount ?? 0 }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="通过次数">{{ detail.acceptedCount ?? 0 }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="创建者">{{ detail.createdByUsername || '-' }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="分类" :span="3">
|
|
|
+ <template v-if="detail.categories?.length">
|
|
|
+ <el-tag v-for="c in detail.categories" :key="c.id" class="category-tag" size="small">{{
|
|
|
+ c.categoryName
|
|
|
+ }}</el-tag>
|
|
|
+ </template>
|
|
|
+ <span v-else>-</span>
|
|
|
+ </el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <el-card class="desc-card" shadow="never">
|
|
|
+ <template #header><span class="card-title">题目描述</span></template>
|
|
|
+ <div class="markdown-body">
|
|
|
+ <MdViewer :value="detail.description || ''" :plugins="mdPlugins" />
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <el-card v-if="detail.cases?.length" class="case-card" shadow="never">
|
|
|
+ <template #header><span class="card-title">样例</span></template>
|
|
|
+ <div v-for="(c, index) in detail.cases" :key="c.id" class="case-block">
|
|
|
+ <div class="case-title">样例 {{ index + 1 }}</div>
|
|
|
+ <div v-if="c.description" class="case-desc markdown-body">
|
|
|
+ <MdViewer :value="c.description" :plugins="mdPlugins" />
|
|
|
+ </div>
|
|
|
+ <div class="case-content">
|
|
|
+ <div class="case-column">
|
|
|
+ <div class="case-label">输入</div>
|
|
|
+ <pre class="case-pre">{{ c.input }}</pre>
|
|
|
+ </div>
|
|
|
+ <div class="case-column">
|
|
|
+ <div class="case-label">输出</div>
|
|
|
+ <pre class="case-pre">{{ c.output }}</pre>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="right-panel">
|
|
|
+ <el-card class="submit-card" shadow="never">
|
|
|
+ <template #header>
|
|
|
+ <div class="submit-header">
|
|
|
+ <span class="card-title">代码提交</span>
|
|
|
+ <el-select v-model="language" size="small" style="width: 110px">
|
|
|
+ <el-option label="Java" value="java" />
|
|
|
+ </el-select>
|
|
|
+ </div>
|
|
|
</template>
|
|
|
- <span v-else>-</span>
|
|
|
- </el-descriptions-item>
|
|
|
- </el-descriptions>
|
|
|
- </el-card>
|
|
|
-
|
|
|
- <el-card class="desc-card" shadow="never">
|
|
|
- <template #header><span class="card-title">题目描述</span></template>
|
|
|
- <pre class="description">{{ detail.description }}</pre>
|
|
|
- </el-card>
|
|
|
-
|
|
|
- <el-card v-if="detail.cases?.length" class="case-card" shadow="never">
|
|
|
- <template #header><span class="card-title">样例</span></template>
|
|
|
- <div v-for="(c, index) in detail.cases" :key="c.id" class="case-block">
|
|
|
- <div class="case-title">样例 {{ index + 1 }}</div>
|
|
|
- <div v-if="c.description" class="case-desc">{{ c.description }}</div>
|
|
|
- <div class="case-content">
|
|
|
- <div class="case-column">
|
|
|
- <div class="case-label">输入</div>
|
|
|
- <pre class="case-pre">{{ c.input }}</pre>
|
|
|
+ <MonacoEditor v-model="code" language="java" :readonly="submitting" height="420px" />
|
|
|
+ <div class="submit-actions">
|
|
|
+ <el-button type="primary" :loading="submitting" :disabled="!isLogin" @click="handleSubmit">
|
|
|
+ 提交代码
|
|
|
+ </el-button>
|
|
|
+ <el-button v-if="!isLogin" text @click="router.push({ path: '/login', query: { redirect: route.fullPath } })">
|
|
|
+ 登录后提交
|
|
|
+ </el-button>
|
|
|
</div>
|
|
|
- <div class="case-column">
|
|
|
- <div class="case-label">输出</div>
|
|
|
- <pre class="case-pre">{{ c.output }}</pre>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <el-card v-if="result" class="result-card" shadow="never">
|
|
|
+ <template #header><span class="card-title">判题结果</span></template>
|
|
|
+ <div class="result-row">
|
|
|
+ <el-tag :type="resultStatus.tag" size="large">{{ resultStatus.label }}</el-tag>
|
|
|
+ <span v-if="result.score !== undefined" class="result-score">
|
|
|
+ <el-icon><Check /></el-icon>得分 {{ result.score }}
|
|
|
+ </span>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ <el-descriptions :column="2" border size="small" class="result-desc">
|
|
|
+ <el-descriptions-item label="用时">{{ result.timeUsedMs ?? '-' }} ms</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="内存">{{ result.memoryUsedKb ?? '-' }} KB</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="通过用例">
|
|
|
+ {{ result.passedTestCases ?? 0 }} / {{ result.totalTestCases ?? 0 }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="提交编号">{{ result.id ?? '-' }}</el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ <el-collapse v-if="result.judgeDetail" class="detail-collapse">
|
|
|
+ <el-collapse-item title="判题详情" name="detail">
|
|
|
+ <pre class="judge-detail">{{ result.judgeDetail }}</pre>
|
|
|
+ </el-collapse-item>
|
|
|
+ </el-collapse>
|
|
|
+ </el-card>
|
|
|
</div>
|
|
|
- </el-card>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
.problem-detail-view {
|
|
|
padding: 20px;
|
|
|
- max-width: 1100px;
|
|
|
+ max-width: 1400px;
|
|
|
margin: 0 auto;
|
|
|
}
|
|
|
|
|
|
@@ -127,9 +258,27 @@ onMounted(fetchDetail)
|
|
|
font-size: 24px;
|
|
|
}
|
|
|
|
|
|
+.main-content {
|
|
|
+ display: flex;
|
|
|
+ gap: 16px;
|
|
|
+ align-items: flex-start;
|
|
|
+}
|
|
|
+
|
|
|
+.left-panel {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.right-panel {
|
|
|
+ width: 44%;
|
|
|
+ min-width: 420px;
|
|
|
+}
|
|
|
+
|
|
|
.info-card,
|
|
|
.desc-card,
|
|
|
-.case-card {
|
|
|
+.case-card,
|
|
|
+.submit-card,
|
|
|
+.result-card {
|
|
|
margin-bottom: 16px;
|
|
|
}
|
|
|
|
|
|
@@ -137,17 +286,21 @@ onMounted(fetchDetail)
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
|
|
|
-.category-tag {
|
|
|
- margin-right: 6px;
|
|
|
+.submit-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
}
|
|
|
|
|
|
-.description {
|
|
|
- margin: 0;
|
|
|
- white-space: pre-wrap;
|
|
|
- word-break: break-word;
|
|
|
- font-family: inherit;
|
|
|
- font-size: 14px;
|
|
|
- line-height: 1.7;
|
|
|
+.submit-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ margin-top: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.category-tag {
|
|
|
+ margin-right: 6px;
|
|
|
}
|
|
|
|
|
|
.case-block {
|
|
|
@@ -189,4 +342,40 @@ onMounted(fetchDetail)
|
|
|
word-break: break-word;
|
|
|
font-size: 13px;
|
|
|
}
|
|
|
-</style>
|
|
|
+
|
|
|
+.result-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.result-score {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.detail-collapse {
|
|
|
+ margin-top: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.judge-detail {
|
|
|
+ margin: 0;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ word-break: break-word;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 960px) {
|
|
|
+ .main-content {
|
|
|
+ flex-direction: column;
|
|
|
+ }
|
|
|
+ .right-panel {
|
|
|
+ width: 100%;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|