library-picturebook-activity/lesingle-creation-frontend/src/views/contests/Activities.vue

1043 lines
26 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">
<div class="status-row">
<span
class="status-dot"
:class="{ 'status-ongoing': contest.status === 'ongoing' }"
></span>
<span class="status-text">{{ getStatusText(contest) }}</span>
</div>
<!-- 操作按钮区域 - 我的活动tab显示 -->
<div v-if="activeTab === 'my'" class="card-actions" @click.stop>
<!-- 学生角色按钮 -->
<template v-if="userRole === 'student'">
<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>
<a-button
v-if="contest.contestType === 'team'"
size="small"
@click="handleViewTeam(contest)"
>
我的队伍
</a-button>
</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"
>
预设评语
</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"
/>
<!-- 我的队伍弹窗 -->
<a-modal
v-model:open="teamModalVisible"
:title="`我的队伍 - ${currentTeamContest?.contestName || ''}`"
:footer="null"
width="600px"
>
<a-spin :spinning="teamLoading">
<div v-if="myTeamInfo" class="team-info">
<a-descriptions :column="2" bordered style="margin-bottom: 16px">
<a-descriptions-item label="团队名称">
{{ myTeamInfo.teamName }}
</a-descriptions-item>
<a-descriptions-item label="成员数">
{{ myTeamInfo.members?.length || 0 }}人
</a-descriptions-item>
</a-descriptions>
<div class="team-members">
<div class="members-title">团队成员</div>
<a-table
:columns="memberColumns"
:data-source="myTeamInfo.members || []"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'nickname'">
{{ record.user?.nickname || "-" }}
</template>
<template v-else-if="column.key === 'username'">
{{ record.user?.username || "-" }}
</template>
<template v-else-if="column.key === 'role'">
<a-tag :color="getMemberRoleColor(record.role)">
{{ getMemberRoleText(record.role) }}
</a-tag>
</template>
</template>
</a-table>
</div>
</div>
<a-empty v-else description="暂无团队信息" />
</a-spin>
</a-modal>
</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,
registrationsApi,
type Contest,
type QueryContestParams,
type ContestTeam,
} 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 teamModalVisible = ref(false)
const teamLoading = ref(false)
const currentTeamContest = ref<Contest | null>(null)
const myTeamInfo = ref<ContestTeam | null>(null)
// 团队成员表格列
const memberColumns = [
{ title: "姓名", key: "nickname", width: 120 },
{ title: "账号", key: "username", width: 150 },
{ title: "角色", key: "role", width: 100 },
]
// 获取成员角色颜色
const getMemberRoleColor = (role?: string) => {
switch (role) {
case "leader":
return "gold"
case "mentor":
return "purple"
default:
return "blue"
}
}
// 获取成员角色文本
const getMemberRoleText = (role?: string) => {
switch (role) {
case "leader":
return "队长"
case "mentor":
return "指导老师"
default:
return "成员"
}
}
const handleViewTeam = async (contest: Contest) => {
currentTeamContest.value = contest
teamModalVisible.value = true
teamLoading.value = true
myTeamInfo.value = null
try {
// 获取用户在该活动的报名记录(包括团队信息)
const registration = await registrationsApi.getMyRegistration(contest.id)
if (registration?.team) {
myTeamInfo.value = registration.team
}
} catch (error: any) {
message.error(error?.response?.data?.message || "获取团队信息失败")
} finally {
teamLoading.value = false
}
}
// ===== 教师相关操作 =====
// 我的指导
const handleMyGuidance = (id: number) => {
router.push(`/${tenantCode}/student-activities/guidance?contestId=${id}`)
}
// ===== 评委相关操作 =====
// 评审作品
const handleReviewWorks = (id: number) => {
router.push(`/${tenantCode}/activities/review/${id}`)
}
// 预设评语(全局模板,与活动无绑定)
const handlePresetComments = () => {
router.push(`/${tenantCode}/activities/preset-comments`)
}
// 图片加载错误记录
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 {
// 顶部导航栏
.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;
flex-direction: column;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
margin-top: auto;
.status-row {
display: flex;
align-items: center;
}
.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: 6px;
// 渐变主要按钮 - 蓝色系
:deep(.ant-btn-primary) {
border: none;
border-radius: 14px;
padding: 4px 12px;
height: auto;
font-size: 12px;
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: 14px;
padding: 4px 12px;
height: auto;
font-size: 12px;
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;
}
}
}
// 团队信息弹窗样式
.team-info {
.members-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
}
// 响应式适配
@media (max-width: 768px) {
.contests-activities-page {
.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>