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