feat(rbac): 用户管理与数据权限改用内部用户列表

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-03 12:19:15 +08:00
parent 64becd8b1a
commit 117b422a5f
5 changed files with 430 additions and 20 deletions

View File

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

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

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

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

View File

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