Procházet zdrojové kódy

# feat: 比赛管理后台页面

yangyi před 2 měsíci
rodič
revize
714b7c6c81
3 změnil soubory, kde provedl 700 přidání a 2 odebrání
  1. 11 0
      src/router/index.ts
  2. 687 0
      src/view/admin/ContestManageView.vue
  3. 2 2
      todo.md

+ 11 - 0
src/router/index.ts

@@ -10,6 +10,7 @@ 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 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";
@@ -124,6 +125,16 @@ export const adminRoutes:RouteRecordRaw[] = [
                     requiresCoach: true
                 }
             },
+            {
+                path: '/admin/contests',
+                name: 'contestManage',
+                component: ContestManageView,
+                meta:{
+                    title: '比赛管理',
+                    icon: Trophy,
+                    requiresCoach: true
+                }
+            },
             {
                 path: '/admin/users',
                 name: 'users',

+ 687 - 0
src/view/admin/ContestManageView.vue

@@ -0,0 +1,687 @@
+<script setup lang="ts">
+import { getContestController } from '@/api/contest-controller'
+import { getProblemController } from '@/api/problem-controller'
+import type {
+  ContestDto,
+  ContestProblemDto,
+  ContestRegistrationVo,
+  ContestVo,
+  ProblemVo,
+  QueryByPage1Params,
+  QueryByPage2Params
+} from '@/api/models'
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
+import { Delete, Edit, Plus, RefreshRight, Search, UserFilled, View } from '@element-plus/icons-vue'
+import { computed, onMounted, reactive, ref } from 'vue'
+import { useLoginUserStore } from '@/store'
+import type { ContestDetail, ContestProblem } from '@/type/contest'
+
+const api = getContestController()
+const problemApi = getProblemController()
+const loginUserStore = useLoginUserStore()
+const isAdmin = computed(() => loginUserStore.loginUser.user?.role === 'admin')
+
+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) => (t ? t.replace('T', ' ').slice(0, 16) : '-')
+
+// ---------- 搜索 ----------
+const searchForm = reactive({
+  title: '',
+  status: ''
+})
+
+const tableData = ref<ContestVo[]>([])
+const loading = ref(false)
+const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 })
+
+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 dialogVisible = ref(false)
+const dialogTitle = ref('新增比赛')
+const formRef = ref<FormInstance>()
+const contestForm = reactive<Partial<ContestVo> & { description?: string; password?: string }>({
+  id: undefined,
+  title: '',
+  description: '',
+  type: 'public' as ContestVo['type'],
+  password: '',
+  startDatetime: '',
+  endDatetime: ''
+})
+
+const formRules: FormRules = {
+  title: [{ required: true, message: '请输入比赛标题', trigger: 'blur' }],
+  type: [{ required: true, message: '请选择比赛类型', trigger: 'change' }],
+  startDatetime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
+  endDatetime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
+  password: [
+    {
+      validator: (_rule, value, callback) => {
+        if (contestForm.type === 'password' && !value) {
+          callback(new Error('密码型比赛请输入访问密码'))
+        } else {
+          callback()
+        }
+      },
+      trigger: 'blur'
+    }
+  ]
+}
+
+const isEdit = computed(() => !!contestForm.id)
+
+const handleAdd = () => {
+  dialogTitle.value = '新增比赛'
+  contestForm.id = undefined
+  contestForm.title = ''
+  contestForm.description = ''
+  contestForm.type = 'public' as ContestVo['type']
+  contestForm.password = ''
+  contestForm.startDatetime = ''
+  contestForm.endDatetime = ''
+  dialogVisible.value = true
+}
+
+const handleEdit = (row: ContestVo) => {
+  dialogTitle.value = '编辑比赛'
+  contestForm.id = row.id
+  contestForm.title = row.title || ''
+  contestForm.description = ''
+  contestForm.type = row.type
+  contestForm.password = ''
+  contestForm.startDatetime = row.startDatetime || ''
+  contestForm.endDatetime = row.endDatetime || ''
+  dialogVisible.value = true
+}
+
+const handleSave = async () => {
+  const valid = await formRef.value?.validate().catch(() => false)
+  if (!valid) return
+  if (contestForm.startDatetime && contestForm.endDatetime && contestForm.startDatetime >= contestForm.endDatetime) {
+    ElMessage.error('开始时间必须早于结束时间')
+    return
+  }
+  try {
+    const data = {
+      id: contestForm.id,
+      title: contestForm.title!,
+      description: contestForm.description || undefined,
+      type: contestForm.type as ContestDto['type'],
+      password: contestForm.type === 'password' ? contestForm.password : undefined,
+      startDatetime: contestForm.startDatetime!,
+      endDatetime: contestForm.endDatetime!
+    }
+    const res = isEdit.value ? await api.edit2(data) : await api.add2(data)
+    if (res?.code === 200) {
+      ElMessage.success(isEdit.value ? '修改成功' : '新增成功')
+      dialogVisible.value = false
+      fetchData()
+    } else {
+      ElMessage.error(res?.message || '保存失败')
+    }
+  } catch {
+    ElMessage.error('保存失败')
+  }
+}
+
+const handleDelete = (row: ContestVo) => {
+  if (row.id === undefined) return
+  ElMessageBox.confirm('确认删除该比赛吗?', '提示', { type: 'warning' })
+    .then(async () => {
+      try {
+        const res = await api.deleteByIds1({ ids: [row.id!] })
+        if (res?.code === 200) {
+          ElMessage.success('删除成功')
+          fetchData()
+        } else {
+          ElMessage.error(res?.message || '删除失败')
+        }
+      } catch {
+        ElMessage.error('删除失败')
+      }
+    })
+    .catch(() => {})
+}
+
+// ---------- 题目配置 ----------
+const configDialogVisible = ref(false)
+const configContestId = ref<number | undefined>(undefined)
+const configTitle = ref('')
+const configType = ref<ContestVo['type']>('public')
+const configPassword = ref('')
+const configLoaded = ref(false)
+const configSaving = ref(false)
+const configProblems = ref<ContestProblem[]>([])
+const existingProblems = ref<ContestProblem[]>([])
+
+const candSearch = reactive({ title: '' })
+const candData = ref<ProblemVo[]>([])
+const candLoading = ref(false)
+const candPagination = reactive({ pageNum: 1, pageSize: 10, total: 0 })
+
+const loadCandidates = async () => {
+  candLoading.value = true
+  try {
+    const condition: QueryByPage1Params = {
+      title: candSearch.title || undefined,
+      pageNum: candPagination.pageNum,
+      pageSize: candPagination.pageSize
+    }
+    const res = await problemApi.queryByPage1(condition)
+    const pageData = res?.data
+    candData.value = pageData?.data || []
+    candPagination.total = pageData?.total || 0
+  } catch {
+    candData.value = []
+  } finally {
+    candLoading.value = false
+  }
+}
+
+const candHandleSearch = () => {
+  candPagination.pageNum = 1
+  loadCandidates()
+}
+
+const candPageChange = (page: number) => {
+  candPagination.pageNum = page
+  loadCandidates()
+}
+
+const candSizeChange = (size: number) => {
+  candPagination.pageSize = size
+  candPagination.pageNum = 1
+  loadCandidates()
+}
+
+const loadConfig = async () => {
+  if (!configContestId.value) return
+  if (configType.value === 'password' && !configPassword.value) {
+    ElMessage.warning('请输入比赛密码')
+    return
+  }
+  try {
+    const res = await api.getDetail1(configContestId.value, configPassword.value ? { password: configPassword.value } : undefined)
+    if (res?.code === 200 && res.data) {
+      const d = res.data as unknown as ContestDetail
+      if (d.type === 'password' && !d.creatorUsername) {
+        ElMessage.error('比赛密码错误')
+        return
+      }
+      existingProblems.value = (d.problems || []).map((p) => ({ ...p }))
+      configProblems.value = (d.problems || []).map((p) => ({ ...p }))
+      configLoaded.value = true
+    } else {
+      ElMessage.error(res?.message || '获取比赛详情失败')
+    }
+  } catch {
+    ElMessage.error('获取比赛详情失败')
+  }
+}
+
+const isConfigured = (problemId?: number) =>
+  problemId !== undefined && configProblems.value.some((p) => p.problemId === problemId)
+
+const toggleConfigure = (row: ProblemVo, checked: boolean) => {
+  if (row.id === undefined) return
+  if (checked) {
+    if (!isConfigured(row.id)) {
+      configProblems.value.push({
+        problemId: row.id,
+        title: row.title || '',
+        difficulty: row.difficulty || '',
+        displayOrder: configProblems.value.length + 1,
+        score: 100
+      })
+    }
+  } else {
+    configProblems.value = configProblems.value.filter((p) => p.problemId !== row.id)
+  }
+}
+
+const removeConfigured = (problemId: number) => {
+  configProblems.value = configProblems.value.filter((p) => p.problemId !== problemId)
+}
+
+const saveConfig = async () => {
+  if (!configContestId.value) return
+  if (!configProblems.value.length) {
+    ElMessage.warning('请至少配置一道题目')
+    return
+  }
+  configSaving.value = true
+  try {
+    const id = configContestId.value
+    const current = configProblems.value
+    const existing = existingProblems.value
+
+    const existingMap = new Map(existing.map((p) => [p.problemId, p]))
+    const currentMap = new Map(current.map((p) => [p.problemId, p]))
+
+    for (const cp of current) {
+      const old = existingMap.get(cp.problemId)
+      const dto: ContestProblemDto = { problemId: cp.problemId, score: cp.score, displayOrder: cp.displayOrder }
+      if (!old) {
+        const res = await api.addProblem(id, dto)
+        if (res?.code !== 200) {
+          ElMessage.error(res?.message || `添加题目 ${cp.title} 失败`)
+          return
+        }
+      } else if (old.score !== cp.score || old.displayOrder !== cp.displayOrder) {
+        const res = await api.updateProblem(id, dto)
+        if (res?.code !== 200) {
+          ElMessage.error(res?.message || `更新题目 ${cp.title} 失败`)
+          return
+        }
+      }
+    }
+
+    for (const ex of existing) {
+      if (!currentMap.has(ex.problemId)) {
+        const res = await api.removeProblem(id, { problemId: ex.problemId })
+        if (res?.code !== 200) {
+          ElMessage.error(res?.message || `移除题目 ${ex.title} 失败`)
+          return
+        }
+      }
+    }
+
+    ElMessage.success('题目配置保存成功')
+    configDialogVisible.value = false
+  } catch {
+    ElMessage.error('保存题目配置失败')
+  } finally {
+    configSaving.value = false
+  }
+}
+
+const openConfig = (row: ContestVo) => {
+  configContestId.value = row.id
+  configTitle.value = row.title || ''
+  configType.value = row.type
+  configPassword.value = ''
+  configLoaded.value = false
+  existingProblems.value = []
+  configProblems.value = []
+  configDialogVisible.value = true
+  loadConfig()
+}
+
+// ---------- 报名名单 ----------
+const regDialogVisible = ref(false)
+const regTitle = ref('')
+const regLoading = ref(false)
+const regTable = ref<ContestRegistrationVo[]>([])
+
+const openRegistrations = (row: ContestVo) => {
+  const id = row.id
+  if (id === undefined) return
+  regTitle.value = row.title || ''
+  regDialogVisible.value = true
+  regTable.value = []
+  regLoading.value = true
+  api
+    .queryRegistrations(id)
+    .then((res) => {
+      if (res?.code === 200) {
+        regTable.value = res.data || []
+      } else {
+        ElMessage.error(res?.message || '获取报名名单失败')
+      }
+    })
+    .catch(() => ElMessage.error('获取报名名单失败'))
+    .finally(() => {
+      regLoading.value = false
+    })
+}
+
+onMounted(fetchData)
+</script>
+
+<template>
+  <div class="contest-manage-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>
+
+    <div class="toolbar">
+      <el-button type="primary" @click="handleAdd">
+        <el-icon class="el-icon--left"><Plus /></el-icon>新增
+      </el-button>
+      <el-button @click="fetchData">
+        <el-icon class="el-icon--left"><RefreshRight /></el-icon>刷新
+      </el-button>
+    </div>
+
+    <el-card class="table-card" shadow="never">
+      <el-table v-loading="loading" :data="tableData" border stripe style="width: 100%">
+        <el-table-column prop="id" label="ID" width="80" align="center" />
+        <el-table-column prop="title" label="标题" min-width="180" />
+        <el-table-column label="类型" width="90" 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="90" 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="150" align="center">
+          <template #default="{ row }">{{ formatTime(row.startDatetime) }}</template>
+        </el-table-column>
+        <el-table-column label="结束时间" width="150" align="center">
+          <template #default="{ row }">{{ formatTime(row.endDatetime) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="250" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link @click="handleEdit(row)">
+              <el-icon><Edit /></el-icon>编辑
+            </el-button>
+            <el-button type="primary" link @click="openConfig(row)">
+              <el-icon><View /></el-icon>配置题目
+            </el-button>
+            <el-button type="primary" link @click="openRegistrations(row)">
+              <el-icon><UserFilled /></el-icon>报名
+            </el-button>
+            <el-button v-if="isAdmin" type="danger" link @click="handleDelete(row)">
+              <el-icon><Delete /></el-icon>删除
+            </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>
+
+    <!-- 新增/编辑比赛 -->
+    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" draggable>
+      <el-form ref="formRef" :model="contestForm" :rules="formRules" label-width="100px">
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="contestForm.title" placeholder="请输入比赛标题" />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="contestForm.description" type="textarea" :rows="3" placeholder="请输入比赛描述" />
+        </el-form-item>
+        <el-form-item label="类型" prop="type">
+          <el-select v-model="contestForm.type" style="width: 200px">
+            <el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item v-if="contestForm.type === 'password'" label="访问密码" prop="password">
+          <el-input v-model="contestForm.password" type="password" show-password placeholder="请输入访问密码" />
+        </el-form-item>
+        <el-form-item label="开始时间" prop="startDatetime">
+          <el-date-picker
+            v-model="contestForm.startDatetime"
+            type="datetime"
+            value-format="YYYY-MM-DDTHH:mm:ss"
+            placeholder="请选择开始时间"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="结束时间" prop="endDatetime">
+          <el-date-picker
+            v-model="contestForm.endDatetime"
+            type="datetime"
+            value-format="YYYY-MM-DDTHH:mm:ss"
+            placeholder="请选择结束时间"
+            style="width: 100%"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">确定</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 题目配置 -->
+    <el-dialog v-model="configDialogVisible" :title="`配置题目 - ${configTitle}`" width="960px" draggable>
+      <div v-if="configType === 'password' && !configLoaded" class="config-password-row">
+        <el-input
+          v-model="configPassword"
+          type="password"
+          show-password
+          placeholder="该比赛为密码型,请输入访问密码后加载"
+          style="width: 320px"
+          @keyup.enter="loadConfig"
+        />
+        <el-button type="primary" @click="loadConfig">加载</el-button>
+      </div>
+      <div class="config-body">
+        <div class="candidate-panel">
+          <el-form inline class="cand-search">
+            <el-form-item label="标题">
+              <el-input v-model="candSearch.title" placeholder="搜索题目" clearable style="width: 160px" @keyup.enter="candHandleSearch" />
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" size="small" @click="candHandleSearch">搜索</el-button>
+            </el-form-item>
+          </el-form>
+          <el-table v-loading="candLoading" :data="candData" border stripe size="small" height="420">
+            <el-table-column label="选择" width="60" align="center">
+              <template #default="{ row }">
+                <el-checkbox :model-value="isConfigured(row.id)" @change="(v: boolean) => toggleConfigure(row, v)" />
+              </template>
+            </el-table-column>
+            <el-table-column prop="id" label="ID" width="60" align="center" />
+            <el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
+            <el-table-column prop="difficulty" label="难度" width="80" align="center">
+              <template #default="{ row }">{{ row.difficulty || '-' }}</template>
+            </el-table-column>
+          </el-table>
+          <div class="pagination">
+            <el-pagination
+              v-model:current-page="candPagination.pageNum"
+              v-model:page-size="candPagination.pageSize"
+              :page-sizes="[10, 20, 50]"
+              :total="candPagination.total"
+              layout="total, sizes, prev, pager, next"
+              @current-change="candPageChange"
+              @size-change="candSizeChange"
+            />
+          </div>
+        </div>
+
+        <div class="config-panel">
+          <div class="config-header">
+            <span class="card-title">已选题目({{ configProblems.length }})</span>
+            <el-button v-if="configLoaded" type="primary" size="small" :loading="configSaving" @click="saveConfig">保存配置</el-button>
+          </div>
+          <el-table v-if="configProblems.length" :data="configProblems" border stripe size="small" height="420">
+            <el-table-column label="题号" width="90" align="center">
+              <template #default="{ row }">
+                <el-input-number v-model="row.displayOrder" :min="1" size="small" controls-position="right" style="width: 70px" />
+              </template>
+            </el-table-column>
+            <el-table-column prop="title" label="标题" min-width="140" show-overflow-tooltip />
+            <el-table-column label="分值" width="110" align="center">
+              <template #default="{ row }">
+                <el-input-number v-model="row.score" :min="0" :max="1000" size="small" controls-position="right" style="width: 90px" />
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="60" align="center">
+              <template #default="{ row }">
+                <el-button type="danger" link size="small" @click="removeConfigured(row.problemId)">移除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+          <el-empty v-else description="请从左侧选择题目" :image-size="60" />
+        </div>
+      </div>
+      <template #footer>
+        <el-button @click="configDialogVisible = false">关闭</el-button>
+        <el-button type="primary" :loading="configSaving" @click="saveConfig">保存配置</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 报名名单 -->
+    <el-dialog v-model="regDialogVisible" :title="`报名名单 - ${regTitle}`" width="600px" draggable>
+      <el-table v-loading="regLoading" :data="regTable" border stripe size="small">
+        <el-table-column prop="id" label="ID" width="80" align="center" />
+        <el-table-column prop="userId" label="用户ID" width="100" align="center" />
+        <el-table-column prop="username" label="用户名" min-width="160" />
+        <el-table-column prop="createdAt" label="报名时间" width="180" align="center" />
+      </el-table>
+      <el-empty v-if="!regLoading && !regTable.length" description="暂无报名" :image-size="60" />
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+.contest-manage-view {
+  padding: 20px;
+}
+
+.search-card {
+  margin-bottom: 16px;
+}
+
+.toolbar {
+  margin-bottom: 16px;
+}
+
+.table-card {
+  margin-bottom: 16px;
+}
+
+.pagination {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+
+.config-body {
+  display: flex;
+  gap: 16px;
+}
+
+.config-password-row {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.candidate-panel {
+  flex: 1;
+}
+
+.config-panel {
+  flex: 1;
+}
+
+.cand-search {
+  margin-bottom: 8px;
+}
+
+.config-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+}
+
+.card-title {
+  font-weight: 600;
+}
+</style>

+ 2 - 2
todo.md

@@ -29,8 +29,8 @@
 - Commit(ui): `# feat: 比赛列表、详情与排名页面`
 
 ## Phase 4 比赛模块 · 管理后台页
-- [ ] `view/admin/ContestManageView.vue`(/admin/contests):比赛 CRUD、题目配置弹窗(多选题目+displayOrder+score)、报名名单
-- [ ] 注册 adminRoutes + 侧边菜单
+- [x] `view/admin/ContestManageView.vue`(/admin/contests):比赛 CRUD、题目配置弹窗(多选题目+displayOrder+score)、报名名单
+- [x] 注册 adminRoutes + 侧边菜单
 - Commit(ui): `# feat: 比赛管理后台页面`
 
 ## Phase 5 验证