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:
249
src/views/system/menu/modules/menu-context-panel.vue
Normal file
249
src/views/system/menu/modules/menu-context-panel.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { objectTypeRecord, scopeTypeRecord } from '@/constants/business';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuContextPanel' });
|
||||
|
||||
interface Props {
|
||||
total?: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
total: 0,
|
||||
loading: false
|
||||
});
|
||||
|
||||
const scopeType = defineModel<Api.SystemManage.ScopeType>('scopeType', {
|
||||
required: true
|
||||
});
|
||||
|
||||
const objectType = defineModel<Api.SystemManage.ObjectType | undefined>('objectType');
|
||||
|
||||
const isObjectScope = computed(() => scopeType.value === 'object');
|
||||
const scopeOptions = computed(() => [
|
||||
{ label: $t(scopeTypeRecord.global), value: 'global' satisfies Api.SystemManage.ScopeType },
|
||||
{ label: $t(scopeTypeRecord.object), value: 'object' satisfies Api.SystemManage.ScopeType }
|
||||
]);
|
||||
|
||||
const objectTypeOptions = computed(() => [
|
||||
{ label: $t(objectTypeRecord.product), value: 'product' satisfies Api.SystemManage.ObjectType },
|
||||
{ label: $t(objectTypeRecord.project), value: 'project' satisfies Api.SystemManage.ObjectType }
|
||||
]);
|
||||
|
||||
const currentContextLabel = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return $t(scopeTypeRecord.global);
|
||||
}
|
||||
|
||||
if (!objectType.value) {
|
||||
return `${$t(scopeTypeRecord.object)} / --`;
|
||||
}
|
||||
|
||||
return `${$t(scopeTypeRecord.object)} / ${$t(objectTypeRecord[objectType.value])}`;
|
||||
});
|
||||
|
||||
const currentScopeSummary = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return $t('page.system.menu.globalResourceSummary');
|
||||
}
|
||||
|
||||
if (objectType.value === 'product') {
|
||||
return $t('page.system.menu.objectResourceSummaryProduct');
|
||||
}
|
||||
|
||||
if (objectType.value === 'project') {
|
||||
return $t('page.system.menu.objectResourceSummaryProject');
|
||||
}
|
||||
|
||||
return $t('page.system.menu.objectResourceSummary');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="menu-context-panel" body-class="menu-context-panel__body">
|
||||
<div v-loading="props.loading" class="menu-context-panel__layout">
|
||||
<div class="menu-context-panel__controls">
|
||||
<div class="menu-context-panel__field menu-context-panel__field--switch">
|
||||
<ElSegmented v-model="scopeType" :options="scopeOptions" />
|
||||
</div>
|
||||
|
||||
<span v-if="isObjectScope" class="menu-context-panel__divider" aria-hidden="true">|</span>
|
||||
|
||||
<div v-if="isObjectScope" class="menu-context-panel__field menu-context-panel__field--inline">
|
||||
<span class="menu-context-panel__field-label menu-context-panel__field-label--inline">
|
||||
{{ $t('page.system.menu.objectType') }}
|
||||
</span>
|
||||
<ElSelect v-model="objectType" class="w-full" :placeholder="$t('page.system.menu.objectTypePlaceholder')">
|
||||
<ElOption v-for="item in objectTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-context-panel__info">
|
||||
<div class="menu-context-panel__info-main">
|
||||
<div class="menu-context-panel__info-item">
|
||||
<span class="menu-context-panel__info-label">{{ $t('page.system.menu.currentContext') }}</span>
|
||||
<strong class="menu-context-panel__info-value">{{ currentContextLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="menu-context-panel__info-item">
|
||||
<span class="menu-context-panel__info-label">{{ $t('page.system.menu.currentResourceCount') }}</span>
|
||||
<strong class="menu-context-panel__info-value">{{ props.total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="menu-context-panel__info-desc">{{ currentScopeSummary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-context-panel {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
background: var(--el-fill-color-blank);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__body) {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.menu-context-panel__layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.menu-context-panel__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-context-panel__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.menu-context-panel__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.menu-context-panel__field--switch {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__field--switch .el-segmented) {
|
||||
width: auto;
|
||||
padding: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__field--switch .el-segmented__item) {
|
||||
min-height: 40px;
|
||||
min-width: 96px;
|
||||
padding: 0 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.menu-context-panel__field-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-context-panel__field--inline {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu-context-panel__field-label--inline {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-context-panel__divider {
|
||||
color: var(--el-border-color-darker);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__field--inline .el-select) {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-desc {
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.menu-context-panel__layout {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.menu-context-panel__controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.menu-context-panel__info {
|
||||
padding-left: 0;
|
||||
padding-top: 14px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.menu-context-panel__controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { menuRouteKindOptions, menuTypeOptions } from '@/constants/business';
|
||||
import type { ElegantConstRoute } from '@elegant-router/types';
|
||||
import { commonStatusOptions, menuRouteKindOptions } from '@/constants/business';
|
||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||
import { fetchCreateMenu, fetchGetMenu, fetchUpdateMenu } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { createStaticRoutes } from '@/router/routes';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import { $t } from '@/locales';
|
||||
@@ -18,6 +21,8 @@ interface Props {
|
||||
operateType: OperateType;
|
||||
rowData?: Api.SystemManage.Menu | null;
|
||||
allMenus: Api.SystemManage.Menu[];
|
||||
scopeType: Api.SystemManage.ScopeType;
|
||||
objectType?: Api.SystemManage.ObjectType;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -32,8 +37,6 @@ const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
type PageResourceItem = (typeof frontendPageResourceManifest.items)[number];
|
||||
|
||||
type Model = Api.SystemManage.SaveMenuParams & {
|
||||
pageResourcePath: string;
|
||||
iframeUrl: string;
|
||||
@@ -48,12 +51,21 @@ type RuleFormItem = {
|
||||
};
|
||||
|
||||
type ParentTreeOption = {
|
||||
value: number;
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
children?: ParentTreeOption[];
|
||||
};
|
||||
|
||||
type RouteBindingItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
component: string;
|
||||
title: string;
|
||||
keepAlive: boolean;
|
||||
props: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const DIRECTORY_COMPONENT = frontendPageResourceManifest.rules.directoryComponent;
|
||||
const IFRAME_COMPONENT = 'view.iframe-page';
|
||||
|
||||
@@ -62,7 +74,16 @@ const pageResourceItems = frontendPageResourceManifest.items
|
||||
.slice()
|
||||
.sort((prev, next) => prev.path.localeCompare(next.path));
|
||||
|
||||
const pageResourceMap = new Map(pageResourceItems.map(item => [item.path, item]));
|
||||
const globalRouteBindingItems: RouteBindingItem[] = pageResourceItems.map(item => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
component: item.component,
|
||||
title: item.title || item.name,
|
||||
keepAlive: Boolean(item.keepAlive),
|
||||
props: (item.props as Record<string, unknown> | null) ?? null
|
||||
}));
|
||||
|
||||
const staticAuthRoutes = createStaticRoutes().authRoutes;
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
@@ -73,6 +94,7 @@ const initializingModel = ref(false);
|
||||
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
const isAddChild = computed(() => props.operateType === 'addChild');
|
||||
const isObjectScope = computed(() => props.scopeType === 'object');
|
||||
|
||||
const title = computed(() => {
|
||||
const titleMap: Record<OperateType, string> = {
|
||||
@@ -84,15 +106,32 @@ const title = computed(() => {
|
||||
return titleMap[props.operateType];
|
||||
});
|
||||
|
||||
const dialogWidth = '780px';
|
||||
|
||||
const model = ref(createDefaultModel());
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (props.scopeType === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: props.objectType
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
permission: '',
|
||||
scopeType: props.scopeType,
|
||||
objectType: props.scopeType === 'object' ? props.objectType : undefined,
|
||||
type: 2,
|
||||
sort: 0,
|
||||
parentId: 0,
|
||||
parentId: '0',
|
||||
path: '',
|
||||
icon: '',
|
||||
component: '',
|
||||
@@ -171,6 +210,54 @@ function buildComponentNameFromFullPath(fullPath?: string | null) {
|
||||
.join('_');
|
||||
}
|
||||
|
||||
function isPathMatchedByPrefix(path: string, prefix: string) {
|
||||
const normalizedPath = toAbsoluteRoutePath(path);
|
||||
const normalizedPrefix = toAbsoluteRoutePath(prefix);
|
||||
|
||||
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix}/`);
|
||||
}
|
||||
|
||||
function collectObjectRouteBindingItems(
|
||||
routes: ElegantConstRoute[],
|
||||
config: App.ObjectContext.DomainConfig
|
||||
): RouteBindingItem[] {
|
||||
return routes.flatMap(route => {
|
||||
if (route.children?.length) {
|
||||
return collectObjectRouteBindingItems(route.children, config);
|
||||
}
|
||||
|
||||
const routePath = toAbsoluteRoutePath(route.path);
|
||||
|
||||
if (!routePath || route.name === config.entryRouteKey || routePath === config.entryRoutePath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!config.routePathPrefixes.some(prefix => isPathMatchedByPrefix(routePath, prefix))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const component = String(route.component ?? '').trim();
|
||||
|
||||
if (!component.includes('view.')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: String(route.name || routePath),
|
||||
path: routePath,
|
||||
component,
|
||||
title: route.meta?.i18nKey ? $t(route.meta.i18nKey) : String(route.meta?.title || route.name || routePath),
|
||||
keepAlive: Boolean(route.meta?.keepAlive),
|
||||
props:
|
||||
route.props && typeof route.props === 'object' && !Array.isArray(route.props)
|
||||
? (route.props as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function parseRoutePropsJson(value?: string | null) {
|
||||
const text = String(value ?? '').trim();
|
||||
|
||||
@@ -217,13 +304,13 @@ function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function getMenuFullPath(menuId: number) {
|
||||
if (!menuId) {
|
||||
function getMenuFullPath(menuId: string) {
|
||||
if (!menuId || menuId === '0') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const menuMap = new Map(props.allMenus.map(item => [item.id, item]));
|
||||
const visitedIds = new Set<number>();
|
||||
const visitedIds = new Set<string>();
|
||||
const pathSegments: string[] = [];
|
||||
let currentMenu = menuMap.get(menuId);
|
||||
|
||||
@@ -236,7 +323,7 @@ function getMenuFullPath(menuId: number) {
|
||||
pathSegments.unshift(currentPath);
|
||||
}
|
||||
|
||||
if (!currentMenu.parentId) {
|
||||
if (currentMenu.parentId === '0') {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -250,11 +337,18 @@ function getMenuFullPathByData(data: Pick<Api.SystemManage.Menu, 'parentId' | 'p
|
||||
return joinRoutePaths(getMenuFullPath(data.parentId), data.path);
|
||||
}
|
||||
|
||||
function resolvePageResourcePath(data: Api.SystemManage.Menu) {
|
||||
function resolveRouteBindingPath(data: Api.SystemManage.Menu) {
|
||||
const viewComponent = extractViewComponent(data.component);
|
||||
const objectDomainConfig = props.objectType
|
||||
? objectContextDomainConfigs.find(config => config.objectType === props.objectType) || null
|
||||
: null;
|
||||
const candidateItems =
|
||||
isObjectScope.value && objectDomainConfig
|
||||
? collectObjectRouteBindingItems(staticAuthRoutes, objectDomainConfig)
|
||||
: globalRouteBindingItems;
|
||||
|
||||
if (viewComponent) {
|
||||
const matchedByComponent = pageResourceItems.find(item => item.component === viewComponent);
|
||||
const matchedByComponent = candidateItems.find(item => item.component === viewComponent);
|
||||
|
||||
if (matchedByComponent) {
|
||||
return matchedByComponent.path;
|
||||
@@ -263,16 +357,81 @@ function resolvePageResourcePath(data: Api.SystemManage.Menu) {
|
||||
|
||||
const fullPath = getMenuFullPathByData(data);
|
||||
|
||||
return pageResourceItems.find(item => item.path === fullPath)?.path ?? '';
|
||||
return candidateItems.find(item => item.path === fullPath)?.path ?? '';
|
||||
}
|
||||
|
||||
const currentMenuId = computed(() => props.rowData?.id ?? 0);
|
||||
const currentMenuId = computed(() => props.rowData?.id ?? '0');
|
||||
const currentParentFullPath = computed(() => getMenuFullPath(model.value.parentId));
|
||||
const isButton = computed(() => model.value.type === 3);
|
||||
const isMenu = computed(() => model.value.type === 2);
|
||||
const currentObjectDomainConfig = computed(() => {
|
||||
if (!props.objectType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return objectContextDomainConfigs.find(config => config.objectType === props.objectType) || null;
|
||||
});
|
||||
|
||||
const objectRouteBindingItems = computed<RouteBindingItem[]>(() => {
|
||||
if (!isObjectScope.value || !currentObjectDomainConfig.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collectObjectRouteBindingItems(staticAuthRoutes, currentObjectDomainConfig.value);
|
||||
});
|
||||
|
||||
const routeBindingItems = computed<RouteBindingItem[]>(() => {
|
||||
return isObjectScope.value ? objectRouteBindingItems.value : globalRouteBindingItems;
|
||||
});
|
||||
|
||||
const routeBindingMap = computed(() => new Map(routeBindingItems.value.map(item => [item.path, item])));
|
||||
const routeBindingOptions = computed(() =>
|
||||
routeBindingItems.value.map(item => ({
|
||||
value: item.path,
|
||||
label: `${item.title || item.name} (${item.path})`
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedRouteBinding = computed(() => routeBindingMap.value.get(model.value.pageResourcePath) ?? null);
|
||||
const routeBindingFieldLabel = computed(() =>
|
||||
isObjectScope.value ? $t('page.system.menu.boundRoute') : $t('page.system.menu.pageResource')
|
||||
);
|
||||
const routeBindingFieldPlaceholder = computed(() =>
|
||||
isObjectScope.value ? $t('page.system.menu.form.boundRoute') : $t('page.system.menu.form.pageResource')
|
||||
);
|
||||
const routeBindingFieldTip = computed(() =>
|
||||
isObjectScope.value ? $t('page.system.menu.tips.boundRoute') : $t('page.system.menu.tips.pageResource')
|
||||
);
|
||||
|
||||
const menuTypeRadioOptions = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return [
|
||||
{ value: 1 as Api.SystemManage.MenuType, label: 'page.system.menu.type.directory', disabled: false },
|
||||
{ value: 2 as Api.SystemManage.MenuType, label: 'page.system.menu.type.menu', disabled: false },
|
||||
{ value: 3 as Api.SystemManage.MenuType, label: 'page.system.menu.type.button', disabled: false }
|
||||
];
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ value: 2 as Api.SystemManage.MenuType, label: 'page.system.menu.type.navigation', disabled: false },
|
||||
{ value: 3 as Api.SystemManage.MenuType, label: 'page.system.menu.type.actionButton', disabled: false }
|
||||
];
|
||||
|
||||
if (isEdit.value && model.value.type === 1) {
|
||||
return [
|
||||
{ value: 1 as Api.SystemManage.MenuType, label: 'page.system.menu.type.directory', disabled: true },
|
||||
...options
|
||||
];
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const showRouteFields = computed(() => !isButton.value);
|
||||
const showRouteSection = computed(() => showRouteFields.value);
|
||||
const showPermissionField = computed(() => isButton.value);
|
||||
const showIconField = computed(() => showRouteFields.value);
|
||||
const showRouteKindField = computed(() => showRouteFields.value && !isObjectScope.value);
|
||||
|
||||
const isDirectoryRoute = computed(() => model.value.routeKind === 'dir');
|
||||
const isViewRoute = computed(() => model.value.routeKind === 'view');
|
||||
@@ -296,7 +455,9 @@ const showExternalUrlField = computed(() => isExternalRoute.value);
|
||||
const showRedirectTargetField = computed(() => isRedirectRoute.value);
|
||||
const showReadonlyRouteProps = computed(() => isIframeRoute.value);
|
||||
const showRoutePropsEditor = computed(() => isSingleRoute.value);
|
||||
const canKeepAlive = computed(() => isMenu.value && !isExternalRoute.value && !isRedirectRoute.value);
|
||||
const canKeepAlive = computed(
|
||||
() => !isObjectScope.value && isMenu.value && !isExternalRoute.value && !isRedirectRoute.value
|
||||
);
|
||||
const showDisplaySection = computed(() => canKeepAlive.value);
|
||||
|
||||
const keepAliveSwitch = computed({
|
||||
@@ -314,6 +475,10 @@ const iconFieldValue = computed({
|
||||
});
|
||||
|
||||
const routeKindSelectOptions = computed(() => {
|
||||
if (isObjectScope.value && model.value.type === 2) {
|
||||
return menuRouteKindOptions.filter(item => item.value === 'view');
|
||||
}
|
||||
|
||||
if (model.value.type === 1) {
|
||||
return menuRouteKindOptions.filter(item => item.value === 'dir');
|
||||
}
|
||||
@@ -338,14 +503,11 @@ const routeKindTipItems = computed(() =>
|
||||
}))
|
||||
);
|
||||
|
||||
const pageResourceOptions = pageResourceItems.map(item => ({
|
||||
value: item.path,
|
||||
label: `${item.title || item.name} (${item.path})`
|
||||
}));
|
||||
|
||||
const selectedPageResource = computed(() => pageResourceMap.get(model.value.pageResourcePath) ?? null);
|
||||
|
||||
const displayRoutePath = computed(() => {
|
||||
if (isObjectScope.value && isMenu.value) {
|
||||
return selectedRouteBinding.value?.path || toAbsoluteRoutePath(model.value.path);
|
||||
}
|
||||
|
||||
return joinRoutePaths(currentParentFullPath.value, model.value.path);
|
||||
});
|
||||
|
||||
@@ -354,6 +516,24 @@ const hasCompatibleViewRouteData = computed(() =>
|
||||
);
|
||||
|
||||
const rules = computed(() => {
|
||||
const permissionRule: RuleFormItem = {
|
||||
message: $t('page.system.menu.form.permission'),
|
||||
trigger: 'blur',
|
||||
validator: (_, value, callback) => {
|
||||
if (!showPermissionField.value) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(value ?? '').trim()) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error($t('page.system.menu.form.permission')));
|
||||
}
|
||||
};
|
||||
|
||||
const pathRule: RuleFormItem = {
|
||||
message: $t('page.system.menu.form.path'),
|
||||
trigger: 'blur',
|
||||
@@ -373,7 +553,7 @@ const rules = computed(() => {
|
||||
};
|
||||
|
||||
const pageResourceRule: RuleFormItem = {
|
||||
message: $t('page.system.menu.form.pageResource'),
|
||||
message: isObjectScope.value ? $t('page.system.menu.form.boundRoute') : $t('page.system.menu.form.pageResource'),
|
||||
trigger: 'change',
|
||||
validator: (_, value, callback) => {
|
||||
if (!showPageResourceField.value) {
|
||||
@@ -387,7 +567,11 @@ const rules = computed(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error($t('page.system.menu.form.pageResource')));
|
||||
callback(
|
||||
new Error(
|
||||
isObjectScope.value ? $t('page.system.menu.form.boundRoute') : $t('page.system.menu.form.pageResource')
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -514,7 +698,9 @@ const rules = computed(() => {
|
||||
name: createRequiredRule($t('page.system.menu.form.menuName')),
|
||||
type: createRequiredRule($t('page.system.menu.form.menuType')),
|
||||
parentId: createRequiredRule($t('page.system.menu.form.parentId')),
|
||||
permission: permissionRule,
|
||||
sort: createRequiredRule($t('page.system.menu.form.sort')),
|
||||
status: createRequiredRule($t('page.system.menu.form.menuStatus')),
|
||||
path: pathRule,
|
||||
pageResourcePath: pageResourceRule,
|
||||
component: componentRule,
|
||||
@@ -528,10 +714,24 @@ const rules = computed(() => {
|
||||
|
||||
const parentTreeOptions = computed<ParentTreeOption[]>(() => {
|
||||
const menuTree = buildMenuTree(props.allMenus);
|
||||
const descendantIds = currentMenuId.value ? collectDescendantIds(menuTree, currentMenuId.value) : [];
|
||||
const disabledIds = new Set<number>([currentMenuId.value, ...descendantIds].filter(Boolean));
|
||||
const descendantIds = currentMenuId.value !== '0' ? collectDescendantIds(menuTree, currentMenuId.value) : [];
|
||||
const disabledIds = new Set<string>([currentMenuId.value, ...descendantIds].filter(id => id !== '0'));
|
||||
|
||||
const availableMenus = props.allMenus.filter(item => item.type !== 3);
|
||||
const availableMenus = props.allMenus.filter(item => {
|
||||
if (item.type === 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isObjectScope.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isMenu.value) {
|
||||
return item.type === 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const availableMenuTree = buildMenuTree(availableMenus);
|
||||
|
||||
function mapTreeOptions(nodes: Api.SystemManage.Menu[]): ParentTreeOption[] {
|
||||
@@ -545,7 +745,7 @@ const parentTreeOptions = computed<ParentTreeOption[]>(() => {
|
||||
|
||||
return [
|
||||
{
|
||||
value: 0,
|
||||
value: '0',
|
||||
label: $t('page.system.menu.topLevel'),
|
||||
children: mapTreeOptions(availableMenuTree)
|
||||
}
|
||||
@@ -572,6 +772,19 @@ function clearFormValidation() {
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function clearRouteFields() {
|
||||
model.value.path = '';
|
||||
model.value.icon = '';
|
||||
model.value.component = '';
|
||||
model.value.componentName = '';
|
||||
model.value.routeKind = null;
|
||||
model.value.routePropsJson = '';
|
||||
model.value.pageResourcePath = '';
|
||||
model.value.iframeUrl = '';
|
||||
model.value.externalUrl = '';
|
||||
model.value.redirectTarget = '';
|
||||
}
|
||||
|
||||
function applyMenuTypePreset(type: Api.SystemManage.MenuType) {
|
||||
if (type === 1) {
|
||||
model.value.permission = '';
|
||||
@@ -579,28 +792,23 @@ function applyMenuTypePreset(type: Api.SystemManage.MenuType) {
|
||||
model.value.keepAlive = false;
|
||||
}
|
||||
|
||||
if (type === 2 && (!model.value.routeKind || model.value.routeKind === 'dir')) {
|
||||
if (type === 2 && (isObjectScope.value || !model.value.routeKind || model.value.routeKind === 'dir')) {
|
||||
model.value.permission = '';
|
||||
model.value.routeKind = 'view';
|
||||
}
|
||||
|
||||
if (type === 3) {
|
||||
model.value.path = '';
|
||||
model.value.icon = '';
|
||||
model.value.component = '';
|
||||
model.value.componentName = '';
|
||||
model.value.routeKind = null;
|
||||
model.value.routePropsJson = '';
|
||||
model.value.pageResourcePath = '';
|
||||
model.value.iframeUrl = '';
|
||||
model.value.externalUrl = '';
|
||||
model.value.redirectTarget = '';
|
||||
clearRouteFields();
|
||||
model.value.keepAlive = false;
|
||||
model.value.alwaysShow = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultRouteKind(type: Api.SystemManage.MenuType) {
|
||||
if (isObjectScope.value && type === 2) {
|
||||
return 'view';
|
||||
}
|
||||
|
||||
if (type === 1) {
|
||||
return 'dir';
|
||||
}
|
||||
@@ -621,22 +829,23 @@ function syncDirectoryRouteFields() {
|
||||
}
|
||||
|
||||
function syncViewRouteFields() {
|
||||
const pageResource = selectedPageResource.value;
|
||||
const routeBinding = selectedRouteBinding.value;
|
||||
|
||||
if (!pageResource) {
|
||||
if (!routeBinding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRelativePath =
|
||||
getRelativeRoutePath(pageResource.path, currentParentFullPath.value) ||
|
||||
normalizeRoutePart(model.value.path) ||
|
||||
normalizeRoutePart(pageResource.path).split('/').filter(Boolean).at(-1) ||
|
||||
'';
|
||||
const nextPath = isObjectScope.value
|
||||
? toAbsoluteRoutePath(routeBinding.path)
|
||||
: getRelativeRoutePath(routeBinding.path, currentParentFullPath.value) ||
|
||||
normalizeRoutePart(model.value.path) ||
|
||||
normalizeRoutePart(routeBinding.path).split('/').filter(Boolean).at(-1) ||
|
||||
'';
|
||||
|
||||
model.value.path = nextRelativePath;
|
||||
model.value.component = pageResource.component;
|
||||
model.value.componentName = pageResource.name;
|
||||
model.value.routePropsJson = stringifyRouteProps(pageResource.props as Record<string, unknown> | null);
|
||||
model.value.path = nextPath;
|
||||
model.value.component = routeBinding.component;
|
||||
model.value.componentName = routeBinding.name;
|
||||
model.value.routePropsJson = stringifyRouteProps(routeBinding.props);
|
||||
}
|
||||
|
||||
function syncIframeRouteFields() {
|
||||
@@ -724,8 +933,8 @@ function syncCurrentRouteFields() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyPageResourceMeta(pageResource: PageResourceItem) {
|
||||
model.value.keepAlive = Boolean(pageResource.keepAlive);
|
||||
function applyRouteBindingMeta(routeBinding: RouteBindingItem) {
|
||||
model.value.keepAlive = Boolean(routeBinding.keepAlive);
|
||||
}
|
||||
|
||||
function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
@@ -734,6 +943,8 @@ function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
return {
|
||||
name: data.name,
|
||||
permission: data.permission ?? '',
|
||||
scopeType: data.scopeType ?? props.scopeType,
|
||||
objectType: data.objectType || (props.scopeType === 'object' ? props.objectType : undefined),
|
||||
type: data.type,
|
||||
sort: data.sort,
|
||||
parentId: data.parentId,
|
||||
@@ -747,7 +958,7 @@ function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
visible: data.visible ?? true,
|
||||
keepAlive: data.keepAlive ?? false,
|
||||
alwaysShow: data.alwaysShow ?? false,
|
||||
pageResourcePath: data.routeKind === 'view' ? resolvePageResourcePath(data) : '',
|
||||
pageResourcePath: data.routeKind === 'view' ? resolveRouteBindingPath(data) : '',
|
||||
iframeUrl: data.routeKind === 'iframe' ? getRoutePropText(routeProps, 'url') : '',
|
||||
externalUrl: data.routeKind === 'external' ? getRoutePropText(routeProps, 'url') : '',
|
||||
redirectTarget: data.routeKind === 'redirect' ? getRoutePropText(routeProps, 'redirect') : ''
|
||||
@@ -755,28 +966,41 @@ function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
}
|
||||
|
||||
function getSubmitData(): Api.SystemManage.SaveMenuParams {
|
||||
const scopeData = getCurrentScopeParams();
|
||||
let submitPath: string | null = null;
|
||||
|
||||
if (showRouteFields.value) {
|
||||
submitPath =
|
||||
isObjectScope.value && isMenu.value
|
||||
? getNullableText(toAbsoluteRoutePath(model.value.path))
|
||||
: getNullableText(model.value.path);
|
||||
}
|
||||
|
||||
return {
|
||||
...scopeData,
|
||||
name: model.value.name.trim(),
|
||||
type: model.value.type,
|
||||
sort: model.value.sort,
|
||||
parentId: model.value.parentId,
|
||||
status: model.value.status,
|
||||
permission: isButton.value ? getNullableText(model.value.permission) : null,
|
||||
path: showRouteFields.value ? getNullableText(model.value.path) : null,
|
||||
path: submitPath,
|
||||
icon: showIconField.value ? getNullableText(model.value.icon) : null,
|
||||
component: showRouteFields.value ? getNullableText(model.value.component) : null,
|
||||
componentName: showRouteFields.value ? getNullableText(model.value.componentName) : null,
|
||||
routeKind: showRouteFields.value ? (model.value.routeKind ?? null) : null,
|
||||
routePropsJson: showRouteFields.value ? getNullableText(model.value.routePropsJson) : null,
|
||||
visible: isButton.value ? false : Boolean(model.value.visible),
|
||||
keepAlive: canKeepAlive.value ? Boolean(model.value.keepAlive) : false,
|
||||
keepAlive: showRouteFields.value ? Boolean(model.value.keepAlive) : false,
|
||||
alwaysShow: false
|
||||
};
|
||||
}
|
||||
|
||||
async function submitMenu(data: Api.SystemManage.SaveMenuParams) {
|
||||
if (isEdit.value && props.rowData) {
|
||||
return fetchUpdateMenu({ id: props.rowData.id, ...data });
|
||||
const { scopeType: _scopeType, objectType: _objectType, ...updateData } = data;
|
||||
|
||||
return fetchUpdateMenu({ id: props.rowData.id, ...updateData });
|
||||
}
|
||||
|
||||
return fetchCreateMenu(data);
|
||||
@@ -788,11 +1012,16 @@ async function initModel() {
|
||||
|
||||
if (isAddChild.value && props.rowData) {
|
||||
model.value.parentId = props.rowData.id;
|
||||
|
||||
if (isObjectScope.value && props.rowData.type === 2) {
|
||||
model.value.type = 3;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEdit.value || !props.rowData) {
|
||||
applyMenuTypePreset(model.value.type);
|
||||
syncCurrentRouteFields();
|
||||
|
||||
await nextTick();
|
||||
clearFormValidation();
|
||||
initializingModel.value = false;
|
||||
@@ -807,6 +1036,7 @@ async function initModel() {
|
||||
|
||||
if (!error) {
|
||||
model.value = mapMenuDetailToModel(data);
|
||||
applyMenuTypePreset(model.value.type);
|
||||
syncCurrentRouteFields();
|
||||
}
|
||||
|
||||
@@ -860,7 +1090,6 @@ watch(
|
||||
() => model.value.parentId,
|
||||
async () => {
|
||||
syncCurrentRouteFields();
|
||||
|
||||
await nextTick();
|
||||
clearFormValidation();
|
||||
}
|
||||
@@ -882,14 +1111,14 @@ watch(
|
||||
return;
|
||||
}
|
||||
|
||||
const pageResource = pageResourceMap.get(value);
|
||||
const routeBinding = routeBindingMap.value.get(value);
|
||||
|
||||
if (!pageResource) {
|
||||
if (!routeBinding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initializingModel.value) {
|
||||
applyPageResourceMeta(pageResource);
|
||||
applyRouteBindingMeta(routeBinding);
|
||||
}
|
||||
|
||||
syncViewRouteFields();
|
||||
@@ -934,7 +1163,7 @@ watch(visible, value => {
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="780px"
|
||||
:width="dialogWidth"
|
||||
:loading="detailLoading"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
@@ -945,7 +1174,12 @@ watch(visible, value => {
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuType')" prop="type">
|
||||
<ElRadioGroup v-model="model.type" class="business-form-radio-group" :disabled="isEdit">
|
||||
<ElRadio v-for="{ label, value } in menuTypeOptions" :key="value" :value="value">
|
||||
<ElRadio
|
||||
v-for="{ label, value, disabled } in menuTypeRadioOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
>
|
||||
{{ $t(label) }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
@@ -987,12 +1221,21 @@ watch(visible, value => {
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuStatus')" prop="status">
|
||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||
<ElRadio v-for="{ label, value } in commonStatusOptions" :key="value" :value="value">
|
||||
{{ $t(label) }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection v-if="showRouteFields" :title="$t('page.system.menu.sections.route')">
|
||||
<BusinessFormSection v-if="showRouteSection" :title="$t('page.system.menu.sections.route')">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElCol v-if="showRouteKindField" :span="12">
|
||||
<ElFormItem prop="routeKind">
|
||||
<template #label>
|
||||
<span class="business-form-label-with-tip">
|
||||
@@ -1048,9 +1291,9 @@ watch(visible, value => {
|
||||
<ElFormItem prop="pageResourcePath">
|
||||
<template #label>
|
||||
<span class="business-form-label-with-tip">
|
||||
<span>{{ $t('page.system.menu.pageResource') }}</span>
|
||||
<span>{{ routeBindingFieldLabel }}</span>
|
||||
<ElTooltip
|
||||
:content="$t('page.system.menu.tips.pageResource')"
|
||||
:content="routeBindingFieldTip"
|
||||
popper-class="business-form-label-tooltip"
|
||||
placement="top-start"
|
||||
>
|
||||
@@ -1060,12 +1303,8 @@ watch(visible, value => {
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
<ElSelect
|
||||
v-model="model.pageResourcePath"
|
||||
filterable
|
||||
:placeholder="$t('page.system.menu.form.pageResource')"
|
||||
>
|
||||
<ElOption v-for="{ label, value } in pageResourceOptions" :key="value" :label="label" :value="value" />
|
||||
<ElSelect v-model="model.pageResourcePath" filterable :placeholder="routeBindingFieldPlaceholder">
|
||||
<ElOption v-for="{ label, value } in routeBindingOptions" :key="value" :label="label" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuSearch' });
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
@@ -23,12 +32,26 @@ function search() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel :model="model" :action-col-lg="6" :action-col-md="8" @reset="reset" @search="search">
|
||||
<TableSearchPanel
|
||||
:model="model"
|
||||
:disabled="props.disabled"
|
||||
:action-col-lg="8"
|
||||
:action-col-md="24"
|
||||
@reset="reset"
|
||||
@search="search"
|
||||
>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuName')" prop="name">
|
||||
<ElInput v-model="model.name" clearable :placeholder="$t('page.system.menu.form.menuName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuStatus')" prop="status">
|
||||
<ElSelect v-model="model.status" clearable class="w-full" :placeholder="$t('page.system.menu.form.menuStatus')">
|
||||
<ElOption v-for="{ label, value } in commonStatusOptions" :key="value" :label="$t(label)" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user