library-picturebook-activity/frontend/src/views/contests/Activities.vue
2026-01-16 14:48:14 +08:00

925 lines
23 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>
<div class="contests-activities-page">
<!-- 顶部导航栏 -->
<div class="page-header">
<div class="header-left">
<!-- 自定义 Tab 切换 -->
<div class="custom-tabs">
<div
class="tab-item"
:class="{ active: activeTab === 'my' }"
@click="switchTab('my')"
>
<TrophyOutlined />
<span>{{ myTabTitle }}</span>
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'all' }"
@click="switchTab('all')"
>
<AppstoreOutlined />
<span>全部赛事</span>
</div>
</div>
</div>
<div class="header-right">
<!-- 自定义搜索框 -->
<div class="custom-search">
<SearchOutlined class="search-icon" />
<input
v-model="searchKeyword"
type="text"
placeholder="搜索赛事名称..."
class="search-input"
@keyup.enter="handleSearch"
/>
<div v-if="searchKeyword" class="search-clear" @click="clearSearch">
<CloseCircleFilled />
</div>
</div>
</div>
</div>
<!-- 赛事列表 -->
<div v-if="loading" class="loading-container">
<a-spin size="large" />
</div>
<div v-else-if="dataSource.length === 0" class="empty-container">
<a-empty description="暂无赛事" />
</div>
<div v-else class="contests-grid">
<div
v-for="contest in dataSource"
:key="contest.id"
class="contest-card"
@click="handleViewDetail(contest.id)"
>
<!-- 海报区域 -->
<div class="card-poster">
<img
v-if="contest.posterUrl || contest.coverUrl"
:src="contest.posterUrl || contest.coverUrl"
alt="赛事海报"
class="poster-image"
@error="(e) => handleImageError(e, contest.id)"
/>
<div
v-else
class="poster-placeholder"
:style="{ background: getGradientByIndex(contest.id) }"
>
<TrophyOutlined class="placeholder-icon" />
</div>
<!-- 状态角标 -->
<div
v-if="getStageText(contest)"
class="stage-badge"
:class="getStageClass(contest)"
>
{{ getStageText(contest) }}
</div>
<!-- 赛事类型角标 -->
<div class="type-badge">
{{ contest.contestType === "individual" ? "个人赛" : "团队赛" }}
</div>
</div>
<!-- 内容区域 -->
<div class="card-content">
<!-- 卡片标题 -->
<div class="card-title">{{ contest.contestName }}</div>
<!-- 时间信息 -->
<div class="card-meta">
<div class="meta-item">
<CalendarOutlined />
<span
>{{ formatDate(contest.startTime) }} ~
{{ formatDate(contest.endTime) }}</span
>
</div>
</div>
<!-- 底部区域 -->
<div class="card-footer">
<span
class="status-dot"
:class="{ 'status-ongoing': contest.status === 'ongoing' }"
></span>
<span class="status-text">{{ getStatusText(contest) }}</span>
<!-- 操作按钮区域 - 我的赛事tab显示 -->
<div v-if="activeTab === 'my'" class="card-actions" @click.stop>
<!-- 学生角色按钮 -->
<template v-if="userRole === 'student'">
<template v-if="contest.contestType === 'individual'">
<a-button
v-if="isSubmitting(contest)"
type="primary"
size="small"
@click="handleUploadWork(contest.id)"
>
上传作品
</a-button>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
</template>
<template v-else>
<a-button size="small" @click="handleViewWorks(contest.id)">
参赛作品
</a-button>
<a-button size="small" @click="handleViewTeam(contest.id)">
我的队伍
</a-button>
</template>
</template>
<!-- 教师角色按钮 -->
<template v-if="userRole === 'teacher'">
<a-button
type="primary"
size="small"
@click="handleMyGuidance(contest.id)"
>
我的指导
</a-button>
</template>
<!-- 评委角色按钮 -->
<template v-if="userRole === 'judge'">
<a-button
type="primary"
size="small"
:disabled="isReviewEnded(contest)"
@click="handleReviewWorks(contest.id)"
>
评审作品
</a-button>
<a-button
size="small"
:disabled="isReviewEnded(contest)"
@click="handlePresetComments(contest.id)"
>
预设评语
</a-button>
</template>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-container">
<a-pagination
v-model:current="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:show-size-changer="true"
:page-size-options="['12', '24', '50', '100']"
:show-total="(total: number) => `共 ${total} 条`"
@change="handlePageChange"
@show-size-change="handlePageChange"
/>
</div>
</div>
<!-- 上传作品抽屉 -->
<SubmitWorkDrawer
v-model:open="submitWorkDrawerVisible"
:contest-id="currentContestId"
@success="handleSubmitWorkSuccess"
/>
<!-- 查看参赛作品抽屉 -->
<ViewWorkDrawer
v-model:open="viewWorkDrawerVisible"
:contest-id="currentContestIdForView"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue"
import { useRouter, useRoute } from "vue-router"
import { message } from "ant-design-vue"
import {
TrophyOutlined,
CalendarOutlined,
AppstoreOutlined,
SearchOutlined,
CloseCircleFilled,
} from "@ant-design/icons-vue"
import dayjs from "dayjs"
import {
contestsApi,
type Contest,
type QueryContestParams,
} from "@/api/contests"
import { useAuthStore } from "@/stores/auth"
import SubmitWorkDrawer from "./components/SubmitWorkDrawer.vue"
import ViewWorkDrawer from "./components/ViewWorkDrawer.vue"
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const tenantCode = route.params.tenantCode as string
// 获取用户角色
const userRole = computed((): "student" | "teacher" | "judge" => {
const roles = authStore.user?.roles || []
// 按优先级判断角色roles 是字符串数组如 ['judge', 'student']
if (roles.includes("judge")) {
return "judge"
}
if (roles.includes("teacher")) {
return "teacher"
}
if (roles.includes("student")) {
return "student"
}
return "student" // 默认学生
})
// 根据角色计算Tab标题
const myTabTitle = computed(() => {
switch (userRole.value) {
case "teacher":
return "我参与的赛事"
case "judge":
return "我评审的赛事"
case "student":
default:
return "我报名的赛事"
}
})
// Tab切换
const activeTab = ref<"all" | "my">("my")
const searchKeyword = ref<string>("")
// 加载状态
const loading = ref(false)
const dataSource = ref<Contest[]>([])
const pagination = reactive({
current: 1,
pageSize: 12,
total: 0,
})
const searchParams = reactive<Partial<QueryContestParams>>({})
// 获取列表数据
const fetchList = async () => {
loading.value = true
try {
const pageSize = Math.min(pagination.pageSize, 100)
const params: QueryContestParams = {
...searchParams,
page: pagination.current,
pageSize,
}
let response
if (activeTab.value === "my") {
// 根据角色调用API传递 role 参数
response = await contestsApi.getMyContests({
...params,
role: userRole.value,
})
} else {
response = await contestsApi.getList(params)
}
dataSource.value = response.list
pagination.total = response.total
} catch (error) {
message.error("获取赛事列表失败")
console.error("List request error:", error)
} finally {
loading.value = false
}
}
// Tab切换处理
const handleTabChange = () => {
searchKeyword.value = ""
Object.assign(searchParams, {})
pagination.current = 1
fetchList()
}
// 切换Tab
const switchTab = (tab: "all" | "my") => {
if (activeTab.value !== tab) {
activeTab.value = tab
handleTabChange()
}
}
// 清空搜索
const clearSearch = () => {
searchKeyword.value = ""
handleSearch()
}
// 搜索处理
const handleSearch = () => {
searchParams.contestName = searchKeyword.value || undefined
pagination.current = 1
fetchList()
}
// 分页变化处理
const handlePageChange = (page?: number, size?: number) => {
if (size !== undefined) {
pagination.pageSize = Math.min(size, 100)
}
if (page !== undefined) {
pagination.current = page
}
fetchList()
}
// 查看详情
// 跳转到竞赛详情页
const handleViewDetail = (contestId: number) => {
router.push(`/${tenantCode}/contests/${contestId}`)
}
// ===== 学生相关操作 =====
// 上传作品
const submitWorkDrawerVisible = ref(false)
const currentContestId = ref<number>(0)
const handleUploadWork = (id: number) => {
currentContestId.value = id
submitWorkDrawerVisible.value = true
}
const handleSubmitWorkSuccess = () => {
message.success("作品提交成功")
}
// 查看参赛作品
const viewWorkDrawerVisible = ref(false)
const currentContestIdForView = ref<number>(0)
const handleViewWorks = (id: number) => {
currentContestIdForView.value = id
viewWorkDrawerVisible.value = true
}
// 查看我的队伍
const handleViewTeam = (id: number) => {
// TODO: 跳转到我的队伍页面或打开抽屉
message.info("查看我的队伍功能开发中")
}
// ===== 教师相关操作 =====
// 我的指导
const handleMyGuidance = (id: number) => {
router.push(`/${tenantCode}/student-activities/guidance?contestId=${id}`)
}
// ===== 评委相关操作 =====
// 评审作品
const handleReviewWorks = (id: number) => {
router.push(`/${tenantCode}/student-activities/review?contestId=${id}`)
}
// 预设评语
const handlePresetComments = (id: number) => {
router.push(`/${tenantCode}/student-activities/comments?contestId=${id}`)
}
// 图片加载错误记录
const imageErrors = ref<Record<number, boolean>>({})
const handleImageError = (event: Event, contestId: number) => {
imageErrors.value[contestId] = true
// 隐藏加载失败的图片
const img = event.target as HTMLImageElement
img.style.display = "none"
}
// 根据ID生成渐变背景色
const gradients = [
"linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
"linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
"linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)",
"linear-gradient(135deg, #fa709a 0%, #fee140 100%)",
"linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)",
"linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%)",
"linear-gradient(135deg, #96fbc4 0%, #f9f586 100%)",
]
const getGradientByIndex = (id: number): string => {
return gradients[id % gradients.length]
}
// 获取阶段样式类
const getStageClass = (contest: Contest): string => {
if (isRegistering(contest)) return "stage-registering"
if (isSubmitting(contest)) return "stage-submitting"
if (isReviewing(contest)) return "stage-reviewing"
if (contest.status === "finished") return "stage-finished"
return ""
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD")
}
// 判断是否在报名中
const isRegistering = (contest: Contest): boolean => {
const now = dayjs()
const start = dayjs(contest.registerStartTime)
const end = dayjs(contest.registerEndTime)
return now.isAfter(start) && now.isBefore(end)
}
// 判断是否在征稿中
const isSubmitting = (contest: Contest): boolean => {
if (!contest.submitStartTime || !contest.submitEndTime) {
return false
}
const now = dayjs()
const start = dayjs(contest.submitStartTime)
const end = dayjs(contest.submitEndTime)
return (
(now.isAfter(start) || now.isSame(start, "day")) &&
(now.isBefore(end) || now.isSame(end, "day"))
)
}
// 判断是否在评审中
const isReviewing = (contest: Contest): boolean => {
if (!contest.reviewStartTime || !contest.reviewEndTime) {
return false
}
const now = dayjs()
const start = dayjs(contest.reviewStartTime)
const end = dayjs(contest.reviewEndTime)
return now.isAfter(start) && now.isBefore(end)
}
// 判断评审是否已结束
const isReviewEnded = (contest: Contest): boolean => {
if (!contest.reviewEndTime) {
return false
}
const now = dayjs()
const end = dayjs(contest.reviewEndTime)
return now.isAfter(end)
}
// 判断评审是否已开始
const isReviewStarted = (contest: Contest): boolean => {
if (!contest.reviewStartTime) {
return false
}
const now = dayjs()
const start = dayjs(contest.reviewStartTime)
return now.isAfter(start)
}
// 获取当前阶段文本
const getStageText = (contest: Contest): string => {
if (isRegistering(contest)) {
return "报名中"
}
if (isSubmitting(contest)) {
return "征稿中"
}
if (isReviewing(contest)) {
return "评审中"
}
// 如果赛事已结束
if (contest.status === "finished") {
return "已结束"
}
return ""
}
// 获取状态文本
const getStatusText = (contest: Contest): string => {
if (contest.status === "finished") {
return "赛事已结束"
}
return "赛事进行中"
}
// 初始化
onMounted(() => {
fetchList()
})
</script>
<style lang="scss" scoped>
// 主题色 - 统一色系
$primary: #1890ff;
$primary-dark: #0958d9;
$primary-light: #40a9ff;
.contests-activities-page {
padding: 24px;
// 顶部导航栏
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding: 16px 20px;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.header-left {
flex: 1;
}
.header-right {
flex-shrink: 0;
}
}
// 自定义 Tab 切换
.custom-tabs {
display: inline-flex;
background: #f5f7fa;
border-radius: 12px;
padding: 4px;
gap: 4px;
.tab-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.65);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
.anticon {
font-size: 16px;
}
&:hover {
color: $primary;
background: rgba($primary, 0.08);
}
&.active {
color: #fff;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
box-shadow: 0 4px 12px rgba($primary, 0.35);
.anticon {
color: #fff;
}
}
}
}
// 自定义搜索框
.custom-search {
position: relative;
display: flex;
align-items: center;
width: 280px;
height: 44px;
background: #f5f7fa;
border-radius: 12px;
padding: 0 16px;
transition: all 0.3s ease;
border: 2px solid transparent;
&:focus-within {
background: #fff;
border-color: $primary-light;
box-shadow: 0 0 0 4px rgba($primary, 0.1);
}
.search-icon {
font-size: 18px;
color: rgba(0, 0, 0, 0.35);
margin-right: 12px;
transition: color 0.3s ease;
}
&:focus-within .search-icon {
color: $primary;
}
.search-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
&::placeholder {
color: rgba(0, 0, 0, 0.35);
}
}
.search-clear {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-left: 8px;
color: rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: rgba(0, 0, 0, 0.45);
}
}
}
.loading-container,
.empty-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.contests-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
.contest-card {
background: #fff;
border-radius: 16px;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
&:hover {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
transform: translateY(-6px);
.card-poster {
.poster-image,
.poster-placeholder {
transform: scale(1.05);
}
}
}
// 海报区域
.card-poster {
position: relative;
width: 100%;
height: 180px;
overflow: hidden;
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.poster-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.4s ease;
.placeholder-icon {
font-size: 48px;
color: rgba(255, 255, 255, 0.8);
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
}
// 阶段角标
.stage-badge {
position: absolute;
top: 12px;
left: 12px;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
color: #fff;
backdrop-filter: blur(8px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
&.stage-registering {
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
}
&.stage-submitting {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
}
&.stage-reviewing {
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
}
&.stage-finished {
background: linear-gradient(135deg, #8c8c8c 0%, #bfbfbf 100%);
}
}
// 类型角标
.type-badge {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
color: #fff;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
}
}
// 内容区域
.card-content {
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
.card-title {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
display: flex;
flex-direction: column;
gap: 8px;
.meta-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
.anticon {
font-size: 14px;
color: rgba(0, 0, 0, 0.35);
}
}
}
.card-footer {
display: flex;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
margin-top: auto;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #d9d9d9;
margin-right: 8px;
&.status-ongoing {
background: #52c41a;
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.2);
}
}
.status-text {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-left: auto;
// 渐变主要按钮 - 蓝色系
:deep(.ant-btn-primary) {
border: none;
border-radius: 16px;
padding: 6px 16px;
height: auto;
font-size: 13px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
box-shadow: 0 2px 8px rgba($primary, 0.3);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, $primary-light 0%, $primary 100%);
box-shadow: 0 4px 12px rgba($primary, 0.4);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba($primary, 0.3);
}
}
// 渐变次要按钮
:deep(.ant-btn-default) {
border: none;
border-radius: 16px;
padding: 6px 16px;
height: auto;
font-size: 13px;
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%);
color: rgba(0, 0, 0, 0.75);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, #e8ecf0 0%, #dce1e6 100%);
color: $primary;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
}
}
}
}
}
.pagination-container {
grid-column: 1 / -1;
margin-top: 32px;
display: flex;
justify-content: center;
}
}
}
// 响应式适配
@media (max-width: 768px) {
.contests-activities-page {
padding: 16px;
.contests-grid {
grid-template-columns: 1fr;
gap: 16px;
.contest-card {
.card-poster {
height: 160px;
}
.card-content {
padding: 16px;
.card-footer {
flex-wrap: wrap;
gap: 12px;
.card-actions {
width: 100%;
margin-left: 0;
justify-content: flex-start;
}
}
}
}
}
}
}
</style>