feat: 工作台仪表盘补全、快捷操作与租户菜单优化
- TenantDashboard: 快捷操作去掉用户管理;最近活动依赖后端 recentContests - ContestServiceImpl: getDashboard 返回最近活动、进行中、待审、今日报名及租户信息 - 机构管理: 子菜单全未选时剔除父菜单 ID(pruneOrphanParentMenuIds) - 菜单管理: AntdIconPicker 与表单调整;设计文档同步 Made-with: Cursor
This commit is contained in:
parent
4915f1ab6d
commit
7384a0423c
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -85,6 +85,7 @@
|
||||
```
|
||||
工作台 (id=50) ← TenantDashboard
|
||||
页面内容:欢迎信息 + 6个统计卡片 + 快捷操作 + 待办提醒 + 最近活动
|
||||
快捷操作:活动列表、报名管理、作品管理、评委管理(按权限显示;不含用户管理,见系统设置)
|
||||
|
||||
数据统计 (id=52) ← 租户端专属
|
||||
├── 运营概览 (53) — 指标卡片 + 漏斗图 + 月度趋势 + 活动对比
|
||||
|
||||
@ -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 接口
|
||||
|
||||
352
frontend/src/components/AntdIconPicker.vue
Normal file
352
frontend/src/components/AntdIconPicker.vue
Normal 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>
|
||||
@ -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;
|
||||
|
||||
@ -165,7 +165,14 @@
|
||||
<a-checkbox :checked="isMenuAllChecked(parentMenu)"
|
||||
:indeterminate="isMenuIndeterminate(parentMenu)"
|
||||
@change="handleMenuCheckAll(parentMenu, $event)">
|
||||
<strong>{{ parentMenu.name }}</strong>
|
||||
<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)">
|
||||
{{ childMenu.name }}
|
||||
<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>
|
||||
|
||||
@ -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(() =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user