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:
@@ -1,13 +1,20 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElTag } from 'element-plus';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { menuRouteKindRecord, menuTypeRecord } from '@/constants/business';
|
||||
import {
|
||||
commonStatusRecord,
|
||||
menuRouteKindRecord,
|
||||
menuTypeRecord,
|
||||
objectTypeRecord,
|
||||
scopeTypeRecord
|
||||
} from '@/constants/business';
|
||||
import { fetchBatchDeleteMenu, fetchDeleteMenu, fetchGetMenuList } from '@/service/api';
|
||||
import { useUITable } from '@/hooks/common/table';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import MenuContextPanel from './modules/menu-context-panel.vue';
|
||||
import MenuIconCell from './modules/menu-icon-cell';
|
||||
import MenuOperateDialog, { type OperateType } from './modules/menu-operate-dialog.vue';
|
||||
import MenuOperateCell from './modules/menu-operate-cell';
|
||||
@@ -32,6 +39,28 @@ function getMenuTypeTagType(type: Api.SystemManage.MenuType): UI.ThemeColor {
|
||||
return tagMap[type];
|
||||
}
|
||||
|
||||
function getStatusTagType(status: Api.SystemManage.CommonStatus): UI.ThemeColor {
|
||||
return status === 0 ? 'success' : 'warning';
|
||||
}
|
||||
|
||||
function getStatusLabel(status: Api.SystemManage.CommonStatus) {
|
||||
return $t(commonStatusRecord[status]);
|
||||
}
|
||||
|
||||
function getMenuTypeLabel(type: Api.SystemManage.MenuType, currentScopeType: Api.SystemManage.ScopeType) {
|
||||
if (currentScopeType === 'object') {
|
||||
if (type === 2) {
|
||||
return $t('page.system.menu.type.navigation');
|
||||
}
|
||||
|
||||
if (type === 3) {
|
||||
return $t('page.system.menu.type.actionButton');
|
||||
}
|
||||
}
|
||||
|
||||
return $t(menuTypeRecord[type]);
|
||||
}
|
||||
|
||||
function getRouteKindLabel(routeKind?: Api.SystemManage.MenuRouteKind | null) {
|
||||
if (!routeKind) {
|
||||
return '--';
|
||||
@@ -42,9 +71,30 @@ function getRouteKindLabel(routeKind?: Api.SystemManage.MenuRouteKind | null) {
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const flatMenuList = ref<Api.SystemManage.Menu[]>([]);
|
||||
const scopeType = ref<Api.SystemManage.ScopeType>('global');
|
||||
const objectType = ref<Api.SystemManage.ObjectType>('product');
|
||||
|
||||
const isObjectScope = computed(() => scopeType.value === 'object');
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (scopeType.value === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: objectType.value
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
api: () => fetchGetMenuList(searchParams),
|
||||
api: () =>
|
||||
fetchGetMenuList({
|
||||
...searchParams,
|
||||
...getCurrentScopeParams()
|
||||
}),
|
||||
transform: response => {
|
||||
if (!response.error) {
|
||||
flatMenuList.value = response.data;
|
||||
@@ -56,13 +106,20 @@ const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'name', label: $t('page.system.menu.menuName'), minWidth: 220, showOverflowTooltip: true },
|
||||
{ prop: 'name', label: $t('page.system.menu.menuName'), minWidth: 240, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'type',
|
||||
label: $t('page.system.menu.menuType'),
|
||||
width: 96,
|
||||
width: 108,
|
||||
align: 'center',
|
||||
formatter: row => <ElTag type={getMenuTypeTagType(row.type)}>{$t(menuTypeRecord[row.type])}</ElTag>
|
||||
formatter: row => <ElTag type={getMenuTypeTagType(row.type)}>{getMenuTypeLabel(row.type, scopeType.value)}</ElTag>
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: $t('page.system.menu.menuStatus'),
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => <ElTag type={getStatusTagType(row.status)}>{getStatusLabel(row.status)}</ElTag>
|
||||
},
|
||||
{
|
||||
prop: 'icon',
|
||||
@@ -73,6 +130,7 @@ const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
return <MenuIconCell icon={row.icon ?? ''} />;
|
||||
}
|
||||
},
|
||||
{ prop: 'sort', label: $t('page.system.menu.order'), width: 88, align: 'center' },
|
||||
{ prop: 'permission', label: $t('page.system.menu.permission'), minWidth: 180, showOverflowTooltip: true },
|
||||
{ prop: 'path', label: $t('page.system.menu.routePath'), minWidth: 160, showOverflowTooltip: true },
|
||||
{
|
||||
@@ -99,11 +157,23 @@ const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
const { bool: visible, setTrue: openModal, setFalse: closeModal } = useBoolean();
|
||||
const operateType = ref<OperateType>('add');
|
||||
const editingData = ref<Api.SystemManage.Menu | null>(null);
|
||||
const checkedRowKeys = ref<number[]>([]);
|
||||
const checkedRowKeys = ref<string[]>([]);
|
||||
const tableRef = ref<TableInstance>();
|
||||
const tableRenderKey = ref(0);
|
||||
|
||||
const allMenus = computed(() => flatMenuList.value);
|
||||
const currentScopeLabel = computed(() => $t(scopeTypeRecord[scopeType.value]));
|
||||
const currentObjectTypeLabel = computed(() => {
|
||||
return objectType.value ? $t(objectTypeRecord[objectType.value]) : '';
|
||||
});
|
||||
const currentContextTagLabel = computed(() => {
|
||||
return isObjectScope.value && currentObjectTypeLabel.value
|
||||
? `${currentScopeLabel.value} / ${currentObjectTypeLabel.value}`
|
||||
: currentScopeLabel.value;
|
||||
});
|
||||
const currentTableTitle = computed(() => {
|
||||
return $t(isObjectScope.value ? 'page.system.menu.objectResourceTitle' : 'page.system.menu.globalResourceTitle');
|
||||
});
|
||||
const expandedRowKeys = computed(() => {
|
||||
const firstRootMenu = data.value[0];
|
||||
|
||||
@@ -140,7 +210,7 @@ function openEdit(item: Api.SystemManage.Menu) {
|
||||
openModal();
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
async function handleDelete(id: string) {
|
||||
const { error } = await fetchDeleteMenu(id);
|
||||
|
||||
if (error) {
|
||||
@@ -193,27 +263,83 @@ async function handleSubmitted() {
|
||||
closeModal();
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
watch(scopeType, value => {
|
||||
if (value === 'object' && !objectType.value) {
|
||||
objectType.value = 'product';
|
||||
}
|
||||
});
|
||||
|
||||
let contextChangeToken = 0;
|
||||
|
||||
watch([scopeType, objectType], async ([nextScope, nextObject], [prevScope, prevObject]) => {
|
||||
if (nextScope === prevScope && nextObject === prevObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextChangeToken += 1;
|
||||
const token = contextChangeToken;
|
||||
await nextTick();
|
||||
|
||||
if (token !== contextChangeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
operateType.value = 'add';
|
||||
editingData.value = null;
|
||||
closeModal();
|
||||
await reloadTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-560px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<MenuContextPanel
|
||||
v-model:scope-type="scopeType"
|
||||
v-model:object-type="objectType"
|
||||
:total="flatMenuList.length"
|
||||
:loading="loading"
|
||||
/>
|
||||
|
||||
<MenuSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper sm:flex-1-hidden" body-class="menu-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-8px">
|
||||
<p>{{ $t('page.system.menu.title') }}</p>
|
||||
<ElTag effect="plain">{{ flatMenuList.length }}</ElTag>
|
||||
<div class="menu-card-header">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p class="truncate text-16px font-600">{{ currentTableTitle }}</p>
|
||||
<ElTag effect="plain" :type="isObjectScope ? 'success' : 'primary'">{{ currentContextTagLabel }}</ElTag>
|
||||
<ElTag effect="plain">{{ flatMenuList.length }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="checkedRowKeys.length === 0"
|
||||
:loading="loading"
|
||||
@add="openAdd"
|
||||
@delete="handleBatchDelete"
|
||||
@refresh="reloadTable"
|
||||
/>
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="handleBatchDelete">
|
||||
<template #reference>
|
||||
<ElButton type="danger" plain :disabled="checkedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -238,6 +364,8 @@ async function handleSubmitted() {
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
:all-menus="allMenus"
|
||||
:scope-type="scopeType"
|
||||
:object-type="objectType"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</div>
|
||||
@@ -249,4 +377,18 @@ async function handleSubmitted() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.menu-card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { commonStatusRecord } from '@/constants/business';
|
||||
import { commonStatusRecord, objectTypeRecord, scopeTypeRecord } from '@/constants/business';
|
||||
import { fetchDeleteRole, fetchGetMenuSimpleList, fetchGetRolePage } from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import RoleContextPanel from './modules/role-context-panel.vue';
|
||||
import RoleOperateDialog from './modules/role-operate-dialog.vue';
|
||||
import RoleResourcePanel from './modules/role-resource-panel.vue';
|
||||
import RoleSearch from './modules/role-search.vue';
|
||||
@@ -61,15 +62,36 @@ function getStatusLabel(status: Api.SystemManage.CommonStatus) {
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const selectedRoleId = ref<number | null>(null);
|
||||
const pendingSelectedRoleId = ref<number | null>(null);
|
||||
const selectedRoleId = ref<string | null>(null);
|
||||
const pendingSelectedRoleId = ref<string | null>(null);
|
||||
const scopeType = ref<Api.SystemManage.ScopeType>('global');
|
||||
const objectType = ref<Api.SystemManage.ObjectType>('product');
|
||||
|
||||
const isObjectScope = computed(() => scopeType.value === 'object');
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (scopeType.value === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: objectType.value
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetRolePage(searchParams),
|
||||
api: () =>
|
||||
fetchGetRolePage({
|
||||
...searchParams,
|
||||
...getCurrentScopeParams()
|
||||
}),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo, searchParams.pageSize),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
@@ -134,8 +156,9 @@ const menuTree = ref<Api.SystemManage.MenuSimple[]>([]);
|
||||
|
||||
async function getMenuTreeData() {
|
||||
menuTreeLoading.value = true;
|
||||
menuTree.value = [];
|
||||
|
||||
const { error, data: menuList } = await fetchGetMenuSimpleList();
|
||||
const { error, data: menuList } = await fetchGetMenuSimpleList(getCurrentScopeParams());
|
||||
|
||||
menuTreeLoading.value = false;
|
||||
|
||||
@@ -148,6 +171,19 @@ async function getMenuTreeData() {
|
||||
}
|
||||
|
||||
const currentRole = computed(() => data.value.find(item => item.id === selectedRoleId.value) ?? null);
|
||||
const currentRoleTotal = computed(() => mobilePagination.value.total || data.value.length);
|
||||
const currentScopeLabel = computed(() => $t(scopeTypeRecord[scopeType.value]));
|
||||
const currentObjectTypeLabel = computed(() => {
|
||||
return objectType.value ? $t(objectTypeRecord[objectType.value]) : '';
|
||||
});
|
||||
const currentContextTagLabel = computed(() => {
|
||||
return isObjectScope.value && currentObjectTypeLabel.value
|
||||
? `${currentScopeLabel.value} / ${currentObjectTypeLabel.value}`
|
||||
: currentScopeLabel.value;
|
||||
});
|
||||
const currentTableTitle = computed(() => {
|
||||
return $t(isObjectScope.value ? 'page.system.role.objectRoleTitle' : 'page.system.role.globalRoleTitle');
|
||||
});
|
||||
|
||||
const { bool: operateVisible, setTrue: openOperateModal, setFalse: closeOperateModal } = useBoolean();
|
||||
const operateType = ref<UI.TableOperateType>('add');
|
||||
@@ -204,7 +240,7 @@ function handleSearch() {
|
||||
getDataByPage(1);
|
||||
}
|
||||
|
||||
function selectRole(roleId: number) {
|
||||
function selectRole(roleId: string) {
|
||||
selectedRoleId.value = roleId;
|
||||
}
|
||||
|
||||
@@ -212,7 +248,7 @@ function handleRowClick(row: Api.SystemManage.Role) {
|
||||
selectRole(row.id);
|
||||
}
|
||||
|
||||
function handleSubmitted(roleId: number) {
|
||||
function handleSubmitted(roleId: string) {
|
||||
pendingSelectedRoleId.value = roleId;
|
||||
closeOperateModal();
|
||||
getData();
|
||||
@@ -241,82 +277,133 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(scopeType, value => {
|
||||
if (value === 'object' && !objectType.value) {
|
||||
objectType.value = 'product';
|
||||
}
|
||||
});
|
||||
|
||||
let contextChangeToken = 0;
|
||||
|
||||
watch([scopeType, objectType], async ([nextScope, nextObject], [prevScope, prevObject]) => {
|
||||
if (nextScope === prevScope && nextObject === prevObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextChangeToken += 1;
|
||||
const token = contextChangeToken;
|
||||
await nextTick();
|
||||
|
||||
if (token !== contextChangeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
selectedRoleId.value = null;
|
||||
pendingSelectedRoleId.value = null;
|
||||
editingData.value = null;
|
||||
closeOperateModal();
|
||||
await getMenuTreeData();
|
||||
await getDataByPage(1);
|
||||
});
|
||||
|
||||
getMenuTreeData();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[minmax(0,1fr)_360px] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<RoleSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="role-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>{{ $t('page.system.role.title') }}</p>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
highlight-current-row
|
||||
:data="data"
|
||||
:current-row-key="selectedRoleId ?? undefined"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch xl:min-h-0">
|
||||
<RoleResourcePanel :role="currentRole" :menu-tree="menuTree" :loading="menuTreeLoading" />
|
||||
</div>
|
||||
|
||||
<RoleOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
@submitted="handleSubmitted"
|
||||
<div class="min-h-560px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<RoleContextPanel
|
||||
v-model:scope-type="scopeType"
|
||||
v-model:object-type="objectType"
|
||||
:total="currentRoleTotal"
|
||||
:loading="loading || menuTreeLoading"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="role-page-content gap-16px overflow-hidden xl:grid xl:grid-cols-[minmax(0,1fr)_360px] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<RoleSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="role-table-card-body">
|
||||
<template #header>
|
||||
<div class="role-card-header">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p class="truncate text-16px font-600">{{ currentTableTitle }}</p>
|
||||
<ElTag effect="plain" :type="isObjectScope ? 'success' : 'primary'">
|
||||
{{ currentContextTagLabel }}
|
||||
</ElTag>
|
||||
<ElTag effect="plain">{{ currentRoleTotal }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
highlight-current-row
|
||||
:data="data"
|
||||
:current-row-key="selectedRoleId ?? undefined"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch xl:min-h-0">
|
||||
<RoleResourcePanel :role="currentRole" :menu-tree="menuTree" :loading="menuTreeLoading" />
|
||||
</div>
|
||||
|
||||
<RoleOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
:scope-type="scopeType"
|
||||
:object-type="objectType"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.role-page-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.role-table-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
@@ -345,4 +432,18 @@ getMenuTreeData();
|
||||
:deep(.el-row) {
|
||||
margin: 0 0 -15px 0;
|
||||
}
|
||||
|
||||
.role-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.role-card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
249
src/views/system/role/modules/role-context-panel.vue
Normal file
249
src/views/system/role/modules/role-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: 'RoleContextPanel' });
|
||||
|
||||
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.role.globalRoleSummary');
|
||||
}
|
||||
|
||||
if (objectType.value === 'product') {
|
||||
return $t('page.system.role.objectRoleSummaryProduct');
|
||||
}
|
||||
|
||||
if (objectType.value === 'project') {
|
||||
return $t('page.system.role.objectRoleSummaryProject');
|
||||
}
|
||||
|
||||
return $t('page.system.role.objectRoleSummary');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="role-context-panel" body-class="role-context-panel__body">
|
||||
<div v-loading="props.loading" class="role-context-panel__layout">
|
||||
<div class="role-context-panel__controls">
|
||||
<div class="role-context-panel__field role-context-panel__field--switch">
|
||||
<ElSegmented v-model="scopeType" :options="scopeOptions" />
|
||||
</div>
|
||||
|
||||
<span v-if="isObjectScope" class="role-context-panel__divider" aria-hidden="true">|</span>
|
||||
|
||||
<div v-if="isObjectScope" class="role-context-panel__field role-context-panel__field--inline">
|
||||
<span class="role-context-panel__field-label role-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="role-context-panel__info">
|
||||
<div class="role-context-panel__info-main">
|
||||
<div class="role-context-panel__info-item">
|
||||
<span class="role-context-panel__info-label">{{ $t('page.system.menu.currentContext') }}</span>
|
||||
<strong class="role-context-panel__info-value">{{ currentContextLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="role-context-panel__info-item">
|
||||
<span class="role-context-panel__info-label">{{ $t('page.system.role.currentRoleCount') }}</span>
|
||||
<strong class="role-context-panel__info-value">{{ props.total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="role-context-panel__info-desc">{{ currentScopeSummary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-context-panel {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
background: var(--el-fill-color-blank);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__body) {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.role-context-panel__layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.role-context-panel__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-context-panel__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.role-context-panel__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.role-context-panel__field--switch {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__field--switch .el-segmented) {
|
||||
width: auto;
|
||||
padding: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__field--switch .el-segmented__item) {
|
||||
min-height: 40px;
|
||||
min-width: 96px;
|
||||
padding: 0 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.role-context-panel__field-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-context-panel__field--inline {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.role-context-panel__field-label--inline {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-context-panel__divider {
|
||||
color: var(--el-border-color-darker);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__field--inline .el-select) {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.role-context-panel__info-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.role-context-panel__info-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.role-context-panel__info-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.role-context-panel__info-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.role-context-panel__info-desc {
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.role-context-panel__layout {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.role-context-panel__controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.role-context-panel__info {
|
||||
padding-left: 0;
|
||||
padding-top: 14px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.role-context-panel__controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.role-context-panel__info-item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,12 +11,14 @@ defineOptions({ name: 'RoleOperateDialog' });
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.SystemManage.Role | null;
|
||||
scopeType: Api.SystemManage.ScopeType;
|
||||
objectType?: Api.SystemManage.ObjectType;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', roleId: number): void;
|
||||
(e: 'submitted', roleId: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
@@ -49,12 +51,27 @@ function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
code: '',
|
||||
scopeType: props.scopeType,
|
||||
objectType: props.scopeType === 'object' ? props.objectType : undefined,
|
||||
sort: 0,
|
||||
status: 0,
|
||||
remark: ''
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (props.scopeType === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: props.objectType
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: createRequiredRule($t('page.system.role.form.roleName')),
|
||||
code: createRequiredRule($t('page.system.role.form.roleCode')),
|
||||
@@ -85,6 +102,8 @@ async function initModel() {
|
||||
model.value = {
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
scopeType: data.scopeType ?? props.scopeType,
|
||||
objectType: data.objectType || (props.scopeType === 'object' ? props.objectType : undefined),
|
||||
sort: data.sort,
|
||||
status: data.status,
|
||||
remark: data.remark ?? ''
|
||||
@@ -102,26 +121,35 @@ async function handleSubmit() {
|
||||
|
||||
const submitData: Api.SystemManage.SaveRoleParams = {
|
||||
...model.value,
|
||||
...getCurrentScopeParams(),
|
||||
name: model.value.name.trim(),
|
||||
code: model.value.code.trim(),
|
||||
remark: model.value.remark?.trim() || null
|
||||
};
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? fetchUpdateRole({ id: props.rowData.id, ...submitData })
|
||||
: fetchCreateRole(submitData);
|
||||
let roleId = props.rowData?.id ?? '';
|
||||
|
||||
const { error, data } = await request;
|
||||
if (isEdit.value && props.rowData) {
|
||||
const { scopeType: _scopeType, objectType: _objectType, ...updateData } = submitData;
|
||||
const { error } = await fetchUpdateRole({ id: props.rowData.id, ...updateData });
|
||||
|
||||
submitting.value = false;
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { error, data } = await fetchCreateRole(submitData);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
roleId = data;
|
||||
}
|
||||
|
||||
const roleId = isEdit.value && props.rowData ? props.rowData.id : Number(data);
|
||||
|
||||
window.$message?.success($t(isEdit.value ? 'common.updateSuccess' : 'common.addSuccess'));
|
||||
|
||||
closeModal();
|
||||
@@ -142,7 +170,6 @@ watch(visible, value => {
|
||||
preset="md"
|
||||
:loading="detailLoading"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
|
||||
@@ -25,7 +25,7 @@ const treeRef = ref<TreeInstance | null>(null);
|
||||
const permissionLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const filterKeyword = ref('');
|
||||
const checkedKeys = ref<number[]>([]);
|
||||
const checkedKeys = ref<string[]>([]);
|
||||
|
||||
const disabled = computed(() => !props.role || props.role.status === 1);
|
||||
const checkedCount = computed(() => checkedKeys.value.length);
|
||||
@@ -37,7 +37,7 @@ const treeProps = {
|
||||
label: 'name'
|
||||
} as const;
|
||||
|
||||
function applyCheckedKeys(keys: number[]) {
|
||||
function applyCheckedKeys(keys: string[]) {
|
||||
checkedKeys.value = [...keys];
|
||||
treeRef.value?.setCheckedKeys(keys);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ function filterNode(value: string, data: any) {
|
||||
}
|
||||
|
||||
function collectExpandableNodeIds(nodes: Api.SystemManage.MenuSimple[]) {
|
||||
const ids: number[] = [];
|
||||
const ids: string[] = [];
|
||||
|
||||
const walk = (items: Api.SystemManage.MenuSimple[]) => {
|
||||
items.forEach(item => {
|
||||
@@ -112,7 +112,7 @@ async function loadRoleMenus() {
|
||||
}
|
||||
|
||||
function handleCheck() {
|
||||
checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as number[]) ?? [];
|
||||
checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -120,7 +120,7 @@ async function handleSave() {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuIds = (treeRef.value?.getCheckedKeys(false) as number[]) ?? [];
|
||||
const menuIds = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
type TreeNodeId = string | number;
|
||||
|
||||
type TreeNode = {
|
||||
id: number;
|
||||
parentId: number;
|
||||
id: TreeNodeId;
|
||||
parentId: TreeNodeId;
|
||||
sort?: number | null;
|
||||
children?: TreeNode[] | null;
|
||||
};
|
||||
|
||||
export function buildMenuTree<T extends TreeNode>(list: T[]) {
|
||||
const nodeMap = new Map<number, T>();
|
||||
const nodeMap = new Map<TreeNodeId, T>();
|
||||
const roots: T[] = [];
|
||||
|
||||
list.forEach(item => {
|
||||
@@ -17,7 +19,7 @@ export function buildMenuTree<T extends TreeNode>(list: T[]) {
|
||||
});
|
||||
|
||||
nodeMap.forEach(node => {
|
||||
if (node.parentId === 0) {
|
||||
if (isRootParentId(node.parentId)) {
|
||||
roots.push(node);
|
||||
return;
|
||||
}
|
||||
@@ -35,17 +37,17 @@ export function buildMenuTree<T extends TreeNode>(list: T[]) {
|
||||
return sortMenuTree(roots);
|
||||
}
|
||||
|
||||
export function collectDescendantIds<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: number) {
|
||||
export function collectDescendantIds<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: T['id']) {
|
||||
const target = findTreeNode(nodes, targetId);
|
||||
|
||||
if (!target?.children?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ids: number[] = [];
|
||||
const ids: T['id'][] = [];
|
||||
|
||||
walkTree(target.children, item => {
|
||||
ids.push(item.id);
|
||||
ids.push(item.id as T['id']);
|
||||
});
|
||||
|
||||
return ids;
|
||||
@@ -63,7 +65,7 @@ function sortMenuTree<T extends TreeNode>(nodes: T[]) {
|
||||
return sortedNodes;
|
||||
}
|
||||
|
||||
function findTreeNode<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: number): T | null {
|
||||
function findTreeNode<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: T['id']): T | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === targetId) {
|
||||
return node;
|
||||
@@ -81,6 +83,10 @@ function findTreeNode<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], t
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRootParentId(parentId: TreeNodeId) {
|
||||
return parentId === 0 || parentId === '0';
|
||||
}
|
||||
|
||||
function walkTree<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], callback: (node: T) => void) {
|
||||
for (const node of nodes) {
|
||||
callback(node);
|
||||
|
||||
@@ -54,10 +54,6 @@ const { fromUserIndex = false, deptId = 100, orgType = 'company' } = defineProps
|
||||
*
|
||||
* @param data 节点数据
|
||||
*/
|
||||
function isRootNode(data: Api.SystemManage.UserManagementRelationTreeRespVO): boolean {
|
||||
return treeData.value.some(node => node.userId === data.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断母节点的编辑按钮是否应该隐藏
|
||||
*
|
||||
@@ -89,11 +85,15 @@ const userList = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const relationTreeRef = ref<InstanceType<typeof ElTree>>();
|
||||
|
||||
// 已选中的节点 ID 列表
|
||||
const checkedNodeKeys = ref<number[]>([]);
|
||||
const checkedNodeKeys = ref<string[]>([]);
|
||||
|
||||
// 树形数据
|
||||
const treeData = ref<Api.SystemManage.UserManagementRelationTreeRespVO[]>([]);
|
||||
|
||||
function isRootNode(data: Api.SystemManage.UserManagementRelationTreeRespVO): boolean {
|
||||
return treeData.value.some(node => node.userId === data.userId);
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -225,7 +225,8 @@ function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
operateType.value = 'add';
|
||||
// 如果是从某一行的新增按钮触发,则默认管理者为当前节点用户
|
||||
// 否则默认管理者为当前登录用户(在对话框组件中处理)
|
||||
editingData.value = item ? {
|
||||
editingData.value = item
|
||||
? {
|
||||
id: null,
|
||||
managerUserId: item.userId,
|
||||
subordinateUserId: null,
|
||||
@@ -233,7 +234,8 @@ function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
effectiveUntil: null,
|
||||
remark: null,
|
||||
createTime: Date.now()
|
||||
} : null;
|
||||
}
|
||||
: null;
|
||||
openOperateModal();
|
||||
}
|
||||
|
||||
@@ -245,7 +247,8 @@ function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
function openEdit(item: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
operateType.value = 'edit';
|
||||
// 构建树节点数据为编辑所需格式
|
||||
editingData.value = item.id ? {
|
||||
editingData.value = item.id
|
||||
? {
|
||||
id: item.id,
|
||||
managerUserId: item.managerUserId,
|
||||
subordinateUserId: item.userId,
|
||||
@@ -253,7 +256,8 @@ function openEdit(item: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
effectiveUntil: null,
|
||||
remark: null,
|
||||
createTime: Date.now()
|
||||
} : null;
|
||||
}
|
||||
: null;
|
||||
openOperateModal();
|
||||
}
|
||||
|
||||
@@ -300,7 +304,9 @@ async function handleBatchDelete() {
|
||||
* @param checkedInfo 包含 checkedKeys 和 halfCheckedKeys 的对象
|
||||
*/
|
||||
function handleNodeCheck(checkedData: any, checkedInfo: any) {
|
||||
checkedNodeKeys.value = checkedInfo.checkedNodes.map((node: any) => node.id);
|
||||
checkedNodeKeys.value = checkedInfo.checkedNodes
|
||||
.map((node: any) => node.id)
|
||||
.filter((id: string | null): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,7 +314,7 @@ function handleNodeCheck(checkedData: any, checkedInfo: any) {
|
||||
*
|
||||
* @param relationId 提交后的关系 ID
|
||||
*/
|
||||
function handleSubmitted(relationId: number) {
|
||||
function handleSubmitted(_relationId: string) {
|
||||
closeOperateModal();
|
||||
reloadTreeData();
|
||||
}
|
||||
@@ -411,14 +417,20 @@ onMounted(async () => {
|
||||
<span>{{ node.label }}</span>
|
||||
<!-- <ElTag v-if="data.managerNickname" size="small" type="info">上级:{{ data.managerNickname }}</ElTag>-->
|
||||
</span>
|
||||
<div class="flex items-center" style="min-width: 200px;">
|
||||
<div class="flex items-center" style="min-width: 200px">
|
||||
<ElButton link type="primary" size="default" @click.stop="openAdd(data)">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
新增
|
||||
</ElButton>
|
||||
<ElButton v-if="!(isRootNode(data) && shouldHideRootEdit)" link type="primary" size="small" @click.stop="openEdit(data)">
|
||||
<ElButton
|
||||
v-if="!(isRootNode(data) && shouldHideRootEdit)"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click.stop="openEdit(data)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-edit class="text-icon" />
|
||||
</template>
|
||||
|
||||
@@ -50,7 +50,7 @@ const props = defineProps<Props>();
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
/** 提交事件:返回提交后的关系 ID */
|
||||
submitted: [relationId: number];
|
||||
submitted: [relationId: string];
|
||||
}>();
|
||||
|
||||
/**
|
||||
@@ -126,11 +126,38 @@ function closeModal() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function resetValidateState() {
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function resolveDefaultManagerUserId() {
|
||||
if (props.rowData?.managerUserId) {
|
||||
return props.rowData.managerUserId;
|
||||
}
|
||||
|
||||
const currentUserId = authStore.userInfo.userId;
|
||||
const currentUserName = authStore.userInfo.userName;
|
||||
|
||||
if (!currentUserId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedById = props.userList.find(user => user.id === currentUserId);
|
||||
|
||||
if (matchedById) {
|
||||
return matchedById.id;
|
||||
}
|
||||
|
||||
return currentUserName ? props.userList.find(user => user.nickname === currentUserName)?.id : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化表单模型
|
||||
*
|
||||
* 编辑模式下加载详情数据,新增模式下设置默认值
|
||||
*/
|
||||
// eslint-disable-next-line complexity
|
||||
async function initModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
@@ -138,18 +165,18 @@ async function initModel() {
|
||||
// 新增模式:设置管理者用户
|
||||
// 优先使用 rowData 中传入的管理者用户 ID(如从树形节点新增)
|
||||
// 否则使用当前登录用户
|
||||
let managerUserIdToSet: number | undefined;
|
||||
let managerUserIdToSet = resolveDefaultManagerUserId();
|
||||
|
||||
if (props.rowData && props.rowData.managerUserId) {
|
||||
// 从树形节点点击新增,管理者为当前节点用户
|
||||
managerUserIdToSet = props.rowData.managerUserId;
|
||||
} else if (authStore.userInfo.userId) {
|
||||
// 头部新增,管理者为当前登录用户
|
||||
const currentUserId = Number(authStore.userInfo.userId);
|
||||
const currentUserId = authStore.userInfo.userId;
|
||||
const currentUserName = authStore.userInfo.userName;
|
||||
|
||||
// 先尝试通过 ID 匹配
|
||||
let currentUser = props.userList.find(user => Number(user.id) === currentUserId);
|
||||
let currentUser = props.userList.find(user => user.id === currentUserId);
|
||||
|
||||
// 如果 ID 匹配失败,尝试通过用户名匹配
|
||||
if (!currentUser && currentUserName) {
|
||||
@@ -165,26 +192,31 @@ async function initModel() {
|
||||
model.value.managerUserId = managerUserIdToSet;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
await resetValidateState();
|
||||
return;
|
||||
}
|
||||
|
||||
// 编辑模式:加载详情数据
|
||||
if (!props.rowData) {
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
await resetValidateState();
|
||||
return;
|
||||
}
|
||||
|
||||
const relationId = props.rowData.id;
|
||||
|
||||
if (!relationId) {
|
||||
await resetValidateState();
|
||||
return;
|
||||
}
|
||||
|
||||
detailLoading.value = true;
|
||||
|
||||
try {
|
||||
const { error, data } = await fetchGetUserManagementRelation(props.rowData.id);
|
||||
const { error, data } = await fetchGetUserManagementRelation(relationId);
|
||||
|
||||
if (data !== null && !error) {
|
||||
model.value = {
|
||||
id: data.id,
|
||||
id: data.id ?? undefined,
|
||||
managerUserId: data.managerUserId,
|
||||
subordinateUserId: data.subordinateUserId,
|
||||
effectiveFrom: data.effectiveFrom ? new Date(data.effectiveFrom).getTime() : null,
|
||||
@@ -196,8 +228,7 @@ async function initModel() {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
await resetValidateState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,23 +246,31 @@ async function handleSubmit() {
|
||||
...model.value
|
||||
};
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? await fetchUpdateUserManagementRelation({ id: props.rowData.id, ...submitData })
|
||||
: await fetchCreateUserManagementRelation(submitData);
|
||||
const editRelationId = props.rowData?.id ?? null;
|
||||
|
||||
const { error, data } = request;
|
||||
if (isEdit.value && editRelationId) {
|
||||
const { error } = await fetchUpdateUserManagementRelation({ ...submitData, id: editRelationId });
|
||||
|
||||
if (error) {
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('淇敼鎴愬姛');
|
||||
closeModal();
|
||||
emit('submitted', editRelationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const relationId = isEdit.value && props.rowData ? props.rowData.id : Number(data);
|
||||
const { error, data } = await fetchCreateUserManagementRelation(submitData);
|
||||
|
||||
if (error || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(isEdit.value ? '修改成功' : '新增成功');
|
||||
|
||||
closeModal();
|
||||
emit('submitted', relationId);
|
||||
emit('submitted', data);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="tsx">
|
||||
import {computed, nextTick, onMounted, reactive, ref, watch} from 'vue';
|
||||
import type {TableInstance} from 'element-plus';
|
||||
import {ElButton, ElPopconfirm, ElSwitch, ElTag} from 'element-plus';
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElButton, ElPopconfirm, ElSwitch, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import type {FlatResponseData} from '@sa/axios';
|
||||
import {userGenderRecord} from '@/constants/business';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { userGenderRecord } from '@/constants/business';
|
||||
import {
|
||||
fetchBatchDeleteUser,
|
||||
fetchDeleteDept,
|
||||
@@ -18,11 +18,11 @@ import {
|
||||
fetchUpdateUser,
|
||||
fetchUpdateUserStatus
|
||||
} from '@/service/api';
|
||||
import {useUIPaginatedTable} from '@/hooks/common/table';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {$t} from '@/locales';
|
||||
import {buildMenuTree} from '@/views/system/shared/menu-tree';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import UserManagementRelation from '@/views/system/user-management-relation/index.vue';
|
||||
import UserOperateDialog from './modules/user-operate-dialog.vue';
|
||||
import UserOrgLeaderDialog from './modules/user-org-leader-dialog.vue';
|
||||
@@ -32,7 +32,7 @@ import UserResignedDialog from './modules/user-resigned-dialog.vue';
|
||||
import UserResetPasswordDialog from './modules/user-reset-password-dialog.vue';
|
||||
import UserSearch from './modules/user-search.vue';
|
||||
|
||||
defineOptions({name: 'UserManage'});
|
||||
defineOptions({ name: 'UserManage' });
|
||||
|
||||
function getInitSearchParams(): Api.SystemManage.UserSearchParams {
|
||||
return {
|
||||
@@ -158,7 +158,7 @@ const deptTree = computed(() => buildMenuTree(deptList.value));
|
||||
const currentDept = computed(() => deptList.value.find(item => item.id === currentDeptId.value) ?? null);
|
||||
const deptCount = computed(() => deptList.value.length);
|
||||
|
||||
const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} = useUIPaginatedTable<
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
FlatResponseData<any, Api.SystemManage.UserList>,
|
||||
Api.SystemManage.User
|
||||
>({
|
||||
@@ -182,9 +182,9 @@ const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} =
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{prop: 'selection', type: 'selection', width: 48},
|
||||
{prop: 'index', type: 'index', label: $t('common.index'), width: 64},
|
||||
{prop: 'username', label: $t('page.system.user.userName'), minWidth: 140, showOverflowTooltip: true},
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'index', type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'username', label: $t('page.system.user.userName'), minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'nickname',
|
||||
label: $t('page.system.user.nickName'),
|
||||
@@ -274,9 +274,9 @@ const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} =
|
||||
formatter: row => {
|
||||
const state = getUserResignedState(row);
|
||||
const stateMap: Record<UserResignedState, { type: UI.ThemeColor; label: App.I18n.I18nKey }> = {
|
||||
active: {type: 'success', label: 'page.system.user.resignedStateEnum.active'},
|
||||
pending: {type: 'warning', label: 'page.system.user.resignedStateEnum.pending'},
|
||||
resigned: {type: 'info', label: 'page.system.user.resignedStateEnum.resigned'}
|
||||
active: { type: 'success', label: 'page.system.user.resignedStateEnum.active' },
|
||||
pending: { type: 'warning', label: 'page.system.user.resignedStateEnum.pending' },
|
||||
resigned: { type: 'info', label: 'page.system.user.resignedStateEnum.resigned' }
|
||||
};
|
||||
|
||||
return <ElTag type={stateMap[state].type}>{$t(stateMap[state].label)}</ElTag>;
|
||||
@@ -337,7 +337,7 @@ const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} =
|
||||
async function loadDeptTree() {
|
||||
deptLoading.value = true;
|
||||
|
||||
const {error, data: deptItems} = await fetchGetDeptList({
|
||||
const { error, data: deptItems } = await fetchGetDeptList({
|
||||
status: 0
|
||||
});
|
||||
|
||||
@@ -452,7 +452,7 @@ function openOrgLeader(row: Api.SystemManage.Dept) {
|
||||
}
|
||||
|
||||
async function handleDeleteDeptAction(row: Api.SystemManage.Dept) {
|
||||
const {error} = await fetchDeleteDept(row.id);
|
||||
const { error } = await fetchDeleteDept(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
@@ -477,7 +477,7 @@ async function handleDeleteAction(row: Api.SystemManage.User) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {error} = await fetchDeleteUser(row.id);
|
||||
const { error } = await fetchDeleteUser(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
@@ -496,7 +496,7 @@ async function updateUserResignedAt(userId: number, value: number | null) {
|
||||
|
||||
const user = detailResult.data;
|
||||
|
||||
const {error} = await fetchUpdateUser({
|
||||
const { error } = await fetchUpdateUser({
|
||||
id: userId,
|
||||
username: user.username,
|
||||
nickname: user.nickname ?? null,
|
||||
@@ -548,7 +548,7 @@ async function handleBatchDelete() {
|
||||
return;
|
||||
}
|
||||
|
||||
const {error} = await fetchBatchDeleteUser(userCheckedRowKeys.value);
|
||||
const { error } = await fetchBatchDeleteUser(userCheckedRowKeys.value);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
@@ -561,7 +561,7 @@ async function handleBatchDelete() {
|
||||
async function handleToggleStatus(row: Api.SystemManage.User, enabled: boolean) {
|
||||
statusLoadingIds.value = [...statusLoadingIds.value, row.id];
|
||||
|
||||
const {error} = await fetchUpdateUserStatus({
|
||||
const { error } = await fetchUpdateUserStatus({
|
||||
id: row.id,
|
||||
status: enabled ? 0 : 1
|
||||
});
|
||||
@@ -671,13 +671,13 @@ onMounted(async () => {
|
||||
<template #default>
|
||||
<ElButton plain type="primary" :disabled="!currentDept" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon"/>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElButton plain type="primary" :disabled="!currentDept" @click="userManagementRelationVisible = true">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon"/>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
管理链路
|
||||
</ElButton>
|
||||
@@ -685,7 +685,7 @@ onMounted(async () => {
|
||||
<template #reference>
|
||||
<ElButton type="danger" plain :disabled="userCheckedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon"/>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</ElButton>
|
||||
@@ -707,7 +707,7 @@ onMounted(async () => {
|
||||
:data="data"
|
||||
@selection-change="handleUserSelectionChange"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col"/>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
<div class="mt-20px flex justify-end">
|
||||
@@ -722,7 +722,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<div v-else class="h-full flex items-center justify-center">
|
||||
<ElEmpty :description="$t('page.system.user.emptyOrg')"/>
|
||||
<ElEmpty :description="$t('page.system.user.emptyOrg')" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
@@ -763,7 +763,7 @@ onMounted(async () => {
|
||||
@submitted="handleDeptSubmitted"
|
||||
/>
|
||||
|
||||
<UserOrgLeaderDialog v-model:visible="orgLeaderVisible" :dept="leaderDeptData"/>
|
||||
<UserOrgLeaderDialog v-model:visible="orgLeaderVisible" :dept="leaderDeptData" />
|
||||
|
||||
<BusinessFormDialog
|
||||
v-model="userManagementRelationVisible"
|
||||
@@ -772,7 +772,7 @@ onMounted(async () => {
|
||||
:show-footer="false"
|
||||
max-body-height="70vh"
|
||||
>
|
||||
<UserManagementRelation :from-user-index="true" :dept-id="currentDeptId" :org-type="currentDept?.orgType"/>
|
||||
<UserManagementRelation :from-user-index="true" :dept-id="currentDeptId" :org-type="currentDept?.orgType" />
|
||||
</BusinessFormDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -56,7 +56,7 @@ const title = computed(() => {
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
|
||||
type Model = Api.SystemManage.SaveUserParams & {
|
||||
roleIds: number[];
|
||||
roleIds: string[];
|
||||
};
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, ref, watch} from 'vue';
|
||||
import {commonStatusOptions} from '@/constants/business';
|
||||
import {fetchCreateDept, fetchUpdateDept} from '@/service/api';
|
||||
import {useForm, useFormRules} from '@/hooks/common/form';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { fetchCreateDept, fetchUpdateDept } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {$t} from '@/locales';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({name: 'UserOrgOperateDialog'});
|
||||
defineOptions({ name: 'UserOrgOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
@@ -28,8 +28,8 @@ const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const {formRef, validate} = useForm();
|
||||
const {createRequiredRule} = useFormRules();
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
@@ -44,10 +44,10 @@ const title = computed(() => {
|
||||
});
|
||||
|
||||
const orgTypeOptions: CommonType.Option<Api.SystemManage.DeptOrgType, App.I18n.I18nKey>[] = [
|
||||
{value: 'company', label: 'page.system.user.orgType.company'},
|
||||
{value: 'dept', label: 'page.system.user.orgType.dept'},
|
||||
{value: 'direction', label: 'page.system.user.orgType.direction'},
|
||||
{value: 'team', label: 'page.system.user.orgType.team'}
|
||||
{ value: 'company', label: 'page.system.user.orgType.company' },
|
||||
{ value: 'dept', label: 'page.system.user.orgType.dept' },
|
||||
{ value: 'direction', label: 'page.system.user.orgType.direction' },
|
||||
{ value: 'team', label: 'page.system.user.orgType.team' }
|
||||
];
|
||||
|
||||
type Model = Api.SystemManage.SaveDeptParams;
|
||||
@@ -149,7 +149,7 @@ async function handleSubmit() {
|
||||
} as Api.SystemManage.SaveDeptParams;
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
const {error} = await fetchUpdateDept({
|
||||
const { error } = await fetchUpdateDept({
|
||||
id: props.rowData.id,
|
||||
...payload
|
||||
});
|
||||
@@ -166,7 +166,7 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
const {error, data} = await fetchCreateDept(payload);
|
||||
const { error, data } = await fetchCreateDept(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
@@ -203,7 +203,7 @@ watch(visible, async value => {
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.orgName')" prop="name">
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.user.form.orgName')"/>
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.user.form.orgName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
@@ -222,7 +222,7 @@ watch(visible, async value => {
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.orgTypeLabel')" prop="orgType">
|
||||
<ElSelect v-model="model.orgType" :placeholder="$t('page.system.user.form.orgTypeLabel')">
|
||||
<ElOption v-for="item in orgTypeOptions" :key="item.value" :label="$t(item.label)" :value="item.value"/>
|
||||
<ElOption v-for="item in orgTypeOptions" :key="item.value" :label="$t(item.label)" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
@@ -236,7 +236,7 @@ watch(visible, async value => {
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElInput v-else v-model="model.code" :placeholder="$t('page.system.user.form.orgCode')"/>
|
||||
<ElInput v-else v-model="model.code" :placeholder="$t('page.system.user.form.orgCode')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { SYSTEM_USER_COMPANY_DICT_CODE } from '@/constants/dict';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
@@ -8,7 +10,6 @@ defineOptions({ name: 'UserSearch' });
|
||||
|
||||
interface Props {
|
||||
roleOptions: Api.SystemManage.RoleSimple[];
|
||||
companyOptions: Api.Dict.DictData[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -75,15 +76,13 @@ const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem label="所属公司" prop="company">
|
||||
<ElSelect
|
||||
<DictSelect
|
||||
v-model="model.company"
|
||||
clearable
|
||||
:dict-code="SYSTEM_USER_COMPANY_DICT_CODE"
|
||||
filterable
|
||||
:disabled="disabled"
|
||||
placeholder="请选择所属公司"
|
||||
>
|
||||
<ElOption v-for="item in companyOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
|
||||
Reference in New Issue
Block a user