租户端基础设施: - 新增工作台首页(欢迎信息/统计/待办/快捷操作/新手引导) - 新增机构信息管理页(自助查看编辑机构信息) - 修复403报错(fetchTenants加超管守卫) - 修复权限(log:read/notice:update/notice:delete/contest:work:read) - 修复评审规则组件映射 活动管理全模块优化(机构端视角): - 活动列表:加统计概览+精简列+筛选自动查询+发布弹窗修复+操作逻辑优化 - 创建/编辑活动:重构布局(去card嵌套+栅格响应式+分区卡片) - 评委管理:统一主色调+冻结确认+导入导出disabled - 报名管理:去Tab+统计+审核状态列+批量审核接口 - 报名记录:统计概览+去机构列+撤销审核+返回按钮+去参与方式列 - 作品管理:去Tab+统计+递交进度彩色+筛选修复(assignStatus/submitTime) - 评审进度:去Tab+统计+实际完成率状态+筛选修复 - 评审规则:表格加评委数/计算方式+描述列修复+删除保护 - 成果发布:去Tab+统计+操作文案优化 - 通知公告:统一主色调+发布确认+操作逻辑+状态筛选+时间范围 成果发布详情功能补全: - 计算得分/排名/设置奖项三步操作流程 - 排名列(金银铜徽章)+奖项列+奖项筛选 - 自定义奖项(动态添加行替代硬编码一二三等奖) - 后端AutoSetAwardsDto改为awards数组格式 数据统计看板(新模块): - 后端analytics module(overview+review两个接口) - 运营概览:6指标卡片+报名转化漏斗+ECharts月度趋势+活动对比表 - 评审分析:4效率卡片+评委工作量表+ECharts奖项分布饼图 - 菜单注册:数据统计→运营概览+评审分析 Bug修复: - 超管重置其他租户用户密码报"用户不存在" - gdlib登录快捷标签密码不一致 - 分配评委去掉评审时间限制 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
|