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:
2026-04-23 09:05:55 +08:00
parent c5911ea34b
commit 4122dfa50d
95 changed files with 9581 additions and 801 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 {