feat: 工作台仪表盘补全、快捷操作与租户菜单优化

- TenantDashboard: 快捷操作去掉用户管理;最近活动依赖后端 recentContests

- ContestServiceImpl: getDashboard 返回最近活动、进行中、待审、今日报名及租户信息

- 机构管理: 子菜单全未选时剔除父菜单 ID(pruneOrphanParentMenuIds)

- 菜单管理: AntdIconPicker 与表单调整;设计文档同步

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-09 18:25:13 +08:00
parent 4915f1ab6d
commit 7384a0423c
7 changed files with 588 additions and 75 deletions

View File

@ -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<ContestMapper, BizContest> i
}
long totalContests = count(contestWrapper);
long totalRegistrations = contestRegistrationMapper.selectCount(new LambdaQueryWrapper<BizContestRegistration>());
LambdaQueryWrapper<BizContest> 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<BizContestWork>());
LambdaQueryWrapper<BizContestRegistration> regBase = new LambdaQueryWrapper<>();
regBase.eq(BizContestRegistration::getValidState, 1);
if (tenantId != null) {
regBase.eq(BizContestRegistration::getTenantId, tenantId);
}
long totalRegistrations = contestRegistrationMapper.selectCount(regBase);
LambdaQueryWrapper<BizContestRegistration> 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<BizContestRegistration> 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<BizContestWork> workWrapper = new LambdaQueryWrapper<>();
workWrapper.eq(BizContestWork::getValidState, 1);
if (tenantId != null) {
workWrapper.eq(BizContestWork::getTenantId, tenantId);
}
long totalWorks = contestWorkMapper.selectCount(workWrapper);
// 最近活动按创建时间倒序取 5 结构与活动列表一致含报名数作品数
LambdaQueryWrapper<BizContest> 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<BizContest> recentPage = new Page<>(1, 5);
Page<BizContest> recentResult = contestMapper.selectPage(recentPage, recentQ);
List<BizContest> recentList = recentResult.getRecords();
List<Long> recentIds = recentList.stream().map(BizContest::getId).toList();
Map<Long, Long> registrationCountMap = new HashMap<>();
Map<Long, Long> workCountMap = new HashMap<>();
if (!recentIds.isEmpty()) {
contestRegistrationMapper.selectList(
new LambdaQueryWrapper<BizContestRegistration>()
.in(BizContestRegistration::getContestId, recentIds))
.stream()
.collect(Collectors.groupingBy(BizContestRegistration::getContestId, Collectors.counting()))
.forEach(registrationCountMap::put);
List<BizContestWork> recentWorks = contestWorkMapper.selectList(
new LambdaQueryWrapper<BizContestWork>()
.in(BizContestWork::getContestId, recentIds)
.eq(BizContestWork::getIsLatest, true)
.eq(BizContestWork::getValidState, 1));
for (BizContestWork w : recentWorks) {
workCountMap.merge(w.getContestId(), 1L, Long::sum);
}
}
List<Map<String, Object>> recentContests = recentList.stream()
.map(entity -> {
Map<String, Object> map = entityToMap(entity);
Map<String, Object> 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<String, Object> 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<String, Object> tenantMap = new LinkedHashMap<>();
tenantMap.put("name", tenant.getName());
tenantMap.put("tenantType", tenant.getTenantType());
dashboard.put("tenant", tenantMap);
}
}
return dashboard;
}

View File

@ -85,6 +85,7 @@
```
工作台 (id=50) ← TenantDashboard
页面内容:欢迎信息 + 6个统计卡片 + 快捷操作 + 待办提醒 + 最近活动
快捷操作:活动列表、报名管理、作品管理、评委管理(按权限显示;不含用户管理,见系统设置)
数据统计 (id=52) ← 租户端专属
├── 运营概览 (53) — 指标卡片 + 漏斗图 + 月度趋势 + 活动对比

View File

@ -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 接口

View File

@ -0,0 +1,352 @@
<template>
<div class="antd-icon-picker">
<a-input-group compact class="antd-icon-picker-row">
<a-input
v-model:value="iconName"
:placeholder="placeholder"
:maxlength="maxlength"
allow-clear
class="antd-icon-picker-input"
>
<template v-if="iconName" #prefix>
<span class="antd-icon-picker-prefix">
<component v-if="previewRender" :is="previewRender" />
</span>
</template>
</a-input>
<a-popover
v-model:open="pickerOpen"
trigger="click"
placement="bottomLeft"
:overlay-style="{ width: '400px' }"
>
<template #content>
<a-form-item-rest>
<a-input-search
v-model:value="keyword"
placeholder="搜索图标名称"
allow-clear
class="mb-2"
/>
<a-tabs v-model:active-key="iconStyleTab" size="small" class="antd-icon-picker-tabs mb-2">
<a-tab-pane key="outlined" tab="线条" />
<a-tab-pane key="filled" tab="实底" />
<a-tab-pane key="twoTone" tab="双色" />
</a-tabs>
<!-- 虚拟列表只挂载可视区域内的行避免一次性渲染上千个图标组件 -->
<div
v-if="filteredNames.length > 0"
ref="scrollRef"
class="antd-icon-picker-grid"
@scroll.passive="onGridScroll"
>
<div class="antd-icon-picker-grid-spacer" :style="{ height: `${totalScrollHeight}px` }">
<div
class="antd-icon-picker-grid-inner"
:style="{ transform: `translateY(${virtualOffsetY}px)` }"
>
<div
v-for="(row, rowIdx) in visibleRows"
:key="virtualStartRow + rowIdx"
class="antd-icon-picker-grid-row"
>
<button
v-for="name in row"
:key="name"
type="button"
:title="name"
class="antd-icon-picker-cell"
:class="{ 'is-active': iconName === name }"
@click="selectIcon(name)"
>
<component :is="getIconRenderCached(name)" />
</button>
</div>
</div>
</div>
</div>
<div v-else class="text-center text-gray-400 py-4 text-sm">无匹配图标</div>
</a-form-item-rest>
</template>
<a-button type="default" class="antd-icon-picker-trigger">
<template #icon><AppstoreOutlined /></template>
选择
</a-button>
</a-popover>
</a-input-group>
</div>
</template>
<script setup lang="ts">
import { computed, h, nextTick, ref, watch } from 'vue'
import type { VNode } from 'vue'
import * as Icons from '@ant-design/icons-vue'
import { AppstoreOutlined } from '@ant-design/icons-vue'
withDefaults(
defineProps<{
placeholder?: string
maxlength?: number
}>(),
{
placeholder: '请输入或点击选择图标Ant Design Icons',
maxlength: 50,
}
)
const iconName = defineModel<string>('value', { default: '' })
const keyword = ref('')
const pickerOpen = ref(false)
const scrollRef = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
/** 每行高度(与样式 .antd-icon-picker-grid-row 一致) */
const ROW_HEIGHT = 44
const COLS = 8
const BUFFER_ROWS = 3
const GRID_MAX_HEIGHT = 280
const EXCLUDED_ICON_MODULE_KEYS = new Set([
'default',
'createFromIconfontCN',
'Icon',
'getTwoToneColor',
'setTwoToneColor',
])
function isVueIconExport(val: unknown): boolean {
if (val === null || val === undefined) return false
const t = typeof val
return t === 'function' || t === 'object'
}
function buildNamesBySuffix(suffix: 'Outlined' | 'Filled' | 'TwoTone'): string[] {
return Object.keys(Icons)
.filter((k) => {
if (EXCLUDED_ICON_MODULE_KEYS.has(k)) return false
if (!k.endsWith(suffix)) return false
return isVueIconExport((Icons as Record<string, unknown>)[k])
})
.sort((a, b) => a.localeCompare(b))
}
/** 线条 / 实底 / 双色 三套列表,分页切换减少单次渲染量 */
const OUTLINED_ICON_NAMES = buildNamesBySuffix('Outlined')
const FILLED_ICON_NAMES = buildNamesBySuffix('Filled')
const TWO_TONE_ICON_NAMES = buildNamesBySuffix('TwoTone')
type IconStyleTab = 'outlined' | 'filled' | 'twoTone'
const iconStyleTab = ref<IconStyleTab>('outlined')
const namesByTab = computed(() => {
switch (iconStyleTab.value) {
case 'filled':
return FILLED_ICON_NAMES
case 'twoTone':
return TWO_TONE_ICON_NAMES
default:
return OUTLINED_ICON_NAMES
}
})
const filteredNames = computed(() => {
const q = keyword.value.trim().toLowerCase()
const list = namesByTab.value
if (!q) return list
return list.filter((n) => n.toLowerCase().includes(q))
})
const totalRows = computed(() => Math.ceil(filteredNames.value.length / COLS))
const totalScrollHeight = computed(() => Math.max(0, totalRows.value * ROW_HEIGHT))
const virtualStartRow = computed(() => {
const tr = totalRows.value
if (tr === 0) return 0
const top = scrollTop.value
const start = Math.max(0, Math.floor(top / ROW_HEIGHT) - BUFFER_ROWS)
const maxStart = Math.max(0, tr - 1)
return Math.min(start, maxStart)
})
const virtualEndRow = computed(() => {
const tr = totalRows.value
if (tr === 0) return 0
const h = GRID_MAX_HEIGHT
const top = scrollTop.value
const end = Math.ceil((top + h) / ROW_HEIGHT) + BUFFER_ROWS
return Math.min(tr, Math.max(0, end))
})
/** 当前窗口内要渲染的行(每行最多 COLS 个名称) */
const visibleRows = computed(() => {
const list = filteredNames.value
const start = virtualStartRow.value
const end = virtualEndRow.value
const rows: string[][] = []
for (let r = start; r < end; r++) {
const row: string[] = []
for (let c = 0; c < COLS; c++) {
const i = r * COLS + c
if (i < list.length) row.push(list[i]!)
}
if (row.length) rows.push(row)
}
return rows
})
const virtualOffsetY = computed(() => virtualStartRow.value * ROW_HEIGHT)
function onGridScroll(e: Event) {
const el = e.target as HTMLElement
scrollTop.value = el.scrollTop
}
function resetGridScroll() {
scrollTop.value = 0
nextTick(() => {
if (scrollRef.value) scrollRef.value.scrollTop = 0
})
}
function inferTabFromIconName(name: string): IconStyleTab {
if (name.endsWith('TwoTone')) return 'twoTone'
if (name.endsWith('Filled')) return 'filled'
return 'outlined'
}
const iconRenderCache = new Map<string, () => VNode>()
function getIconRenderCached(name: string) {
let fn = iconRenderCache.get(name)
if (!fn) {
const C = (Icons as Record<string, unknown>)[name]
if (!C) {
fn = () => h('span')
} else {
const Comp = C as Parameters<typeof h>[0]
fn = () => h(Comp)
}
iconRenderCache.set(name, fn)
}
return fn
}
const previewRender = computed(() => {
const n = iconName.value
if (!n) return null
return getIconRenderCached(n)
})
function selectIcon(name: string) {
iconName.value = name
pickerOpen.value = false
keyword.value = ''
}
watch(keyword, () => {
resetGridScroll()
})
watch(iconStyleTab, () => {
resetGridScroll()
})
watch(pickerOpen, (open) => {
if (!open) {
keyword.value = ''
return
}
iconStyleTab.value = inferTabFromIconName(iconName.value || '')
resetGridScroll()
})
</script>
<style scoped>
.antd-icon-picker-row {
display: flex;
width: 100%;
}
.antd-icon-picker-input {
flex: 1;
min-width: 0;
}
.antd-icon-picker-prefix {
display: inline-flex;
align-items: center;
font-size: 16px;
line-height: 1;
}
.antd-icon-picker-trigger {
flex-shrink: 0;
}
.antd-icon-picker-tabs :deep(.ant-tabs-nav) {
margin-bottom: 8px;
}
.antd-icon-picker-tabs :deep(.ant-tabs-tab) {
padding: 6px 10px;
}
.antd-icon-picker-grid {
max-height: 280px;
overflow-x: hidden;
overflow-y: auto;
}
.antd-icon-picker-grid-spacer {
position: relative;
width: 100%;
}
.antd-icon-picker-grid-inner {
position: absolute;
left: 0;
right: 0;
top: 0;
}
.antd-icon-picker-grid-row {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
height: 44px;
margin-bottom: 0;
box-sizing: border-box;
}
.antd-icon-picker-cell {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 0;
height: 40px;
padding: 0;
margin: 0;
border: 1px solid var(--ant-color-border-secondary, #f0f0f0);
border-radius: 6px;
background: var(--ant-color-bg-container, #fff);
cursor: pointer;
font-size: 18px;
line-height: 1;
transition: border-color 0.2s, background 0.2s, color 0.2s;
}
.antd-icon-picker-cell:hover {
border-color: var(--ant-color-primary, #0958d9);
color: var(--ant-color-primary, #0958d9);
}
.antd-icon-picker-cell.is-active {
border-color: var(--ant-color-primary, #0958d9);
background: var(--ant-color-primary-bg, #e6f4ff);
color: var(--ant-color-primary, #0958d9);
}
</style>

View File

@ -12,23 +12,13 @@
<a-card title="菜单目录" size="small">
<div class="left-tree-scroll">
<a-spin :spinning="loading">
<a-tree
v-if="dataSource.length > 0"
:tree-data="dataSource"
:field-names="{ title: 'name', key: 'id', children: 'children' }"
:selected-keys="selectedKeys"
default-expand-all
show-line
block-node
@select="onTreeSelect"
>
<a-tree v-if="dataSource.length > 0" :tree-data="dataSource"
:field-names="{ title: 'name', key: 'id', children: 'children' }" :selected-keys="selectedKeys"
default-expand-all show-line block-node @select="onTreeSelect">
<template #title="{ dataRef }">
<span class="tree-node-title">
<component
v-if="dataRef.icon && getIconComponent(dataRef.icon)"
:is="getIconComponent(dataRef.icon)!"
class="tree-node-icon"
/>
<component v-if="dataRef.icon && getIconComponent(dataRef.icon)"
:is="getIconComponent(dataRef.icon)!" class="tree-node-icon" />
<span>{{ dataRef.name }}</span>
</span>
</template>
@ -46,17 +36,14 @@
<a-descriptions bordered size="small" :column="2" class="mb-4">
<a-descriptions-item label="ID">{{ detailForm.id }}</a-descriptions-item>
<a-descriptions-item label="修改人">{{ detailForm.modifier ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">{{ formatTime(detailForm.createTime) }}</a-descriptions-item>
<a-descriptions-item label="编辑时间" :span="2">{{ formatTime(detailForm.modifyTime) }}</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">{{ formatTime(detailForm.createTime)
}}</a-descriptions-item>
<a-descriptions-item label="编辑时间" :span="2">{{ formatTime(detailForm.modifyTime)
}}</a-descriptions-item>
</a-descriptions>
<a-form
ref="detailFormRef"
:model="detailForm"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-form ref="detailFormRef" :model="detailForm" :rules="rules" :label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }">
<a-form-item label="菜单名称" name="name">
<a-input v-model:value="detailForm.name" placeholder="请输入菜单名称" :maxlength="50" />
</a-form-item>
@ -64,30 +51,28 @@
<a-input v-model:value="detailForm.path" placeholder="请输入路由路径(如:/system/users" :maxlength="200" />
</a-form-item>
<a-form-item label="图标" name="icon">
<a-input v-model:value="detailForm.icon" placeholder="请输入图标名称Ant Design Icons" :maxlength="50" />
<AntdIconPicker v-model:value="detailForm.icon" :maxlength="50" />
</a-form-item>
<a-form-item label="组件路径" name="component">
<a-input v-model:value="detailForm.component" placeholder="请输入组件路径system/users/Index" :maxlength="200" />
<a-input v-model:value="detailForm.component" placeholder="请输入组件路径system/users/Index"
:maxlength="200" />
</a-form-item>
<a-form-item label="权限编码" name="permission">
<a-input v-model:value="detailForm.permission" placeholder="请输入权限编码menu:read留空则所有用户可见" :maxlength="100" />
<a-input v-model:value="detailForm.permission" placeholder="请输入权限编码menu:read留空则所有用户可见"
:maxlength="100" />
</a-form-item>
<a-form-item label="父菜单" name="parentId">
<a-tree-select
v-model:value="detailForm.parentId"
:tree-data="menuTreeOptions"
placeholder="请选择父菜单(不选则为顶级菜单)"
:allow-clear="true"
tree-default-expand-all
style="width: 100%"
/>
<a-tree-select v-model:value="detailForm.parentId" :tree-data="menuTreeOptions"
placeholder="请选择父菜单(不选则为顶级菜单)" :allow-clear="true" tree-default-expand-all style="width: 100%" />
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="detailForm.sort" :min="0" :max="9999" placeholder="请输入排序值" style="width: 100%" />
<a-input-number v-model:value="detailForm.sort" :min="0" :max="9999" placeholder="请输入排序值"
style="width: 100%" />
</a-form-item>
<a-form-item :wrapper-col="{ span: 18, offset: 6 }">
<a-space>
<a-button v-permission="'menu:update'" type="primary" :loading="saveDetailLoading" @click="handleSaveDetail">
<a-button v-permission="'menu:update'" type="primary" :loading="saveDetailLoading"
@click="handleSaveDetail">
保存
</a-button>
<a-button v-permission.hide="'menu:delete'" danger @click="handleDeleteDetail">删除</a-button>
@ -102,21 +87,10 @@
</div>
<!-- 新增菜单弹窗 -->
<a-modal
v-model:open="modalVisible"
title="新增菜单"
:confirm-loading="submitLoading"
@ok="handleSubmitCreate"
@cancel="handleCancelCreate"
width="600px"
>
<a-form
ref="createFormRef"
:model="createForm"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-modal v-model:open="modalVisible" title="新增菜单" :confirm-loading="submitLoading" @ok="handleSubmitCreate"
@cancel="handleCancelCreate" width="600px">
<a-form ref="createFormRef" :model="createForm" :rules="rules" :label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }">
<a-form-item label="菜单名称" name="name">
<a-input v-model:value="createForm.name" placeholder="请输入菜单名称" :maxlength="50" />
</a-form-item>
@ -124,26 +98,22 @@
<a-input v-model:value="createForm.path" placeholder="请输入路由路径(如:/system/users" :maxlength="200" />
</a-form-item>
<a-form-item label="图标" name="icon">
<a-input v-model:value="createForm.icon" placeholder="请输入图标名称Ant Design Icons" :maxlength="50" />
<AntdIconPicker v-model:value="createForm.icon" :maxlength="50" />
</a-form-item>
<a-form-item label="组件路径" name="component">
<a-input v-model:value="createForm.component" placeholder="请输入组件路径system/users/Index" :maxlength="200" />
</a-form-item>
<a-form-item label="权限编码" name="permission">
<a-input v-model:value="createForm.permission" placeholder="请输入权限编码menu:read留空则所有用户可见" :maxlength="100" />
<a-input v-model:value="createForm.permission" placeholder="请输入权限编码menu:read留空则所有用户可见"
:maxlength="100" />
</a-form-item>
<a-form-item label="父菜单" name="parentId">
<a-tree-select
v-model:value="createForm.parentId"
:tree-data="menuTreeOptionsForCreate"
placeholder="请选择父菜单(不选则为顶级菜单)"
:allow-clear="true"
tree-default-expand-all
style="width: 100%"
/>
<a-tree-select v-model:value="createForm.parentId" :tree-data="menuTreeOptionsForCreate"
placeholder="请选择父菜单(不选则为顶级菜单)" :allow-clear="true" tree-default-expand-all style="width: 100%" />
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="createForm.sort" :min="0" :max="9999" placeholder="请输入排序值" style="width: 100%" />
<a-input-number v-model:value="createForm.sort" :min="0" :max="9999" placeholder="请输入排序值"
style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
@ -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 = () => {
})
}
</script>
<style>
.menus-page .ant-tree-switcher.ant-tree-switcher-noop {
display: none;
}
</style>
<style scoped>
.page-content {
display: flex;

View File

@ -165,7 +165,14 @@
<a-checkbox :checked="isMenuAllChecked(parentMenu)"
:indeterminate="isMenuIndeterminate(parentMenu)"
@change="handleMenuCheckAll(parentMenu, $event)">
<span class="menu-checkbox-label">
<component
v-if="getIconComponent(resolveMenuIcon(parentMenu))"
:is="getIconComponent(resolveMenuIcon(parentMenu))!"
class="menu-item-icon"
/>
<strong>{{ parentMenu.name }}</strong>
</span>
</a-checkbox>
</div>
<div v-if="parentMenu.children && parentMenu.children.length > 0" class="menu-items">
@ -173,7 +180,14 @@
<a-col :span="8" v-for="childMenu in parentMenu.children" :key="childMenu.id">
<a-checkbox :checked="form.menuIds?.includes(childMenu.id)"
@change="handleChildMenuChange(childMenu.id, $event)">
<span class="menu-checkbox-label">
<component
v-if="getIconComponent(resolveMenuIcon(childMenu))"
:is="getIconComponent(resolveMenuIcon(childMenu))!"
class="menu-item-icon"
/>
{{ childMenu.name }}
</span>
</a-checkbox>
</a-col>
</a-row>
@ -229,6 +243,7 @@ import {
type CreateTenantResult,
} from '@/api/tenants'
import { menusApi, type Menu } from '@/api/menus'
import { getIconComponent } from '@/utils/menu'
const loading = ref(false)
const dataSource = ref<Tenant[]>([])
@ -396,9 +411,27 @@ const getTenantMenuIds = (): Set<number> => {
const preservedExcludedMenuIds = ref<number[]>([])
/** 编辑时从 /tenants/:id/menus 合并 icon优先于全量菜单列表中的 icon */
const menuIconById = ref<Record<number, string>>({})
function collectIconsFromTree(menus: Menu[], map: Record<number, string>) {
menus.forEach((m) => {
if (m.icon) map[m.id] = m.icon
if (m.children?.length) collectIconsFromTree(m.children, map)
})
}
function resolveMenuIcon(menu: Menu): string | undefined {
return menuIconById.value[menu.id] ?? menu.icon ?? undefined
}
const fetchTenantMenus = async (tenantId: number) => {
try {
const tenantMenus = await tenantsApi.getTenantMenus(tenantId)
const iconMap: Record<number, string> = {}
collectIconsFromTree(tenantMenus, iconMap)
menuIconById.value = iconMap
const extractMenuIds = (menus: Menu[]): number[] => {
const ids: number[] = []
menus.forEach((menu) => {
@ -475,6 +508,32 @@ const buildMenuTree = (menus: Menu[]): Menu[] => {
return rootMenus
}
/** 子树(不含当前节点)内是否存在任意已选菜单 id用于判断父级是否应随子级一并保留 */
const subtreeContainsSelectedId = (node: Menu, selected: Set<number>): boolean => {
if (selected.has(node.id)) return true
if (!node.children?.length) return false
return node.children.some((c) => subtreeContainsSelectedId(c, selected))
}
/**
* 有子节点的父菜单若子树中无任何已选 id则从 menuIds 中移除该父 id
* 解决加载时 extractMenuIds 含父级取消全部子项后父级仍留在 form.menuIds但界面显示未选的问题
*/
const pruneOrphanParentMenuIds = (menuIds: number[], roots: Menu[]): number[] => {
const set = new Set(menuIds)
const walk = (nodes: Menu[]) => {
for (const node of nodes) {
if (node.children?.length) {
walk(node.children)
const anyChildSubtreeSelected = node.children.some((c) => subtreeContainsSelectedId(c, set))
if (!anyChildSubtreeSelected && set.has(node.id)) set.delete(node.id)
}
}
}
walk(roots)
return [...set]
}
const isMenuAllChecked = (menu: Menu): boolean => {
if (!menu.children?.length) return form.menuIds?.includes(menu.id) || false
return menu.children.every((c) => form.menuIds?.includes(c.id))
@ -500,7 +559,9 @@ const handleMenuCheckAll = (menu: Menu, e: any) => {
childIds.forEach((id) => { if (!ids.includes(id)) ids.push(id) })
form.menuIds = ids
} else {
form.menuIds = (form.menuIds || []).filter((id) => !childIds.includes(id))
form.menuIds = (form.menuIds || []).filter(
(id) => !childIds.includes(id) && id !== menu.id,
)
}
}
@ -511,6 +572,7 @@ const handleChildMenuChange = (menuId: number, e: any) => {
} else {
form.menuIds = form.menuIds.filter((id) => id !== menuId)
}
form.menuIds = pruneOrphanParentMenuIds(form.menuIds, topLevelMenus.value)
}
// ========== CRUD ==========
@ -521,6 +583,7 @@ const handleAdd = () => {
activeTab.value = 'basic'
modalVisible.value = true
preservedExcludedMenuIds.value = []
menuIconById.value = {}
nextTick(() => {
formRef.value?.resetFields()
Object.assign(form, { name: '', code: '', domain: '', description: '', tenantType: 'other', validState: 1, menuIds: [], adminUsername: '', adminPassword: '' })
@ -532,6 +595,7 @@ const handleEdit = async (record: Tenant) => {
editingId.value = record.id
activeTab.value = 'basic'
modalVisible.value = true
menuIconById.value = {}
detailLoading.value = true
try {
const detail = await tenantsApi.getDetail(record.id)
@ -588,7 +652,10 @@ const handleSubmit = async () => {
submitLoading.value = true
const excludeIds = getTenantMenuIds()
const visibleMenuIds = (form.menuIds || []).filter((id) => !excludeIds.has(id))
const visibleMenuIds = pruneOrphanParentMenuIds(
(form.menuIds || []).filter((id) => !excludeIds.has(id)),
topLevelMenus.value,
)
const menuIds = editingId.value
? [...visibleMenuIds, ...preservedExcludedMenuIds.value]
: visibleMenuIds
@ -603,6 +670,7 @@ const handleSubmit = async () => {
} as UpdateTenantForm)
message.success('保存成功')
modalVisible.value = false
menuIconById.value = {}
} else {
const result = await tenantsApi.create({
name: form.name, code: form.code,
@ -614,6 +682,7 @@ const handleSubmit = async () => {
adminPassword: form.adminPassword || undefined,
} as CreateTenantForm)
modalVisible.value = false
menuIconById.value = {}
// +
lastCreatedName.value = form.name
lastCreatedCode.value = form.code
@ -651,6 +720,7 @@ const handleCancel = () => {
formRef.value?.resetFields()
activeTab.value = 'basic'
form.menuIds = []
menuIconById.value = {}
}
onMounted(() => { fetchList(); fetchAllMenus() })
@ -713,4 +783,18 @@ $primary: #6366f1;
.menu-group-header { margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
.menu-items { padding-left: 24px; }
}
.menu-checkbox-label {
display: inline-flex;
align-items: center;
gap: 6px;
vertical-align: middle;
}
.menu-item-icon {
font-size: 16px;
line-height: 1;
color: #6366f1;
flex-shrink: 0;
}
</style>

View File

@ -100,7 +100,7 @@
<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 v-if="!dashboard.recentContests?.length" style="text-align: center; padding: 30px; color: #9ca3af">
暂无活动数据
</div>
<div v-else class="contest-list">
@ -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(() =>