feat(rbac): 用户管理与数据权限改用内部用户列表
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
64becd8b1a
commit
117b422a5f
@ -6,6 +6,8 @@
|
|||||||
// 导出所有生成的类型和服务
|
// 导出所有生成的类型和服务
|
||||||
export * from './generated'
|
export * from './generated'
|
||||||
|
|
||||||
|
export { getInternalUserList } from './internal-user'
|
||||||
|
|
||||||
// 配置 API
|
// 配置 API
|
||||||
import { OpenAPI } from './generated/core/OpenAPI'
|
import { OpenAPI } from './generated/core/OpenAPI'
|
||||||
import { axiosInstance } from './axios-config'
|
import { axiosInstance } from './axios-config'
|
||||||
|
|||||||
21
src/api/rbac/internal-user.ts
Normal file
21
src/api/rbac/internal-user.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { UserDto } from './generated/models/UserDto'
|
||||||
|
import type { UserDtoPagedResultDto } from './generated/models/UserDtoPagedResultDto'
|
||||||
|
import { axiosInstance } from './axios-config'
|
||||||
|
import { resolveAdminApiPath } from '@/utils/adminApi'
|
||||||
|
|
||||||
|
function internalUserListUrl() {
|
||||||
|
return resolveAdminApiPath('/rbac/user/internal-list')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内部用户列表(排除纯客户账号),响应结构与 UserDtoPagedResultDto 兼容 */
|
||||||
|
export async function getInternalUserList(params: {
|
||||||
|
keyword?: string
|
||||||
|
organizationId?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}): Promise<UserDtoPagedResultDto> {
|
||||||
|
const res = await axiosInstance.get<UserDtoPagedResultDto>(internalUserListUrl(), { params })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { UserDto }
|
||||||
180
src/views/system/dataPerm/components/ApplyTemplateModal.vue
Normal file
180
src/views/system/dataPerm/components/ApplyTemplateModal.vue
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:visible="visible"
|
||||||
|
title="套用权限模板"
|
||||||
|
:confirm-loading="loading"
|
||||||
|
width="560px"
|
||||||
|
@cancel="handleClose"
|
||||||
|
@ok="handleOk"
|
||||||
|
>
|
||||||
|
<a-form :model="form" layout="vertical">
|
||||||
|
<a-form-item label="模板" required>
|
||||||
|
<a-select
|
||||||
|
v-model="form.templateId"
|
||||||
|
:options="templateOptions"
|
||||||
|
placeholder="选择模板"
|
||||||
|
allow-search
|
||||||
|
:loading="templatesLoading"
|
||||||
|
:disabled="!!defaultTemplateId"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="allowPickTargets" label="目标用户" required>
|
||||||
|
<a-select
|
||||||
|
v-model="form.targetUserIds"
|
||||||
|
:options="userOptions"
|
||||||
|
multiple
|
||||||
|
allow-search
|
||||||
|
placeholder="选择用户"
|
||||||
|
:loading="usersLoading"
|
||||||
|
@search="searchUsers"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="套用模式">
|
||||||
|
<a-radio-group v-model="form.mode">
|
||||||
|
<a-radio value="replace">覆盖直配</a-radio>
|
||||||
|
<a-radio value="merge">与现有合并</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-checkbox v-model="form.temporaryEnabled">临时授权</a-checkbox>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="form.temporaryEnabled">
|
||||||
|
<a-form-item label="生效时间">
|
||||||
|
<a-date-picker
|
||||||
|
v-model="form.effectiveTime"
|
||||||
|
show-time
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="过期时间" required>
|
||||||
|
<a-date-picker
|
||||||
|
v-model="form.expireTime"
|
||||||
|
show-time
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-alert type="info">
|
||||||
|
批量套用时部分目标失败不会回滚已成功项,失败原因在结果中展示。
|
||||||
|
</a-alert>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import { applyTemplate, getTemplateList } from '@/api/rbac/data-perm'
|
||||||
|
import { getInternalUserList } from '@/api/rbac'
|
||||||
|
import { handleApiError } from '@/utils/rbac-error-handler'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
targetType: 'user' | 'role'
|
||||||
|
targetIds: string[]
|
||||||
|
defaultTemplateId?: string
|
||||||
|
allowPickTargets?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:visible': [boolean]; success: [] }>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const templatesLoading = ref(false)
|
||||||
|
const usersLoading = ref(false)
|
||||||
|
const templateOptions = ref<{ value: string; label: string }[]>([])
|
||||||
|
const userOptions = ref<{ value: string; label: string }[]>([])
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
templateId: '',
|
||||||
|
targetUserIds: [] as string[],
|
||||||
|
mode: 'replace' as 'replace' | 'merge',
|
||||||
|
temporaryEnabled: false,
|
||||||
|
effectiveTime: '',
|
||||||
|
expireTime: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function searchUsers(keyword?: string) {
|
||||||
|
usersLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getInternalUserList({ keyword, page: 1, pageSize: 50 })
|
||||||
|
userOptions.value = (res?.items || []).map((u) => ({
|
||||||
|
value: u.id!,
|
||||||
|
label: `${u.name || u.account}(${u.account})`,
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
usersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
templatesLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getTemplateList({ page: 1, pageSize: 100 })
|
||||||
|
templateOptions.value = (res.items || []).map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: `${t.name}(${t.code})`,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
templateOptions.value = []
|
||||||
|
} finally {
|
||||||
|
templatesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOk() {
|
||||||
|
if (!form.templateId) {
|
||||||
|
Message.warning('请选择模板')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ids = props.allowPickTargets ? form.targetUserIds : props.targetIds
|
||||||
|
if (!ids.length) {
|
||||||
|
Message.warning('未选择目标')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await applyTemplate({
|
||||||
|
templateId: form.templateId,
|
||||||
|
targetType: props.targetType,
|
||||||
|
targetIds: ids,
|
||||||
|
mode: form.mode,
|
||||||
|
temporary: form.temporaryEnabled
|
||||||
|
? { enabled: true, effectiveTime: form.effectiveTime, expireTime: form.expireTime }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
if (res.failures?.length) {
|
||||||
|
Message.warning(`成功 ${res.successCount} 个,失败 ${res.failures.length} 个`)
|
||||||
|
} else {
|
||||||
|
Message.success(`已成功套用 ${res.successCount} 个目标`)
|
||||||
|
}
|
||||||
|
emit('success')
|
||||||
|
handleClose()
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
form.templateId = props.defaultTemplateId || ''
|
||||||
|
form.targetUserIds = [...props.targetIds]
|
||||||
|
form.mode = 'replace'
|
||||||
|
form.temporaryEnabled = false
|
||||||
|
loadTemplates()
|
||||||
|
if (props.allowPickTargets) searchUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
218
src/views/system/dataPerm/users/index.vue
Normal file
218
src/views/system/dataPerm/users/index.vue
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
<template>
|
||||||
|
<div class="data-perm-users-container">
|
||||||
|
<div class="data-perm-users-card">
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<span class="page-title">{{ t('system.dataPerm.users.title') }}</span>
|
||||||
|
<span class="page-desc">{{ t('system.dataPerm.users.desc') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<a-tree-select
|
||||||
|
v-model="filterOrganizationId"
|
||||||
|
:placeholder="t('system.user.search.organizationPlaceholder')"
|
||||||
|
:data="organizationTreeData"
|
||||||
|
allow-clear
|
||||||
|
style="width: 180px"
|
||||||
|
@change="handleSearch"
|
||||||
|
/>
|
||||||
|
<a-input-search
|
||||||
|
v-model="searchKeyword"
|
||||||
|
:placeholder="t('system.user.search.accountOrNamePlaceholder')"
|
||||||
|
style="width: 240px"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
<a-button @click="loadData">
|
||||||
|
<template #icon><icon-refresh /></template>
|
||||||
|
</a-button>
|
||||||
|
<PaginationPro
|
||||||
|
:total-count="pagination.total"
|
||||||
|
v-model:current-page="pagination.current"
|
||||||
|
v-model:page-size="pagination.pageSize"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
@page-size-change="handlePageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="tableContainerRef" class="list-wrap">
|
||||||
|
<HandsontablePro
|
||||||
|
:columns="hotColumns"
|
||||||
|
:data="tableRows"
|
||||||
|
:total="pagination.total"
|
||||||
|
:current-page="pagination.current"
|
||||||
|
:page-size="pagination.pageSize"
|
||||||
|
:show-action-column="true"
|
||||||
|
:action-column-width="100"
|
||||||
|
:action-renderer="actionRenderer"
|
||||||
|
:stretch-columns="true"
|
||||||
|
:height="handsontableHeight"
|
||||||
|
:read-only="true"
|
||||||
|
@action-click="handleActionClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataPermDrawer v-model:visible="drawerVisible" :target-user="currentUser" @saved="loadData" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||||
|
import { getInternalUserList, OrganizationService } from '@/api/rbac'
|
||||||
|
import type { UserDto, OrganizationTreeDto } from '@/api/rbac'
|
||||||
|
import { hasPermission } from '@/utils/rbac-permission'
|
||||||
|
import { handleApiError } from '@/utils/rbac-error-handler'
|
||||||
|
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
||||||
|
import PaginationPro from '@/components/PaginationPro/index.vue'
|
||||||
|
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
||||||
|
import DataPermDrawer from '../components/DataPermDrawer.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { tableContainerRef, tableHeight } = useTableContainerHeight()
|
||||||
|
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
|
||||||
|
|
||||||
|
const dataSource = ref<UserDto[]>([])
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const filterOrganizationId = ref<string>()
|
||||||
|
const organizationTree = ref<OrganizationTreeDto[]>([])
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const currentUser = ref<UserDto | null>(null)
|
||||||
|
|
||||||
|
const pagination = reactive({ current: 1, pageSize: 30, total: 0 })
|
||||||
|
|
||||||
|
const organizationTreeData = computed(() => {
|
||||||
|
const convert = (data: OrganizationTreeDto[]): any[] =>
|
||||||
|
data.map((item) => ({
|
||||||
|
key: item.id,
|
||||||
|
title: item.name,
|
||||||
|
value: item.id,
|
||||||
|
children: item.children?.length ? convert(item.children) : undefined,
|
||||||
|
}))
|
||||||
|
return convert(organizationTree.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hotColumns = computed(() => [
|
||||||
|
{ key: 'account', label: t('system.user.table.account'), width: 140 },
|
||||||
|
{ key: 'name', label: t('system.user.table.realName'), width: 120 },
|
||||||
|
{ key: 'rolesText', label: t('system.user.table.roles'), width: 200 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const tableRows = computed(() =>
|
||||||
|
dataSource.value.map((row) => ({
|
||||||
|
...row,
|
||||||
|
rolesText: (row.roles || []).map((r) => r.name).join('、') || '—',
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const permIcon =
|
||||||
|
'<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><path d="M24 4 6 12v12c0 11.046 7.954 20.674 18 22 10.046-1.326 18-10.954 18-22V12L24 4Z"/></svg>'
|
||||||
|
|
||||||
|
function actionRenderer(_row: UserDto, rowIndex: number) {
|
||||||
|
if (!hasPermission('Rbac.DataPerm.Query')) return ''
|
||||||
|
return `<span data-action="config" data-row-index="${rowIndex}" style="cursor:pointer;color:var(--app-primary-color,#2D8C8C);display:inline-flex;" title="配置数据权限">${permIcon}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionClick(row: UserDto) {
|
||||||
|
currentUser.value = row
|
||||||
|
drawerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrganizationTree() {
|
||||||
|
try {
|
||||||
|
organizationTree.value = (await OrganizationService.getRbacOrganizationGetTree()) || []
|
||||||
|
} catch {
|
||||||
|
organizationTree.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const res = await getInternalUserList({
|
||||||
|
keyword: searchKeyword.value || undefined,
|
||||||
|
organizationId: filterOrganizationId.value,
|
||||||
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
})
|
||||||
|
dataSource.value = res?.items || []
|
||||||
|
pagination.total = res?.total || 0
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
pagination.current = page
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageSizeChange(size: number) {
|
||||||
|
pagination.pageSize = size
|
||||||
|
pagination.current = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadOrganizationTree()
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.data-perm-users-container {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f0f3f4;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.data-perm-users-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 16px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.page-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.list-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -158,7 +158,7 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
} from '@arco-design/web-vue/es/icon'
|
} from '@arco-design/web-vue/es/icon'
|
||||||
import { UserService, RoleService, OrganizationService } from '@/api/rbac'
|
import { getInternalUserList, UserService, RoleService, OrganizationService } from '@/api/rbac'
|
||||||
import type { UserDto, UserCreateDto, UserUpdateDto, RoleSimpleDto, OrganizationTreeDto } from '@/api/rbac'
|
import type { UserDto, UserCreateDto, UserUpdateDto, RoleSimpleDto, OrganizationTreeDto } from '@/api/rbac'
|
||||||
import { hasPermission } from '@/utils/rbac-permission'
|
import { hasPermission } from '@/utils/rbac-permission'
|
||||||
import { handleApiError } from '@/utils/rbac-error-handler'
|
import { handleApiError } from '@/utils/rbac-error-handler'
|
||||||
@ -319,25 +319,14 @@ onMounted(() => {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// 注意:当前 API 不支持 organizationId 参数,如果需要组织筛选,需要后端支持
|
const result = await getInternalUserList({
|
||||||
// 或者在前端进行筛选
|
keyword: searchKeyword.value || undefined,
|
||||||
const result = await UserService.getRbacUserGetList(
|
organizationId: filterOrganizationId.value,
|
||||||
searchKeyword.value || undefined,
|
page: pagination.current,
|
||||||
undefined, // roleId
|
pageSize: pagination.pageSize,
|
||||||
pagination.current,
|
})
|
||||||
pagination.pageSize
|
dataSource.value = result.items || []
|
||||||
)
|
pagination.total = result.total || 0
|
||||||
// 如果设置了组织筛选,在前端进行过滤
|
|
||||||
let filteredData = result.items || []
|
|
||||||
if (filterOrganizationId.value) {
|
|
||||||
filteredData = filteredData.filter(
|
|
||||||
(user) => user.organizationId === filterOrganizationId.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
dataSource.value = filteredData
|
|
||||||
pagination.total = filterOrganizationId.value
|
|
||||||
? filteredData.length
|
|
||||||
: result.total || 0
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error)
|
handleApiError(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user