feat: 添加权限配置页面

This commit is contained in:
yangsy
2026-01-13 13:15:42 +08:00
parent 2c1d91bdb6
commit 209b3bd979
9 changed files with 485 additions and 1 deletions

View File

@@ -1,3 +1,4 @@
export * from './device';
export * from './global';
export * from './permission';
export * from './station';

View File

@@ -0,0 +1 @@
export * from './permission-config-modal';

View File

@@ -0,0 +1,6 @@
import type { ComponentInstance } from 'vue';
import PermissionConfigModal from './permission-config-modal.vue';
export type PermissionConfigModalProps = ComponentInstance<typeof PermissionConfigModal>['$props'];
export { PermissionConfigModal };

View File

@@ -0,0 +1,263 @@
<script setup lang="ts">
import {
deletePermissionApi,
detailBaseEmployeeApi,
pagePermissionApi,
savePermissionApi,
type BaseEmployeeResultVO,
type NdmPermissionResultVO,
type NdmPermissionSaveVO,
type Station,
} from '@/apis';
import { PERMISSION_TYPE_NAMES, type PermissionType } from '@/enums';
import { useStationStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { objectEntries } from '@vueuse/core';
import { isCancel } from 'axios';
import { cloneDeep, groupBy } from 'es-toolkit';
import { NButton, NCheckbox, NDataTable, NFlex, NModal, NText, type DataTableColumn, type DataTableColumns } from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, h, ref, toRefs } from 'vue';
/**
* 为避免修改权限记录而带来复杂的状态管理,引入 `action` 字段
*
* - `save`:新增权限
* - `delete`:删除权限
*
* 修改权限的过程中会对本地的权限列表进行修改:
* - 当勾选权限时,在本地新增一条权限,`action` 为 `save`
* - 当取消勾选权限时,如果该权限来自服务器,则将`action` 改为 `delete`,否则直接删除权限记录
*
* 最后,保存权限时,只需要遍历本地权限列表:
* - 如果 `action` 为 `save`,则调用 `savePermissionApi` 新增权限
* - 如果 `action` 为 `delete`,则调用 `deletePermissionApi` 删除权限
*/
type PermissionAction = 'save' | 'delete';
type NdmPermissionSaveOrResultVO = NdmPermissionSaveVO | NdmPermissionResultVO;
type NdmPermissionExtendedSaveVO = NdmPermissionSaveVO & { action?: PermissionAction };
type NdmPermissionExtendedResultVO = NdmPermissionResultVO & { action?: PermissionAction };
type NdmPermissionExtendedSaveOrResultVO = NdmPermissionExtendedSaveVO | NdmPermissionExtendedResultVO;
const omitActionField = (extendedSaveOrResultVO: NdmPermissionExtendedSaveOrResultVO) => {
const copy = cloneDeep(extendedSaveOrResultVO);
delete copy.action;
const saveOrResultVO = copy as NdmPermissionSaveOrResultVO;
return saveOrResultVO;
};
const isSaveVO = (saveOrResultVO: NdmPermissionSaveOrResultVO): saveOrResultVO is NdmPermissionSaveVO => {
return !('id' in saveOrResultVO);
};
const isResultVO = (saveOrResultVO: NdmPermissionSaveOrResultVO): saveOrResultVO is NdmPermissionResultVO => {
return 'id' in saveOrResultVO;
};
const props = defineProps<{
employeeId?: string;
}>();
const stationStore = useStationStore();
const { stations } = storeToRefs(stationStore);
const show = defineModel<boolean>('show', { default: false });
const { employeeId } = toRefs(props);
const abortController = ref<AbortController>(new AbortController());
const employee = ref<BaseEmployeeResultVO>();
const { mutateAsync: getEmployeeAsync } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
if (!employeeId.value) return;
const signal = abortController.value.signal;
const data = await detailBaseEmployeeApi(employeeId.value, { signal });
return data;
},
onSuccess: (data) => {
if (!data) return;
employee.value = data;
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const permissions = ref<Record<Station['code'], NdmPermissionExtendedSaveOrResultVO[]>>({});
const { mutate: getPermissions, isPending: permissionsLoading } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
if (!employeeId.value) return;
const signal = abortController.value.signal;
const data = await pagePermissionApi(
{
model: {
employeeId: employeeId.value,
},
extra: {},
current: 1,
size: Object.keys(PERMISSION_TYPE_NAMES).length * stations.value.length,
sort: 'id',
order: 'ascending',
},
{ signal },
);
return data;
},
onSuccess: (data) => {
if (!data) return;
const { records } = data;
permissions.value = groupBy(records, (record) => record.stationCode ?? '');
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onUpdatePermissionChecked = (checked: boolean, stationCode: Station['code'], permissionType: PermissionType) => {
if (!employeeId.value) return;
if (checked) {
// 勾选时,新增一条权限记录,`action` 为 `save`
if (!permissions.value[stationCode]) permissions.value[stationCode] = [];
permissions.value[stationCode].push({ employeeId: employeeId.value, stationCode, type: permissionType, action: 'save' });
} else {
if (!permissions.value[stationCode]) return;
// 取消勾选时,先找到所有该类型的权限并遍历,
// 如果权限来自数据库(即有 `id` 字段),则将其 `action` 设为 `delete`
// 否则直接从权限记录中移除
const targets = permissions.value[stationCode].filter((permission) => permission.type === permissionType);
for (const permission of targets) {
if (isResultVO(permission)) {
permission.action = 'delete';
} else {
permissions.value[stationCode].splice(permissions.value[stationCode].indexOf(permission), 1);
}
}
}
};
const tableColumns = computed<DataTableColumns<Station>>(() => {
return [
{ title: '车站编号', key: 'code', align: 'center', width: 120 },
{ title: '车站名称', key: 'name', align: 'center', width: 360 },
// 「权限」列
...objectEntries(PERMISSION_TYPE_NAMES).map<DataTableColumn<Station>>(([permissionType, title]) => ({
title: title,
key: permissionType,
align: 'center',
render: (rowData) => {
const { code: stationCode } = rowData;
return h(NCheckbox, {
// 如果权限记录中存在该权限且 `action` 不为 `delete`,则处于勾选状态
checked: !!(permissions.value[stationCode] ?? []).find((permission) => permission.type === permissionType && permission.action !== 'delete'),
// 改变勾选状态
onUpdateChecked: (checked) => onUpdatePermissionChecked(checked, stationCode, permissionType),
});
},
})),
];
});
const { mutate: savePermissions, isPending: permissionsSaving } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
const signal = abortController.value.signal;
// 遍历所有的权限记录,根据 `action` 进行保存或删除
for (const stationPermissions of Object.values(permissions.value)) {
for (const permission of stationPermissions) {
const saveOrResultVO = omitActionField(permission);
if (permission.action === 'save' && isSaveVO(saveOrResultVO)) {
await savePermissionApi(saveOrResultVO, { signal });
}
if (permission.action === 'delete' && isResultVO(saveOrResultVO)) {
const id = saveOrResultVO.id;
if (!id) continue;
await deletePermissionApi([id], { signal });
}
}
}
},
onSuccess: () => {
window.$message.success('权限配置保存成功');
getPermissions();
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onAfterEnter = () => {
getEmployeeAsync().then(() => getPermissions());
};
const onAfterLeave = () => {
employee.value = undefined;
permissions.value = {};
};
const onClose = () => {
abortController.value.abort();
show.value = false;
};
</script>
<template>
<NModal
v-model:show="show"
preset="card"
style="width: 100vw; height: 100vh"
:content-style="{ height: '100%', overflow: 'hidden' }"
:close-on-esc="false"
:mask-closable="false"
:auto-focus="false"
@after-enter="onAfterEnter"
@after-leave="onAfterLeave"
@close="onClose"
>
<template #header>
<span>{{ `配置权限 - ${employee?.realName ?? ''}` }}</span>
</template>
<template #default>
<NDataTable flex-height style="height: 100%" :columns="tableColumns" :data="stations" :loading="permissionsLoading" :single-line="false" />
</template>
<template #footer>
<NText depth="3" style="font-size: smaller">*未勾选任何权限的用户将被认为拥有所有权限</NText>
</template>
<template #action>
<NFlex justify="end">
<NButton size="small" @click="onClose">取消</NButton>
<NButton type="primary" size="small" :loading="permissionsSaving" @click="() => savePermissions()">保存</NButton>
</NFlex>
</template>
</NModal>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,3 +1,4 @@
export * from './alarm-type';
export * from './device-type';
export * from './fault-level';
export * from './permission-type';

View File

@@ -0,0 +1,13 @@
export const PERMISSION_TYPE_LITERALS = {
VIEW: 'VIEW',
OPERATION: 'OPERATION',
} as const;
export type PermissionType = keyof typeof PERMISSION_TYPE_LITERALS;
export const PERMISSION_TYPE_NAMES = {
[PERMISSION_TYPE_LITERALS.VIEW]: '查看',
[PERMISSION_TYPE_LITERALS.OPERATION]: '操作',
} as const;
export type PermissionTypeEnum = typeof PERMISSION_TYPE_NAMES;

View File

@@ -6,7 +6,7 @@ import { useAlarmStore, useSettingStore, useUserStore } from '@/stores';
import { parseErrorFeedback } from '@/utils';
import { useIsFetching, useIsMutating, useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import { ChevronDownIcon, ChevronsLeftIcon, ChevronsRightIcon, ComputerIcon, LogOutIcon, LogsIcon, MapPinIcon, SettingsIcon, SirenIcon } from 'lucide-vue-next';
import { ChevronDownIcon, ChevronsLeftIcon, ChevronsRightIcon, ComputerIcon, KeyIcon, LogOutIcon, LogsIcon, MapPinIcon, SettingsIcon, SirenIcon } from 'lucide-vue-next';
import {
NBadge,
NButton,
@@ -106,6 +106,12 @@ const menuOptions: MenuOption[] = [
},
],
},
{
label: () => h(RouterLink, { to: '/permission' }, { default: () => '权限管理' }),
key: '/permission',
show: userStore.isLamp,
icon: renderIcon(KeyIcon),
},
];
const dropdownOptions: DropdownOption[] = [

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import { pageBaseEmployeeApi, type BaseEmployeePageQuery, type BaseEmployeeResultVO } from '@/apis';
import { PermissionConfigModal } from '@/components';
import { parseErrorFeedback } from '@/utils';
import { useMutation } from '@tanstack/vue-query';
import { isCancel } from 'axios';
import { KeyIcon } from 'lucide-vue-next';
import { NButton, NDataTable, NFlex, NForm, NFormItemGi, NGrid, NGridItem, NInput, type DataTableColumns, type DataTableRowData, type PaginationProps } from 'naive-ui';
import { h, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
interface SearchFields extends BaseEmployeePageQuery {}
const searchFields = ref<SearchFields>({});
const resetSearchFields = () => {
searchFields.value = {
realName: '',
};
};
const getModelFields = (): BaseEmployeePageQuery => {
return {
realName: searchFields.value.realName,
};
};
const searchFieldsChanged = ref(false);
watch(searchFields, () => {
searchFieldsChanged.value = true;
});
const showPermissionConfigModal = ref(false);
const selectedEmployeeId = ref('');
const tableColumns: DataTableColumns<BaseEmployeeResultVO> = [
{ title: '姓名', key: 'realName', align: 'center' },
{ title: '创建时间', key: 'createdTime', align: 'center' },
{
title: '操作',
key: 'action',
align: 'center',
width: 150,
render: (rowData) => {
return h(
NButton,
{
secondary: true,
type: 'info',
size: 'small',
onClick: () => {
const { id } = rowData;
if (!id) return;
selectedEmployeeId.value = id;
showPermissionConfigModal.value = true;
},
},
{
icon: () => h(KeyIcon),
default: () => '配置权限',
},
);
},
},
];
const tableData = ref<DataTableRowData[]>([]);
const DEFAULT_PAGE_SIZE = 10;
const pagination = reactive<PaginationProps>({
showSizePicker: true,
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
pageSizes: [5, 10, 20, 50, 80, 100],
itemCount: 0,
prefix: ({ itemCount }) => {
return h('div', {}, { default: () => `${itemCount}` });
},
onUpdatePage: (page: number) => {
pagination.page = page;
getTableData();
},
onUpdatePageSize: (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
getTableData();
},
});
const abortController = ref(new AbortController());
const { mutate: getTableData, isPending: tableLoading } = useMutation({
mutationFn: async () => {
abortController.value.abort();
abortController.value = new AbortController();
const signal = abortController.value.signal;
const res = await pageBaseEmployeeApi(
{
model: getModelFields(),
extra: {},
current: pagination.page ?? 1,
size: pagination.pageSize ?? DEFAULT_PAGE_SIZE,
order: 'descending',
sort: 'id',
},
{
signal,
},
);
return res;
},
onSuccess: (res) => {
const { records, size, total } = res;
pagination.pageSize = parseInt(size);
pagination.itemCount = parseInt(total);
tableData.value = records;
},
onError: (error) => {
if (isCancel(error)) return;
console.error(error);
const errorFeedback = parseErrorFeedback(error);
window.$message.error(errorFeedback);
},
});
const onClickReset = () => {
resetSearchFields();
pagination.page = 1;
pagination.pageSize = DEFAULT_PAGE_SIZE;
pagination.itemCount = 0;
getTableData();
};
const onClickQuery = () => {
if (searchFieldsChanged.value) {
pagination.page = 1;
pagination.pageSize = DEFAULT_PAGE_SIZE;
searchFieldsChanged.value = false;
}
getTableData();
};
onMounted(() => {
getTableData();
});
onBeforeUnmount(() => {
abortController.value.abort();
});
</script>
<template>
<NFlex vertical :size="0" style="height: 100%">
<!-- 查询面板 -->
<NForm style="flex: 0 0 auto; padding: 8px">
<NGrid cols="3" :x-gap="24">
<NFormItemGi span="1" label="姓名" label-placement="left">
<NInput v-model:value="searchFields.realName" />
</NFormItemGi>
</NGrid>
<!-- 操作按钮 -->
<NGrid :cols="1">
<NGridItem>
<NFlex>
<NButton @click="onClickReset">重置</NButton>
<NButton type="primary" :loading="tableLoading" @click="onClickQuery">查询</NButton>
</NFlex>
</NGridItem>
</NGrid>
</NForm>
<!-- 数据表格工具栏 -->
<NFlex align="center" style="padding: 8px; flex: 0 0 auto">
<div style="font-size: medium">用户权限列表</div>
<NFlex style="margin-left: auto">
<!-- <NButton type="primary" :loading="exporting" @click="() => exportTableData()">导出</NButton> -->
</NFlex>
</NFlex>
<!-- 数据表格 -->
<NDataTable remote :columns="tableColumns" :data="tableData" :pagination="pagination" :loading="tableLoading" :single-line="false" flex-height style="height: 100%; padding: 8px; flex: 1 1 auto" />
</NFlex>
<PermissionConfigModal v-model:show="showPermissionConfigModal" :employee-id="selectedEmployeeId" />
</template>
<style scoped lang="scss"></style>

View File

@@ -47,6 +47,15 @@ const router = createRouter({
},
],
},
{
path: 'permission',
component: () => import('@/pages/permission-page.vue'),
beforeEnter: () => {
const userStore = useUserStore();
if (userStore.isLamp) return true;
return { path: '/404' };
},
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/pages/not-found-page.vue'),