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 { getInternalUserList } from './internal-user'
|
||||
|
||||
// 配置 API
|
||||
import { OpenAPI } from './generated/core/OpenAPI'
|
||||
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,
|
||||
IconRefresh,
|
||||
} 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 { hasPermission } from '@/utils/rbac-permission'
|
||||
import { handleApiError } from '@/utils/rbac-error-handler'
|
||||
@ -319,25 +319,14 @@ onMounted(() => {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 注意:当前 API 不支持 organizationId 参数,如果需要组织筛选,需要后端支持
|
||||
// 或者在前端进行筛选
|
||||
const result = await UserService.getRbacUserGetList(
|
||||
searchKeyword.value || undefined,
|
||||
undefined, // roleId
|
||||
pagination.current,
|
||||
pagination.pageSize
|
||||
)
|
||||
// 如果设置了组织筛选,在前端进行过滤
|
||||
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
|
||||
const result = await getInternalUserList({
|
||||
keyword: searchKeyword.value || undefined,
|
||||
organizationId: filterOrganizationId.value,
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
})
|
||||
dataSource.value = result.items || []
|
||||
pagination.total = result.total || 0
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user