feat(rbac): 数据权限三页左右分栏与右侧详情壳优化
新增 RbacDetailShell、DataPermPanel 五维配置、权限模板/角色管理右侧 Tab 与卡片布局,并补充 i18n。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
117b422a5f
commit
99f7ddf73c
303
src/api/rbac/data-perm.ts
Normal file
303
src/api/rbac/data-perm.ts
Normal 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
1
src/components.d.ts
vendored
@ -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']
|
||||
|
||||
324
src/components/RbacSplit/RbacDetailShell.vue
Normal file
324
src/components/RbacSplit/RbacDetailShell.vue
Normal 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>
|
||||
85
src/components/RbacSplit/rbac-split-layout.less
Normal file
85
src/components/RbacSplit/rbac-split-layout.less
Normal 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;
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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)。同一字典类型下字典编码唯一;已存在的「类型+编码」将自动跳过。',
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
40
src/utils/rbacLeftMenuTree.ts
Normal file
40
src/utils/rbacLeftMenuTree.ts
Normal 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)
|
||||
}
|
||||
173
src/views/system/dataPerm/audit/index.vue
Normal file
173
src/views/system/dataPerm/audit/index.vue
Normal 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>
|
||||
122
src/views/system/dataPerm/components/BizPermPanel.vue
Normal file
122
src/views/system/dataPerm/components/BizPermPanel.vue
Normal 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>
|
||||
38
src/views/system/dataPerm/components/DataPermDrawer.vue
Normal file
38
src/views/system/dataPerm/components/DataPermDrawer.vue
Normal 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>
|
||||
443
src/views/system/dataPerm/components/DataPermPanel.vue
Normal file
443
src/views/system/dataPerm/components/DataPermPanel.vue
Normal 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>
|
||||
94
src/views/system/dataPerm/components/DataScopePanel.vue
Normal file
94
src/views/system/dataPerm/components/DataScopePanel.vue
Normal 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>
|
||||
@ -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>
|
||||
121
src/views/system/dataPerm/components/FilterRulePanel.vue
Normal file
121
src/views/system/dataPerm/components/FilterRulePanel.vue
Normal 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>
|
||||
95
src/views/system/dataPerm/components/OrgPermPanel.vue
Normal file
95
src/views/system/dataPerm/components/OrgPermPanel.vue
Normal 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>
|
||||
94
src/views/system/dataPerm/components/SecretPermPanel.vue
Normal file
94
src/views/system/dataPerm/components/SecretPermPanel.vue
Normal 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>
|
||||
44
src/views/system/dataPerm/constants.ts
Normal file
44
src/views/system/dataPerm/constants.ts
Normal 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 }[] }
|
||||
}
|
||||
549
src/views/system/dataPerm/templates/index.vue
Normal file
549
src/views/system/dataPerm/templates/index.vue
Normal 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>
|
||||
@ -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"
|
||||
/>
|
||||
</div>
|
||||
</RbacDetailShell>
|
||||
</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>
|
||||
|
||||
@ -1,226 +1,180 @@
|
||||
<template>
|
||||
<div class="role-page">
|
||||
<div class="role-page-card">
|
||||
<div class="page-header">
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="hasPermission('Rbac.Roles.Create')"
|
||||
type="primary"
|
||||
@click="handleAdd"
|
||||
>
|
||||
<template #icon><icon-plus /></template>
|
||||
{{ t('system.role.actions.add') }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
<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)"
|
||||
/>
|
||||
|
||||
<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"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
:rules="formRules"
|
||||
:label-col-props="{ span: 6 }"
|
||||
:wrapper-col-props="{ span: 16 }"
|
||||
<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'"
|
||||
>
|
||||
<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>
|
||||
<template #icon>
|
||||
<div class="icon-badge-circle">
|
||||
<icon-user-group />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="hasPermission('Rbac.Roles.Update')"
|
||||
type="primary"
|
||||
:loading="modalLoading"
|
||||
@click="handleSaveRole"
|
||||
>
|
||||
{{ 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-form-item :label="t('system.role.form.code')" field="code">
|
||||
<a-input
|
||||
v-model="formState.code"
|
||||
:placeholder="t('system.role.form.codePlaceholder')"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('system.role.form.boundWarehouse')" field="boundWarehouseId">
|
||||
<a-select
|
||||
v-model="formState.boundWarehouseId"
|
||||
:placeholder="t('system.role.form.boundWarehousePlaceholder')"
|
||||
allow-clear
|
||||
:options="warehouseOptions"
|
||||
/>
|
||||
</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 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="!isCreating"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('system.role.form.boundWarehouse')" field="boundWarehouseId">
|
||||
<a-select
|
||||
v-model="formState.boundWarehouseId"
|
||||
:placeholder="t('system.role.form.boundWarehousePlaceholder')"
|
||||
allow-clear
|
||||
: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>
|
||||
</div>
|
||||
<a-tree
|
||||
v-if="permissionTreeData.length > 0"
|
||||
v-model:checked-keys="selectedPermissions"
|
||||
v-model:expanded-keys="permissionExpandedKeys"
|
||||
checkable
|
||||
:data="permissionTreeData"
|
||||
: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>
|
||||
</a-modal>
|
||||
|
||||
<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="filteredPermissionTreeData.length > 0"
|
||||
v-model:checked-keys="selectedPermissions"
|
||||
v-model:expanded-keys="permissionExpandedKeys"
|
||||
checkable
|
||||
:data="filteredPermissionTreeData"
|
||||
:field-names="{ title: 'name', key: 'code', children: 'children' }"
|
||||
check-strictly
|
||||
/>
|
||||
<div v-else class="permission-empty">
|
||||
{{ permissionKeyword ? t('system.role.permission.noMatch') : t('system.role.permission.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RbacDetailShell>
|
||||
</div>
|
||||
</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() {
|
||||
loading.value = true
|
||||
try {
|
||||
loading.value = true
|
||||
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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user