735 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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