feat(rbac): 数据权限三页左右分栏与右侧详情壳优化

新增 RbacDetailShell、DataPermPanel 五维配置、权限模板/角色管理右侧 Tab 与卡片布局,并补充 i18n。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-03 16:44:46 +08:00
parent 117b422a5f
commit 99f7ddf73c
21 changed files with 3286 additions and 597 deletions

303
src/api/rbac/data-perm.ts Normal file
View File

@ -0,0 +1,303 @@
/**
* 数据权限 API(契约见 docs/数据权限设计.md §十四)
* 后端未就绪时接口会 404,页面需 catch 并提示。
*/
import { axiosInstance } from '@/api/rbac'
import { resolveAdminApiPath } from '@/utils/adminApi'
function rbacDataPermUrl(path: string) {
return resolveAdminApiPath(`/rbac/data-perm${path.startsWith('/') ? path : `/${path}`}`)
}
export interface EnumOption {
value: number | string
label: string
}
export interface DataResourceMeta {
code: string
name: string
mainTable?: string
filterableFields?: string[]
}
export interface DataPermMetaDto {
orgScopes: EnumOption[]
dataScopes: EnumOption[]
bizTypes: EnumOption[]
secretLevels: EnumOption[]
dataResources: DataResourceMeta[]
filterOperators: string[]
}
export interface OrgPermItem {
id?: string
organizationId: string
organizationName?: string
orgScope: number
effectiveTime?: string | null
expireTime?: string | null
isTemporary?: boolean
isExpired?: boolean
}
export interface DataScopeItem {
id?: string
dataScope: number
dataResourceCode?: string | null
expireTime?: string | null
isExpired?: boolean
}
export interface BizPermItem {
id?: string
bizType: string
bizId: string
bizName?: string
expireTime?: string | null
isExpired?: boolean
}
export interface SecretPermItem {
id?: string
secretLevel: number
dataResourceCode?: string | null
expireTime?: string | null
isExpired?: boolean
}
export interface FilterCondition {
field: string
op: string
value?: unknown
}
export interface FilterContent {
logic: 'and' | 'or'
conditions: FilterCondition[]
}
export interface FilterRuleItem {
id?: string
dataResourceCode: string
filterContent: FilterContent
expireTime?: string | null
isExpired?: boolean
}
export interface UserDataPermDto {
userId: string
userName?: string
organizationId?: string
organizationName?: string
orgPerms: OrgPermItem[]
dataScopes: DataScopeItem[]
bizPerms: BizPermItem[]
secretPerms: SecretPermItem[]
filterRules: FilterRuleItem[]
}
export interface SaveUserDataPermInput {
orgPerms: Omit<OrgPermItem, 'id' | 'organizationName' | 'isExpired'>[]
dataScopes: Omit<DataScopeItem, 'id' | 'isExpired'>[]
bizPerms: Omit<BizPermItem, 'id' | 'isExpired'>[]
secretPerms: Omit<SecretPermItem, 'id' | 'isExpired'>[]
filterRules: Omit<FilterRuleItem, 'id' | 'isExpired'>[]
}
export interface EffectiveDataScopeDto {
userId: string
isAdmin: boolean
dataResourceCode?: string
organizationIds?: string[]
organizationNames?: string[]
dataScope?: number
bizFilters?: { bizType: string; bizIds: string[] }[]
secretLevels?: number[]
summaryText?: string
}
export interface RoleDataPermDto {
roleId: string
roleName?: string
orgPerms: OrgPermItem[]
dataScopes: DataScopeItem[]
bizPerms: BizPermItem[]
secretPerms: SecretPermItem[]
filterRules: FilterRuleItem[]
}
export interface DataPermTemplateListItem {
id: string
name: string
code: string
description?: string
roleCode?: string | null
isBuiltIn?: boolean
itemCount?: number
}
export interface DataPermTemplateItem {
id?: string
dimensionType: string
itemContent: Record<string, unknown>
sortOrder?: number
}
export interface DataPermTemplateDetailDto {
id: string
name: string
code: string
description?: string
roleCode?: string | null
isBuiltIn?: boolean
items: DataPermTemplateItem[]
}
export interface BizOptionItem {
bizId: string
bizName: string
extra?: unknown
}
export interface FieldMaskItem {
fieldName: string
secretLevel: number
maskType: string
maskPattern?: string | null
}
export interface ChangeLogItem {
id: number
targetType: string
targetId: string
action: string
operatorUserId?: string
operatorMapId?: number
createTime: string
}
export interface AccessLogItem {
id: number
userId: string
dataResourceCode: string
apiPath: string
rowCount?: number
createTime: string
}
export interface PagedResult<T> {
total: number
items: T[]
}
export async function getDataPermMeta() {
const res = await axiosInstance.get<DataPermMetaDto>(rbacDataPermUrl('/meta'))
return res.data
}
export async function getBizOptions(params: {
bizType: string
keyword?: string
page?: number
pageSize?: number
}) {
const res = await axiosInstance.get<PagedResult<BizOptionItem>>(rbacDataPermUrl('/biz-options'), { params })
return res.data
}
export async function getUserDataPerm(userId: string) {
const res = await axiosInstance.get<UserDataPermDto>(rbacDataPermUrl('/get-by-user'), { params: { userId } })
return res.data
}
export async function getEffectiveDataScope(userId: string, dataResourceCode = 'Order') {
const res = await axiosInstance.get<EffectiveDataScopeDto>(rbacDataPermUrl('/get-effective-by-user'), {
params: { userId, dataResourceCode },
})
return res.data
}
export async function saveUserDataPerm(userId: string, body: SaveUserDataPermInput) {
const res = await axiosInstance.put<UserDataPermDto>(rbacDataPermUrl('/save-user'), body, { params: { userId } })
return res.data
}
export async function getRoleDataPerm(roleId: string) {
const res = await axiosInstance.get<RoleDataPermDto>(rbacDataPermUrl('/get-by-role'), { params: { roleId } })
return res.data
}
export async function saveRoleDataPerm(roleId: string, body: SaveUserDataPermInput) {
const res = await axiosInstance.put<RoleDataPermDto>(rbacDataPermUrl('/save-role'), body, { params: { roleId } })
return res.data
}
export async function getTemplateList(params: { keyword?: string; page?: number; pageSize?: number }) {
const res = await axiosInstance.get<PagedResult<DataPermTemplateListItem>>(rbacDataPermUrl('/template/list'), {
params,
})
return res.data
}
export async function getTemplateDetail(id: string) {
const res = await axiosInstance.get<DataPermTemplateDetailDto>(rbacDataPermUrl('/template/get'), { params: { id } })
return res.data
}
export async function createTemplate(body: {
name: string
code: string
description?: string
roleCode?: string
items: DataPermTemplateItem[]
}) {
const res = await axiosInstance.post<DataPermTemplateDetailDto>(rbacDataPermUrl('/template/create'), body)
return res.data
}
export async function updateTemplate(id: string, body: Parameters<typeof createTemplate>[0]) {
const res = await axiosInstance.put<DataPermTemplateDetailDto>(rbacDataPermUrl('/template/update'), body, {
params: { id },
})
return res.data
}
export async function deleteTemplate(id: string) {
await axiosInstance.delete(rbacDataPermUrl('/template/delete'), { params: { id } })
}
export async function applyTemplate(body: {
templateId: string
targetType: 'user' | 'role'
targetIds: string[]
mode: 'replace' | 'merge'
temporary?: { enabled: boolean; effectiveTime?: string; expireTime?: string }
}) {
const res = await axiosInstance.post<{ successCount: number; failures: { targetId: string; reason: string }[] }>(
rbacDataPermUrl('/template/apply'),
body
)
return res.data
}
export async function getFieldMaskList(dataResourceCode: string) {
const res = await axiosInstance.get<{ items: FieldMaskItem[] }>(rbacDataPermUrl('/field-mask/list'), {
params: { dataResourceCode },
})
return res.data
}
export async function saveFieldMask(dataResourceCode: string, items: FieldMaskItem[]) {
await axiosInstance.put(rbacDataPermUrl('/field-mask/save'), { dataResourceCode, items })
}
export async function getChangeLogList(params: Record<string, unknown>) {
const res = await axiosInstance.get<PagedResult<ChangeLogItem>>(rbacDataPermUrl('/change-log/list'), { params })
return res.data
}
export async function getAccessLogList(params: Record<string, unknown>) {
const res = await axiosInstance.get<PagedResult<AccessLogItem>>(rbacDataPermUrl('/access-log/list'), { params })
return res.data
}

1
src/components.d.ts vendored
View File

@ -38,6 +38,7 @@ declare module 'vue' {
Package: typeof import('./components/CustomSelectFilter/Package/index.vue')['default']
PaginationPro: typeof import('./components/PaginationPro/index.vue')['default']
QuantityInput: typeof import('./components/QuantityInput/index.vue')['default']
RbacDetailShell: typeof import('./components/RbacSplit/RbacDetailShell.vue')['default']
RightTopMessage: typeof import('./components/top-nav/RightTopMessage.vue')['default']
RightTopUser: typeof import('./components/top-nav/RightTopUser.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']

View File

@ -0,0 +1,324 @@
<template>
<div class="rbac-detail-shell">
<div v-if="empty" class="shell-empty">
<div class="empty-icon">
<slot name="empty-icon">
<icon-select-all />
</slot>
</div>
<p class="empty-title">{{ emptyTitle }}</p>
<p v-if="emptyDesc" class="empty-desc">{{ emptyDesc }}</p>
</div>
<template v-else>
<div class="shell-header">
<div class="header-left">
<div v-if="$slots.icon" class="header-icon">
<slot name="icon" />
</div>
<div class="header-text">
<div class="header-title-row">
<h2 class="header-title">{{ title }}</h2>
<a-tag
v-for="tag in tags"
:key="tag.label"
:color="tag.color"
size="small"
>
{{ tag.label }}
</a-tag>
</div>
<p v-if="subtitle" class="header-subtitle">{{ subtitle }}</p>
</div>
</div>
<div v-if="stats.length" class="header-stats">
<div v-for="stat in stats" :key="stat.label" class="stat-item">
<span class="stat-value">{{ stat.value }}</span>
<span class="stat-label">{{ stat.label }}</span>
</div>
</div>
<div v-if="$slots.actions" class="header-actions">
<slot name="actions" />
</div>
</div>
<div v-if="tabs.length" class="shell-tabs">
<div class="tabs-container">
<div
v-for="tab in tabs"
:key="tab.key"
:class="['tab-item', { active: modelTab === tab.key }]"
@click="onTabClick(tab.key)"
>
<span>{{ tab.label }}</span>
<a-badge
v-if="tab.badge != null && tab.badge !== ''"
:count="tab.badge"
:max-count="999"
class="tab-badge"
/>
</div>
</div>
<div v-if="$slots['tabs-extra']" class="tabs-extra">
<slot name="tabs-extra" />
</div>
</div>
<div class="shell-body" :class="{ 'shell-body--loading': loading }">
<a-spin :loading="loading" style="width: 100%; min-height: 120px">
<slot />
</a-spin>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { IconSelectAll } from '@arco-design/web-vue/es/icon'
export interface RbacDetailTag {
label: string
color?: string
}
export interface RbacDetailStat {
label: string
value: string | number
}
export interface RbacDetailTab {
key: string
label: string
badge?: string | number
}
const props = withDefaults(
defineProps<{
empty?: boolean
emptyTitle?: string
emptyDesc?: string
title?: string
subtitle?: string
tags?: RbacDetailTag[]
stats?: RbacDetailStat[]
tabs?: RbacDetailTab[]
activeTab?: string
loading?: boolean
}>(),
{
empty: false,
emptyTitle: '请从左侧选择一项',
emptyDesc: '',
title: '',
subtitle: '',
tags: () => [],
stats: () => [],
tabs: () => [],
activeTab: '',
loading: false,
}
)
const emit = defineEmits<{ 'update:activeTab': [string] }>()
const modelTab = computed({
get: () => props.activeTab || props.tabs[0]?.key || '',
set: (v: string) => emit('update:activeTab', v),
})
function onTabClick(key: string) {
modelTab.value = key
}
</script>
<style scoped lang="less">
.rbac-detail-shell {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fff;
}
.shell-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--color-text-3);
.empty-icon {
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--color-fill-2);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: var(--color-text-4);
margin-bottom: 16px;
}
.empty-title {
font-size: 15px;
font-weight: 500;
color: var(--color-text-2);
margin: 0 0 8px;
}
.empty-desc {
font-size: 13px;
margin: 0;
max-width: 360px;
text-align: center;
line-height: 1.6;
}
}
.shell-header {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--color-border-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.header-left {
display: flex;
align-items: flex-start;
gap: 14px;
flex: 1;
min-width: 200px;
}
.header-icon {
flex-shrink: 0;
}
.header-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.header-title {
margin: 0;
font-size: 18px;
font-weight: 600;
line-height: 1.3;
color: var(--color-text-1);
}
.header-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--color-text-3);
}
.header-stats {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.stat-item {
min-width: 72px;
padding: 8px 14px;
border-radius: 8px;
background: var(--color-fill-2);
text-align: center;
.stat-value {
display: block;
font-size: 18px;
font-weight: 600;
color: var(--app-primary-color, #2d8c8c);
line-height: 1.2;
}
.stat-label {
display: block;
font-size: 11px;
color: var(--color-text-3);
margin-top: 2px;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.shell-tabs {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid var(--color-border-2);
flex-shrink: 0;
}
.tabs-container {
display: flex;
align-items: stretch;
}
.tab-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 14px 18px;
cursor: pointer;
border-bottom: 3px solid transparent;
font-size: 14px;
color: var(--color-text-2);
transition: color 0.2s, border-color 0.2s, background 0.2s;
user-select: none;
&:hover {
color: var(--app-primary-color, #2d8c8c);
background: var(--color-fill-1);
}
&.active {
color: var(--app-primary-color, #2d8c8c);
font-weight: 600;
border-bottom-color: var(--app-primary-color, #2d8c8c);
}
}
.tab-badge :deep(.arco-badge-number) {
background: var(--color-fill-3);
color: var(--color-text-2);
box-shadow: none;
}
.tab-item.active .tab-badge :deep(.arco-badge-number) {
background: rgba(var(--primary-6), 0.12);
color: var(--app-primary-color, #2d8c8c);
}
.tabs-extra {
padding: 8px 0;
}
.shell-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 20px 24px 24px;
}
.shell-body--loading {
overflow: hidden;
}
</style>

View File

@ -0,0 +1,85 @@
.rbac-split-layout {
display: flex;
height: calc(100vh - 86px);
max-height: calc(100vh - 86px);
padding: 16px;
gap: 16px;
background: #f0f3f4;
overflow: hidden;
box-sizing: border-box;
width: 100%;
}
.rbac-split-layout .left-sidebar {
width: 280px;
flex-shrink: 0;
height: 100%;
}
.rbac-split-layout .right-panel {
flex: 1;
min-width: 0;
height: 100%;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
overflow: hidden;
display: flex;
flex-direction: column;
}
.rbac-split-layout .section-card {
background: var(--color-fill-1);
border: 1px solid var(--color-border-2);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.rbac-split-layout .section-card-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
margin: 0 0 12px;
}
.rbac-split-layout .sticky-toolbar {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
margin: -4px 0 16px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
border: 1px solid var(--color-border-2);
border-radius: 8px;
flex-wrap: wrap;
}
.rbac-split-layout .avatar-circle {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, var(--app-primary-color, #2d8c8c) 0%, #3aa8a8 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
}
.rbac-split-layout .icon-badge-circle {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(var(--primary-6), 0.1);
color: var(--app-primary-color, #2d8c8c);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}

View File

@ -144,6 +144,60 @@ export default {
'menu.system.warehouseAddress': 'Warehouse Address',
'menu.system.batchEmail': 'Batch Email',
'menu.system.xhsAiAssistant': 'AI Assistant',
'menu.system.dataPerm': 'Data Permission',
'menu.system.dataPermUsers': 'User Data Permission',
'menu.system.dataPermTemplates': 'Permission Templates',
'menu.system.dataPermAudit': 'Permission Audit',
'system.dataPerm.users.title': 'User Data Permission',
'system.dataPerm.users.desc': 'Configure direct five-dimension permissions for internal users',
'system.dataPerm.users.loading': 'Loading users...',
'system.dataPerm.users.emptySelect': 'Select a user on the left',
'system.dataPerm.users.emptyDesc': 'Configure org, data scope, biz tags, secret level and custom filters for internal users',
'system.dataPerm.panel.primaryOrg': 'Primary organization',
'system.dataPerm.panel.configType': 'Config type',
'system.dataPerm.panel.configTypeDirect': 'Direct user config',
'system.dataPerm.panel.previewModule': 'Preview module',
'system.dataPerm.panel.cannotEditSelf': 'You cannot edit your own data permissions',
'system.dataPerm.panel.stat.total': 'Direct items',
'system.dataPerm.panel.tab.org': 'Organization',
'system.dataPerm.panel.tab.scope': 'Data scope',
'system.dataPerm.panel.tab.biz': 'Business tags',
'system.dataPerm.panel.tab.secret': 'Secret level',
'system.dataPerm.panel.tab.filter': 'Custom filter',
'system.dataPerm.templates.add': 'New template',
'system.dataPerm.templates.search': 'Search name or code',
'system.dataPerm.templates.title': 'Permission templates',
'system.dataPerm.templates.loading': 'Loading templates...',
'system.dataPerm.templates.emptySelect': 'Select a template on the left, or click Add',
'system.dataPerm.templates.emptyDesc': 'Templates define five-dimension permission rules and can be applied to users or roles',
'system.dataPerm.templates.save': 'Save',
'system.dataPerm.templates.apply': 'Apply template',
'system.dataPerm.templates.field.name': 'Name',
'system.dataPerm.templates.field.code': 'Code',
'system.dataPerm.templates.field.description': 'Description',
'system.dataPerm.templates.field.roleCode': 'Bound role code',
'system.dataPerm.templates.roleCodePlaceholder': 'Optional, auto-apply when role is assigned',
'system.dataPerm.templates.itemsTitle': 'Template items',
'system.dataPerm.templates.itemJsonPlaceholder': 'JSON, e.g. {"dataScope":1}',
'system.dataPerm.templates.addItem': 'Add item row',
'system.dataPerm.templates.removeItem': 'Remove',
'system.dataPerm.templates.nameCodeRequired': 'Name and code are required',
'system.dataPerm.templates.invalidJson': 'Invalid JSON in template items',
'system.dataPerm.templates.createSuccess': 'Created',
'system.dataPerm.templates.updateSuccess': 'Updated',
'system.dataPerm.templates.deleteSuccess': 'Deleted',
'system.dataPerm.templates.builtinDeleteDenied': 'Built-in templates cannot be deleted',
'system.dataPerm.templates.deleteConfirm': 'Delete template "{name}"?',
'system.dataPerm.templates.tab.basic': 'Basic info',
'system.dataPerm.templates.tab.items': 'Template items',
'system.dataPerm.templates.itemsHint': 'Each row is a JSON config for one permission dimension',
'system.dataPerm.templates.itemsEmpty': 'No items yet. Click below to add one.',
'system.dataPerm.templates.jsonPreset': 'JSON preset',
'system.dataPerm.templates.dimensionPlaceholder': 'Select dimension',
'system.dataPerm.templates.tag.creating': 'Creating',
'system.dataPerm.templates.tag.builtin': 'Built-in',
'system.dataPerm.templates.stat.items': 'Items',
'system.dataPerm.templates.stat.dimensions': 'Dimensions',
'menu.xhs': 'Xiaohongshu',
'menu.xhs.startService': 'Start Service',
@ -775,6 +829,13 @@ export default {
'system.role.actions.add': 'Add',
'system.role.actions.edit': 'Edit',
'system.role.actions.delete': 'Delete',
'system.role.actions.save': 'Save',
'system.role.emptySelect': 'Select a role on the left, or click Add',
'system.role.emptyDesc': 'Edit basic info and assign permissions after selecting a role',
'system.role.pageTitle': 'Role management',
'system.role.tab.info': 'Basic info',
'system.role.loading': 'Loading roles...',
'system.role.permission.save': 'Save permissions',
'system.role.status.enabled': 'Enabled',
'system.role.status.disabled': 'Disabled',
'system.role.category.business': 'Business role',
@ -920,6 +981,13 @@ export default {
'system.role.permission.uncheckAll': 'Uncheck All',
'system.role.permission.loading': 'Loading permission data...',
'system.role.permission.assignSuccess': 'Permission assigned successfully',
'system.role.permission.search': 'Search permission name or code',
'system.role.permission.selected': 'Selected',
'system.role.permission.total': 'Total',
'system.role.permission.selectedCount': '{selected} / {total} selected',
'system.role.permission.noMatch': 'No matching permissions',
'system.role.tag.creating': 'Creating',
'system.role.tag.boundWarehouse': 'Warehouse bound',
'system.role.message.deleteSuccess': 'Delete successful',
'system.role.message.updateSuccess': 'Update successful',
'system.role.message.createSuccess': 'Create successful',

View File

@ -145,6 +145,10 @@ export default {
'menu.system.warehouseAddress': '仓库地址',
'menu.system.batchEmail': '批量发送邮件',
'menu.system.xhsAiAssistant': 'AI 助手',
'menu.system.dataPerm': '数据权限',
'menu.system.dataPermUsers': '用户数据权限',
'menu.system.dataPermTemplates': '权限模板',
'menu.system.dataPermAudit': '权限审计',
'menu.xhs': '小红书',
'menu.xhs.startService': '启动服务',
@ -776,6 +780,13 @@ export default {
'system.role.actions.add': '新增角色',
'system.role.actions.edit': '修改',
'system.role.actions.delete': '删除',
'system.role.actions.save': '保存',
'system.role.emptySelect': '请从左侧选择角色,或点击新增',
'system.role.emptyDesc': '选择角色后可编辑基本信息与分配功能权限',
'system.role.pageTitle': '角色管理',
'system.role.tab.info': '基本信息',
'system.role.loading': '正在加载角色列表...',
'system.role.permission.save': '保存权限',
'system.role.status.enabled': '启用',
'system.role.status.disabled': '禁用',
'system.role.category.business': '业务角色',
@ -921,6 +932,13 @@ export default {
'system.role.permission.uncheckAll': '取消全选',
'system.role.permission.loading': '加载权限数据中...',
'system.role.permission.assignSuccess': '权限分配成功',
'system.role.permission.search': '搜索权限名称或编码',
'system.role.permission.selected': '已选',
'system.role.permission.total': '总计',
'system.role.permission.selectedCount': '已选 {selected} / 共 {total}',
'system.role.permission.noMatch': '未找到匹配的权限',
'system.role.tag.creating': '新建中',
'system.role.tag.boundWarehouse': '已绑仓库',
'system.role.message.deleteSuccess': '删除成功',
'system.role.message.updateSuccess': '更新成功',
'system.role.message.createSuccess': '创建成功',
@ -1045,6 +1063,57 @@ export default {
'system.dict.actions.delete': '删除',
'system.dict.actions.batchImport': '批量导入',
'system.dict.actions.batchExport': '批量导出',
'system.dataPerm.users.title': '用户数据权限',
'system.dataPerm.users.desc': '配置内部用户直配五维权限;运行时 = 角色继承 ∪ 用户直配',
'system.dataPerm.users.loading': '正在加载用户列表...',
'system.dataPerm.users.emptySelect': '请从左侧选择用户',
'system.dataPerm.users.emptyDesc': '选择内部用户后,可配置组织、数据范围、业务标签、密级与自定义过滤五维权限',
'system.dataPerm.panel.primaryOrg': '主归属组织',
'system.dataPerm.panel.configType': '配置类型',
'system.dataPerm.panel.configTypeDirect': '用户直配',
'system.dataPerm.panel.previewModule': '预览模块',
'system.dataPerm.panel.cannotEditSelf': '不能修改自己的数据权限',
'system.dataPerm.panel.stat.total': '直配项',
'system.dataPerm.panel.tab.org': '组织',
'system.dataPerm.panel.tab.scope': '数据范围',
'system.dataPerm.panel.tab.biz': '业务标签',
'system.dataPerm.panel.tab.secret': '密级',
'system.dataPerm.panel.tab.filter': '自定义过滤',
'system.dataPerm.templates.add': '新建模板',
'system.dataPerm.templates.search': '搜索模板名称/编码',
'system.dataPerm.templates.title': '权限模板',
'system.dataPerm.templates.loading': '正在加载模板列表...',
'system.dataPerm.templates.emptySelect': '请从左侧选择模板,或点击新建',
'system.dataPerm.templates.emptyDesc': '权限模板可批量定义五维权限规则,并一键套用到用户或角色',
'system.dataPerm.templates.save': '保存',
'system.dataPerm.templates.apply': '套用模板',
'system.dataPerm.templates.field.name': '名称',
'system.dataPerm.templates.field.code': '编码',
'system.dataPerm.templates.field.description': '说明',
'system.dataPerm.templates.field.roleCode': '绑定角色 Code',
'system.dataPerm.templates.roleCodePlaceholder': '可选,分配角色时自动套用',
'system.dataPerm.templates.itemsTitle': '模板明细',
'system.dataPerm.templates.itemJsonPlaceholder': 'JSON,如 {"dataScope":1}',
'system.dataPerm.templates.addItem': '添加明细行',
'system.dataPerm.templates.removeItem': '删',
'system.dataPerm.templates.nameCodeRequired': '请填写名称和编码',
'system.dataPerm.templates.invalidJson': '明细 JSON 格式不正确',
'system.dataPerm.templates.createSuccess': '创建成功',
'system.dataPerm.templates.updateSuccess': '更新成功',
'system.dataPerm.templates.deleteSuccess': '删除成功',
'system.dataPerm.templates.builtinDeleteDenied': '内置模板不可删除',
'system.dataPerm.templates.deleteConfirm': '确定删除模板「{name}」?',
'system.dataPerm.templates.tab.basic': '基本信息',
'system.dataPerm.templates.tab.items': '模板明细',
'system.dataPerm.templates.itemsHint': '每条明细对应一个维度的权限 JSON 配置',
'system.dataPerm.templates.itemsEmpty': '暂无明细,点击下方按钮添加',
'system.dataPerm.templates.jsonPreset': 'JSON 预设',
'system.dataPerm.templates.dimensionPlaceholder': '选择维度',
'system.dataPerm.templates.tag.creating': '新建中',
'system.dataPerm.templates.tag.builtin': '内置',
'system.dataPerm.templates.stat.items': '明细数',
'system.dataPerm.templates.stat.dimensions': '维度数',
'system.dict.import.modalTitle': '批量导入字典',
'system.dict.import.tip':
'请先下载模板或 OMS 预置字典,按列填写后上传 Excel(.xlsx/.xls)。同一字典类型下字典编码唯一;已存在的「类型+编码」将自动跳过。',

View File

@ -109,6 +109,36 @@ const SYSTEM_MANAGEMENT: AppRouteRecordRaw = {
permission: 'Xhs.Ai.Use',
},
},
{
path: 'dataPerm/users',
name: 'SystemDataPermUsers',
component: () => import('@/views/system/dataPerm/users/index.vue'),
meta: {
locale: 'menu.system.dataPermUsers',
requiresAuth: true,
permission: 'Rbac.DataPerm.Query',
},
},
{
path: 'dataPerm/templates',
name: 'SystemDataPermTemplates',
component: () => import('@/views/system/dataPerm/templates/index.vue'),
meta: {
locale: 'menu.system.dataPermTemplates',
requiresAuth: true,
permission: 'Rbac.DataPerm.Template',
},
},
{
path: 'dataPerm/audit',
name: 'SystemDataPermAudit',
component: () => import('@/views/system/dataPerm/audit/index.vue'),
meta: {
locale: 'menu.system.dataPermAudit',
requiresAuth: true,
permission: 'Rbac.DataPerm.Audit',
},
},
],
};

View File

@ -0,0 +1,40 @@
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'
export function buildFlatLeftMenuTree(
categoryKey: string,
categoryTitle: string,
items: Array<{ id?: string; name?: string | null; account?: string | null; code?: string | null }>,
options?: { titleKey?: 'name' | 'account'; codeKey?: 'code' | 'account' }
): CustomLeftMenuTreeNode[] {
const titleKey = options?.titleKey ?? 'name'
const codeKey = options?.codeKey ?? 'code'
const leaves = items
.filter((item) => item.id)
.map((item) => ({
key: `item-${item.id}`,
title: String(item[titleKey] || item.account || item.name || item.id),
isLeaf: true,
id: item.id,
name: item.name ?? undefined,
code: String(item[codeKey as keyof typeof item] || item.code || item.account || ''),
}))
if (leaves.length === 0) return []
return [
{
key: categoryKey,
title: categoryTitle,
children: leaves,
},
]
}
export function findLeftMenuItemByKey<T extends { id?: string }>(
key: string,
items: T[]
): T | undefined {
if (!key.startsWith('item-')) return undefined
const id = key.slice(5)
return items.find((item) => item.id === id)
}

View File

@ -0,0 +1,173 @@
<template>
<div class="data-perm-audit-container">
<div class="data-perm-audit-card">
<a-tabs v-model:active-key="activeTab">
<a-tab-pane key="change" title="配置变更日志">
<div class="table-toolbar">
<a-space wrap>
<a-select v-model="changeQuery.targetType" placeholder="目标类型" allow-clear style="width: 120px">
<a-option value="user">用户</a-option>
<a-option value="role">角色</a-option>
<a-option value="template">模板</a-option>
</a-select>
<a-input v-model="changeQuery.targetId" placeholder="目标 ID" allow-clear style="width: 200px" />
<a-range-picker v-model="changeQuery.timeRange" show-time style="width: 360px" />
<a-button type="primary" @click="loadChangeLog">查询</a-button>
</a-space>
<PaginationPro
:total-count="changePagination.total"
v-model:current-page="changePagination.current"
v-model:page-size="changePagination.pageSize"
@page-change="(p) => { changePagination.current = p; loadChangeLog() }"
@page-size-change="(s) => { changePagination.pageSize = s; changePagination.current = 1; loadChangeLog() }"
/>
</div>
<a-table :data="changeLogs" :loading="changeLoading" :pagination="false" row-key="id" size="small">
<template #columns>
<a-table-column title="时间" data-index="createTime" :width="180" />
<a-table-column title="目标" :width="100">
<template #cell="{ record }">{{ record.targetType }}</template>
</a-table-column>
<a-table-column title="目标 ID" data-index="targetId" ellipsis />
<a-table-column title="动作" data-index="action" :width="100" />
<a-table-column title="操作人 mapId" data-index="operatorMapId" :width="110" />
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="access" title="数据访问日志">
<a-alert type="info" style="margin-bottom: 12px">
P1 后端暂不自动写入访问日志;本页为预留查询界面,待内审需求启用后再对接。
</a-alert>
<div class="table-toolbar">
<a-space wrap>
<a-input v-model="accessQuery.userId" placeholder="用户 GUID" allow-clear style="width: 220px" />
<a-select
v-model="accessQuery.dataResourceCode"
placeholder="模块"
allow-clear
style="width: 140px"
:options="[
{ value: 'Order', label: '订单' },
{ value: 'Customer', label: '客户' },
]"
/>
<a-range-picker v-model="accessQuery.timeRange" show-time style="width: 360px" />
<a-button type="primary" @click="loadAccessLog">查询</a-button>
</a-space>
<PaginationPro
:total-count="accessPagination.total"
v-model:current-page="accessPagination.current"
v-model:page-size="accessPagination.pageSize"
@page-change="(p) => { accessPagination.current = p; loadAccessLog() }"
@page-size-change="(s) => { accessPagination.pageSize = s; accessPagination.current = 1; loadAccessLog() }"
/>
</div>
<a-table :data="accessLogs" :loading="accessLoading" :pagination="false" row-key="id" size="small">
<template #columns>
<a-table-column title="时间" data-index="createTime" :width="180" />
<a-table-column title="模块" data-index="dataResourceCode" :width="100" />
<a-table-column title="接口" data-index="apiPath" ellipsis />
<a-table-column title="行数" data-index="rowCount" :width="80" align="center" />
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { getAccessLogList, getChangeLogList, type AccessLogItem, type ChangeLogItem } from '@/api/rbac/data-perm'
import { handleApiError } from '@/utils/rbac-error-handler'
const activeTab = ref('change')
const changeLoading = ref(false)
const changeLogs = ref<ChangeLogItem[]>([])
const changeQuery = reactive({
targetType: '',
targetId: '',
timeRange: [] as string[],
})
const changePagination = reactive({ current: 1, pageSize: 30, total: 0 })
const accessLoading = ref(false)
const accessLogs = ref<AccessLogItem[]>([])
const accessQuery = reactive({
userId: '',
dataResourceCode: '',
timeRange: [] as string[],
})
const accessPagination = reactive({ current: 1, pageSize: 30, total: 0 })
function buildTimeParams(range: string[]) {
if (!range?.length || range.length < 2) return {}
return { startTime: range[0], endTime: range[1] }
}
async function loadChangeLog() {
changeLoading.value = true
try {
const res = await getChangeLogList({
targetType: changeQuery.targetType || undefined,
targetId: changeQuery.targetId || undefined,
page: changePagination.current,
pageSize: changePagination.pageSize,
...buildTimeParams(changeQuery.timeRange),
})
changeLogs.value = res.items || []
changePagination.total = res.total || 0
} catch (e) {
changeLogs.value = []
handleApiError(e)
} finally {
changeLoading.value = false
}
}
async function loadAccessLog() {
accessLoading.value = true
try {
const res = await getAccessLogList({
userId: accessQuery.userId || undefined,
dataResourceCode: accessQuery.dataResourceCode || undefined,
page: accessPagination.current,
pageSize: accessPagination.pageSize,
...buildTimeParams(accessQuery.timeRange),
})
accessLogs.value = res.items || []
accessPagination.total = res.total || 0
} catch (e) {
accessLogs.value = []
handleApiError(e)
} finally {
accessLoading.value = false
}
}
loadChangeLog()
</script>
<style scoped>
.data-perm-audit-container {
padding: 16px;
background: #f0f3f4;
flex: 1;
}
.data-perm-audit-card {
background: #fff;
border-radius: 8px;
padding: 16px;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
</style>

View File

@ -0,0 +1,122 @@
<template>
<div class="biz-perm-panel">
<div class="panel-toolbar">
<a-button type="primary" size="small" @click="addRow">
<template #icon><icon-plus /></template>
添加业务标签
</a-button>
<span class="hint">未配置任何标签 = 不限制业务维度</span>
</div>
<a-table :data="modelValue" :pagination="false" size="small" row-key="_key">
<template #columns>
<a-table-column title="类型" :width="140">
<template #cell="{ record }">
<a-select v-model="record.bizType" :options="bizTypeOptions" @change="onBizTypeChange(record)" />
</template>
</a-table-column>
<a-table-column title="标签">
<template #cell="{ record }">
<a-select
v-model="record.bizId"
:options="record._bizOptions || []"
placeholder="选择标签"
allow-search
:loading="record._loading"
@popup-visible-change="(v: boolean) => v && loadBizOptions(record)"
@change="(v: string) => onBizIdChange(record, v)"
/>
</template>
</a-table-column>
<a-table-column title="过期时间" :width="180">
<template #cell="{ record }">
<a-date-picker
v-model="record.expireTime"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
placeholder="永久"
allow-clear
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column title="操作" :width="70" align="center">
<template #cell="{ rowIndex }">
<a-button type="text" status="danger" size="mini" @click="removeRow(rowIndex)">删除</a-button>
</template>
</a-table-column>
</template>
</a-table>
</div>
</template>
<script setup lang="ts">
import { IconPlus } from '@arco-design/web-vue/es/icon'
import { getBizOptions, type BizPermItem } from '@/api/rbac/data-perm'
import { DEFAULT_BIZ_TYPES } from '../constants'
type Row = BizPermItem & {
_key: string
_bizOptions?: { value: string; label: string }[]
_loading?: boolean
}
const props = defineProps<{
modelValue: Row[]
bizTypeOptions?: { value: string; label: string }[]
}>()
const emit = defineEmits<{ 'update:modelValue': [Row[]] }>()
const bizTypeOptions = props.bizTypeOptions?.length ? props.bizTypeOptions : DEFAULT_BIZ_TYPES
async function loadBizOptions(record: Row) {
if (!record.bizType || record._bizOptions?.length) return
record._loading = true
try {
const res = await getBizOptions({ bizType: record.bizType, page: 1, pageSize: 100 })
record._bizOptions = (res.items || []).map((i) => ({ value: i.bizId, label: i.bizName }))
} catch {
record._bizOptions = []
} finally {
record._loading = false
}
}
function onBizTypeChange(record: Row) {
record.bizId = ''
record.bizName = ''
record._bizOptions = []
}
function onBizIdChange(record: Row, bizId: string) {
const opt = record._bizOptions?.find((o) => o.value === bizId)
record.bizName = opt?.label
}
function addRow() {
emit('update:modelValue', [
...props.modelValue,
{ _key: `biz-${Date.now()}`, bizType: 'product_line', bizId: '', bizName: '', expireTime: null },
])
}
function removeRow(index: number) {
const next = [...props.modelValue]
next.splice(index, 1)
emit('update:modelValue', next)
}
</script>
<style scoped>
.panel-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.hint {
color: var(--color-text-3);
font-size: 12px;
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<a-drawer
:visible="visible"
:width="920"
unmount-on-close
:title="drawerTitle"
:footer="false"
@cancel="handleClose"
>
<DataPermPanel
:target-user="targetUser"
show-close
@close="handleClose"
@saved="emit('saved')"
/>
</a-drawer>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { UserDto } from '@/api/rbac'
import DataPermPanel from './DataPermPanel.vue'
const props = defineProps<{
visible: boolean
targetUser: UserDto | null
}>()
const emit = defineEmits<{ 'update:visible': [boolean]; saved: [] }>()
const drawerTitle = computed(() =>
props.targetUser ? `数据权限 — ${props.targetUser.name || props.targetUser.account}` : '数据权限'
)
function handleClose() {
emit('update:visible', false)
}
</script>

View File

@ -0,0 +1,443 @@
<template>
<div class="data-perm-panel" :class="{ 'data-perm-panel--embedded': embedded }">
<template v-if="!embedded && !targetUser">
<div class="empty-state">
<p>{{ emptyText }}</p>
</div>
</template>
<template v-else-if="targetUser">
<div v-if="!embedded" class="panel-head section-card">
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item :label="t('system.user.table.account')">{{ targetUser.account }}</a-descriptions-item>
<a-descriptions-item :label="t('system.user.table.realName')">{{ targetUser.name }}</a-descriptions-item>
<a-descriptions-item :label="t('system.dataPerm.panel.primaryOrg')">{{ permDto?.organizationName || '—' }}</a-descriptions-item>
<a-descriptions-item :label="t('system.dataPerm.panel.configType')">{{ t('system.dataPerm.panel.configTypeDirect') }}</a-descriptions-item>
</a-descriptions>
</div>
<a-spin :loading="loading" style="width: 100%">
<div class="section-card scope-card">
<EffectiveScopeBanner
ref="bannerRef"
:user-id="targetUser.id"
:data-resource-code="previewResource"
/>
</div>
<div class="sticky-toolbar">
<a-space wrap>
<a-select
v-model="previewResource"
:options="resourceOptions"
style="width: 160px"
:placeholder="t('system.dataPerm.panel.previewModule')"
/>
<a-button @click="reload">{{ t('system.user.actions.refresh') }}</a-button>
<a-button v-if="canUpdate" @click="applyModalVisible = true">
{{ t('system.dataPerm.templates.apply') }}
</a-button>
</a-space>
<a-space v-if="canUpdate">
<a-button v-if="showClose" @click="emit('close')">{{ t('system.role.modal.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">
{{ t('system.dataPerm.templates.save') }}
</a-button>
</a-space>
</div>
<div class="inline-tabs">
<div
v-for="tab in tabItems"
:key="tab.key"
:class="['inline-tab-item', { active: activeTab === tab.key }]"
@click="activeTab = tab.key"
>
<span>{{ tab.label }}</span>
<a-badge
v-if="tab.count > 0"
:count="tab.count"
:max-count="999"
class="inline-tab-badge"
/>
</div>
</div>
<div class="tab-pane section-card">
<OrgPermPanel
v-show="activeTab === 'org'"
v-model="form.orgPerms"
:org-tree="orgTree"
:org-scope-options="orgScopeOptions"
/>
<DataScopePanel
v-show="activeTab === 'scope'"
v-model="form.dataScopes"
:data-scope-options="dataScopeOptions"
:data-resources="meta.dataResources"
/>
<BizPermPanel
v-show="activeTab === 'biz'"
v-model="form.bizPerms"
:biz-type-options="meta.bizTypes"
/>
<SecretPermPanel
v-show="activeTab === 'secret'"
v-model="form.secretPerms"
:secret-level-options="meta.secretLevels"
:data-resources="meta.dataResources"
/>
<FilterRulePanel
v-show="activeTab === 'filter'"
v-model="form.filterRules"
:data-resources="meta.dataResources"
/>
</div>
</a-spin>
</template>
<ApplyTemplateModal
v-model:visible="applyModalVisible"
target-type="user"
:target-ids="targetUser?.id ? [targetUser.id] : []"
@success="onApplySuccess"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import type { UserDto } from '@/api/rbac'
import {
getDataPermMeta,
getUserDataPerm,
saveUserDataPerm,
type DataPermMetaDto,
type SaveUserDataPermInput,
} from '@/api/rbac/data-perm'
import { OrganizationService } from '@/api/rbac'
import type { OrganizationTreeDto } from '@/api/rbac'
import { hasPermission } from '@/utils/rbac-permission'
import { handleApiError } from '@/utils/rbac-error-handler'
import { useRbacUserStore } from '@/store/modules/rbac-user'
import {
DEFAULT_ORG_SCOPES,
DEFAULT_DATA_SCOPES,
DEFAULT_SECRET_LEVELS,
DEFAULT_BIZ_TYPES,
emptySaveInput,
} from '../constants'
import EffectiveScopeBanner from './EffectiveScopeBanner.vue'
import OrgPermPanel from './OrgPermPanel.vue'
import DataScopePanel from './DataScopePanel.vue'
import BizPermPanel from './BizPermPanel.vue'
import SecretPermPanel from './SecretPermPanel.vue'
import FilterRulePanel from './FilterRulePanel.vue'
import ApplyTemplateModal from './ApplyTemplateModal.vue'
const props = withDefaults(
defineProps<{
targetUser: UserDto | null
showClose?: boolean
emptyText?: string
embedded?: boolean
}>(),
{
showClose: false,
emptyText: '请从左侧选择用户',
embedded: false,
}
)
const emit = defineEmits<{
saved: []
close: []
'stats-change': [Record<string, number>]
}>()
const { t } = useI18n()
const rbacStore = useRbacUserStore()
const canUpdate = computed(() => hasPermission('Rbac.DataPerm.Update'))
const loading = ref(false)
const saving = ref(false)
const activeTab = ref('org')
const previewResource = ref('Order')
const applyModalVisible = ref(false)
const bannerRef = ref<InstanceType<typeof EffectiveScopeBanner>>()
const permDto = ref<Awaited<ReturnType<typeof getUserDataPerm>> | null>(null)
const orgTree = ref<{ key: string; title: string; value: string; children?: unknown[] }[]>([])
const metaLoaded = ref(false)
const meta = reactive<DataPermMetaDto>({
orgScopes: DEFAULT_ORG_SCOPES,
dataScopes: DEFAULT_DATA_SCOPES,
secretLevels: DEFAULT_SECRET_LEVELS,
bizTypes: DEFAULT_BIZ_TYPES,
dataResources: [{ code: 'Order', name: '订单', filterableFields: ['CreateTime', 'OrderStatus', 'IsPaid'] }],
filterOperators: [],
})
const form = reactive({
orgPerms: [] as any[],
dataScopes: [] as any[],
bizPerms: [] as any[],
secretPerms: [] as any[],
filterRules: [] as any[],
})
const tabItems = computed(() => [
{ key: 'org', label: t('system.dataPerm.panel.tab.org'), count: form.orgPerms.length },
{ key: 'scope', label: t('system.dataPerm.panel.tab.scope'), count: form.dataScopes.length },
{ key: 'biz', label: t('system.dataPerm.panel.tab.biz'), count: form.bizPerms.length },
{ key: 'secret', label: t('system.dataPerm.panel.tab.secret'), count: form.secretPerms.length },
{ key: 'filter', label: t('system.dataPerm.panel.tab.filter'), count: form.filterRules.length },
])
const orgScopeOptions = computed(() =>
meta.orgScopes.map((o) => ({ value: Number(o.value), label: String(o.label) }))
)
const dataScopeOptions = computed(() =>
meta.dataScopes.map((o) => ({ value: Number(o.value), label: String(o.label) }))
)
const resourceOptions = computed(() =>
meta.dataResources.map((r) => ({ value: r.code, label: r.name }))
)
function emitStats() {
emit('stats-change', {
org: form.orgPerms.length,
scope: form.dataScopes.length,
biz: form.bizPerms.length,
secret: form.secretPerms.length,
filter: form.filterRules.length,
})
}
function convertOrgTree(data: OrganizationTreeDto[]): typeof orgTree.value {
return data.map((item) => ({
key: item.id!,
title: item.name!,
value: item.id!,
children: item.children?.length ? convertOrgTree(item.children) : undefined,
}))
}
function mapRows<T>(rows: T[], prefix: string) {
return (rows || []).map((r, i) => ({ ...r, _key: (r as any).id || `${prefix}-${i}` }))
}
function toSavePayload(): SaveUserDataPermInput {
const strip = <T extends Record<string, unknown>>(rows: T[]) =>
rows.map(({ _key, _bizOptions, _loading, isExpired, organizationName, id, ...rest }) => rest)
return {
orgPerms: strip(form.orgPerms),
dataScopes: strip(form.dataScopes),
bizPerms: strip(form.bizPerms),
secretPerms: strip(form.secretPerms),
filterRules: strip(form.filterRules).map((r) => ({
...r,
filterContent: {
...((r as any).filterContent || { logic: 'and', conditions: [] }),
conditions: ((r as any).filterContent?.conditions || []).map((c: any) => ({
...c,
value: parseFilterValue(c.op, c.value),
})),
},
})),
}
}
function parseFilterValue(op: string, raw: unknown) {
if (raw == null || raw === '') return raw
if (typeof raw !== 'string') return raw
if (op === 'in' || op === 'notIn' || op === 'between') {
return raw.split(',').map((s) => s.trim())
}
return raw
}
async function loadMeta() {
if (metaLoaded.value) return
try {
const m = await getDataPermMeta()
Object.assign(meta, m)
} catch {
/* 使用 constants 兜底 */
}
try {
const tree = await OrganizationService.getRbacOrganizationGetTree()
orgTree.value = convertOrgTree(tree || [])
} catch {
orgTree.value = []
}
metaLoaded.value = true
}
async function loadPerm() {
if (!props.targetUser?.id) return
loading.value = true
try {
const dto = await getUserDataPerm(props.targetUser.id)
permDto.value = dto
form.orgPerms = mapRows(dto.orgPerms, 'org')
form.dataScopes = mapRows(dto.dataScopes, 'scope')
form.bizPerms = mapRows(dto.bizPerms, 'biz')
form.secretPerms = mapRows(dto.secretPerms, 'secret')
form.filterRules = mapRows(dto.filterRules, 'filter')
emitStats()
} catch (e) {
Object.assign(form, emptySaveInput())
emitStats()
handleApiError(e)
} finally {
loading.value = false
}
}
async function reload() {
await loadPerm()
bannerRef.value?.reload()
}
async function handleSave() {
if (!props.targetUser?.id) return
if (props.targetUser.id === rbacStore.userInfo?.id) {
Message.warning(t('system.dataPerm.panel.cannotEditSelf'))
return
}
saving.value = true
try {
await saveUserDataPerm(props.targetUser.id, toSavePayload())
Message.success(t('system.dataPerm.templates.updateSuccess'))
emit('saved')
await reload()
} catch (e) {
handleApiError(e)
} finally {
saving.value = false
}
}
function onApplySuccess() {
reload()
}
watch(
() => [
form.orgPerms.length,
form.dataScopes.length,
form.bizPerms.length,
form.secretPerms.length,
form.filterRules.length,
],
() => emitStats()
)
watch(
() => props.targetUser?.id,
async (userId) => {
if (!userId) {
permDto.value = null
Object.assign(form, emptySaveInput())
emitStats()
return
}
activeTab.value = 'org'
await loadMeta()
await loadPerm()
},
{ immediate: true }
)
onMounted(() => {
loadMeta()
})
defineExpose({ permDto, loading })
</script>
<style scoped lang="less">
@import '@/components/RbacSplit/rbac-split-layout.less';
.data-perm-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
&--embedded {
height: auto;
overflow: visible;
:deep(.shell-body) {
padding: 0;
}
}
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-3);
font-size: 14px;
}
.scope-card {
margin-bottom: 0;
padding: 12px 16px;
}
.tab-pane {
min-height: 280px;
}
.inline-tabs {
display: flex;
align-items: stretch;
border-bottom: 1px solid var(--color-border-2);
margin-bottom: 16px;
overflow-x: auto;
}
.inline-tab-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 12px 16px;
cursor: pointer;
border-bottom: 3px solid transparent;
font-size: 14px;
color: var(--color-text-2);
white-space: nowrap;
transition: color 0.2s, border-color 0.2s, background 0.2s;
user-select: none;
&:hover {
color: var(--app-primary-color);
background: var(--color-fill-1);
}
&.active {
color: var(--app-primary-color);
font-weight: 600;
border-bottom-color: var(--app-primary-color);
}
}
.inline-tab-badge :deep(.arco-badge-number) {
background: var(--color-fill-3);
color: var(--color-text-2);
box-shadow: none;
}
.inline-tab-item.active .inline-tab-badge :deep(.arco-badge-number) {
background: rgba(var(--primary-6), 0.12);
color: var(--app-primary-color);
}
</style>

View File

@ -0,0 +1,94 @@
<template>
<div class="data-scope-panel">
<div class="panel-toolbar">
<a-button type="primary" size="small" @click="addRow">
<template #icon><icon-plus /></template>
添加范围
</a-button>
<span class="hint">无配置时运行时默认为「个人级」</span>
</div>
<a-table :data="modelValue" :pagination="false" size="small" row-key="_key">
<template #columns>
<a-table-column title="数据范围" :width="160">
<template #cell="{ record }">
<a-select v-model="record.dataScope" :options="dataScopeOptions" />
</template>
</a-table-column>
<a-table-column title="适用模块">
<template #cell="{ record }">
<a-select
v-model="record.dataResourceCode"
:options="resourceOptions"
placeholder="全局(全部模块)"
allow-clear
/>
</template>
</a-table-column>
<a-table-column title="过期时间" :width="180">
<template #cell="{ record }">
<a-date-picker
v-model="record.expireTime"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
placeholder="永久"
allow-clear
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column title="操作" :width="70" align="center">
<template #cell="{ rowIndex }">
<a-button type="text" status="danger" size="mini" @click="removeRow(rowIndex)">删除</a-button>
</template>
</a-table-column>
</template>
</a-table>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import type { DataScopeItem } from '@/api/rbac/data-perm'
type Row = DataScopeItem & { _key: string }
const props = defineProps<{
modelValue: Row[]
dataScopeOptions: { value: number; label: string }[]
dataResources: { code: string; name: string }[]
}>()
const emit = defineEmits<{ 'update:modelValue': [Row[]] }>()
const resourceOptions = computed(() =>
props.dataResources.map((r) => ({ value: r.code, label: r.name }))
)
function addRow() {
emit('update:modelValue', [
...props.modelValue,
{ _key: `scope-${Date.now()}`, dataScope: 1, dataResourceCode: null, expireTime: null },
])
}
function removeRow(index: number) {
const next = [...props.modelValue]
next.splice(index, 1)
emit('update:modelValue', next)
}
</script>
<style scoped>
.panel-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.hint {
color: var(--color-text-3);
font-size: 12px;
}
</style>

View File

@ -0,0 +1,45 @@
<template>
<a-alert v-if="loading" type="info">正在加载生效范围…</a-alert>
<a-alert v-else-if="effective?.isAdmin" type="success">
{{ effective.summaryText || '超级管理员,无数据限制' }}
</a-alert>
<a-alert v-else type="normal">
<template #title>当前生效范围(预览)</template>
{{ effective?.summaryText || '—' }}
<template v-if="effective?.secretLevels?.length" #action>
<a-tag v-for="lv in effective.secretLevels" :key="lv" size="small">密级 {{ lv }}</a-tag>
</template>
</a-alert>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getEffectiveDataScope, type EffectiveDataScopeDto } from '@/api/rbac/data-perm'
const props = defineProps<{
userId?: string
dataResourceCode?: string
}>()
const loading = ref(false)
const effective = ref<EffectiveDataScopeDto | null>(null)
async function load() {
if (!props.userId) {
effective.value = null
return
}
loading.value = true
try {
effective.value = await getEffectiveDataScope(props.userId, props.dataResourceCode || 'Order')
} catch {
effective.value = null
} finally {
loading.value = false
}
}
watch(() => [props.userId, props.dataResourceCode], load, { immediate: true })
defineExpose({ reload: load })
</script>

View File

@ -0,0 +1,121 @@
<template>
<div class="filter-rule-panel">
<a-tabs v-model:active-key="activeResource" type="rounded">
<a-tab-pane v-for="res in dataResources" :key="res.code" :title="res.name">
<div class="panel-toolbar">
<a-button type="primary" size="small" @click="addCondition(res.code)">
<template #icon><icon-plus /></template>
添加条件
</a-button>
<span class="hint">条件之间 AND;保存时由后端校验字段白名单</span>
</div>
<a-empty v-if="!getRule(res.code).conditions.length" description="暂无过滤条件" />
<div v-for="(cond, idx) in getRule(res.code).conditions" :key="idx" class="cond-row">
<a-select
v-model="cond.field"
:options="fieldOptions(res.code)"
placeholder="字段"
style="width: 160px"
/>
<a-select v-model="cond.op" :options="operatorOptions" style="width: 120px" />
<a-input v-model="cond.value" placeholder="值(in/between 用逗号分隔)" style="flex: 1" />
<a-button type="text" status="danger" @click="removeCondition(res.code, idx)">删除</a-button>
</div>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import type { FilterRuleItem, FilterContent, DataResourceMeta } from '@/api/rbac/data-perm'
import { FILTER_OPERATORS, emptyFilterContent } from '../constants'
type Row = FilterRuleItem & { _key: string }
const props = defineProps<{
modelValue: Row[]
dataResources: DataResourceMeta[]
}>()
const emit = defineEmits<{ 'update:modelValue': [Row[]] }>()
const activeResource = ref(props.dataResources[0]?.code || 'Order')
const operatorOptions = FILTER_OPERATORS.map((op) => ({ value: op, label: op }))
watch(
() => props.dataResources,
(list) => {
if (list?.length && !list.find((r) => r.code === activeResource.value)) {
activeResource.value = list[0].code
}
},
{ immediate: true }
)
function findRuleIndex(code: string) {
return props.modelValue.findIndex((r) => r.dataResourceCode === code)
}
function getRule(code: string): FilterContent {
const row = props.modelValue.find((r) => r.dataResourceCode === code)
return row?.filterContent || emptyFilterContent()
}
function fieldOptions(code: string) {
const res = props.dataResources.find((r) => r.code === code)
return (res?.filterableFields || ['CreateTime', 'OrderStatus']).map((f) => ({ value: f, label: f }))
}
function upsertRule(code: string, content: FilterContent) {
const next = [...props.modelValue]
const idx = findRuleIndex(code)
if (idx >= 0) {
next[idx] = { ...next[idx], filterContent: content }
} else if (content.conditions.length) {
next.push({
_key: `filter-${code}`,
dataResourceCode: code,
filterContent: content,
expireTime: null,
})
}
emit(
'update:modelValue',
next.filter((r) => r.filterContent?.conditions?.length)
)
}
function addCondition(code: string) {
const content = { ...getRule(code), conditions: [...getRule(code).conditions] }
content.conditions.push({ field: '', op: 'eq', value: '' })
upsertRule(code, content)
}
function removeCondition(code: string, index: number) {
const content = { ...getRule(code), conditions: [...getRule(code).conditions] }
content.conditions.splice(index, 1)
upsertRule(code, content)
}
</script>
<style scoped>
.panel-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.hint {
color: var(--color-text-3);
font-size: 12px;
}
.cond-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
</style>

View File

@ -0,0 +1,95 @@
<template>
<div class="org-perm-panel">
<div class="panel-toolbar">
<a-button type="primary" size="small" @click="addRow">
<template #icon><icon-plus /></template>
添加组织
</a-button>
</div>
<a-table :data="modelValue" :pagination="false" size="small" row-key="_key">
<template #columns>
<a-table-column title="组织" data-index="organizationId">
<template #cell="{ record }">
<a-tree-select
v-model="record.organizationId"
:data="orgTree"
placeholder="选择组织"
allow-search
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column title="范围" :width="140">
<template #cell="{ record }">
<a-select v-model="record.orgScope" :options="orgScopeOptions" />
</template>
</a-table-column>
<a-table-column title="过期时间" :width="180">
<template #cell="{ record }">
<a-date-picker
v-model="record.expireTime"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
placeholder="永久"
allow-clear
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column title="临时" :width="70" align="center">
<template #cell="{ record }">
<a-checkbox v-model="record.isTemporary" />
</template>
</a-table-column>
<a-table-column title="操作" :width="70" align="center">
<template #cell="{ rowIndex }">
<a-button type="text" status="danger" size="mini" @click="removeRow(rowIndex)">
删除
</a-button>
</template>
</a-table-column>
</template>
</a-table>
</div>
</template>
<script setup lang="ts">
import { IconPlus } from '@arco-design/web-vue/es/icon'
import type { OrgPermItem } from '@/api/rbac/data-perm'
type Row = OrgPermItem & { _key: string }
const props = defineProps<{
modelValue: Row[]
orgTree: { key: string; title: string; value: string; children?: unknown[] }[]
orgScopeOptions: { value: number; label: string }[]
}>()
const emit = defineEmits<{ 'update:modelValue': [Row[]] }>()
function addRow() {
emit('update:modelValue', [
...props.modelValue,
{
_key: `org-${Date.now()}`,
organizationId: '',
orgScope: 2,
expireTime: null,
isTemporary: false,
},
])
}
function removeRow(index: number) {
const next = [...props.modelValue]
next.splice(index, 1)
emit('update:modelValue', next)
}
</script>
<style scoped>
.panel-toolbar {
margin-bottom: 12px;
}
</style>

View File

@ -0,0 +1,94 @@
<template>
<div class="secret-perm-panel">
<div class="panel-toolbar">
<a-button type="primary" size="small" @click="addRow">
<template #icon><icon-plus /></template>
添加密级
</a-button>
<span class="hint">行级过滤 + 字段脱敏;无配置时默认公开+内部</span>
</div>
<a-table :data="modelValue" :pagination="false" size="small" row-key="_key">
<template #columns>
<a-table-column title="密级" :width="140">
<template #cell="{ record }">
<a-select v-model="record.secretLevel" :options="secretLevelOptions" />
</template>
</a-table-column>
<a-table-column title="适用模块">
<template #cell="{ record }">
<a-select
v-model="record.dataResourceCode"
:options="resourceOptions"
placeholder="全局"
allow-clear
/>
</template>
</a-table-column>
<a-table-column title="过期时间" :width="180">
<template #cell="{ record }">
<a-date-picker
v-model="record.expireTime"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
placeholder="永久"
allow-clear
style="width: 100%"
/>
</template>
</a-table-column>
<a-table-column title="操作" :width="70" align="center">
<template #cell="{ rowIndex }">
<a-button type="text" status="danger" size="mini" @click="removeRow(rowIndex)">删除</a-button>
</template>
</a-table-column>
</template>
</a-table>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import type { SecretPermItem } from '@/api/rbac/data-perm'
type Row = SecretPermItem & { _key: string }
const props = defineProps<{
modelValue: Row[]
secretLevelOptions: { value: number; label: string }[]
dataResources: { code: string; name: string }[]
}>()
const emit = defineEmits<{ 'update:modelValue': [Row[]] }>()
const resourceOptions = computed(() =>
props.dataResources.map((r) => ({ value: r.code, label: r.name }))
)
function addRow() {
emit('update:modelValue', [
...props.modelValue,
{ _key: `secret-${Date.now()}`, secretLevel: 2, dataResourceCode: null, expireTime: null },
])
}
function removeRow(index: number) {
const next = [...props.modelValue]
next.splice(index, 1)
emit('update:modelValue', next)
}
</script>
<style scoped>
.panel-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.hint {
color: var(--color-text-3);
font-size: 12px;
}
</style>

View File

@ -0,0 +1,44 @@
/** 前端兜底枚举(meta 接口失败时使用) */
export const DEFAULT_ORG_SCOPES = [
{ value: 1, label: '仅本级' },
{ value: 2, label: '本级+下级' },
]
export const DEFAULT_DATA_SCOPES = [
{ value: 1, label: '个人级' },
{ value: 2, label: '小组级' },
{ value: 3, label: '部门级' },
{ value: 4, label: '组织树级' },
{ value: 5, label: '全局级' },
]
export const DEFAULT_SECRET_LEVELS = [
{ value: 1, label: '公开' },
{ value: 2, label: '内部' },
{ value: 3, label: '机密' },
{ value: 4, label: '绝密' },
]
export const DEFAULT_BIZ_TYPES = [
{ value: 'product_line', label: '产品线' },
{ value: 'kpi_area', label: 'KPI分区' },
{ value: 'customer_group', label: '客户分层' },
{ value: 'warehouse', label: '仓库' },
{ value: 'region', label: '区域' },
]
export const FILTER_OPERATORS = ['eq', 'ne', 'in', 'notIn', 'gt', 'gte', 'lt', 'lte', 'between', 'like']
export function emptySaveInput() {
return {
orgPerms: [] as any[],
dataScopes: [] as any[],
bizPerms: [] as any[],
secretPerms: [] as any[],
filterRules: [] as any[],
}
}
export function emptyFilterContent() {
return { logic: 'and' as const, conditions: [] as { field: string; op: string; value?: unknown }[] }
}

View File

@ -0,0 +1,549 @@
<template>
<div class="rbac-split-layout">
<CustomLeftMenu
class="left-sidebar"
:tree-data="templateTreeData"
:selected-keys="selectedKeys"
v-model:expanded-keys="expandedKeys"
v-model:search-keyword="searchKeyword"
:loading="loading"
:search-placeholder="t('system.dataPerm.templates.search')"
:refresh-title="t('system.user.actions.refresh')"
:add-title="t('system.dataPerm.templates.add')"
:delete-label="t('system.role.actions.delete')"
:loading-text="t('system.dataPerm.templates.loading')"
:show-add="hasPermission('Rbac.DataPerm.Template')"
:show-node-actions="hasPermission('Rbac.DataPerm.Template')"
:show-method="false"
:show-map-count="false"
:show-node-code="true"
:show-pagination="true"
:page-size="20"
@search="handleSearch"
@refresh="loadData"
@add="openCreate"
@select="handleTemplateSelect"
@node-click="handleTemplateNodeClick"
@delete-node="handleDeleteNode"
>
<template #node-second-row-extra="{ nodeData }">
<span v-if="nodeData?.isBuiltIn" class="built-in-tag">内置</span>
</template>
</CustomLeftMenu>
<div class="right-panel">
<RbacDetailShell
:empty="!isEditing"
:empty-title="t('system.dataPerm.templates.emptySelect')"
:empty-desc="t('system.dataPerm.templates.emptyDesc')"
:title="panelTitle"
:subtitle="isCreating ? '' : form.code"
:tags="templateTags"
:stats="templateStats"
:tabs="templateTabs"
v-model:active-tab="activeTab"
:loading="detailLoading"
>
<template #icon>
<div class="icon-badge-circle">
<icon-file />
</div>
</template>
<template #actions>
<a-space>
<a-button
v-if="!isCreating && hasPermission('Rbac.DataPerm.Template')"
@click="openApply"
>
{{ t('system.dataPerm.templates.apply') }}
</a-button>
<a-button
v-if="hasPermission('Rbac.DataPerm.Template')"
type="primary"
:loading="formLoading"
@click="handleSave"
>
{{ t('system.dataPerm.templates.save') }}
</a-button>
</a-space>
</template>
<div v-show="activeTab === 'basic'" class="section-card">
<h3 class="section-card-title">{{ t('system.dataPerm.templates.tab.basic') }}</h3>
<a-form :model="form" layout="vertical" class="template-form">
<a-row :gutter="20">
<a-col :span="12">
<a-form-item :label="t('system.dataPerm.templates.field.name')" required>
<a-input v-model="form.name" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.dataPerm.templates.field.code')" required>
<a-input v-model="form.code" :disabled="!isCreating && form.isBuiltIn" />
</a-form-item>
</a-col>
</a-row>
<a-form-item :label="t('system.dataPerm.templates.field.description')">
<a-textarea v-model="form.description" :auto-size="{ minRows: 2 }" />
</a-form-item>
<a-form-item :label="t('system.dataPerm.templates.field.roleCode')">
<a-input v-model="form.roleCode" :placeholder="t('system.dataPerm.templates.roleCodePlaceholder')" />
</a-form-item>
</a-form>
</div>
<div v-show="activeTab === 'items'">
<div class="sticky-toolbar">
<span class="items-hint">{{ t('system.dataPerm.templates.itemsHint') }}</span>
<a-button type="dashed" @click="addItem">
<template #icon><icon-plus /></template>
{{ t('system.dataPerm.templates.addItem') }}
</a-button>
</div>
<div v-if="form.items.length === 0" class="items-empty section-card">
<p>{{ t('system.dataPerm.templates.itemsEmpty') }}</p>
<a-button type="primary" @click="addItem">{{ t('system.dataPerm.templates.addItem') }}</a-button>
</div>
<div v-for="(item, idx) in form.items" :key="idx" class="item-card section-card">
<div class="item-card-head">
<div class="item-card-head-left">
<span class="item-index">#{{ idx + 1 }}</span>
<a-tag :color="dimensionColor(item.dimensionType)" size="small">
{{ dimensionLabel(item.dimensionType) }}
</a-tag>
</div>
<a-space>
<a-dropdown @select="(v: string) => applyJsonPreset(idx, v)">
<a-button size="mini" type="text">{{ t('system.dataPerm.templates.jsonPreset') }}</a-button>
<template #content>
<a-doption v-for="preset in jsonPresets" :key="preset.key" :value="preset.key">
{{ preset.label }}
</a-doption>
</template>
</a-dropdown>
<a-button type="text" status="danger" size="mini" @click="form.items.splice(idx, 1)">
{{ t('system.dataPerm.templates.removeItem') }}
</a-button>
</a-space>
</div>
<div class="item-card-body">
<a-select
v-model="item.dimensionType"
:options="dimensionOptions"
style="width: 160px"
:placeholder="t('system.dataPerm.templates.dimensionPlaceholder')"
/>
<a-textarea
v-model="item.itemContentJson"
:auto-size="{ minRows: 3, maxRows: 8 }"
:placeholder="t('system.dataPerm.templates.itemJsonPlaceholder')"
class="item-json-input"
/>
</div>
</div>
</div>
</RbacDetailShell>
</div>
<ApplyTemplateModal
v-model:visible="applyVisible"
target-type="user"
:target-ids="[]"
:default-template-id="applyTemplateId"
allow-pick-targets
@success="loadData"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import { IconFile, IconPlus } from '@arco-design/web-vue/es/icon'
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
import RbacDetailShell from '@/components/RbacSplit/RbacDetailShell.vue'
import type { RbacDetailTag } from '@/components/RbacSplit/RbacDetailShell.vue'
import { hasPermission } from '@/utils/rbac-permission'
import { handleApiError } from '@/utils/rbac-error-handler'
import { buildFlatLeftMenuTree, findLeftMenuItemByKey } from '@/utils/rbacLeftMenuTree'
import {
createTemplate,
deleteTemplate,
getTemplateDetail,
getTemplateList,
updateTemplate,
type DataPermTemplateListItem,
} from '@/api/rbac/data-perm'
import ApplyTemplateModal from '../components/ApplyTemplateModal.vue'
const { t } = useI18n()
const loading = ref(false)
const detailLoading = ref(false)
const formLoading = ref(false)
const dataSource = ref<DataPermTemplateListItem[]>([])
const searchKeyword = ref('')
const selectedTemplate = ref<DataPermTemplateListItem | null>(null)
const isCreating = ref(false)
const expandedKeys = ref(['category-templates'])
const activeTab = ref('basic')
const pagination = reactive({ current: 1, pageSize: 100, total: 0 })
const applyVisible = ref(false)
const applyTemplateId = ref('')
const dimensionOptions = computed(() => [
{ value: 'org', label: t('system.dataPerm.panel.tab.org') },
{ value: 'data_scope', label: t('system.dataPerm.panel.tab.scope') },
{ value: 'biz', label: t('system.dataPerm.panel.tab.biz') },
{ value: 'secret', label: t('system.dataPerm.panel.tab.secret') },
{ value: 'filter', label: t('system.dataPerm.panel.tab.filter') },
])
const jsonPresets = [
{ key: 'dataScopeSelf', label: 'dataScope: 仅本人', json: '{"dataScope":1}' },
{ key: 'dataScopeDept', label: 'dataScope: 本部门', json: '{"dataScope":2}' },
{ key: 'dataScopeAll', label: 'dataScope: 全部', json: '{"dataScope":99}' },
{ key: 'orgScope', label: '组织范围', json: '{"orgScope":1,"organizationIds":[]}' },
]
const form = reactive({
name: '',
code: '',
description: '',
roleCode: '',
isBuiltIn: false,
items: [] as { dimensionType: string; itemContentJson: string; sortOrder: number }[],
})
const isEditing = computed(() => isCreating.value || !!selectedTemplate.value)
const panelTitle = computed(() =>
isCreating.value
? t('system.dataPerm.templates.add')
: form.name || t('system.dataPerm.templates.title')
)
const templateTags = computed((): RbacDetailTag[] => {
const tags: RbacDetailTag[] = []
if (isCreating.value) {
tags.push({ label: t('system.dataPerm.templates.tag.creating'), color: 'orangered' })
} else if (form.isBuiltIn) {
tags.push({ label: t('system.dataPerm.templates.tag.builtin'), color: 'arcoblue' })
}
if (form.roleCode) {
tags.push({ label: form.roleCode, color: 'purple' })
}
return tags
})
const templateStats = computed(() => [
{ label: t('system.dataPerm.templates.stat.items'), value: form.items.length },
{
label: t('system.dataPerm.templates.stat.dimensions'),
value: new Set(form.items.map((i) => i.dimensionType)).size,
},
])
const templateTabs = computed(() => [
{ key: 'basic', label: t('system.dataPerm.templates.tab.basic') },
{
key: 'items',
label: t('system.dataPerm.templates.tab.items'),
badge: form.items.length || undefined,
},
])
const templateTreeData = computed(() =>
buildFlatLeftMenuTree('category-templates', t('system.dataPerm.templates.title'), dataSource.value, {
titleKey: 'name',
codeKey: 'code',
}).map((group) => ({
...group,
children: group.children?.map((node) => {
const tpl = dataSource.value.find((x) => x.id === node.id)
return { ...node, isBuiltIn: tpl?.isBuiltIn, code: tpl?.code || node.code }
}),
}))
)
const selectedKeys = computed(() => {
if (isCreating.value) return []
return selectedTemplate.value?.id ? [`item-${selectedTemplate.value.id}`] : []
})
function dimensionLabel(type: string) {
return dimensionOptions.value.find((o) => o.value === type)?.label || type
}
function dimensionColor(type: string) {
const map: Record<string, string> = {
org: 'blue',
data_scope: 'green',
biz: 'orange',
secret: 'red',
filter: 'purple',
}
return map[type] || 'gray'
}
function resetForm() {
Object.assign(form, {
name: '',
code: '',
description: '',
roleCode: '',
isBuiltIn: false,
items: [{ dimensionType: 'data_scope', itemContentJson: '{"dataScope":1}', sortOrder: 1 }],
})
}
function addItem() {
form.items.push({
dimensionType: 'data_scope',
itemContentJson: '{"dataScope":1}',
sortOrder: form.items.length + 1,
})
}
function applyJsonPreset(idx: number, presetKey: string) {
const preset = jsonPresets.find((p) => p.key === presetKey)
if (preset && form.items[idx]) {
form.items[idx].itemContentJson = preset.json
}
}
function selectTemplateFromKey(key: string) {
const tpl = findLeftMenuItemByKey(key, dataSource.value)
if (tpl) {
isCreating.value = false
selectedTemplate.value = tpl
}
}
function handleTemplateSelect(keys: string[]) {
const key = keys[0]
if (key) selectTemplateFromKey(key)
}
function handleTemplateNodeClick(key: string) {
selectTemplateFromKey(key)
}
async function loadData() {
loading.value = true
try {
const res = await getTemplateList({
keyword: searchKeyword.value || undefined,
page: pagination.current,
pageSize: pagination.pageSize,
})
dataSource.value = res.items || []
pagination.total = res.total || 0
if (selectedTemplate.value?.id) {
const stillExists = dataSource.value.find((x) => x.id === selectedTemplate.value?.id)
selectedTemplate.value = stillExists || null
}
if (!isCreating.value && !selectedTemplate.value && dataSource.value.length > 0) {
selectedTemplate.value = dataSource.value[0]
}
} catch (e) {
handleApiError(e)
} finally {
loading.value = false
}
}
async function loadDetail(id: string) {
detailLoading.value = true
try {
const detail = await getTemplateDetail(id)
Object.assign(form, {
name: detail.name,
code: detail.code,
description: detail.description || '',
roleCode: detail.roleCode || '',
isBuiltIn: detail.isBuiltIn,
items: (detail.items || []).map((it, i) => ({
dimensionType: it.dimensionType,
itemContentJson: JSON.stringify(it.itemContent, null, 2),
sortOrder: it.sortOrder ?? i + 1,
})),
})
} catch (e) {
handleApiError(e)
} finally {
detailLoading.value = false
}
}
function handleSearch() {
pagination.current = 1
loadData()
}
function openCreate() {
isCreating.value = true
selectedTemplate.value = null
activeTab.value = 'basic'
resetForm()
}
function openApply() {
if (!selectedTemplate.value?.id) return
applyTemplateId.value = selectedTemplate.value.id
applyVisible.value = true
}
async function handleSave() {
if (!form.name?.trim() || !form.code?.trim()) {
Message.warning(t('system.dataPerm.templates.nameCodeRequired'))
return
}
formLoading.value = true
try {
const items = form.items.map((it, i) => ({
dimensionType: it.dimensionType,
itemContent: JSON.parse(it.itemContentJson || '{}'),
sortOrder: i + 1,
}))
const body = {
name: form.name,
code: form.code,
description: form.description,
roleCode: form.roleCode || undefined,
items,
}
if (isCreating.value) {
const created = await createTemplate(body)
Message.success(t('system.dataPerm.templates.createSuccess'))
isCreating.value = false
await loadData()
if (created?.id) {
selectedTemplate.value = dataSource.value.find((x) => x.id === created.id) || null
}
} else if (selectedTemplate.value?.id) {
await updateTemplate(selectedTemplate.value.id, body)
Message.success(t('system.dataPerm.templates.updateSuccess'))
await loadData()
}
} catch (e: any) {
if (e instanceof SyntaxError) {
Message.error(t('system.dataPerm.templates.invalidJson'))
return
}
handleApiError(e)
} finally {
formLoading.value = false
}
}
function handleDeleteNode(nodeData: { id?: string; isBuiltIn?: boolean; name?: string }) {
if (!nodeData?.id) return
if (nodeData.isBuiltIn) {
Message.warning(t('system.dataPerm.templates.builtinDeleteDenied'))
return
}
Modal.confirm({
content: t('system.dataPerm.templates.deleteConfirm', { name: nodeData.name || '' }),
onOk: async () => {
try {
await deleteTemplate(nodeData.id!)
Message.success(t('system.dataPerm.templates.deleteSuccess'))
if (selectedTemplate.value?.id === nodeData.id) {
selectedTemplate.value = null
isCreating.value = false
}
await loadData()
} catch (e) {
handleApiError(e)
}
},
})
}
watch(
() => selectedTemplate.value?.id,
async (id) => {
if (!id || isCreating.value) return
activeTab.value = 'basic'
await loadDetail(id)
}
)
onMounted(loadData)
</script>
<style scoped lang="less">
@import '@/components/RbacSplit/rbac-split-layout.less';
.built-in-tag {
font-size: 10px;
color: rgba(255, 255, 255, 0.65);
margin-left: 4px;
}
.template-form {
max-width: 960px;
}
.items-hint {
font-size: 13px;
color: var(--color-text-3);
}
.items-empty {
text-align: center;
padding: 40px 24px;
color: var(--color-text-3);
p {
margin: 0 0 16px;
}
}
.item-card {
margin-bottom: 12px;
padding: 0;
overflow: hidden;
}
.item-card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--color-fill-1);
border-bottom: 1px solid var(--color-border-2);
}
.item-card-head-left {
display: flex;
align-items: center;
gap: 10px;
}
.item-index {
font-size: 12px;
font-weight: 600;
color: var(--color-text-3);
}
.item-card-body {
display: flex;
gap: 12px;
padding: 16px;
align-items: flex-start;
}
.item-json-input {
flex: 1;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
</style>

View File

@ -1,86 +1,85 @@
<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">
<div class="rbac-split-layout">
<CustomLeftMenu
class="left-sidebar"
:tree-data="userTreeData"
:selected-keys="selectedKeys"
v-model:expanded-keys="expandedKeys"
v-model:search-keyword="searchKeyword"
:loading="loading"
:search-placeholder="t('system.user.search.accountOrNamePlaceholder')"
:refresh-title="t('system.user.actions.refresh')"
:loading-text="t('system.dataPerm.users.loading')"
:show-add="false"
:show-node-actions="false"
:show-method="false"
:show-map-count="false"
:show-node-code="true"
:show-pagination="true"
:page-size="30"
@search="handleSearch"
@refresh="loadData"
@select="handleUserSelect"
@node-click="handleUserNodeClick"
/>
<div class="right-panel">
<RbacDetailShell
:empty="!selectedUser"
:empty-title="t('system.dataPerm.users.emptySelect')"
:empty-desc="t('system.dataPerm.users.emptyDesc')"
:title="selectedUser?.name || ''"
:subtitle="selectedUser?.account || ''"
:tags="userTags"
:stats="userStats"
>
<template #icon>
<div class="avatar-circle">{{ userInitials }}</div>
</template>
<template #actions>
<a-tree-select
v-model="filterOrganizationId"
:placeholder="t('system.user.search.organizationPlaceholder')"
:data="organizationTreeData"
allow-clear
style="width: 180px"
style="width: 200px"
@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>
</template>
<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"
<DataPermPanel
embedded
:target-user="selectedUser"
@saved="loadData"
@stats-change="onStatsChange"
/>
</RbacDetailShell>
</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 { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { IconRefresh } from '@arco-design/web-vue/es/icon'
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
import RbacDetailShell from '@/components/RbacSplit/RbacDetailShell.vue'
import type { RbacDetailStat, RbacDetailTag } from '@/components/RbacSplit/RbacDetailShell.vue'
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'
import { buildFlatLeftMenuTree, findLeftMenuItemByKey } from '@/utils/rbacLeftMenuTree'
import DataPermPanel from '../components/DataPermPanel.vue'
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
const loading = ref(false)
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 selectedUser = ref<UserDto | null>(null)
const expandedKeys = ref(['category-users'])
const permCounts = ref({ org: 0, scope: 0, biz: 0, secret: 0, filter: 0 })
const organizationTreeData = computed(() => {
const convert = (data: OrganizationTreeDto[]): any[] =>
@ -93,30 +92,72 @@ const organizationTreeData = computed(() => {
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 userTreeData = computed(() =>
buildFlatLeftMenuTree('category-users', t('system.dataPerm.users.title'), dataSource.value, {
titleKey: 'name',
codeKey: 'account',
})
)
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>'
const selectedKeys = computed(() =>
selectedUser.value?.id ? [`item-${selectedUser.value.id}`] : []
)
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>`
const userInitials = computed(() => {
const name = selectedUser.value?.name || selectedUser.value?.account || '?'
return name.slice(0, 1).toUpperCase()
})
const userTags = computed((): RbacDetailTag[] => {
if (!selectedUser.value) return []
const tags: RbacDetailTag[] = [
{ label: t('system.dataPerm.panel.configTypeDirect'), color: 'arcoblue' },
]
selectedUser.value.roles?.slice(0, 3).forEach((role) => {
if (role.name) tags.push({ label: role.name, color: 'gray' })
})
if ((selectedUser.value.roles?.length || 0) > 3) {
tags.push({ label: `+${(selectedUser.value.roles?.length || 0) - 3}`, color: 'gray' })
}
return tags
})
const userStats = computed((): RbacDetailStat[] => {
const total =
permCounts.value.org +
permCounts.value.scope +
permCounts.value.biz +
permCounts.value.secret +
permCounts.value.filter
return [
{ label: t('system.dataPerm.panel.stat.total'), value: total },
{ label: t('system.dataPerm.panel.tab.org'), value: permCounts.value.org },
{ label: t('system.dataPerm.panel.tab.scope'), value: permCounts.value.scope },
]
})
function onStatsChange(counts: Record<string, number>) {
permCounts.value = {
org: counts.org || 0,
scope: counts.scope || 0,
biz: counts.biz || 0,
secret: counts.secret || 0,
filter: counts.filter || 0,
}
}
function handleActionClick(row: UserDto) {
currentUser.value = row
drawerVisible.value = true
function selectUserFromKey(key: string) {
const user = findLeftMenuItemByKey(key, dataSource.value)
if (user) selectedUser.value = user
}
function handleUserSelect(keys: string[]) {
const key = keys[0]
if (key) selectUserFromKey(key)
}
function handleUserNodeClick(key: string) {
selectUserFromKey(key)
}
async function loadOrganizationTree() {
@ -128,91 +169,38 @@ async function loadOrganizationTree() {
}
async function loadData() {
loading.value = true
try {
const res = await getInternalUserList({
keyword: searchKeyword.value || undefined,
organizationId: filterOrganizationId.value,
page: pagination.current,
pageSize: pagination.pageSize,
page: 1,
pageSize: 200,
})
dataSource.value = res?.items || []
pagination.total = res?.total || 0
if (selectedUser.value?.id) {
const stillExists = dataSource.value.find((u) => u.id === selectedUser.value?.id)
selectedUser.value = stillExists || dataSource.value[0] || null
} else if (dataSource.value.length > 0) {
selectedUser.value = dataSource.value[0]
}
} catch (e) {
handleApiError(e)
} finally {
loading.value = false
}
}
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()
onMounted(async () => {
await loadOrganizationTree()
await 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 scoped lang="less">
@import '@/components/RbacSplit/rbac-split-layout.less';
</style>

View File

@ -1,79 +1,90 @@
<template>
<div class="role-page">
<div class="role-page-card">
<div class="page-header">
<div class="rbac-split-layout">
<CustomLeftMenu
class="left-sidebar"
:tree-data="roleTreeData"
:selected-keys="selectedKeys"
v-model:expanded-keys="expandedKeys"
v-model:search-keyword="searchKeyword"
:loading="loading"
:search-placeholder="t('system.role.search.placeholder.roleNameOrCode')"
:refresh-title="t('system.user.actions.refresh')"
:add-title="t('system.role.actions.add')"
:delete-label="t('system.role.actions.delete')"
:loading-text="t('system.role.loading')"
:show-add="hasPermission('Rbac.Roles.Create')"
:show-node-actions="hasPermission('Rbac.Roles.Delete')"
:show-method="false"
:show-map-count="false"
:show-node-code="true"
:show-pagination="true"
:page-size="20"
@search="handleSearch"
@refresh="loadData"
@add="handleAdd"
@select="handleRoleSelect"
@node-click="handleRoleNodeClick"
@delete-node="(node) => handleDelete(node?.id)"
/>
<div class="right-panel">
<RbacDetailShell
:empty="!selectedRole"
:empty-title="t('system.role.emptySelect')"
:empty-desc="t('system.role.emptyDesc')"
:title="selectedRole?.name || ''"
:subtitle="selectedRole?.code || ''"
:tags="roleTags"
:stats="roleStats"
:tabs="roleTabs"
v-model:active-tab="activeTab"
:loading="permissionLoading && activeTab === 'permissions'"
>
<template #icon>
<div class="icon-badge-circle">
<icon-user-group />
</div>
</template>
<template #actions>
<a-space>
<a-button
v-if="hasPermission('Rbac.Roles.Create')"
v-if="hasPermission('Rbac.Roles.Update')"
type="primary"
@click="handleAdd"
:loading="modalLoading"
@click="handleSaveRole"
>
<template #icon><icon-plus /></template>
{{ t('system.role.actions.add') }}
{{ t('system.role.actions.save') }}
</a-button>
<a-button
v-if="hasPermission('Rbac.Roles.AssignPermissions') && activeTab === 'permissions'"
:loading="permissionSaving"
@click="handleSavePermissions"
>
{{ t('system.role.permission.save') }}
</a-button>
</a-space>
</template>
<a-space>
<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"
/>
</a-space>
</div>
<div ref="tableContainerRef" class="list-wrap">
<HandsontablePro
ref="handsontableRef"
:columns="hotColumns"
:data="tableData"
:total="pagination.total"
:current-page="pagination.current"
:page-size="pagination.pageSize"
:show-action-column="true"
:action-column-position="'end'"
:action-column-width="120"
:action-renderer="actionRenderer"
:custom-cell-renderer="customCellRenderer"
:stretch-columns="true"
:height="handsontableHeight"
:read-only="true"
@action-click="handleActionClick"
/>
</div>
</div>
<!-- 新增/编辑角色弹窗 -->
<a-modal
v-model:visible="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
@ok="handleModalOk"
width="600px"
>
<div v-show="activeTab === 'info'" class="section-card">
<h3 class="section-card-title">{{ t('system.role.tab.info') }}</h3>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
class="role-form"
>
<a-form-item :label="t('system.role.form.name')" field="name">
<a-input v-model="formState.name" :placeholder="t('system.role.form.namePlaceholder')" />
</a-form-item>
<a-form-item :label="t('system.role.form.code')" field="code">
<a-input
v-model="formState.code"
:placeholder="t('system.role.form.codePlaceholder')"
:disabled="isEdit"
:disabled="!isCreating"
/>
</a-form-item>
<a-form-item :label="t('system.role.form.boundWarehouse')" field="boundWarehouseId">
<a-select
v-model="formState.boundWarehouseId"
@ -82,145 +93,88 @@
:options="warehouseOptions"
/>
</a-form-item>
<a-form-item v-if="!isCreating" :label="t('system.role.table.createTime')">
<span>{{ formatTime(selectedRole?.createdAt) }}</span>
</a-form-item>
</a-form>
</a-modal>
<!-- 分配权限弹窗 -->
<a-modal
v-model:visible="permissionModalVisible"
:title="t('system.role.modal.assignPermissions')"
:confirm-loading="permissionModalLoading"
@ok="handlePermissionModalOk"
width="800px"
>
<div class="permission-tree-container">
<div class="permission-actions">
<a-button size="small" @click="expandAllPermissions">全部展开</a-button>
<a-button size="small" @click="collapseAllPermissions">全部收起</a-button>
<a-button size="small" @click="checkAllPermissions">全选</a-button>
<a-button size="small" @click="uncheckAllPermissions">取消全选</a-button>
</div>
<div v-show="activeTab === 'permissions'" class="permission-section">
<div class="sticky-toolbar">
<a-space wrap>
<a-input-search
v-model="permissionKeyword"
:placeholder="t('system.role.permission.search')"
allow-clear
style="width: 220px"
/>
<a-button size="small" @click="expandAllPermissions">{{ t('system.role.permission.expandAll') }}</a-button>
<a-button size="small" @click="collapseAllPermissions">{{ t('system.role.permission.collapseAll') }}</a-button>
<a-button size="small" @click="checkAllPermissions">{{ t('system.role.permission.checkAll') }}</a-button>
<a-button size="small" @click="uncheckAllPermissions">{{ t('system.role.permission.uncheckAll') }}</a-button>
</a-space>
<span class="perm-summary">
{{ t('system.role.permission.selectedCount', { selected: selectedPermissions.length, total: totalPermissionCount }) }}
</span>
</div>
<div class="section-card permission-tree-card">
<a-tree
v-if="permissionTreeData.length > 0"
v-if="filteredPermissionTreeData.length > 0"
v-model:checked-keys="selectedPermissions"
v-model:expanded-keys="permissionExpandedKeys"
checkable
:data="permissionTreeData"
:data="filteredPermissionTreeData"
:field-names="{ title: 'name', key: 'code', children: 'children' }"
check-strictly
style="max-height: 400px; overflow: auto;"
/>
<a-spin v-else :loading="true" style="width: 100%; padding: 40px;">
<div style="text-align: center;">{{ t('system.role.permission.loading') }}</div>
</a-spin>
<div v-else class="permission-empty">
{{ permissionKeyword ? t('system.role.permission.noMatch') : t('system.role.permission.loading') }}
</div>
</div>
</div>
</RbacDetailShell>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus, IconRefresh } from '@arco-design/web-vue/es/icon'
import { IconUserGroup } from '@arco-design/web-vue/es/icon'
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
import RbacDetailShell from '@/components/RbacSplit/RbacDetailShell.vue'
import type { RbacDetailTag } from '@/components/RbacSplit/RbacDetailShell.vue'
import { RoleService, PermissionService } from '@/api/rbac'
import type { RoleDto, RoleCreateDto, RoleUpdateDto, PermissionGroupDto, PermissionDefinitionDto } from '@/api/rbac'
import type {
RoleDto,
RoleCreateDto,
RoleUpdateDto,
PermissionGroupDto,
PermissionDefinitionDto,
} from '@/api/rbac'
import { useWarehouseList } from '@/composables/useWarehouseList'
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 { buildFlatLeftMenuTree, findLeftMenuItemByKey } from '@/utils/rbacLeftMenuTree'
import dayjs from 'dayjs'
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
const loading = ref(false)
const dataSource = ref<RoleDto[]>([])
const tableData = computed(() =>
dataSource.value.map((row) => ({
...row,
boundWarehouse: getWarehouseNameById(roleWarehouseMap.value[row.id || '']),
}))
)
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showJumper: true,
showTotal: true,
})
const { warehouseOptions, getWarehouseNameById } = useWarehouseList()
// 角色与仓库绑定映射(后端未支持时暂存,可后续对接 API)
const roleWarehouseMap = ref<Record<string, string>>({})
const hotColumns = computed(() => [
{ key: 'name', label: t('system.role.table.name'), width: 150 },
{ key: 'code', label: t('system.role.table.code'), width: 150 },
{ key: 'boundWarehouse', label: t('system.role.table.boundWarehouse'), width: 150 },
{ key: 'createdAt', label: t('system.role.table.createTime'), width: 180 },
])
const handsontableRef = ref<InstanceType<typeof HandsontablePro>>()
const actionEditIcon = '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><path d="m30.48 19.038 5.733-5.734a1 1 0 0 0 0-1.414l-5.586-5.586a1 1 0 0 0-1.414 0l-5.734 5.734m7 7L15.763 33.754a1 1 0 0 1-.59.286l-6.048.708a1 1 0 0 1-1.113-1.069l.477-6.31a1 1 0 0 1 .29-.631l14.7-14.7m7 7-7-7M6 42h36"/></svg>'
const actionPermissionIcon = '<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"/><path d="M24 16v16M16 24h16"/></svg>'
const actionDeleteIcon = '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><path d="M5 11h5.5m0 0v29a1 1 0 0 0 1 1h25a1 1 0 0 0 1-1V11m-27 0H16m21.5 0H43m-5.5 0H32m-16 0V7h16v4m-16 0h16M20 18v15m8-15v15"/></svg>'
function customCellRenderer(_row: number, col: number, _value: any, rowData: any): string | null {
const dataColIndex = col - 1
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null
const key = hotColumns.value[dataColIndex].key
if (key === 'createdAt') {
return rowData?.createdAt ? dayjs(rowData.createdAt).format('YYYY-MM-DD HH:mm:ss') : '--'
}
return null
}
function actionRenderer(row: RoleDto, rowIndex: number): string {
const actions: string[] = []
if (hasPermission('Rbac.Roles.Update')) {
actions.push(`<span data-action="edit" data-row-index="${rowIndex}" style="cursor:pointer;color:#165dff;display:inline-flex;" title="${t('system.role.actions.edit')}">${actionEditIcon}</span>`)
}
if (hasPermission('Rbac.Roles.AssignPermissions')) {
actions.push(`<span data-action="assignPermissions" data-row-index="${rowIndex}" style="cursor:pointer;color:#165dff;display:inline-flex;" title="${t('system.role.modal.assignPermissions')}">${actionPermissionIcon}</span>`)
}
if (hasPermission('Rbac.Roles.Delete')) {
actions.push(`<span data-action="delete" data-row-index="${rowIndex}" style="cursor:pointer;color:#f53f3f;display:inline-flex;" title="${t('system.role.actions.delete')}">${actionDeleteIcon}</span>`)
}
return `<div style="display:flex;align-items:center;justify-content:center;gap:20px;width:100%;">${actions.join('')}</div>`
}
function handleActionClick(row: RoleDto, _rowIndex: number, action?: string) {
if (action === 'edit') {
handleEdit(row)
return
}
if (action === 'assignPermissions') {
handleAssignPermissions(row)
return
}
if (action === 'delete') {
Modal.confirm({
content: t('system.role.modal.deleteConfirmContent', { count: 1 }),
onOk: () => handleDelete(row.id),
})
}
}
// 表单相关
const modalVisible = ref(false)
const modalLoading = ref(false)
const modalTitle = computed(() => isEdit.value ? t('system.role.modal.edit') : t('system.role.modal.add'))
const isEdit = ref(false)
const searchKeyword = ref('')
const selectedRole = ref<RoleDto | null>(null)
const isCreating = ref(false)
const expandedKeys = ref(['category-roles'])
const activeTab = ref('info')
const formRef = ref()
const currentId = ref('')
const permissionKeyword = ref('')
const pagination = reactive({ current: 1, pageSize: 100 })
const { warehouseOptions } = useWarehouseList()
const roleWarehouseMap = ref<Record<string, string>>({})
const formState = reactive<RoleCreateDto & { boundWarehouseId?: string }>({
name: '',
@ -233,44 +187,163 @@ const formRules = computed(() => ({
code: [{ required: true, message: t('system.role.rules.code.required') }],
}))
// 权限树相关
const modalLoading = ref(false)
const permissionLoading = ref(false)
const permissionSaving = ref(false)
const permissionTree = ref<PermissionGroupDto[]>([])
const selectedPermissions = ref<string[]>([])
const permissionExpandedKeys = ref<string[]>([])
const permissionModalVisible = ref(false)
const permissionModalLoading = ref(false)
const currentRoleId = ref('')
// 将 PermissionGroupDto[] 转换为树形结构(PermissionDefinitionDto[])
const internalRoles = computed(() =>
dataSource.value.filter((role) => {
const code = role.code || ''
return code !== 'Customer' && !code.startsWith('CustomerSub_')
})
)
const roleTreeData = computed(() =>
buildFlatLeftMenuTree('category-roles', t('system.role.pageTitle'), internalRoles.value, {
titleKey: 'name',
codeKey: 'code',
})
)
const selectedKeys = computed(() =>
selectedRole.value?.id ? [`item-${selectedRole.value.id}`] : []
)
const permissionTreeData = computed(() => {
const treeData: PermissionDefinitionDto[] = []
permissionTree.value.forEach((group: PermissionGroupDto) => {
if (group.permissions && group.permissions.length > 0) {
// 将分组作为父节点,权限作为子节点
const groupNode: PermissionDefinitionDto = {
if (group.permissions?.length) {
treeData.push({
code: group.code || '',
name: group.name || '',
children: group.permissions || [],
}
treeData.push(groupNode)
})
}
})
return treeData
})
// 加载数据
const totalPermissionCount = computed(() => countLeafCodes(permissionTreeData.value))
const filteredPermissionTreeData = computed(() =>
filterPermissionTree(permissionTreeData.value, permissionKeyword.value.trim())
)
const roleTags = computed((): RbacDetailTag[] => {
if (!selectedRole.value) return []
const tags: RbacDetailTag[] = []
if (isCreating.value) {
tags.push({ label: t('system.role.tag.creating'), color: 'orangered' })
}
if (formState.boundWarehouseId) {
tags.push({ label: t('system.role.tag.boundWarehouse'), color: 'green' })
}
return tags
})
const roleStats = computed(() => [
{ label: t('system.role.permission.selected'), value: selectedPermissions.value.length },
{ label: t('system.role.permission.total'), value: totalPermissionCount.value },
])
const roleTabs = computed(() => {
const tabs = [{ key: 'info', label: t('system.role.tab.info') }]
if (hasPermission('Rbac.Roles.AssignPermissions')) {
tabs.push({
key: 'permissions',
label: t('system.role.modal.assignPermissions'),
badge: selectedPermissions.value.length || undefined,
})
}
return tabs
})
function countLeafCodes(nodes: PermissionDefinitionDto[]): number {
let count = 0
nodes.forEach((node) => {
if (node.children?.length) {
count += countLeafCodes(node.children)
} else if (node.code) {
count += 1
}
})
return count
}
function filterPermissionTree(
nodes: PermissionDefinitionDto[],
keyword: string
): PermissionDefinitionDto[] {
if (!keyword) return nodes
const lower = keyword.toLowerCase()
const result: PermissionDefinitionDto[] = []
nodes.forEach((node) => {
const children = node.children?.length ? filterPermissionTree(node.children, keyword) : []
const selfMatch =
(node.name || '').toLowerCase().includes(lower) ||
(node.code || '').toLowerCase().includes(lower)
if (selfMatch || children.length) {
result.push({
...node,
children: children.length ? children : selfMatch ? node.children : undefined,
})
}
})
return result
}
function formatTime(value?: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
function isCustomerRole(role?: RoleDto | null) {
const code = role?.code || ''
return code === 'Customer' || code.startsWith('CustomerSub_')
}
function selectRoleFromKey(key: string) {
const role = findLeftMenuItemByKey(key, internalRoles.value)
if (role) {
isCreating.value = false
selectedRole.value = role
}
}
function handleRoleSelect(keys: string[]) {
const key = keys[0]
if (key) selectRoleFromKey(key)
}
function handleRoleNodeClick(key: string) {
selectRoleFromKey(key)
}
function syncFormFromRole(role: RoleDto) {
Object.assign(formState, {
name: role.name || '',
code: role.code || '',
boundWarehouseId: roleWarehouseMap.value[role.id || ''] || undefined,
})
}
async function loadData() {
try {
loading.value = true
try {
const response = await RoleService.getRbacRoleGetList(
undefined, // keyword
pagination.current, // page (从1开始)
pagination.pageSize, // pageSize
undefined, // orderBy
undefined // descending
searchKeyword.value || undefined,
pagination.current,
pagination.pageSize
)
dataSource.value = response?.items || []
pagination.total = response?.total || 0
if (selectedRole.value?.id) {
const stillExists = internalRoles.value.find((r) => r.id === selectedRole.value?.id)
selectedRole.value = stillExists || internalRoles.value[0] || null
} else if (!isCreating.value && internalRoles.value.length > 0) {
selectedRole.value = internalRoles.value[0]
}
} catch (error) {
handleApiError(error)
} finally {
@ -278,71 +351,76 @@ async function loadData() {
}
}
// 分页变化
function handlePageChange(page: number) {
pagination.current = page
loadData()
}
// 每页数量变化
function handlePageSizeChange(size: number) {
pagination.pageSize = size
pagination.current = 1
loadData()
}
// 新增角色
function handleAdd() {
isEdit.value = false
currentId.value = ''
Object.assign(formState, {
name: '',
code: '',
boundWarehouseId: undefined,
})
modalVisible.value = true
}
// 编辑角色
function handleEdit(record: RoleDto) {
isEdit.value = true
currentId.value = record.id || ''
Object.assign(formState, {
name: record.name,
code: record.code,
boundWarehouseId: roleWarehouseMap.value[record.id || ''] || undefined,
})
modalVisible.value = true
}
// 删除角色
async function handleDelete(id: string | undefined) {
if (!id) return
async function loadPermissionTree() {
if (permissionTree.value.length > 0) return
try {
await RoleService.deleteRbacRoleDelete(id)
Message.success('删除成功')
loadData()
permissionTree.value = (await PermissionService.getRbacPermissionGetPermissionTree()) || []
} catch (error) {
handleApiError(error)
}
}
// 提交表单
async function handleModalOk() {
async function loadRolePermissions(roleId: string) {
permissionLoading.value = true
try {
await formRef.value.validate()
await loadPermissionTree()
selectedPermissions.value = (await RoleService.getRbacRoleGetRolePermissions(roleId)) || []
permissionExpandedKeys.value = expandAllCodes(permissionTreeData.value)
} catch (error) {
handleApiError(error)
} finally {
permissionLoading.value = false
}
}
function expandAllCodes(nodes: PermissionDefinitionDto[]): string[] {
const keys: string[] = []
nodes.forEach((node) => {
if (node.code) keys.push(node.code)
if (node.children?.length) keys.push(...expandAllCodes(node.children))
})
return keys
}
function handleSearch() {
pagination.current = 1
loadData()
}
function handleAdd() {
isCreating.value = true
selectedRole.value = { name: '', code: '' }
Object.assign(formState, { name: '', code: '', boundWarehouseId: undefined })
activeTab.value = 'info'
selectedPermissions.value = []
}
async function handleDelete(id?: string) {
if (!id) return
Modal.confirm({
content: t('system.role.modal.deleteConfirmContent', { count: 1 }),
onOk: async () => {
try {
await RoleService.deleteRbacRoleDelete(id)
Message.success(t('system.role.message.deleteSuccess'))
if (selectedRole.value?.id === id) {
selectedRole.value = null
isCreating.value = false
}
await loadData()
} catch (error) {
handleApiError(error)
}
},
})
}
async function handleSaveRole() {
try {
await formRef.value?.validate()
modalLoading.value = true
if (isEdit.value) {
const updateData: RoleUpdateDto = {
name: formState.name,
}
await RoleService.putRbacRoleUpdate(currentId.value!, updateData)
if (formState.boundWarehouseId !== undefined) {
roleWarehouseMap.value[currentId.value!] = formState.boundWarehouseId || ''
}
Message.success(t('system.role.message.updateSuccess'))
} else {
if (isCreating.value) {
const createData: RoleCreateDto = {
name: formState.name,
code: formState.code,
@ -353,224 +431,109 @@ async function handleModalOk() {
roleWarehouseMap.value[newRoleId] = formState.boundWarehouseId
}
Message.success(t('system.role.message.createSuccess'))
isCreating.value = false
await loadData()
if (newRoleId) {
selectedRole.value = internalRoles.value.find((r) => r.id === newRoleId) || null
}
} else if (selectedRole.value?.id) {
const updateData: RoleUpdateDto = { name: formState.name }
await RoleService.putRbacRoleUpdate(selectedRole.value.id, updateData)
if (formState.boundWarehouseId !== undefined) {
roleWarehouseMap.value[selectedRole.value.id] = formState.boundWarehouseId || ''
}
Message.success(t('system.role.message.updateSuccess'))
await loadData()
}
modalVisible.value = false
loadData()
} catch (error: any) {
if (error.fields) {
return
}
if (error?.fields) return
handleApiError(error)
} finally {
modalLoading.value = false
}
}
// 分配权限
async function handleAssignPermissions(record: RoleDto) {
currentRoleId.value = record.id || ''
permissionModalVisible.value = true
permissionModalLoading.value = true
async function handleSavePermissions() {
if (!selectedRole.value?.id || isCreating.value) return
permissionSaving.value = true
try {
// 加载权限树
const permissionResponse = await PermissionService.getRbacPermissionGetPermissionTree()
permissionTree.value = permissionResponse || []
// 加载角色已有权限
const roleResponse = await RoleService.getRbacRoleGetRolePermissions(record.id!)
// 返回的是权限编码数组,直接使用
selectedPermissions.value = roleResponse || []
// 默认展开所有节点
const expandAll = (nodes: PermissionDefinitionDto[]): string[] => {
const keys: string[] = []
nodes.forEach(node => {
if (node.code) keys.push(node.code)
if (node.children && node.children.length > 0) {
keys.push(...expandAll(node.children))
}
})
return keys
}
permissionExpandedKeys.value = expandAll(permissionTreeData.value)
} catch (error) {
handleApiError(error)
} finally {
permissionModalLoading.value = false
}
}
// 提交权限分配
async function handlePermissionModalOk() {
try {
permissionModalLoading.value = true
await RoleService.postRbacRoleAssignPermissions(
currentRoleId.value,
selectedPermissions.value
)
await RoleService.postRbacRoleAssignPermissions(selectedRole.value.id, selectedPermissions.value)
Message.success(t('system.role.permission.assignSuccess'))
permissionModalVisible.value = false
loadData()
} catch (error) {
handleApiError(error)
} finally {
permissionModalLoading.value = false
permissionSaving.value = false
}
}
// 展开所有权限
function expandAllPermissions() {
const expandAll = (nodes: PermissionDefinitionDto[]): string[] => {
const keys: string[] = []
nodes.forEach(node => {
if (node.code) keys.push(node.code)
if (node.children && node.children.length > 0) {
keys.push(...expandAll(node.children))
}
})
return keys
}
permissionExpandedKeys.value = expandAll(permissionTreeData.value)
permissionExpandedKeys.value = expandAllCodes(filteredPermissionTreeData.value)
}
// 收起所有权限
function collapseAllPermissions() {
permissionExpandedKeys.value = []
}
// 全选权限
function checkAllPermissions() {
const getAllCodes = (nodes: PermissionDefinitionDto[]): string[] => {
const codes: string[] = []
nodes.forEach(node => {
if (node.code) codes.push(node.code)
if (node.children && node.children.length > 0) {
codes.push(...getAllCodes(node.children))
}
})
return codes
}
selectedPermissions.value = getAllCodes(permissionTreeData.value)
selectedPermissions.value = expandAllCodes(permissionTreeData.value)
}
// 取消全选
function uncheckAllPermissions() {
selectedPermissions.value = []
}
// 初始化
onMounted(() => {
loadData()
watch(
() => selectedRole.value?.id,
async (roleId) => {
if (!roleId || isCreating.value) return
if (isCustomerRole(selectedRole.value)) return
syncFormFromRole(selectedRole.value!)
activeTab.value = 'info'
permissionKeyword.value = ''
await loadRolePermissions(roleId)
}
)
watch(permissionKeyword, (kw) => {
if (kw.trim() && activeTab.value === 'permissions') {
permissionExpandedKeys.value = expandAllCodes(filteredPermissionTreeData.value)
}
})
onMounted(async () => {
await loadData()
await loadPermissionTree()
})
</script>
<style scoped>
.role-page {
width: 100%;
box-sizing: border-box;
padding: 16px 16px 24px;
background-color: #f0f3f4;
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: hidden;
<style scoped lang="less">
@import '@/components/RbacSplit/rbac-split-layout.less';
.role-form {
max-width: 640px;
}
.permission-section {
display: flex;
flex-direction: column;
gap: 0;
}
.role-page-card {
background: #fff;
border-radius: 8px;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-sizing: border-box;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.page-header {
display: flex;
justify-content: space-between;
padding: 16px 16px 0;
margin-bottom: 16px;
flex-shrink: 0;
gap: 12px;
flex-wrap: nowrap;
}
.list-wrap {
width: 100%;
flex: 1;
min-height: 0;
padding: 0 16px 16px;
box-sizing: border-box;
overflow: hidden;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
}
.list-wrap :deep(.handsontable-pro-container) {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.permission-tree-container {
max-height: 500px;
.permission-tree-card {
min-height: 320px;
max-height: calc(100vh - 340px);
overflow: auto;
}
.permission-actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
.perm-summary {
font-size: 13px;
color: var(--color-text-3);
white-space: nowrap;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
.action-btn {
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
&.edit-btn {
color: var(--color-text-2);
&:hover {
background-color: var(--color-fill-2);
color: var(--color-text-1);
}
}
&.assign-btn {
color: var(--color-text-2);
&:hover {
background-color: var(--color-fill-2);
color: var(--color-text-1);
}
}
&.delete-btn {
color: #ff4d4f;
&:hover {
background-color: #fff2f0;
color: #ff7875;
}
}
}
.permission-empty {
padding: 48px 24px;
text-align: center;
color: var(--color-text-3);
}
</style>