Prechádzať zdrojové kódy

# feat: 题目代码提交与判题结果展示(Monaco+bytemd),新增我的提交记录页

yangyi 1 mesiac pred
rodič
commit
7de7e767db

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1305 - 31
package-lock.json


+ 5 - 0
package.json

@@ -10,9 +10,14 @@
     "api:gen": "orval"
   },
   "dependencies": {
+    "@bytemd/plugin-gfm": "^1.22.0",
+    "@bytemd/plugin-highlight": "^1.22.0",
+    "@bytemd/vue-next": "^1.22.0",
     "axios": "^1.15.2",
+    "bytemd": "^1.22.0",
     "dayjs": "^1.11.21",
     "element-plus": "^2.13.6",
+    "monaco-editor": "^0.56.0",
     "orval": "7.0",
     "pinia": "^3.0.4",
     "pinia-plugin-persistedstate": "^4.7.1",

+ 97 - 0
src/components/MonacoEditor.vue

@@ -0,0 +1,97 @@
+<script setup lang="ts">
+import * as monaco from '../../node_modules/monaco-editor/esm/vs/editor/editor.api.js'
+import '../../node_modules/monaco-editor/esm/vs/languages/definitions/java/register.js'
+import editorWorker from '../../node_modules/monaco-editor/esm/vs/editor/editor.worker?worker'
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
+
+self.MonacoEnvironment = {
+  getWorker: () => new editorWorker()
+}
+
+const props = withDefaults(
+  defineProps<{
+    modelValue: string
+    language?: string
+    readonly?: boolean
+    height?: string
+  }>(),
+  {
+    language: 'java',
+    readonly: false,
+    height: '400px'
+  }
+)
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: string): void
+}>()
+
+const containerRef = ref<HTMLElement>()
+let editor: monaco.editor.IStandaloneCodeEditor | undefined
+
+onMounted(() => {
+  if (!containerRef.value) return
+  editor = monaco.editor.create(containerRef.value, {
+    value: props.modelValue,
+    language: props.language,
+    theme: 'vs',
+    readOnly: props.readonly,
+    automaticLayout: true,
+    minimap: { enabled: false },
+    fontSize: 14,
+    tabSize: 4,
+    insertSpaces: true,
+    scrollBeyondLastLine: false,
+    wordWrap: 'on',
+    lineNumbers: 'on',
+    scrollbar: {
+      verticalScrollbarSize: 10,
+      horizontalScrollbarSize: 10
+    }
+  })
+  editor.onDidChangeModelContent(() => {
+    const value = editor?.getValue() ?? ''
+    if (value !== props.modelValue) emit('update:modelValue', value)
+  })
+})
+
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (editor && val !== editor.getValue()) {
+      editor.setValue(val ?? '')
+    }
+  }
+)
+
+watch(
+  () => props.language,
+  (lang) => {
+    const model = editor?.getModel()
+    if (model) monaco.editor.setModelLanguage(model, lang)
+  }
+)
+
+watch(
+  () => props.readonly,
+  (ro) => editor?.updateOptions({ readOnly: ro })
+)
+
+onBeforeUnmount(() => {
+  editor?.dispose()
+  editor = undefined
+})
+</script>
+
+<template>
+  <div ref="containerRef" class="monaco-editor" :style="{ height }" />
+</template>
+
+<style scoped>
+.monaco-editor {
+  width: 100%;
+  border: 1px solid var(--el-border-color);
+  border-radius: 4px;
+  overflow: hidden;
+}
+</style>

+ 2 - 0
src/main.ts

@@ -2,6 +2,8 @@ import { createApp } from 'vue'
 import ElementPlus from 'element-plus'
 import zhCn from 'element-plus/es/locale/lang/zh-cn'
 import 'element-plus/dist/index.css'
+import 'bytemd/dist/index.css'
+import 'highlight.js/styles/github.css'
 import App from './App.vue'
 import router from "./router";
 import { createPinia } from 'pinia'

+ 11 - 0
src/router/index.ts

@@ -14,6 +14,7 @@ import ContestManageView from "../view/admin/ContestManageView.vue";
 import ContestListView from "../view/contest/ContestListView.vue";
 import ContestDetailView from "../view/contest/ContestDetailView.vue";
 import ContestRankingView from "../view/contest/ContestRankingView.vue";
+import SubmissionListView from "../view/submission/SubmissionListView.vue";
 import {
     HomeFilled,
     Menu,
@@ -22,6 +23,7 @@ import {
     Files,
     Collection,
     Trophy,
+    Tickets,
 } from '@element-plus/icons-vue'
 import {useLoginUserStore} from "../store";
 import {ElMessage} from "element-plus";
@@ -95,6 +97,15 @@ export const userRoutes:RouteRecordRaw[] = [
                     icon: Trophy
                 }
             },
+            {
+                path: '/submissions',
+                name: 'submissions',
+                component: SubmissionListView,
+                meta: {
+                    title: '我的提交',
+                    icon: Tickets
+                }
+            },
         ],
     },
 ]

+ 56 - 0
src/style.css

@@ -77,3 +77,59 @@ button:focus-visible {
     background-color: #f9f9f9;
   }
 }
+
+/* markdown 渲染排版(bytemd Viewer) */
+.markdown-body {
+  word-break: break-word;
+  line-height: 1.7;
+}
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3,
+.markdown-body h4 {
+  margin: 1em 0 0.5em;
+  line-height: 1.3;
+}
+.markdown-body h1:first-child,
+.markdown-body h2:first-child,
+.markdown-body h3:first-child {
+  margin-top: 0;
+}
+.markdown-body p {
+  margin: 0.5em 0;
+}
+.markdown-body pre {
+  background: #f6f8fa;
+  padding: 12px;
+  border-radius: 6px;
+  overflow: auto;
+}
+.markdown-body code {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 13px;
+  background: rgba(175, 184, 193, 0.2);
+  padding: 0.2em 0.4em;
+  border-radius: 4px;
+}
+.markdown-body pre code {
+  background: none;
+  padding: 0;
+}
+.markdown-body blockquote {
+  margin: 0;
+  padding: 0 1em;
+  color: #57606a;
+  border-left: 0.25em solid #d0d7de;
+}
+.markdown-body table {
+  border-collapse: collapse;
+  margin: 0.5em 0;
+}
+.markdown-body th,
+.markdown-body td {
+  border: 1px solid #d0d7de;
+  padding: 6px 12px;
+}
+.markdown-body img {
+  max-width: 100%;
+}

+ 20 - 0
src/util/markdown.ts

@@ -0,0 +1,20 @@
+import { defineAsyncComponent, type Component } from 'vue'
+import gfm from '@bytemd/plugin-gfm'
+import highlight from '@bytemd/plugin-highlight'
+import type { BytemdPlugin } from 'bytemd'
+
+export const mdPlugins: BytemdPlugin[] = [gfm(), highlight()]
+
+/**
+ * @bytemd/vue-next 的类型声明解析为 `{ default: ... }` 且不包含命名导出,
+ * 故显式断言为组件模块以规避 verbatimModuleSyntax 的类型误判。
+ */
+type BytemdComponents = { Editor: Component; Viewer: Component }
+
+const loadBytemd = async (): Promise<BytemdComponents> => {
+  const m = await import('@bytemd/vue-next')
+  return m as unknown as BytemdComponents
+}
+
+export const MdEditor = defineAsyncComponent(() => loadBytemd().then((m) => m.Editor))
+export const MdViewer = defineAsyncComponent(() => loadBytemd().then((m) => m.Viewer))

+ 16 - 0
src/util/submission.ts

@@ -0,0 +1,16 @@
+const STATUS_MAP: { value: string; label: string; tag: 'success' | 'warning' | 'danger' | 'info' }[] = [
+  { value: 'SUBMITTED', label: '已提交', tag: 'info' },
+  { value: 'JUDGING', label: '判题中', tag: 'warning' },
+  { value: 'AC', label: '通过', tag: 'success' },
+  { value: 'WA', label: '答案错误', tag: 'danger' },
+  { value: 'TLE', label: '超时', tag: 'warning' },
+  { value: 'MLE', label: '内存超限', tag: 'warning' },
+  { value: 'RE', label: '运行错误', tag: 'danger' },
+  { value: 'CE', label: '编译失败', tag: 'info' },
+  { value: 'SE', label: '系统错误', tag: 'danger' }
+]
+
+export const getStatusInfo = (status?: string) => {
+  const item = STATUS_MAP.find((s) => s.value === status)
+  return item ? item : { value: status || '', label: status || '-', tag: 'info' as const }
+}

+ 1 - 1
src/view/admin/ContestManageView.vue

@@ -271,7 +271,7 @@ const loadConfig = async () => {
     return
   }
   try {
-    const res = await api.getDetail1(configContestId.value, configPassword.value ? { password: configPassword.value } : undefined)
+    const res = await api.getDetail2(configContestId.value, configPassword.value ? { password: configPassword.value } : undefined)
     if (res?.code === 200 && res.data) {
       const d = res.data
       if (d.type === 'password' && !d.creatorUsername) {

+ 19 - 6
src/view/admin/ProblemManageView.vue

@@ -17,6 +17,7 @@ import { Delete, Edit, Plus, RefreshRight, Search, View } from '@element-plus/ic
 import { computed, onMounted, reactive, ref } from 'vue'
 import { useLoginUserStore } from '@/store'
 import { formatTime } from '@/util/format-time'
+import { MdEditor, mdPlugins } from '@/util/markdown'
 
 const api = getProblemController()
 const loginUserStore = useLoginUserStore()
@@ -176,7 +177,7 @@ const handleEdit = (row: ProblemVo) => {
   const id = row.id
   if (id === undefined) return
   api
-    .getDetail(id)
+    .getDetail1(id)
     .then((res) => {
       const d = res?.data
       if (!d) return
@@ -265,7 +266,7 @@ const handleBatchDelete = () => {
 
 // ---------- 详情弹窗 ----------
 const loadDetail = async (id: number) => {
-  const [detailRes, testRes] = await Promise.all([api.getDetail(id), api.queryTests(id)])
+  const [detailRes, testRes] = await Promise.all([api.getDetail1(id), api.queryTests(id)])
   detail.value = detailRes?.data || {}
   caseTable.value = detail.value.cases || []
   testTable.value = testRes?.data || []
@@ -540,13 +541,15 @@ onMounted(() => {
     </el-card>
 
     <!-- 新增/编辑题目 -->
-    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" draggable>
+    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="900px" draggable>
       <el-form ref="formRef" :model="problemForm" :rules="formRules" label-width="90px">
         <el-form-item label="标题" prop="title">
           <el-input v-model="problemForm.title" placeholder="请输入题目标题" />
         </el-form-item>
         <el-form-item label="描述" prop="description">
-          <el-input v-model="problemForm.description" type="textarea" :rows="5" placeholder="请输入题目描述" />
+          <div class="markdown-editor-wrapper">
+            <MdEditor :value="problemForm.description || ''" :plugins="mdPlugins" @change="(v: string) => (problemForm.description = v)" />
+          </div>
         </el-form-item>
         <el-form-item label="时间限制" prop="timeLimitMs">
           <el-input-number v-model="problemForm.timeLimitMs" :min="1" /> ms
@@ -634,7 +637,7 @@ onMounted(() => {
     </el-dialog>
 
     <!-- 样例编辑 -->
-    <el-dialog v-model="caseDialogVisible" :title="caseForm.id ? '编辑样例' : '新增样例'" width="560px" draggable>
+    <el-dialog v-model="caseDialogVisible" :title="caseForm.id ? '编辑样例' : '新增样例'" width="700px" draggable>
       <el-form ref="caseFormRef" :model="caseForm" :rules="caseRules" label-width="80px">
         <el-form-item label="序号">
           <el-input-number v-model="caseForm.sortOrder" :min="1" />
@@ -646,7 +649,9 @@ onMounted(() => {
           <el-input v-model="caseForm.output" type="textarea" :rows="4" />
         </el-form-item>
         <el-form-item label="说明">
-          <el-input v-model="caseForm.description" />
+          <div class="markdown-editor-wrapper case-description">
+            <MdEditor :value="caseForm.description || ''" :plugins="mdPlugins" @change="(v: string) => (caseForm.description = v)" />
+          </div>
         </el-form-item>
       </el-form>
       <template #footer>
@@ -702,4 +707,12 @@ onMounted(() => {
   justify-content: flex-end;
   margin-top: 16px;
 }
+
+.markdown-editor-wrapper :deep(.bytemd) {
+  height: 300px;
+}
+
+.markdown-editor-wrapper.case-description :deep(.bytemd) {
+  height: 180px;
+}
 </style>

+ 14 - 4
src/view/contest/ContestDetailView.vue

@@ -1,12 +1,13 @@
 <script setup lang="ts">
 import { getContestController } from '@/api/contest-controller'
-import type { ContestDetailVo } from '@/api/models'
+import type { ContestDetailVo, ContestProblemVo } from '@/api/models'
 import { ElMessage } from 'element-plus'
 import { ArrowLeft, Key, Trophy } from '@element-plus/icons-vue'
 import { computed, onMounted, ref } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { useLoginUserStore } from '@/store'
 import { formatTime } from '@/util/format-time'
+import { MdViewer, mdPlugins } from '@/util/markdown'
 
 const route = useRoute()
 const router = useRouter()
@@ -76,7 +77,7 @@ const fetchDetail = async (password?: string) => {
   if (!contestId.value) return
   loading.value = true
   try {
-    const res = await api.getDetail1(contestId.value, password ? { password } : undefined)
+    const res = await api.getDetail2(contestId.value, password ? { password } : undefined)
     if (res?.code === 200 && res.data) {
       const d = res.data
       detail.value = d
@@ -153,6 +154,11 @@ const handleRegister = () => {
   }
 }
 
+const goProblem = (row: ContestProblemVo) => {
+  if (!row.problemId) return
+  router.push({ path: `/problems/${row.problemId}`, query: { contestId: String(contestId.value) } })
+}
+
 const goRanking = () => {
   if (!ensureLogin()) return
   router.push(`/contests/${contestId.value}/ranking`)
@@ -193,7 +199,9 @@ onMounted(fetchDetail)
     <template v-else>
       <el-card class="desc-card" shadow="never">
         <template #header><span class="card-title">比赛描述</span></template>
-        <pre class="description">{{ detail.description || '暂无描述' }}</pre>
+        <div class="markdown-body">
+          <MdViewer :value="detail.description || '暂无描述'" :plugins="mdPlugins" />
+        </div>
       </el-card>
 
       <el-card class="table-card" shadow="never">
@@ -203,7 +211,9 @@ onMounted(fetchDetail)
             <template #default="{ row }">{{ row.displayOrder ?? '-' }}</template>
           </el-table-column>
           <el-table-column prop="title" label="标题" min-width="200">
-            <template #default="{ row }">{{ row.title || '-' }}</template>
+            <template #default="{ row }">
+              <el-link type="primary" @click="goProblem(row)">{{ row.title || '-' }}</el-link>
+            </template>
           </el-table-column>
           <el-table-column label="难度" width="100" align="center">
             <template #default="{ row }">{{ difficultyLabel(row.difficulty) }}</template>

+ 1 - 1
src/view/contest/ContestRankingView.vue

@@ -36,7 +36,7 @@ const fetchRanking = async () => {
 
 const fetchTitle = async () => {
   try {
-    const res = await api.getDetail1(contestId)
+    const res = await api.getDetail2(contestId)
     if (res?.code === 200 && res.data) {
       contestTitle.value = res.data.title || ''
     }

+ 245 - 56
src/view/problem/ProblemDetailView.vue

@@ -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>

+ 159 - 0
src/view/submission/SubmissionListView.vue

@@ -0,0 +1,159 @@
+<script setup lang="ts">
+import { getSubmissionController } from '@/api/submission-controller'
+import type { SubmissionVo } from '@/api/models'
+import { ElMessage } from 'element-plus'
+import { onMounted, reactive, ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useLoginUserStore } from '@/store'
+import { formatTime } from '@/util/format-time'
+import { getStatusInfo } from '@/util/submission'
+
+const route = useRoute()
+const router = useRouter()
+const api = getSubmissionController()
+const loginUserStore = useLoginUserStore()
+
+const loading = ref(false)
+const tableData = ref<SubmissionVo[]>([])
+const pagination = reactive({ pageNum: 1, pageSize: 20, total: 0 })
+
+const fetchData = async () => {
+  loading.value = true
+  try {
+    const res = await api.queryMy({ pageNum: pagination.pageNum, pageSize: pagination.pageSize })
+    const pageData = res?.data
+    tableData.value = pageData?.data || []
+    pagination.total = pageData?.total || 0
+  } catch {
+    ElMessage.error('获取提交记录失败')
+    tableData.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+const handlePageChange = (page: number) => {
+  pagination.pageNum = page
+  fetchData()
+}
+
+const handleSizeChange = (size: number) => {
+  pagination.pageSize = size
+  pagination.pageNum = 1
+  fetchData()
+}
+
+const goProblem = (problemId?: number) => {
+  if (!problemId) return
+  router.push(`/problems/${problemId}`)
+}
+
+onMounted(() => {
+  if (!loginUserStore.loginUser.isLogin) {
+    ElMessage.warning('请先登录')
+    router.push({ path: '/login', query: { redirect: route.fullPath } })
+    return
+  }
+  fetchData()
+})
+</script>
+
+<template>
+  <div class="submission-list-view">
+    <el-card class="table-card" shadow="never">
+      <template #header><span class="card-title">我的提交记录</span></template>
+
+      <el-table v-loading="loading" :data="tableData" border stripe style="width: 100%" :empty-text="'暂无提交记录'">
+        <el-table-column type="expand">
+          <template #default="{ row }">
+            <div v-if="row.judgeDetail" class="expand-detail">
+              <div class="expand-title">判题详情</div>
+              <pre class="judge-detail">{{ row.judgeDetail }}</pre>
+            </div>
+            <el-empty v-else description="无判题详情" :image-size="40" />
+          </template>
+        </el-table-column>
+        <el-table-column prop="id" label="编号" width="90" align="center" />
+        <el-table-column label="题目" min-width="120">
+          <template #default="{ row }">
+            <el-link type="primary" @click="goProblem(row.problemId)">题目 #{{ row.problemId ?? '-' }}</el-link>
+          </template>
+        </el-table-column>
+        <el-table-column prop="language" label="语言" width="90" align="center">
+          <template #default="{ row }">{{ (row.language || 'java').toUpperCase() }}</template>
+        </el-table-column>
+        <el-table-column label="状态" width="120" align="center">
+          <template #default="{ row }">
+            <el-tag :type="getStatusInfo(row.status).tag">{{ getStatusInfo(row.status).label }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="得分" width="80" align="center">
+          <template #default="{ row }">{{ row.score ?? '-' }}</template>
+        </el-table-column>
+        <el-table-column label="用时" width="90" align="center">
+          <template #default="{ row }">{{ row.timeUsedMs != null ? `${row.timeUsedMs}ms` : '-' }}</template>
+        </el-table-column>
+        <el-table-column label="内存" width="100" align="center">
+          <template #default="{ row }">{{ row.memoryUsedKb != null ? `${row.memoryUsedKb}KB` : '-' }}</template>
+        </el-table-column>
+        <el-table-column label="通过用例" width="100" align="center">
+          <template #default="{ row }">
+            {{ row.passedTestCases ?? 0 }} / {{ row.totalTestCases ?? 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column label="提交时间" width="170" align="center">
+          <template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination">
+        <el-pagination
+          v-model:current-page="pagination.pageNum"
+          v-model:page-size="pagination.pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="pagination.total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @current-change="handlePageChange"
+          @size-change="handleSizeChange"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<style scoped>
+.submission-list-view {
+  padding: 20px;
+  max-width: 1200px;
+  margin: 0 auto;
+}
+
+.card-title {
+  font-weight: 600;
+}
+
+.pagination {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+
+.expand-detail {
+  padding: 8px 16px;
+}
+
+.expand-title {
+  font-weight: 600;
+  margin-bottom: 6px;
+}
+
+.judge-detail {
+  margin: 0;
+  padding: 12px;
+  background: #f5f7fa;
+  border-radius: 6px;
+  white-space: pre-wrap;
+  word-break: break-word;
+  font-size: 13px;
+}
+</style>

+ 5 - 0
src/vite-env.d.ts

@@ -1 +1,6 @@
 /// <reference types="vite/client" />
+
+declare module '*?worker' {
+  const workerConstructor: { new (options?: { name?: string }): Worker }
+  export default workerConstructor
+}

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov