From 7384a0423ca76f036ac9ee5dfb5c35a7039e685d Mon Sep 17 00:00:00 2001 From: zhonghua Date: Thu, 9 Apr 2026 18:25:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E4=BD=9C=E5=8F=B0=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E8=A1=A5=E5=85=A8=E3=80=81=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E4=B8=8E=E7=A7=9F=E6=88=B7=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TenantDashboard: 快捷操作去掉用户管理;最近活动依赖后端 recentContests - ContestServiceImpl: getDashboard 返回最近活动、进行中、待审、今日报名及租户信息 - 机构管理: 子菜单全未选时剔除父菜单 ID(pruneOrphanParentMenuIds) - 菜单管理: AntdIconPicker 与表单调整;设计文档同步 Made-with: Cursor --- .../service/impl/ContestServiceImpl.java | 106 +++++- docs/design/menu-config.md | 1 + .../org-admin/tenant-portal-optimization.md | 4 +- frontend/src/components/AntdIconPicker.vue | 352 ++++++++++++++++++ frontend/src/views/system/menus/Index.vue | 103 ++--- frontend/src/views/system/tenants/Index.vue | 92 ++++- .../src/views/workbench/TenantDashboard.vue | 5 +- 7 files changed, 588 insertions(+), 75 deletions(-) create mode 100644 frontend/src/components/AntdIconPicker.vue diff --git a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestServiceImpl.java b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestServiceImpl.java index 1d1df1a..f077b33 100644 --- a/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestServiceImpl.java +++ b/backend-java/src/main/java/com/competition/modules/biz/contest/service/impl/ContestServiceImpl.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.competition.common.enums.ErrorCode; import com.competition.common.enums.PublishStatus; +import com.competition.common.enums.RegistrationStatus; import com.competition.common.enums.SubmitRule; import com.competition.common.exception.BusinessException; import com.competition.common.result.PageResult; @@ -29,6 +30,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; @@ -464,14 +466,114 @@ public class ContestServiceImpl extends ServiceImpl i } long totalContests = count(contestWrapper); - long totalRegistrations = contestRegistrationMapper.selectCount(new LambdaQueryWrapper()); + LambdaQueryWrapper ongoingWrapper = new LambdaQueryWrapper<>(); + ongoingWrapper.eq(BizContest::getValidState, 1); + ongoingWrapper.eq(BizContest::getStatus, "ongoing"); + if (tenantId != null) { + ongoingWrapper.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId); + } + long ongoingContests = count(ongoingWrapper); - long totalWorks = contestWorkMapper.selectCount(new LambdaQueryWrapper()); + LambdaQueryWrapper regBase = new LambdaQueryWrapper<>(); + regBase.eq(BizContestRegistration::getValidState, 1); + if (tenantId != null) { + regBase.eq(BizContestRegistration::getTenantId, tenantId); + } + long totalRegistrations = contestRegistrationMapper.selectCount(regBase); + + LambdaQueryWrapper pendingWrapper = new LambdaQueryWrapper<>(); + pendingWrapper.eq(BizContestRegistration::getValidState, 1); + pendingWrapper.eq(BizContestRegistration::getRegistrationState, RegistrationStatus.PENDING.getValue()); + if (tenantId != null) { + pendingWrapper.eq(BizContestRegistration::getTenantId, tenantId); + } + long pendingRegistrations = contestRegistrationMapper.selectCount(pendingWrapper); + + LocalDate today = LocalDate.now(); + LocalDateTime dayStart = today.atStartOfDay(); + LocalDateTime dayEnd = today.plusDays(1).atStartOfDay(); + LambdaQueryWrapper todayWrapper = new LambdaQueryWrapper<>(); + todayWrapper.eq(BizContestRegistration::getValidState, 1); + if (tenantId != null) { + todayWrapper.eq(BizContestRegistration::getTenantId, tenantId); + } + todayWrapper.and(w -> w + .nested(n -> n.ge(BizContestRegistration::getRegistrationTime, dayStart) + .lt(BizContestRegistration::getRegistrationTime, dayEnd)) + .or(n -> n.isNull(BizContestRegistration::getRegistrationTime) + .ge(BizContestRegistration::getCreateTime, dayStart) + .lt(BizContestRegistration::getCreateTime, dayEnd))); + long todayRegistrations = contestRegistrationMapper.selectCount(todayWrapper); + + LambdaQueryWrapper workWrapper = new LambdaQueryWrapper<>(); + workWrapper.eq(BizContestWork::getValidState, 1); + if (tenantId != null) { + workWrapper.eq(BizContestWork::getTenantId, tenantId); + } + long totalWorks = contestWorkMapper.selectCount(workWrapper); + + // 最近活动:按创建时间倒序取 5 条,结构与活动列表一致(含报名数、作品数) + LambdaQueryWrapper recentQ = new LambdaQueryWrapper<>(); + recentQ.eq(BizContest::getValidState, 1); + if (tenantId != null) { + recentQ.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId); + } + recentQ.orderByDesc(BizContest::getCreateTime); + Page recentPage = new Page<>(1, 5); + Page recentResult = contestMapper.selectPage(recentPage, recentQ); + List recentList = recentResult.getRecords(); + + List recentIds = recentList.stream().map(BizContest::getId).toList(); + Map registrationCountMap = new HashMap<>(); + Map workCountMap = new HashMap<>(); + if (!recentIds.isEmpty()) { + contestRegistrationMapper.selectList( + new LambdaQueryWrapper() + .in(BizContestRegistration::getContestId, recentIds)) + .stream() + .collect(Collectors.groupingBy(BizContestRegistration::getContestId, Collectors.counting())) + .forEach(registrationCountMap::put); + + List recentWorks = contestWorkMapper.selectList( + new LambdaQueryWrapper() + .in(BizContestWork::getContestId, recentIds) + .eq(BizContestWork::getIsLatest, true) + .eq(BizContestWork::getValidState, 1)); + for (BizContestWork w : recentWorks) { + workCountMap.merge(w.getContestId(), 1L, Long::sum); + } + } + + List> recentContests = recentList.stream() + .map(entity -> { + Map map = entityToMap(entity); + Map countMap = new LinkedHashMap<>(); + countMap.put("registrations", registrationCountMap.getOrDefault(entity.getId(), 0L)); + countMap.put("works", workCountMap.getOrDefault(entity.getId(), 0L)); + map.put("_count", countMap); + return map; + }) + .toList(); Map dashboard = new LinkedHashMap<>(); dashboard.put("totalContests", totalContests); + dashboard.put("ongoingContests", ongoingContests); dashboard.put("totalRegistrations", totalRegistrations); + dashboard.put("pendingRegistrations", pendingRegistrations); + dashboard.put("todayRegistrations", todayRegistrations); dashboard.put("totalWorks", totalWorks); + dashboard.put("recentContests", recentContests); + + if (tenantId != null) { + SysTenant tenant = sysTenantMapper.selectById(tenantId); + if (tenant != null) { + Map tenantMap = new LinkedHashMap<>(); + tenantMap.put("name", tenant.getName()); + tenantMap.put("tenantType", tenant.getTenantType()); + dashboard.put("tenant", tenantMap); + } + } + return dashboard; } diff --git a/docs/design/menu-config.md b/docs/design/menu-config.md index 869f899..2fa1cce 100644 --- a/docs/design/menu-config.md +++ b/docs/design/menu-config.md @@ -85,6 +85,7 @@ ``` 工作台 (id=50) ← TenantDashboard 页面内容:欢迎信息 + 6个统计卡片 + 快捷操作 + 待办提醒 + 最近活动 + 快捷操作:活动列表、报名管理、作品管理、评委管理(按权限显示;不含用户管理,见系统设置) 数据统计 (id=52) ← 租户端专属 ├── 运营概览 (53) — 指标卡片 + 漏斗图 + 月度趋势 + 活动对比 diff --git a/docs/design/org-admin/tenant-portal-optimization.md b/docs/design/org-admin/tenant-portal-optimization.md index 29b181c..92aa595 100644 --- a/docs/design/org-admin/tenant-portal-optimization.md +++ b/docs/design/org-admin/tenant-portal-optimization.md @@ -3,7 +3,7 @@ > 所属端:租户端(机构管理员视角) > 状态:已优化 > 创建日期:2026-03-31 -> 最后更新:2026-04-08 +> 最后更新:2026-04-09 --- @@ -27,7 +27,7 @@ - [x] 欢迎信息 + 机构标识(时段问候、管理员姓名、机构名称/类型) - [x] 6个统计卡片(可见活动/进行中/总报名/待审核报名/总作品/今日报名),可点击跳转 - [x] 空数据新手引导(三步:创建活动→添加成员→邀请评委) -- [x] 快捷操作按权限动态显示 +- [x] 快捷操作按权限动态显示(固定四项:活动列表、报名管理、作品管理、评委管理;不含「用户管理」,用户管理从系统设置进入) - [x] 待办提醒(待审核报名 + 即将截止的活动) - [x] 最近活动列表 + 查看全部入口 - [x] 后端 GET /contests/dashboard 接口 diff --git a/frontend/src/components/AntdIconPicker.vue b/frontend/src/components/AntdIconPicker.vue new file mode 100644 index 0000000..2d24c6c --- /dev/null +++ b/frontend/src/components/AntdIconPicker.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/frontend/src/views/system/menus/Index.vue b/frontend/src/views/system/menus/Index.vue index 338d25f..60e1de8 100644 --- a/frontend/src/views/system/menus/Index.vue +++ b/frontend/src/views/system/menus/Index.vue @@ -12,23 +12,13 @@
- + @@ -46,17 +36,14 @@ {{ detailForm.id }} {{ detailForm.modifier ?? '-' }} - {{ formatTime(detailForm.createTime) }} - {{ formatTime(detailForm.modifyTime) }} + {{ formatTime(detailForm.createTime) + }} + {{ formatTime(detailForm.modifyTime) + }} - + @@ -64,30 +51,28 @@ - + - + - + - + - + - + 保存 删除 @@ -102,21 +87,10 @@
- - + + @@ -124,26 +98,22 @@ - + - + - + - + @@ -155,6 +125,7 @@ import { ref, reactive, nextTick, computed, h } from 'vue' import { message, Modal } from 'ant-design-vue' import type { FormInstance } from 'ant-design-vue' import * as Icons from '@ant-design/icons-vue' +import AntdIconPicker from '@/components/AntdIconPicker.vue' import { menusApi, type Menu, type CreateMenuForm, type UpdateMenuForm } from '@/api/menus' import { useSimpleListRequest } from '@/composables/useSimpleListRequest' import { useAuthStore } from '@/stores/auth' @@ -404,7 +375,11 @@ const handleDeleteDetail = () => { }) } - + diff --git a/frontend/src/views/workbench/TenantDashboard.vue b/frontend/src/views/workbench/TenantDashboard.vue index 03822bc..d75f8aa 100644 --- a/frontend/src/views/workbench/TenantDashboard.vue +++ b/frontend/src/views/workbench/TenantDashboard.vue @@ -100,7 +100,7 @@ -
+
暂无活动数据
@@ -130,7 +130,7 @@ import { useRouter } from 'vue-router' import { message } from 'ant-design-vue' import { TrophyOutlined, UserAddOutlined, FileTextOutlined, - SolutionOutlined, TeamOutlined, BankOutlined, + SolutionOutlined, BankOutlined, FundViewOutlined, FormOutlined, AuditOutlined, ClockCircleOutlined, RightOutlined, AlertOutlined, InfoCircleOutlined, } from '@ant-design/icons-vue' @@ -187,7 +187,6 @@ const allActions = [ { 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(() =>