Procházet zdrojové kódy

# feat: 比赛列表、详情与排名页面

yangyi před 2 měsíci
rodič
revize
7e87fa30ba

+ 31 - 0
src/router/index.ts

@@ -10,6 +10,9 @@ import ProblemListView from "../view/problem/ProblemListView.vue";
 import ProblemDetailView from "../view/problem/ProblemDetailView.vue";
 import ProblemManageView from "../view/admin/ProblemManageView.vue";
 import CategoryManageView from "../view/admin/CategoryManageView.vue";
+import ContestListView from "../view/contest/ContestListView.vue";
+import ContestDetailView from "../view/contest/ContestDetailView.vue";
+import ContestRankingView from "../view/contest/ContestRankingView.vue";
 import {
     HomeFilled,
     Menu,
@@ -17,6 +20,7 @@ import {
     Document,
     Files,
     Collection,
+    Trophy,
 } from '@element-plus/icons-vue'
 import {useLoginUserStore} from "../store";
 import {ElMessage} from "element-plus";
@@ -63,6 +67,33 @@ export const userRoutes:RouteRecordRaw[] = [
                     icon: Files
                 }
             },
+            {
+                path: '/contests',
+                name: 'contests',
+                component: ContestListView,
+                meta: {
+                    title: '比赛',
+                    icon: Trophy
+                }
+            },
+            {
+                path: '/contests/:id',
+                name: 'contestDetail',
+                component: ContestDetailView,
+                meta: {
+                    title: '比赛详情',
+                    icon: Trophy
+                }
+            },
+            {
+                path: '/contests/:id/ranking',
+                name: 'contestRanking',
+                component: ContestRankingView,
+                meta: {
+                    title: '比赛排名',
+                    icon: Trophy
+                }
+            },
         ],
     },
 ]

+ 16 - 0
src/type/contest.ts

@@ -0,0 +1,16 @@
+import type { ContestVo } from '@/api/models'
+
+export interface ContestProblem {
+  problemId: number
+  title: string
+  difficulty: string
+  displayOrder: number
+  score: number
+}
+
+export interface ContestDetail extends ContestVo {
+  description?: string
+  creatorUsername?: string
+  registered?: boolean
+  problems?: ContestProblem[]
+}

+ 298 - 0
src/view/contest/ContestDetailView.vue

@@ -0,0 +1,298 @@
+<script setup lang="ts">
+import { getContestController } from '@/api/contest-controller'
+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 type { ContestDetail } from '@/type/contest'
+
+const route = useRoute()
+const router = useRouter()
+const api = getContestController()
+const loginUserStore = useLoginUserStore()
+
+const contestId = computed(() => Number(route.params.id))
+const loading = ref(false)
+const detail = ref<ContestDetail>({})
+const unlocked = ref(true)
+const validPassword = ref<string | undefined>(undefined)
+
+const passwordDialogVisible = ref(false)
+const passwordInput = ref('')
+const registerMode = ref(false)
+
+const typeOptions = [
+  { label: '公开', value: 'public' },
+  { label: '私有', value: 'private' },
+  { label: '密码', value: 'password' }
+]
+
+const statusOptions = [
+  { label: '未开始', value: 'pending' },
+  { label: '进行中', value: 'running' },
+  { label: '已结束', value: 'ended' }
+]
+
+const typeLabel = (type?: string) => typeOptions.find((t) => t.value === type)?.label || type || '-'
+const statusLabel = (status?: string) => statusOptions.find((s) => s.value === status)?.label || status || '-'
+
+const statusTag = (status?: string) => {
+  if (status === 'pending') return 'warning'
+  if (status === 'running') return 'success'
+  return 'info'
+}
+
+const difficultyLabel = (d?: string) => {
+  if (d === 'easy') return '简单'
+  if (d === 'medium') return '中等'
+  if (d === 'hard') return '困难'
+  return '-'
+}
+
+const formatTime = (t?: string) => (t ? t.replace('T', ' ').slice(0, 16) : '-')
+
+const isLogin = computed(() => loginUserStore.loginUser.isLogin)
+const isPasswordType = computed(() => detail.value.type === 'password')
+
+const canRegister = computed(() => {
+  if (!isLogin.value) return false
+  const d = detail.value
+  if (d.registered === true) return false
+  if (d.status !== 'pending' && d.status !== 'running') return false
+  if (isPasswordType.value && !unlocked.value) return false
+  return true
+})
+
+const ensureLogin = () => {
+  if (!isLogin.value) {
+    ElMessage.warning('请先登录')
+    router.push({ path: '/login', query: { redirect: route.fullPath } })
+    return false
+  }
+  return true
+}
+
+const fetchDetail = async (password?: string) => {
+  if (!contestId.value) return
+  loading.value = true
+  try {
+    const res = await api.getDetail1(contestId.value, password ? { password } : undefined)
+    if (res?.code === 200 && res.data) {
+      const d = res.data as unknown as ContestDetail
+      detail.value = d
+      if (d.type === 'password') {
+        if (!d.creatorUsername) {
+          unlocked.value = false
+          if (password) {
+            ElMessage.error('访问密码错误')
+          } else {
+            openViewPasswordDialog()
+          }
+        } else {
+          unlocked.value = true
+          if (password) validPassword.value = password
+        }
+      } else {
+        unlocked.value = true
+      }
+    } else {
+      ElMessage.error(res?.message || '获取比赛失败')
+      router.push('/contests')
+    }
+  } catch {
+    ElMessage.error('获取比赛失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const openViewPasswordDialog = () => {
+  registerMode.value = false
+  passwordInput.value = ''
+  passwordDialogVisible.value = true
+}
+
+const submitPassword = async () => {
+  if (!passwordInput.value) {
+    ElMessage.warning('请输入比赛密码')
+    return
+  }
+  const pwd = passwordInput.value
+  passwordInput.value = ''
+  passwordDialogVisible.value = false
+  if (registerMode.value) {
+    await doRegister(pwd)
+  } else {
+    await fetchDetail(pwd)
+  }
+}
+
+const doRegister = async (password?: string) => {
+  try {
+    const res = await api.register(contestId.value, { password })
+    if (res?.code === 200) {
+      ElMessage.success('报名成功')
+      if (isPasswordType.value && password) validPassword.value = password
+      await fetchDetail(validPassword.value)
+    } else {
+      ElMessage.error(res?.message || '报名失败')
+    }
+  } catch {
+    ElMessage.error('报名失败')
+  }
+}
+
+const handleRegister = () => {
+  if (!ensureLogin()) return
+  if (isPasswordType.value) {
+    registerMode.value = true
+    passwordInput.value = ''
+    passwordDialogVisible.value = true
+  } else {
+    doRegister()
+  }
+}
+
+const goRanking = () => {
+  if (!ensureLogin()) return
+  router.push(`/contests/${contestId.value}/ranking`)
+}
+
+onMounted(fetchDetail)
+</script>
+
+<template>
+  <div class="contest-detail-view" v-loading="loading">
+    <div class="detail-header">
+      <el-button text @click="router.push('/contests')">
+        <el-icon><ArrowLeft /></el-icon>返回列表
+      </el-button>
+      <div class="title-row">
+        <h2 class="title">{{ detail.title }}</h2>
+        <el-tag type="primary">{{ typeLabel(detail.type) }}</el-tag>
+        <el-tag :type="statusTag(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
+      </div>
+    </div>
+
+    <el-card class="info-card" shadow="never">
+      <el-descriptions :column="3" border>
+        <el-descriptions-item label="开始时间">{{ formatTime(detail.startDatetime) }}</el-descriptions-item>
+        <el-descriptions-item label="结束时间">{{ formatTime(detail.endDatetime) }}</el-descriptions-item>
+        <el-descriptions-item label="创建者">{{ detail.creatorUsername || '-' }}</el-descriptions-item>
+      </el-descriptions>
+    </el-card>
+
+    <el-card v-if="isPasswordType && !unlocked" class="locked-card" shadow="never">
+      <div class="locked-hint">
+        <el-icon class="locked-icon"><Key /></el-icon>
+        <span>该比赛需要密码才能查看</span>
+        <el-button type="primary" size="small" @click="openViewPasswordDialog">输入密码</el-button>
+      </div>
+    </el-card>
+
+    <template v-else>
+      <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 class="table-card" shadow="never">
+        <template #header><span class="card-title">比赛题目</span></template>
+        <el-table v-if="(detail.problems || []).length" :data="detail.problems" border stripe style="width: 100%">
+          <el-table-column label="题号" width="80" align="center">
+            <template #default="{ row }">{{ row.displayOrder ?? '-' }}</template>
+          </el-table-column>
+          <el-table-column prop="title" label="标题" min-width="200">
+            <template #default="{ row }">{{ row.title || '-' }}</template>
+          </el-table-column>
+          <el-table-column label="难度" width="100" align="center">
+            <template #default="{ row }">{{ difficultyLabel(row.difficulty) }}</template>
+          </el-table-column>
+          <el-table-column label="分值" width="100" align="center">
+            <template #default="{ row }">{{ row.score ?? '-' }}</template>
+          </el-table-column>
+        </el-table>
+        <el-empty v-else description="暂无题目" :image-size="60" />
+      </el-card>
+    </template>
+
+    <div class="action-bar">
+      <el-button v-if="canRegister" type="primary" @click="handleRegister">报名比赛</el-button>
+      <el-button v-else-if="detail.registered === true" disabled>已报名</el-button>
+      <el-button v-if="isLogin" type="success" plain @click="goRanking">
+        <el-icon class="el-icon--left"><Trophy /></el-icon>查看排名
+      </el-button>
+    </div>
+
+    <el-dialog v-model="passwordDialogVisible" :title="registerMode ? '报名比赛' : '输入比赛密码'" width="420px">
+      <el-form label-width="90px">
+        <el-form-item label="比赛密码">
+          <el-input v-model="passwordInput" type="password" show-password placeholder="请输入比赛密码" @keyup.enter="submitPassword" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="passwordDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitPassword">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+.contest-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,
+.locked-card,
+.desc-card,
+.table-card {
+  margin-bottom: 16px;
+}
+
+.card-title {
+  font-weight: 600;
+}
+
+.locked-hint {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  color: #606266;
+}
+
+.locked-icon {
+  font-size: 18px;
+}
+
+.description {
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+  font-family: inherit;
+  font-size: 14px;
+  line-height: 1.7;
+}
+
+.action-bar {
+  margin-top: 16px;
+}
+</style>

+ 199 - 0
src/view/contest/ContestListView.vue

@@ -0,0 +1,199 @@
+<script setup lang="ts">
+import { getContestController } from '@/api/contest-controller'
+import type { ContestVo, QueryByPage2Params } 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 = getContestController()
+
+const searchForm = reactive({
+  title: '',
+  status: ''
+})
+
+const tableData = ref<ContestVo[]>([])
+const loading = ref(false)
+const pagination = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  total: 0
+})
+
+const typeOptions = [
+  { label: '公开', value: 'public' },
+  { label: '私有', value: 'private' },
+  { label: '密码', value: 'password' }
+]
+
+const statusOptions = [
+  { label: '未开始', value: 'pending' },
+  { label: '进行中', value: 'running' },
+  { label: '已结束', value: 'ended' }
+]
+
+const typeTag = (type: ContestVo['type']) => {
+  if (type === 'public') return 'success'
+  if (type === 'password') return 'warning'
+  return 'info'
+}
+
+const typeLabel = (type: ContestVo['type']) =>
+  typeOptions.find((t) => t.value === type)?.label || type || '-'
+
+const statusTag = (status: ContestVo['status']) => {
+  if (status === 'pending') return 'warning'
+  if (status === 'running') return 'success'
+  return 'info'
+}
+
+const statusLabel = (status: ContestVo['status']) =>
+  statusOptions.find((s) => s.value === status)?.label || status || '-'
+
+const formatTime = (t?: string) => {
+  if (!t) return '-'
+  return t.replace('T', ' ').slice(0, 16)
+}
+
+const fetchData = async () => {
+  loading.value = true
+  try {
+    const condition: QueryByPage2Params = {
+      title: searchForm.title || undefined,
+      status: searchForm.status || undefined,
+      pageNum: pagination.pageNum,
+      pageSize: pagination.pageSize
+    }
+    const res = await api.queryByPage2(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.status = ''
+  handleSearch()
+}
+
+const handlePageChange = (page: number) => {
+  pagination.pageNum = page
+  fetchData()
+}
+
+const handleSizeChange = (size: number) => {
+  pagination.pageSize = size
+  pagination.pageNum = 1
+  fetchData()
+}
+
+const goDetail = (row: ContestVo) => {
+  if (row.id !== undefined) {
+    router.push(`/contests/${row.id}`)
+  }
+}
+
+onMounted(fetchData)
+</script>
+
+<template>
+  <div class="contest-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.status" placeholder="全部" clearable style="width: 120px">
+            <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </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="typeTag(row.type)">{{ typeLabel(row.type) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="开始时间" width="160" align="center">
+          <template #default="{ row }">{{ formatTime(row.startDatetime) }}</template>
+        </el-table-column>
+        <el-table-column label="结束时间" width="160" align="center">
+          <template #default="{ row }">{{ formatTime(row.endDatetime) }}</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>
+.contest-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>

+ 123 - 0
src/view/contest/ContestRankingView.vue

@@ -0,0 +1,123 @@
+<script setup lang="ts">
+import { getContestController } from '@/api/contest-controller'
+import type { RankVo } from '@/api/models'
+import { ElMessage } from 'element-plus'
+import { ArrowLeft, Trophy } from '@element-plus/icons-vue'
+import { onMounted, ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useLoginUserStore } from '@/store'
+
+const route = useRoute()
+const router = useRouter()
+const api = getContestController()
+const loginUserStore = useLoginUserStore()
+
+const contestId = Number(route.params.id)
+const loading = ref(false)
+const tableData = ref<RankVo[]>([])
+const contestTitle = ref('')
+
+const fetchRanking = async () => {
+  loading.value = true
+  try {
+    const res = await api.getRanking(contestId)
+    if (res?.code === 200) {
+      tableData.value = res.data || []
+    } else {
+      ElMessage.error(res?.message || '获取排行榜失败')
+    }
+  } catch {
+    ElMessage.error('获取排行榜失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const fetchTitle = async () => {
+  try {
+    const res = await api.getDetail1(contestId)
+    if (res?.code === 200 && res.data) {
+      contestTitle.value = res.data.title || ''
+    }
+  } catch {
+    // 忽略,仅用于展示标题
+  }
+}
+
+onMounted(() => {
+  if (!loginUserStore.loginUser.isLogin) {
+    ElMessage.warning('请先登录')
+    router.push({ path: '/login', query: { redirect: route.fullPath } })
+    return
+  }
+  fetchTitle()
+  fetchRanking()
+})
+</script>
+
+<template>
+  <div class="contest-ranking-view" v-loading="loading">
+    <div class="ranking-header">
+      <el-button text @click="router.push(`/contests/${contestId}`)">
+        <el-icon><ArrowLeft /></el-icon>返回详情
+      </el-button>
+      <div class="title-row">
+        <el-icon class="trophy-icon"><Trophy /></el-icon>
+        <h2 class="title">排行榜{{ contestTitle ? ` - ${contestTitle}` : '' }}</h2>
+      </div>
+    </div>
+
+    <el-card class="table-card" shadow="never">
+      <el-table v-if="tableData.length" :data="tableData" border stripe style="width: 100%">
+        <el-table-column label="名次" width="80" align="center">
+          <template #default="{ $index }">{{ $index + 1 }}</template>
+        </el-table-column>
+        <el-table-column prop="username" label="用户" min-width="180">
+          <template #default="{ row }">{{ row.username || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="总分" width="120" align="center">
+          <template #default="{ row }">{{ row.totalScore ?? 0 }}</template>
+        </el-table-column>
+        <el-table-column label="AC最大耗时(分钟)" width="160" align="center">
+          <template #default="{ row }">{{ row.maxAcMinutes ?? '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="regTime" label="报名时间" width="180" align="center">
+          <template #default="{ row }">{{ row.regTime ? row.regTime.replace('T', ' ').slice(0, 16) : '-' }}</template>
+        </el-table-column>
+      </el-table>
+      <el-empty v-else description="暂无排名数据(提交模块尚未实现,属正常现象)" :image-size="80" />
+    </el-card>
+  </div>
+</template>
+
+<style scoped>
+.contest-ranking-view {
+  padding: 20px;
+  max-width: 1100px;
+  margin: 0 auto;
+}
+
+.ranking-header {
+  margin-bottom: 16px;
+}
+
+.title-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.trophy-icon {
+  font-size: 22px;
+  color: #e6a23c;
+}
+
+.title {
+  margin: 0;
+  font-size: 24px;
+}
+
+.table-card {
+  margin-bottom: 16px;
+}
+</style>

+ 14 - 14
todo.md

@@ -3,29 +3,29 @@
 > 实现顺序:题目模块 → 比赛模块。提交/判题模块本期不实现:题目详情页暂不加代码编辑器;比赛排名接口数据为空(待提交模块)。后端每完成一批接口先 `npm run api:gen`。
 
 ## Phase 0 路由与权限扩展
-- [ ] router 新增 `meta.requiresCoach`(admin | coach 可访问),保留 `requiresAdmin`(仅 admin,用户管理)
-- [ ] AdminLayout:菜单按角色过滤;UserLayout:后台管理入口对 admin 和 coach 均显示
+- [x] router 新增 `meta.requiresCoach`(admin | coach 可访问),保留 `requiresAdmin`(仅 admin,用户管理)
+- [x] AdminLayout:菜单按角色过滤;UserLayout:后台管理入口对 admin 和 coach 均显示
 - Commit(ui): `# feat: 路由与布局支持coach后台入口`
 
 ## Phase 1 题目模块 · API生成 + 用户浏览页
-- [ ] `npm run api:gen` → problem-controller.ts / category-controller.ts + models
-- [ ] `view/problem/ProblemListView.vue`(/problems):标题/难度/分类搜索、分页、难度与公开状态标签
-- [ ] `view/problem/ProblemDetailView.vue`(/problems/:id):描述、时间/内存限制、提交/通过数、样例、分类标签
-- [ ] 注册 userRoutes + 顶部导航菜单
+- [x] `npm run api:gen` → problem-controller.ts / category-controller.ts + models
+- [x] `view/problem/ProblemListView.vue`(/problems):标题/难度/分类搜索、分页、难度与公开状态标签
+- [x] `view/problem/ProblemDetailView.vue`(/problems/:id):描述、时间/内存限制、提交/通过数、样例、分类标签
+- [x] 注册 userRoutes + 顶部导航菜单
 - Commit(ui): `# feat: 题目列表与详情页`
 
 ## Phase 2 题目模块 · 管理后台页
-- [ ] `view/admin/ProblemManageView.vue`(/admin/problems):仿 UserView,搜索卡+表格+弹窗;编辑时维护分类勾选;详情弹窗管理样例与评测用例(含隐藏标记)
-- [ ] `view/admin/CategoryManageView.vue`(/admin/categories):分类 CRUD
-- [ ] 注册 adminRoutes + 侧边菜单
+- [x] `view/admin/ProblemManageView.vue`(/admin/problems):仿 UserView,搜索卡+表格+弹窗;编辑时维护分类勾选;详情弹窗管理样例与评测用例(含隐藏标记)
+- [x] `view/admin/CategoryManageView.vue`(/admin/categories):分类 CRUD
+- [x] 注册 adminRoutes + 侧边菜单
 - Commit(ui): `# feat: 题目管理与分类管理后台页面`
 
 ## Phase 3 比赛模块 · API生成 + 用户浏览页
-- [ ] `npm run api:gen` → contest-controller.ts + models
-- [ ] `view/contest/ContestListView.vue`(/contests):标题/状态搜索、类型标签、起止时间、分页
-- [ ] `view/contest/ContestDetailView.vue`(/contests/:id):基本信息、题目列表(题号/分值)、报名(password 型先弹密码框再拉取)、已报名/比赛状态判断
-- [ ] `view/contest/ContestRankingView.vue`(/contests/:id/ranking):排名表格(名次/用户/总分/AC最大耗时)
-- [ ] 注册 userRoutes + 导航菜单
+- [x] `npm run api:gen` → contest-controller.ts + models
+- [x] `view/contest/ContestListView.vue`(/contests):标题/状态搜索、类型标签、起止时间、分页
+- [x] `view/contest/ContestDetailView.vue`(/contests/:id):基本信息、题目列表(题号/分值)、报名(password 型先弹密码框再拉取)、已报名/比赛状态判断
+- [x] `view/contest/ContestRankingView.vue`(/contests/:id/ranking):排名表格(名次/用户/总分/AC最大耗时)
+- [x] 注册 userRoutes + 导航菜单
 - Commit(ui): `# feat: 比赛列表、详情与排名页面`
 
 ## Phase 4 比赛模块 · 管理后台页