feat(product): 新增产品管理模块与字典组件功能

- 新增产品管理相关路由和页面(dashboard、list、requirement、setting)
- 实现产品基础信息编辑弹窗组件(base-info-dialog.vue)
- 添加运行时字典功能(dict-select、dict-text、dict-tag组件)
- 集成字典管理store和API调用
- 规范ID类型定义为string避免精度丢失问题
- 完善国际化资源文件支持中英文对照
- 新增对象上下文业务域入口页导航实现说明
- 添加Vue DevTools浮动入口注释说明
- 统一权限控制支持全局和对象作用域区分
- 规范分页查询参数类型定义与使用方式
This commit is contained in:
2026-04-23 09:05:55 +08:00
parent c5911ea34b
commit 4122dfa50d
95 changed files with 9581 additions and 801 deletions

View File

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

View 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>

View File

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

View File

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

View File

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

View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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