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