334 lines
14 KiB
Vue
334 lines
14 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="tenant-dashboard">
|
|||
|
|
<!-- #1 欢迎信息 + 机构标识 -->
|
|||
|
|
<div class="welcome-banner">
|
|||
|
|
<div class="welcome-left">
|
|||
|
|
<h1>{{ greetingText }},{{ authStore.user?.nickname || '管理员' }}</h1>
|
|||
|
|
<p v-if="dashboard.tenant">
|
|||
|
|
<bank-outlined /> {{ dashboard.tenant.name }}
|
|||
|
|
<a-tag :color="tenantTypeColor(dashboard.tenant.tenantType)" style="margin-left: 8px">{{ tenantTypeLabel(dashboard.tenant.tenantType) }}</a-tag>
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div class="welcome-right">
|
|||
|
|
<span class="date-text">{{ todayText }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- #6 待办提醒 -->
|
|||
|
|
<div v-if="dashboard.todos?.length > 0" class="todo-section">
|
|||
|
|
<div v-for="(todo, idx) in dashboard.todos" :key="idx" :class="['todo-item', todo.type]" @click="todo.link && goTo(todo.link)">
|
|||
|
|
<alert-outlined v-if="todo.type === 'warning'" />
|
|||
|
|
<info-circle-outlined v-else />
|
|||
|
|
<span>{{ todo.message }}</span>
|
|||
|
|
<right-outlined v-if="todo.link" class="todo-arrow" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- #2 空数据引导 -->
|
|||
|
|
<div v-if="!loading && isEmpty" class="empty-guide">
|
|||
|
|
<a-result title="欢迎使用活动管理平台" sub-title="开始配置你的第一个活动吧">
|
|||
|
|
<template #icon>
|
|||
|
|
<trophy-outlined style="font-size: 48px; color: #6366f1" />
|
|||
|
|
</template>
|
|||
|
|
<template #extra>
|
|||
|
|
<div class="guide-steps">
|
|||
|
|
<div class="guide-step" @click="goTo('/contests/list')">
|
|||
|
|
<div class="step-num">1</div>
|
|||
|
|
<div class="step-content">
|
|||
|
|
<strong>创建活动</strong>
|
|||
|
|
<span>配置活动信息、报名规则和提交要求</span>
|
|||
|
|
</div>
|
|||
|
|
<right-outlined />
|
|||
|
|
</div>
|
|||
|
|
<div class="guide-step" @click="goTo('/system/users')">
|
|||
|
|
<div class="step-num">2</div>
|
|||
|
|
<div class="step-content">
|
|||
|
|
<strong>添加团队成员</strong>
|
|||
|
|
<span>创建管理员和工作人员账号</span>
|
|||
|
|
</div>
|
|||
|
|
<right-outlined />
|
|||
|
|
</div>
|
|||
|
|
<div class="guide-step" @click="goTo('/contests/judges')">
|
|||
|
|
<div class="step-num">3</div>
|
|||
|
|
<div class="step-content">
|
|||
|
|
<strong>邀请评委</strong>
|
|||
|
|
<span>添加评委并分配评审任务</span>
|
|||
|
|
</div>
|
|||
|
|
<right-outlined />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</a-result>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 有数据时的正常视图 -->
|
|||
|
|
<template v-if="!loading && !isEmpty">
|
|||
|
|
<!-- #5 统计卡片可点击 -->
|
|||
|
|
<div class="stats-row">
|
|||
|
|
<div
|
|||
|
|
v-for="item in statsItems"
|
|||
|
|
:key="item.key"
|
|||
|
|
class="stat-card"
|
|||
|
|
:class="{ clickable: !!item.link }"
|
|||
|
|
@click="item.link && goTo(item.link)"
|
|||
|
|
>
|
|||
|
|
<div class="stat-icon" :style="{ background: item.bgColor }">
|
|||
|
|
<component :is="item.icon" :style="{ color: item.color }" />
|
|||
|
|
</div>
|
|||
|
|
<div class="stat-info">
|
|||
|
|
<span class="stat-count">{{ item.value }}</span>
|
|||
|
|
<span class="stat-label">{{ item.label }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- #3 快捷操作(按权限动态显示) -->
|
|||
|
|
<a-card title="快捷操作" :bordered="false" class="section-card">
|
|||
|
|
<div class="action-grid">
|
|||
|
|
<div v-for="act in visibleActions" :key="act.label" class="action-item" @click="goTo(act.path)">
|
|||
|
|
<div class="action-icon" :style="{ background: act.bgColor }">
|
|||
|
|
<component :is="act.icon" :style="{ color: act.color }" />
|
|||
|
|
</div>
|
|||
|
|
<span>{{ act.label }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</a-card>
|
|||
|
|
|
|||
|
|
<!-- #4 最近活动 + 查看全部 -->
|
|||
|
|
<a-card :bordered="false" class="section-card" style="margin-top: 16px">
|
|||
|
|
<template #title>最近活动</template>
|
|||
|
|
<template #extra>
|
|||
|
|
<a-button type="link" size="small" @click="goTo('/contests/list')">查看全部 <right-outlined /></a-button>
|
|||
|
|
</template>
|
|||
|
|
<div v-if="dashboard.recentContests?.length === 0" style="text-align: center; padding: 30px; color: #9ca3af">
|
|||
|
|
暂无活动数据
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="contest-list">
|
|||
|
|
<div v-for="contest in dashboard.recentContests" :key="contest.id" class="contest-item" @click="goTo(`/contests/${contest.id}`)">
|
|||
|
|
<div class="contest-info">
|
|||
|
|
<span class="contest-name">{{ contest.contestName }}</span>
|
|||
|
|
<span class="contest-time">{{ formatDateRange(contest.startTime, contest.endTime) }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="contest-stats">
|
|||
|
|
<a-tag>{{ contest._count?.registrations || 0 }} 报名</a-tag>
|
|||
|
|
<a-tag>{{ contest._count?.works || 0 }} 作品</a-tag>
|
|||
|
|
<a-badge :status="contest.status === 'ongoing' ? 'processing' : 'default'" :text="contest.status === 'ongoing' ? '进行中' : '已结束'" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</a-card>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<!-- loading -->
|
|||
|
|
<div v-if="loading" style="text-align: center; padding: 80px"><a-spin size="large" /></div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, computed, onMounted } from 'vue'
|
|||
|
|
import { useRouter } from 'vue-router'
|
|||
|
|
import { message } from 'ant-design-vue'
|
|||
|
|
import {
|
|||
|
|
TrophyOutlined, UserAddOutlined, FileTextOutlined,
|
|||
|
|
SolutionOutlined, TeamOutlined, BankOutlined,
|
|||
|
|
FundViewOutlined, FormOutlined, AuditOutlined, ClockCircleOutlined,
|
|||
|
|
RightOutlined, AlertOutlined, InfoCircleOutlined,
|
|||
|
|
} from '@ant-design/icons-vue'
|
|||
|
|
import request from '@/utils/request'
|
|||
|
|
import { useAuthStore } from '@/stores/auth'
|
|||
|
|
import dayjs from 'dayjs'
|
|||
|
|
|
|||
|
|
const router = useRouter()
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
const loading = ref(true)
|
|||
|
|
const dashboard = ref<any>({})
|
|||
|
|
|
|||
|
|
// #1 问候语
|
|||
|
|
const greetingText = computed(() => {
|
|||
|
|
const h = new Date().getHours()
|
|||
|
|
if (h < 6) return '夜深了'
|
|||
|
|
if (h < 12) return '上午好'
|
|||
|
|
if (h < 14) return '中午好'
|
|||
|
|
if (h < 18) return '下午好'
|
|||
|
|
return '晚上好'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const todayText = computed(() => dayjs().format('YYYY年MM月DD日 dddd'))
|
|||
|
|
|
|||
|
|
const tenantTypeLabel = (type: string) => {
|
|||
|
|
const map: Record<string, string> = { library: '图书馆', kindergarten: '幼儿园', school: '学校', institution: '社会机构', other: '其他' }
|
|||
|
|
return map[type] || type
|
|||
|
|
}
|
|||
|
|
const tenantTypeColor = (type: string) => {
|
|||
|
|
const map: Record<string, string> = { library: 'purple', kindergarten: 'green', school: 'blue', institution: 'orange', other: 'default' }
|
|||
|
|
return map[type] || 'default'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// #2 空数据判断
|
|||
|
|
const isEmpty = computed(() =>
|
|||
|
|
dashboard.value.totalContests === 0 &&
|
|||
|
|
dashboard.value.totalRegistrations === 0 &&
|
|||
|
|
dashboard.value.totalWorks === 0
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// #5 统计卡片可点击
|
|||
|
|
const statsItems = computed(() => [
|
|||
|
|
{ key: 'contests', label: '可见活动', value: dashboard.value.totalContests || 0, icon: TrophyOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)', link: '/contests/list' },
|
|||
|
|
{ key: 'ongoing', label: '进行中', value: dashboard.value.ongoingContests || 0, icon: ClockCircleOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)', link: '/contests/list' },
|
|||
|
|
{ key: 'registrations', label: '总报名数', value: dashboard.value.totalRegistrations || 0, icon: FormOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)', link: '/contests/registrations' },
|
|||
|
|
{ key: 'pending', label: '待审核报名', value: dashboard.value.pendingRegistrations || 0, icon: AuditOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)', link: '/contests/registrations' },
|
|||
|
|
{ key: 'works', label: '总作品数', value: dashboard.value.totalWorks || 0, icon: FileTextOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)', link: '/contests/works' },
|
|||
|
|
{ key: 'today', label: '今日报名', value: dashboard.value.todayRegistrations || 0, icon: FundViewOutlined, color: '#8b5cf6', bgColor: 'rgba(139,92,246,0.1)', link: '/contests/registrations' },
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
// #3 快捷操作(按权限过滤)
|
|||
|
|
const allActions = [
|
|||
|
|
{ label: '活动列表', path: '/contests/list', permission: 'contest:read', icon: TrophyOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
|
|||
|
|
{ label: '报名管理', path: '/contests/registrations', permission: 'contest:registration:read', icon: UserAddOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
|
|||
|
|
{ label: '作品管理', path: '/contests/works', permission: 'contest:work:read', icon: FileTextOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
|
|||
|
|
{ label: '评委管理', path: '/contests/judges', permission: 'judge:read', icon: SolutionOutlined, color: '#ec4899', bgColor: 'rgba(236,72,153,0.1)' },
|
|||
|
|
{ label: '用户管理', path: '/system/users', permission: 'user:read', icon: TeamOutlined, color: '#3b82f6', bgColor: 'rgba(59,130,246,0.1)' },
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
const visibleActions = computed(() =>
|
|||
|
|
allActions.filter(a => authStore.hasPermission(a.permission))
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const formatDateRange = (start: string, end: string) => {
|
|||
|
|
if (!start || !end) return '-'
|
|||
|
|
return `${dayjs(start).format('MM/DD')} - ${dayjs(end).format('MM/DD')}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const goTo = (path: string) => {
|
|||
|
|
const tenantCode = authStore.tenantCode
|
|||
|
|
router.push(`/${tenantCode}${path}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fetchDashboard = async () => {
|
|||
|
|
loading.value = true
|
|||
|
|
try {
|
|||
|
|
dashboard.value = await request.get('/contests/dashboard')
|
|||
|
|
} catch {
|
|||
|
|
message.error('获取统计数据失败')
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(fetchDashboard)
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped lang="scss">
|
|||
|
|
$primary: #6366f1;
|
|||
|
|
|
|||
|
|
// #1 欢迎横幅
|
|||
|
|
.welcome-banner {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 24px 28px;
|
|||
|
|
background: linear-gradient(135deg, #eef2ff 0%, #fdf2f8 100%);
|
|||
|
|
border-radius: 16px;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
|
|||
|
|
.welcome-left {
|
|||
|
|
h1 { font-size: 20px; font-weight: 700; color: #1e1b4b; margin: 0 0 6px; }
|
|||
|
|
p { font-size: 13px; color: #6b7280; margin: 0; display: flex; align-items: center; gap: 6px; }
|
|||
|
|
}
|
|||
|
|
.welcome-right {
|
|||
|
|
.date-text { font-size: 13px; color: #9ca3af; }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// #6 待办提醒
|
|||
|
|
.todo-section {
|
|||
|
|
display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
.todo-item {
|
|||
|
|
display: flex; align-items: center; gap: 10px;
|
|||
|
|
padding: 10px 16px; border-radius: 10px; font-size: 13px; cursor: pointer; transition: all 0.2s;
|
|||
|
|
|
|||
|
|
&.warning { background: #fef3c7; color: #92400e; border: 1px solid #fde68a;
|
|||
|
|
&:hover { background: #fde68a; }
|
|||
|
|
}
|
|||
|
|
&.info { background: #ede9fe; color: #5b21b6; border: 1px solid #ddd6fe;
|
|||
|
|
&:hover { background: #ddd6fe; }
|
|||
|
|
}
|
|||
|
|
.todo-arrow { margin-left: auto; font-size: 11px; opacity: 0.5; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// #2 空数据引导
|
|||
|
|
.empty-guide {
|
|||
|
|
background: #fff; border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 20px;
|
|||
|
|
:deep(.ant-result) { padding: 24px 0; }
|
|||
|
|
:deep(.ant-result-title) { font-size: 18px; font-weight: 700; color: #1e1b4b; }
|
|||
|
|
:deep(.ant-result-subtitle) { color: #6b7280; }
|
|||
|
|
}
|
|||
|
|
.guide-steps {
|
|||
|
|
display: flex; flex-direction: column; gap: 12px; max-width: 400px; margin: 0 auto; text-align: left;
|
|||
|
|
}
|
|||
|
|
.guide-step {
|
|||
|
|
display: flex; align-items: center; gap: 14px;
|
|||
|
|
padding: 14px 18px; background: #faf9fe; border-radius: 12px;
|
|||
|
|
cursor: pointer; transition: all 0.2s;
|
|||
|
|
&:hover { background: #eef2ff; transform: translateX(4px); }
|
|||
|
|
|
|||
|
|
.step-num {
|
|||
|
|
width: 28px; height: 28px; border-radius: 50%; background: $primary; color: #fff;
|
|||
|
|
display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
.step-content {
|
|||
|
|
flex: 1; display: flex; flex-direction: column;
|
|||
|
|
strong { font-size: 14px; color: #1e1b4b; }
|
|||
|
|
span { font-size: 12px; color: #9ca3af; }
|
|||
|
|
}
|
|||
|
|
:deep(.anticon) { color: #d1d5db; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// #5 统计卡片
|
|||
|
|
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px;
|
|||
|
|
@media (min-width: 1200px) { grid-template-columns: repeat(6, 1fr); }
|
|||
|
|
}
|
|||
|
|
.stat-card {
|
|||
|
|
display: flex; align-items: center; gap: 12px; padding: 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); transition: all 0.2s;
|
|||
|
|
&.clickable { cursor: pointer;
|
|||
|
|
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); transform: translateY(-2px); }
|
|||
|
|
}
|
|||
|
|
.stat-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
|||
|
|
.stat-info { display: flex; flex-direction: column;
|
|||
|
|
.stat-count { font-size: 20px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
|
|||
|
|
.stat-label { font-size: 12px; color: #9ca3af; }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 区块卡片
|
|||
|
|
.section-card {
|
|||
|
|
border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
|||
|
|
:deep(.ant-card-head) { .ant-card-head-title { font-size: 15px; font-weight: 600; } }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// #3 快捷操作
|
|||
|
|
.action-grid {
|
|||
|
|
display: flex; gap: 24px; flex-wrap: wrap;
|
|||
|
|
.action-item {
|
|||
|
|
display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; transition: all 0.2s;
|
|||
|
|
&:hover { transform: translateY(-2px); }
|
|||
|
|
.action-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; }
|
|||
|
|
span { font-size: 12px; color: #374151; font-weight: 500; }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// #4 最近活动
|
|||
|
|
.contest-list { display: flex; flex-direction: column; gap: 8px; }
|
|||
|
|
.contest-item {
|
|||
|
|
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
|||
|
|
padding: 12px 16px; border-radius: 10px; cursor: pointer; transition: background 0.2s;
|
|||
|
|
&:hover { background: rgba($primary, 0.03); }
|
|||
|
|
.contest-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1;
|
|||
|
|
.contest-name { font-size: 14px; font-weight: 600; color: #1e1b4b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|||
|
|
.contest-time { font-size: 12px; color: #9ca3af; }
|
|||
|
|
}
|
|||
|
|
.contest-stats { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|||
|
|
}
|
|||
|
|
</style>
|