Эх сурвалжийг харах

# feat: 题目列表与详情页

yangyi 2 сар өмнө
parent
commit
eda8931c05

+ 22 - 0
src/router/index.ts

@@ -6,10 +6,14 @@ import LoginView from '../view/LoginView.vue'
 import UserLayout from '../layout/UserLayout.vue'
 import AdminLayout from "../layout/AdminLayout.vue";
 import UserView from "../view/UserView.vue";
+import ProblemListView from "../view/problem/ProblemListView.vue";
+import ProblemDetailView from "../view/problem/ProblemDetailView.vue";
 import {
     HomeFilled,
     Menu,
     User,
+    Document,
+    Files,
 } from '@element-plus/icons-vue'
 import {useLoginUserStore} from "../store";
 import {ElMessage} from "element-plus";
@@ -38,6 +42,24 @@ export const userRoutes:RouteRecordRaw[] = [
                     icon: Menu
                 }
             },
+            {
+                path: '/problems',
+                name: 'problems',
+                component: ProblemListView,
+                meta: {
+                    title: '题目',
+                    icon: Document
+                }
+            },
+            {
+                path: '/problems/:id',
+                name: 'problemDetail',
+                component: ProblemDetailView,
+                meta: {
+                    title: '题目详情',
+                    icon: Files
+                }
+            },
         ],
     },
 ]

+ 192 - 0
src/view/problem/ProblemDetailView.vue

@@ -0,0 +1,192 @@
+<script setup lang="ts">
+import { getProblemController } from '@/api/problem-controller'
+import type { ProblemDetailVo } from '@/api/models'
+import { ElMessage } from 'element-plus'
+import { ArrowLeft } from '@element-plus/icons-vue'
+import { onMounted, ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+
+const route = useRoute()
+const router = useRouter()
+const api = getProblemController()
+
+const loading = ref(false)
+const detail = ref<ProblemDetailVo>({})
+
+const difficultyTag = (difficulty: ProblemDetailVo['difficulty']) => {
+  if (difficulty === 'easy') return 'success'
+  if (difficulty === 'medium') return 'warning'
+  return 'danger'
+}
+
+const difficultyLabel = (difficulty: ProblemDetailVo['difficulty']) => {
+  if (difficulty === 'easy') return '简单'
+  if (difficulty === 'medium') return '中等'
+  if (difficulty === 'hard') return '困难'
+  return '-'
+}
+
+const fetchDetail = async () => {
+  const id = Number(route.params.id)
+  if (!id) return
+  loading.value = true
+  try {
+    const res = await api.getDetail(id)
+    if (res?.code === 200 && res.data) {
+      detail.value = res.data
+    } else {
+      ElMessage.error(res?.message || '获取题目失败')
+    }
+  } catch {
+    ElMessage.error('获取题目失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const goBack = () => {
+  router.push('/problems')
+}
+
+onMounted(fetchDetail)
+</script>
+
+<template>
+  <div class="problem-detail-view" v-loading="loading">
+    <div class="detail-header">
+      <el-button text @click="goBack">
+        <el-icon><ArrowLeft /></el-icon>返回列表
+      </el-button>
+      <div class="title-row">
+        <h2 class="title">{{ detail.title }}</h2>
+        <el-tag :type="difficultyTag(detail.difficulty)">{{ difficultyLabel(detail.difficulty) }}</el-tag>
+        <el-tag :type="detail.isPublic === 1 ? 'success' : 'info'">{{ detail.isPublic === 1 ? '公开' : '未公开' }}</el-tag>
+      </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>
+          </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>
+          </div>
+          <div class="case-column">
+            <div class="case-label">输出</div>
+            <pre class="case-pre">{{ c.output }}</pre>
+          </div>
+        </div>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<style scoped>
+.problem-detail-view {
+  padding: 20px;
+  max-width: 1100px;
+  margin: 0 auto;
+}
+
+.detail-header {
+  margin-bottom: 16px;
+}
+
+.title-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.title {
+  margin: 0;
+  font-size: 24px;
+}
+
+.info-card,
+.desc-card,
+.case-card {
+  margin-bottom: 16px;
+}
+
+.card-title {
+  font-weight: 600;
+}
+
+.category-tag {
+  margin-right: 6px;
+}
+
+.description {
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+  font-family: inherit;
+  font-size: 14px;
+  line-height: 1.7;
+}
+
+.case-block {
+  margin-bottom: 16px;
+}
+
+.case-title {
+  font-weight: 600;
+  margin-bottom: 6px;
+}
+
+.case-desc {
+  color: #606266;
+  font-size: 13px;
+  margin-bottom: 6px;
+}
+
+.case-content {
+  display: flex;
+  gap: 16px;
+}
+
+.case-column {
+  flex: 1;
+}
+
+.case-label {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 4px;
+}
+
+.case-pre {
+  margin: 0;
+  padding: 12px;
+  background: #f5f7fa;
+  border-radius: 6px;
+  white-space: pre-wrap;
+  word-break: break-word;
+  font-size: 13px;
+}
+</style>

+ 215 - 0
src/view/problem/ProblemListView.vue

@@ -0,0 +1,215 @@
+<script setup lang="ts">
+import { getProblemController } from '@/api/problem-controller'
+import { getCategoryController } from '@/api/category-controller'
+import type { CategoryVo, ProblemVo, QueryByPage1Params } from '@/api/models'
+import { ElMessage } from 'element-plus'
+import { RefreshRight, Search } from '@element-plus/icons-vue'
+import { onMounted, reactive, ref } from 'vue'
+import { useRouter } from 'vue-router'
+
+const router = useRouter()
+const api = getProblemController()
+
+const searchForm = reactive({
+  title: '',
+  difficulty: '',
+  categoryId: undefined as number | undefined,
+  isPublic: undefined as number | undefined
+})
+
+const categoryOptions = ref<CategoryVo[]>([])
+const loadCategories = async () => {
+  try {
+    const res = await getCategoryController().list()
+    if (res?.code === 200) {
+      categoryOptions.value = res.data || []
+    }
+  } catch {
+    categoryOptions.value = []
+  }
+}
+
+const tableData = ref<ProblemVo[]>([])
+const loading = ref(false)
+const pagination = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  total: 0
+})
+
+const difficultyOptions = [
+  { label: '简单', value: 'easy' },
+  { label: '中等', value: 'medium' },
+  { label: '困难', value: 'hard' }
+]
+
+const difficultyTag = (difficulty: ProblemVo['difficulty']) => {
+  if (difficulty === 'easy') return 'success'
+  if (difficulty === 'medium') return 'warning'
+  return 'danger'
+}
+
+const difficultyLabel = (difficulty: ProblemVo['difficulty']) =>
+  difficultyOptions.find((d) => d.value === difficulty)?.label || difficulty || '-'
+
+const fetchData = async () => {
+  loading.value = true
+  try {
+    const condition: QueryByPage1Params = {
+      title: searchForm.title || undefined,
+      difficulty: searchForm.difficulty || undefined,
+      categoryId: searchForm.categoryId,
+      isPublic: searchForm.isPublic,
+      pageNum: pagination.pageNum,
+      pageSize: pagination.pageSize
+    }
+    const res = await api.queryByPage1(condition)
+    const pageData = res?.data
+    tableData.value = pageData?.data || []
+    pagination.total = pageData?.total || 0
+  } catch {
+    ElMessage.error('获取题目列表失败')
+    tableData.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleSearch = () => {
+  pagination.pageNum = 1
+  fetchData()
+}
+
+const handleReset = () => {
+  searchForm.title = ''
+  searchForm.difficulty = ''
+  searchForm.categoryId = undefined
+  searchForm.isPublic = undefined
+  handleSearch()
+}
+
+const handlePageChange = (page: number) => {
+  pagination.pageNum = page
+  fetchData()
+}
+
+const handleSizeChange = (size: number) => {
+  pagination.pageSize = size
+  pagination.pageNum = 1
+  fetchData()
+}
+
+const goDetail = (row: ProblemVo) => {
+  if (row.id !== undefined) {
+    router.push(`/problems/${row.id}`)
+  }
+}
+
+onMounted(() => {
+  loadCategories()
+  fetchData()
+})
+</script>
+
+<template>
+  <div class="problem-list-view">
+    <el-card class="search-card" shadow="never">
+      <el-form inline>
+        <el-form-item label="标题">
+          <el-input v-model="searchForm.title" placeholder="请输入题目标题" clearable style="width: 200px" @keyup.enter="handleSearch" />
+        </el-form-item>
+        <el-form-item label="难度">
+          <el-select v-model="searchForm.difficulty" placeholder="全部" clearable style="width: 120px">
+            <el-option v-for="item in difficultyOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="分类">
+          <el-select v-model="searchForm.categoryId" placeholder="全部" clearable style="width: 160px">
+            <el-option v-for="item in categoryOptions" :key="item.id" :label="item.categoryName" :value="item.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="公开状态">
+          <el-select v-model="searchForm.isPublic" placeholder="全部" clearable style="width: 120px">
+            <el-option label="公开" :value="1" />
+            <el-option label="未公开" :value="0" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">
+            <el-icon class="el-icon--left"><Search /></el-icon>搜索
+          </el-button>
+          <el-button @click="handleReset">
+            <el-icon class="el-icon--left"><RefreshRight /></el-icon>重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card class="table-card" shadow="never">
+      <el-table v-loading="loading" :data="tableData" border stripe style="width: 100%" @row-click="goDetail">
+        <el-table-column prop="id" label="ID" width="80" align="center" />
+        <el-table-column prop="title" label="标题" min-width="220">
+          <template #default="{ row }">
+            <el-link type="primary" :underline="false">{{ row.title || '-' }}</el-link>
+          </template>
+        </el-table-column>
+        <el-table-column label="难度" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag :type="difficultyTag(row.difficulty)">{{ difficultyLabel(row.difficulty) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="公开状态" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.isPublic === 1 ? 'success' : 'info'">{{ row.isPublic === 1 ? '公开' : '未公开' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="时间限制" width="110" align="center">
+          <template #default="{ row }">{{ row.timeLimitMs ?? '-' }} ms</template>
+        </el-table-column>
+        <el-table-column label="内存限制" width="110" align="center">
+          <template #default="{ row }">{{ row.memoryLimitMb ?? '-' }} MB</template>
+        </el-table-column>
+        <el-table-column label="通过/提交" width="110" align="center">
+          <template #default="{ row }">{{ row.acceptedCount ?? 0 }} / {{ row.tryCount ?? 0 }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="100" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link @click.stop="goDetail(row)">查看</el-button>
+          </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>
+.problem-list-view {
+  padding: 20px;
+}
+
+.search-card {
+  margin-bottom: 16px;
+}
+
+.table-card {
+  margin-bottom: 16px;
+}
+
+.pagination {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+</style>