735 lines
19 KiB
Vue
735 lines
19 KiB
Vue
<template>
|
||
<DefaultLayout>
|
||
<div class="contests-page">
|
||
<div class="container">
|
||
<!-- 页面标题 -->
|
||
<div class="page-header">
|
||
<div class="header-left">
|
||
<h1 class="page-title">
|
||
<Icon name="trophy" size="xl" color="#31f08b" style="margin-right: 12px;" />
|
||
竞赛活动
|
||
</h1>
|
||
<p class="page-desc">参与竞赛,展现实力,赢取奖励!</p>
|
||
</div>
|
||
<div class="header-right" v-if="isAdmin">
|
||
<a-button type="primary" @click="showCreateModal">
|
||
<template #icon><Icon name="plus" size="sm" /></template>
|
||
创建竞赛
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 活动中的竞赛 -->
|
||
<div class="section">
|
||
<h2 class="section-title">
|
||
<Icon name="fire" size="lg" color="#F59E0B" style="margin-right: 8px;" />
|
||
正在进行
|
||
</h2>
|
||
<div v-if="loading" class="loading-container">
|
||
<a-spin size="large" />
|
||
</div>
|
||
<div v-else-if="activeContests.length === 0" class="empty-state">
|
||
<Icon name="inbox" size="xl" color="#6B6B6B" />
|
||
<p>暂无进行中的竞赛</p>
|
||
</div>
|
||
<div v-else class="contest-grid">
|
||
<div v-for="contest in activeContests" :key="contest.id" class="contest-card active">
|
||
<div class="contest-header">
|
||
<div class="contest-badge active">进行中</div>
|
||
<div class="contest-type">{{ getContestTypeName(contest.contestType) }}</div>
|
||
</div>
|
||
<h3 class="contest-name">{{ contest.name }}</h3>
|
||
<p class="contest-desc">{{ contest.description }}</p>
|
||
<div class="contest-info">
|
||
<div class="info-item">
|
||
<Icon name="users" size="sm" color="#AFAFAF" />
|
||
<span>{{ contest.participantCount }} 人参与</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<Icon name="clock" size="sm" color="#AFAFAF" />
|
||
<span>剩余 {{ getRemainingDays(contest.endTime) }} 天</span>
|
||
</div>
|
||
</div>
|
||
<div class="contest-footer">
|
||
<a-button type="primary" @click="viewContest(contest)">
|
||
查看详情
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 我的竞赛数据 -->
|
||
<div class="section" v-if="myContestData">
|
||
<h2 class="section-title">
|
||
<Icon name="chartBar" size="lg" color="#31f08b" style="margin-right: 8px;" />
|
||
我的竞赛数据
|
||
</h2>
|
||
<div class="my-data-card">
|
||
<div class="data-grid">
|
||
<div class="data-item">
|
||
<div class="data-value">{{ myContestData.currentRank || '--' }}</div>
|
||
<div class="data-label">当前排名</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-value">{{ myContestData.totalScore }}</div>
|
||
<div class="data-label">竞赛得分</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-value">{{ myContestData.taskCompleted }}</div>
|
||
<div class="data-label">完成任务</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-value">{{ myContestData.fiveStarCount }}</div>
|
||
<div class="data-label">五星好评</div>
|
||
</div>
|
||
</div>
|
||
<div class="reward-info" v-if="myContestData.rewardScore > 0">
|
||
<span class="reward-label">可领取奖励:</span>
|
||
<span class="reward-value">{{ myContestData.rewardScore }} 积分</span>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
:disabled="myContestData.rewardClaimed === 1"
|
||
@click="handleClaimReward"
|
||
>
|
||
{{ myContestData.rewardClaimed === 1 ? '已领取' : '领取奖励' }}
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 排行榜 -->
|
||
<div class="section" v-if="currentContest && leaderboard.length > 0">
|
||
<h2 class="section-title">
|
||
<Icon name="trophy" size="lg" color="#FFD700" style="margin-right: 8px;" />
|
||
竞赛排行榜
|
||
</h2>
|
||
<div class="leaderboard-card">
|
||
<div class="leaderboard-list">
|
||
<div
|
||
v-for="(participant, index) in leaderboard"
|
||
:key="participant.id"
|
||
:class="['leaderboard-item', { 'is-me': participant.userId === currentUserId }]"
|
||
>
|
||
<div class="rank">
|
||
<span v-if="index === 0" class="rank-badge gold">🥇</span>
|
||
<span v-else-if="index === 1" class="rank-badge silver">🥈</span>
|
||
<span v-else-if="index === 2" class="rank-badge bronze">🥉</span>
|
||
<span v-else class="rank-number">{{ index + 1 }}</span>
|
||
</div>
|
||
<div class="participant-info">
|
||
<div class="participant-name">{{ participant.userName }}</div>
|
||
<div class="participant-dept">{{ participant.deptName }}</div>
|
||
</div>
|
||
<div class="participant-stats">
|
||
<span class="stat">{{ participant.taskCompleted }} 任务</span>
|
||
<span class="stat">{{ participant.fiveStarCount }} 五星</span>
|
||
</div>
|
||
<div class="participant-score">{{ participant.totalScore }} 分</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 历史竞赛 -->
|
||
<div class="section">
|
||
<h2 class="section-title">
|
||
<Icon name="archive" size="lg" color="#6B6B6B" style="margin-right: 8px;" />
|
||
历史竞赛
|
||
</h2>
|
||
<div v-if="endedContests.length === 0" class="empty-state small">
|
||
<p>暂无历史竞赛</p>
|
||
</div>
|
||
<div v-else class="history-list">
|
||
<div v-for="contest in endedContests" :key="contest.id" class="history-item">
|
||
<div class="history-info">
|
||
<span class="history-name">{{ contest.name }}</span>
|
||
<span class="history-time">{{ formatDate(contest.endTime) }} 结束</span>
|
||
</div>
|
||
<a-button size="small" @click="viewContest(contest)">查看结果</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 竞赛详情弹窗 -->
|
||
<a-modal
|
||
v-model:open="detailModalVisible"
|
||
:title="selectedContest?.name"
|
||
width="800px"
|
||
:footer="null"
|
||
>
|
||
<div v-if="selectedContest" class="contest-detail">
|
||
<a-descriptions :column="2" bordered>
|
||
<a-descriptions-item label="竞赛类型">
|
||
{{ getContestTypeName(selectedContest.contestType) }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="状态">
|
||
<a-tag :color="getStatusColor(selectedContest.status)">
|
||
{{ getStatusName(selectedContest.status) }}
|
||
</a-tag>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="开始时间">
|
||
{{ formatDateTime(selectedContest.startTime) }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="结束时间">
|
||
{{ formatDateTime(selectedContest.endTime) }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="参与人数" :span="2">
|
||
{{ selectedContest.participantCount }} 人
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="竞赛说明" :span="2">
|
||
{{ selectedContest.description || '无' }}
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 创建竞赛弹窗 -->
|
||
<a-modal
|
||
v-model:open="createModalVisible"
|
||
title="创建竞赛"
|
||
width="700px"
|
||
@ok="handleCreateContest"
|
||
:confirmLoading="creating"
|
||
>
|
||
<a-form :model="contestForm" layout="vertical">
|
||
<a-form-item label="竞赛名称" required>
|
||
<a-input v-model:value="contestForm.name" placeholder="请输入竞赛名称" />
|
||
</a-form-item>
|
||
<a-form-item label="竞赛描述">
|
||
<a-textarea v-model:value="contestForm.description" :rows="3" placeholder="请输入竞赛描述" />
|
||
</a-form-item>
|
||
<a-row :gutter="16">
|
||
<a-col :span="12">
|
||
<a-form-item label="竞赛类型" required>
|
||
<a-select v-model:value="contestForm.contestType">
|
||
<a-select-option value="monthly">月度竞赛</a-select-option>
|
||
<a-select-option value="weekly">周度竞赛</a-select-option>
|
||
<a-select-option value="special">专项竞赛</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="12">
|
||
<a-form-item label="参与范围" required>
|
||
<a-select v-model:value="contestForm.participantScope">
|
||
<a-select-option value="all">全体设计师</a-select-option>
|
||
<a-select-option value="department">指定部门</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-row :gutter="16">
|
||
<a-col :span="12">
|
||
<a-form-item label="开始时间" required>
|
||
<a-date-picker
|
||
v-model:value="contestForm.startTime"
|
||
show-time
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="12">
|
||
<a-form-item label="结束时间" required>
|
||
<a-date-picker
|
||
v-model:value="contestForm.endTime"
|
||
show-time
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
</a-form>
|
||
</a-modal>
|
||
</DefaultLayout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||
import Icon from '@/components/Icon.vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import dayjs, { Dayjs } from 'dayjs'
|
||
import {
|
||
getContestList,
|
||
getContestLeaderboard,
|
||
getMyContestData,
|
||
claimContestReward,
|
||
createContest,
|
||
type Contest,
|
||
type ContestParticipant
|
||
} from '@/api/contest'
|
||
|
||
const authStore = useAuthStore()
|
||
const currentUserId = computed(() => authStore.userInfo?.id)
|
||
const isAdmin = computed(() => authStore.userInfo?.roles?.some(r => r.roleCode === 'admin' || r.roleCode === 'super_admin'))
|
||
|
||
const loading = ref(false)
|
||
const contests = ref<Contest[]>([])
|
||
const leaderboard = ref<ContestParticipant[]>([])
|
||
const myContestData = ref<ContestParticipant | null>(null)
|
||
const currentContest = ref<Contest | null>(null)
|
||
|
||
const activeContests = computed(() => contests.value.filter(c => c.status === 'active'))
|
||
const endedContests = computed(() => contests.value.filter(c => c.status === 'ended'))
|
||
|
||
const detailModalVisible = ref(false)
|
||
const selectedContest = ref<Contest | null>(null)
|
||
|
||
const createModalVisible = ref(false)
|
||
const creating = ref(false)
|
||
const contestForm = ref({
|
||
name: '',
|
||
description: '',
|
||
contestType: 'monthly',
|
||
participantScope: 'all',
|
||
startTime: null as Dayjs | null,
|
||
endTime: null as Dayjs | null
|
||
})
|
||
|
||
const loadData = async () => {
|
||
loading.value = true
|
||
try {
|
||
const result = await getContestList({ page: 1, pageSize: 50 })
|
||
contests.value = result.records || []
|
||
|
||
// 加载当前活动竞赛的排行榜
|
||
const active = activeContests.value[0]
|
||
if (active) {
|
||
currentContest.value = active
|
||
leaderboard.value = await getContestLeaderboard(active.id, 20)
|
||
|
||
// 加载我的数据
|
||
if (currentUserId.value) {
|
||
try {
|
||
myContestData.value = await getMyContestData(active.id)
|
||
} catch {
|
||
myContestData.value = null
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('加载竞赛列表失败:', error)
|
||
message.error('加载数据失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const viewContest = (contest: Contest) => {
|
||
selectedContest.value = contest
|
||
detailModalVisible.value = true
|
||
}
|
||
|
||
const showCreateModal = () => {
|
||
contestForm.value = {
|
||
name: '',
|
||
description: '',
|
||
contestType: 'monthly',
|
||
participantScope: 'all',
|
||
startTime: null,
|
||
endTime: null
|
||
}
|
||
createModalVisible.value = true
|
||
}
|
||
|
||
const handleCreateContest = async () => {
|
||
if (!contestForm.value.name || !contestForm.value.startTime || !contestForm.value.endTime) {
|
||
message.warning('请填写必填项')
|
||
return
|
||
}
|
||
|
||
creating.value = true
|
||
try {
|
||
const defaultRules = {
|
||
taskCompleted: { score: 2, weight: 30 },
|
||
fiveStarReview: { score: 10, weight: 25 },
|
||
earlyCompletion: { score: 5, weight: 20 },
|
||
praise: { score: 15, weight: 15 }
|
||
}
|
||
const defaultRewards = [
|
||
{ rank: 1, score: 1500, badge: '冠军' },
|
||
{ rank: 2, score: 1000, badge: '亚军' },
|
||
{ rank: 3, score: 800, badge: '季军' }
|
||
]
|
||
|
||
await createContest({
|
||
name: contestForm.value.name,
|
||
description: contestForm.value.description,
|
||
contestType: contestForm.value.contestType,
|
||
participantScope: contestForm.value.participantScope,
|
||
startTime: contestForm.value.startTime!.format('YYYY-MM-DD HH:mm:ss'),
|
||
endTime: contestForm.value.endTime!.format('YYYY-MM-DD HH:mm:ss'),
|
||
rules: JSON.stringify(defaultRules),
|
||
rewards: JSON.stringify(defaultRewards)
|
||
})
|
||
|
||
message.success('创建成功')
|
||
createModalVisible.value = false
|
||
loadData()
|
||
} catch (error) {
|
||
console.error('创建竞赛失败:', error)
|
||
message.error('创建失败')
|
||
} finally {
|
||
creating.value = false
|
||
}
|
||
}
|
||
|
||
const handleClaimReward = async () => {
|
||
if (!currentContest.value) return
|
||
try {
|
||
await claimContestReward(currentContest.value.id)
|
||
message.success('奖励领取成功!')
|
||
loadData()
|
||
} catch (error: any) {
|
||
message.error(error.message || '领取失败')
|
||
}
|
||
}
|
||
|
||
const getContestTypeName = (type: string) => {
|
||
const map: Record<string, string> = {
|
||
monthly: '月度竞赛',
|
||
weekly: '周度竞赛',
|
||
special: '专项竞赛'
|
||
}
|
||
return map[type] || type
|
||
}
|
||
|
||
const getStatusName = (status: string) => {
|
||
const map: Record<string, string> = {
|
||
draft: '草稿',
|
||
scheduled: '待开始',
|
||
active: '进行中',
|
||
ended: '已结束',
|
||
cancelled: '已取消'
|
||
}
|
||
return map[status] || status
|
||
}
|
||
|
||
const getStatusColor = (status: string) => {
|
||
const map: Record<string, string> = {
|
||
draft: 'default',
|
||
scheduled: 'blue',
|
||
active: 'green',
|
||
ended: 'gray',
|
||
cancelled: 'red'
|
||
}
|
||
return map[status] || 'default'
|
||
}
|
||
|
||
const getRemainingDays = (endTime: string) => {
|
||
const end = dayjs(endTime)
|
||
const now = dayjs()
|
||
return Math.max(0, end.diff(now, 'day'))
|
||
}
|
||
|
||
const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD')
|
||
const formatDateTime = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm')
|
||
|
||
onMounted(() => {
|
||
loadData()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.contests-page {
|
||
min-height: 100vh;
|
||
padding: 24px;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
color: #ECECEC;
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.page-desc {
|
||
color: #AFAFAF;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.section {
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #ECECEC;
|
||
margin-bottom: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.contest-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
|
||
.contest-card {
|
||
background: #2A2A2A;
|
||
border: 1px solid #3A3A3A;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
transition: all 0.3s ease;
|
||
|
||
&:hover {
|
||
border-color: #31f08b;
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
&.active {
|
||
border-color: #31f08b;
|
||
}
|
||
}
|
||
|
||
.contest-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.contest-badge {
|
||
padding: 4px 12px;
|
||
border-radius: 12px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
|
||
&.active {
|
||
background: rgba(49, 240, 139, 0.1);
|
||
color: #31f08b;
|
||
}
|
||
}
|
||
|
||
.contest-type {
|
||
color: #AFAFAF;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.contest-name {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: #ECECEC;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.contest-desc {
|
||
color: #AFAFAF;
|
||
font-size: 14px;
|
||
margin-bottom: 16px;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.contest-info {
|
||
display: flex;
|
||
gap: 20px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.info-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: #AFAFAF;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.contest-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.my-data-card {
|
||
background: #2A2A2A;
|
||
border: 1px solid #3A3A3A;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
}
|
||
|
||
.data-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 24px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.data-item {
|
||
text-align: center;
|
||
}
|
||
|
||
.data-value {
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
color: #31f08b;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.data-label {
|
||
color: #AFAFAF;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.reward-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid #3A3A3A;
|
||
}
|
||
|
||
.reward-label {
|
||
color: #AFAFAF;
|
||
}
|
||
|
||
.reward-value {
|
||
color: #FFD700;
|
||
font-weight: 600;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.leaderboard-card {
|
||
background: #2A2A2A;
|
||
border: 1px solid #3A3A3A;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.leaderboard-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
transition: background 0.2s;
|
||
|
||
&:hover {
|
||
background: #333;
|
||
}
|
||
|
||
&.is-me {
|
||
background: rgba(49, 240, 139, 0.1);
|
||
border: 1px solid rgba(49, 240, 139, 0.3);
|
||
}
|
||
}
|
||
|
||
.rank {
|
||
width: 48px;
|
||
text-align: center;
|
||
}
|
||
|
||
.rank-badge {
|
||
font-size: 24px;
|
||
}
|
||
|
||
.rank-number {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #AFAFAF;
|
||
}
|
||
|
||
.participant-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.participant-name {
|
||
font-weight: 500;
|
||
color: #ECECEC;
|
||
}
|
||
|
||
.participant-dept {
|
||
font-size: 12px;
|
||
color: #6B6B6B;
|
||
}
|
||
|
||
.participant-stats {
|
||
display: flex;
|
||
gap: 16px;
|
||
}
|
||
|
||
.stat {
|
||
color: #AFAFAF;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.participant-score {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #31f08b;
|
||
min-width: 80px;
|
||
text-align: right;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 48px;
|
||
color: #6B6B6B;
|
||
|
||
&.small {
|
||
padding: 24px;
|
||
}
|
||
}
|
||
|
||
.history-list {
|
||
background: #2A2A2A;
|
||
border: 1px solid #3A3A3A;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.history-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid #3A3A3A;
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
}
|
||
|
||
.history-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.history-name {
|
||
color: #ECECEC;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.history-time {
|
||
color: #6B6B6B;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.loading-container {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 48px;
|
||
}
|
||
</style>
|