library-picturebook-activity/frontend/src/views/workbench/TenantDashboard.vue
aid 9215465bd5 Day5: 租户端全面优化 + 数据统计看板 + 成果发布完善
租户端基础设施:
- 新增工作台首页(欢迎信息/统计/待办/快捷操作/新手引导)
- 新增机构信息管理页(自助查看编辑机构信息)
- 修复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>
2026-03-31 20:02:24 +08:00

334 lines
14 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="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>