feat(product): 新增产品管理模块与字典组件功能
- 新增产品管理相关路由和页面(dashboard、list、requirement、setting) - 实现产品基础信息编辑弹窗组件(base-info-dialog.vue) - 添加运行时字典功能(dict-select、dict-text、dict-tag组件) - 集成字典管理store和API调用 - 规范ID类型定义为string避免精度丢失问题 - 完善国际化资源文件支持中英文对照 - 新增对象上下文业务域入口页导航实现说明 - 添加Vue DevTools浮动入口注释说明 - 统一权限控制支持全局和对象作用域区分 - 规范分页查询参数类型定义与使用方式
This commit is contained in:
@@ -2,17 +2,31 @@ import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useContext } from '@sa/hooks';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { getObjectContextDomainConfigByPath } from '@/constants/object-context';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);
|
||||
|
||||
function isMenuMatchedByPath(path: string, menuPath: string) {
|
||||
if (!menuPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return path === menuPath || path.startsWith(`${menuPath}/`);
|
||||
}
|
||||
|
||||
function useMixMenu() {
|
||||
const route = useRoute();
|
||||
const routeStore = useRouteStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const activeFirstLevelMenuKey = ref('');
|
||||
|
||||
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
|
||||
|
||||
function setActiveFirstLevelMenuKey(key: string) {
|
||||
activeFirstLevelMenuKey.value = key;
|
||||
}
|
||||
@@ -20,10 +34,16 @@ function useMixMenu() {
|
||||
function getActiveFirstLevelMenuKey() {
|
||||
const [firstLevelMenuKey = ''] = routeStore.getSelectedMenuKeyPath(selectedKey.value);
|
||||
|
||||
setActiveFirstLevelMenuKey(firstLevelMenuKey);
|
||||
}
|
||||
if (firstLevelMenuKey) {
|
||||
setActiveFirstLevelMenuKey(firstLevelMenuKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
|
||||
const fallbackFirstLevelMenuKey =
|
||||
allMenus.value.find(menu => isMenuMatchedByPath(route.path, menu.routePath))?.key || '';
|
||||
|
||||
setActiveFirstLevelMenuKey(fallbackFirstLevelMenuKey);
|
||||
}
|
||||
|
||||
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
|
||||
routeStore.menus.map(menu => {
|
||||
@@ -37,6 +57,25 @@ function useMixMenu() {
|
||||
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
|
||||
);
|
||||
|
||||
const currentObjectContextDomain = computed(() => getObjectContextDomainConfigByPath(route.path) || null);
|
||||
|
||||
const headerMenuMode = computed<'global' | 'object-context'>(() =>
|
||||
currentObjectContextDomain.value && objectContextStore.hasContext ? 'object-context' : 'global'
|
||||
);
|
||||
|
||||
const headerMenus = computed<(App.Global.Menu | App.ObjectContext.Menu)[]>(() => {
|
||||
if (headerMenuMode.value === 'object-context') {
|
||||
return objectContextStore.contextScopedMenus;
|
||||
}
|
||||
|
||||
// 对象型业务域处于入口态时,头部只保留业务域锚点,不继续投影全局子菜单。
|
||||
if (currentObjectContextDomain.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return childLevelMenus.value;
|
||||
});
|
||||
|
||||
const isActiveFirstLevelMenuHasChildren = computed(() => {
|
||||
if (!activeFirstLevelMenuKey.value) {
|
||||
return false;
|
||||
@@ -48,7 +87,7 @@ function useMixMenu() {
|
||||
});
|
||||
|
||||
watch(
|
||||
[selectedKey, allMenus],
|
||||
[selectedKey, allMenus, () => route.path],
|
||||
() => {
|
||||
getActiveFirstLevelMenuKey();
|
||||
},
|
||||
@@ -59,6 +98,9 @@ function useMixMenu() {
|
||||
allMenus,
|
||||
firstLevelMenus,
|
||||
childLevelMenus,
|
||||
headerMenuMode,
|
||||
headerMenus,
|
||||
currentObjectContextDomain,
|
||||
isActiveFirstLevelMenuHasChildren,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { getObjectContextDomainConfigByPath } from '@/constants/object-context';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
@@ -13,29 +15,66 @@ defineOptions({
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const routeStore = useRouteStore();
|
||||
const route = useRoute();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
|
||||
const { routerPush, routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const {
|
||||
allMenus,
|
||||
headerMenuMode,
|
||||
headerMenus,
|
||||
currentObjectContextDomain,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey
|
||||
} = useMixMenuContext();
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const activeFirstLevelMenu = computed(
|
||||
() => allMenus.value.find(menu => menu.key === activeFirstLevelMenuKey.value) || null
|
||||
);
|
||||
const headerMenuHeight = computed(() => `${themeStore.header.height}px`);
|
||||
const selectedMenuKeyPath = computed(() => routeStore.getSelectedMenuKeyPath(selectedKey.value));
|
||||
const showObjectContextInfo = computed(
|
||||
() => headerMenuMode.value === 'object-context' && objectContextStore.hasContext
|
||||
);
|
||||
const activeHeaderMenuKey = computed(() =>
|
||||
headerMenuMode.value === 'object-context' ? String(route.name || '') : selectedKey.value
|
||||
);
|
||||
|
||||
function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
setActiveFirstLevelMenuKey(menu.key);
|
||||
|
||||
const domainConfig = getObjectContextDomainConfigByPath(menu.routePath);
|
||||
|
||||
if (domainConfig) {
|
||||
objectContextStore.clearContext();
|
||||
routerPush({ path: domainConfig.entryRoutePath });
|
||||
return;
|
||||
}
|
||||
|
||||
routerPushByKeyWithMetaQuery(menu.routeKey);
|
||||
}
|
||||
|
||||
function handleClickNavItem(menu: App.Global.Menu) {
|
||||
routerPushByKeyWithMetaQuery(menu.routeKey);
|
||||
function handleClickNavItem(menu: App.Global.Menu | App.ObjectContext.Menu) {
|
||||
if (headerMenuMode.value === 'object-context') {
|
||||
const location = objectContextStore.getMenuRouteLocation(menu as App.ObjectContext.Menu);
|
||||
|
||||
if (location) {
|
||||
routerPush(location);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
routerPushByKeyWithMetaQuery((menu as App.Global.Menu).routeKey);
|
||||
}
|
||||
|
||||
function handleClickDomainAnchor() {
|
||||
if (currentObjectContextDomain.value) {
|
||||
objectContextStore.clearContext();
|
||||
routerPush({ path: currentObjectContextDomain.value.entryRoutePath });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeFirstLevelMenu.value) {
|
||||
return;
|
||||
}
|
||||
@@ -43,8 +82,12 @@ function handleClickDomainAnchor() {
|
||||
routerPushByKeyWithMetaQuery(activeFirstLevelMenu.value.routeKey);
|
||||
}
|
||||
|
||||
function isMenuActive(menu: App.Global.Menu) {
|
||||
return selectedMenuKeyPath.value.includes(menu.key);
|
||||
function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
||||
if (menu.key === activeHeaderMenuKey.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return menu.children?.some(child => isMenuActive(child)) || false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -60,9 +103,19 @@ function isMenuActive(menu: App.Global.Menu) {
|
||||
<component :is="activeFirstLevelMenu.icon" v-if="activeFirstLevelMenu.icon" class="text-icon" />
|
||||
<span class="domain-anchor__label">{{ activeFirstLevelMenu.label }}</span>
|
||||
</button>
|
||||
<div v-if="childLevelMenus.length" class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"></div>
|
||||
<div v-if="childLevelMenus.length" class="header-nav-list h-full min-w-0 flex-1">
|
||||
<template v-for="item in childLevelMenus" :key="item.key">
|
||||
<div
|
||||
v-if="showObjectContextInfo || headerMenus.length"
|
||||
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
|
||||
></div>
|
||||
<div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center">
|
||||
<span class="context-object-tag__label">{{ objectContextStore.objectName }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showObjectContextInfo && headerMenus.length"
|
||||
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
|
||||
></div>
|
||||
<div v-if="headerMenus.length" class="header-nav-list h-full min-w-0 flex-1">
|
||||
<template v-for="item in headerMenus" :key="item.key">
|
||||
<button
|
||||
v-if="!item.children?.length"
|
||||
type="button"
|
||||
@@ -155,6 +208,28 @@ function isMenuActive(menu: App.Global.Menu) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-object-tag {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-object-tag__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 14rem;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgb(148 163 184 / 26%);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgb(248 250 252 / 95%), rgb(241 245 249 / 92%));
|
||||
color: rgb(15 23 42 / 88%);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-nav-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -5,7 +5,9 @@ import { SimpleScrollbar } from '@sa/materials';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { getObjectContextDomainConfigByPath } from '@/constants/object-context';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
@@ -21,9 +23,10 @@ defineOptions({
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { routerPush, routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
|
||||
const {
|
||||
allMenus,
|
||||
@@ -44,6 +47,14 @@ const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value ||
|
||||
function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
setActiveFirstLevelMenuKey(menu.key);
|
||||
|
||||
const domainConfig = getObjectContextDomainConfigByPath(menu.routePath);
|
||||
|
||||
if (domainConfig) {
|
||||
objectContextStore.clearContext();
|
||||
routerPush({ path: domainConfig.entryRoutePath });
|
||||
return;
|
||||
}
|
||||
|
||||
if (menu.children?.length) {
|
||||
setDrawerVisible(true);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user