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']
|
Package: typeof import('./components/CustomSelectFilter/Package/index.vue')['default']
|
||||||
PaginationPro: typeof import('./components/PaginationPro/index.vue')['default']
|
PaginationPro: typeof import('./components/PaginationPro/index.vue')['default']
|
||||||
QuantityInput: typeof import('./components/QuantityInput/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']
|
RightTopMessage: typeof import('./components/top-nav/RightTopMessage.vue')['default']
|
||||||
RightTopUser: typeof import('./components/top-nav/RightTopUser.vue')['default']
|
RightTopUser: typeof import('./components/top-nav/RightTopUser.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
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.warehouseAddress': 'Warehouse Address',
|
||||||
'menu.system.batchEmail': 'Batch Email',
|
'menu.system.batchEmail': 'Batch Email',
|
||||||
'menu.system.xhsAiAssistant': 'AI Assistant',
|
'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': 'Xiaohongshu',
|
||||||
'menu.xhs.startService': 'Start Service',
|
'menu.xhs.startService': 'Start Service',
|
||||||
@ -775,6 +829,13 @@ export default {
|
|||||||
'system.role.actions.add': 'Add',
|
'system.role.actions.add': 'Add',
|
||||||
'system.role.actions.edit': 'Edit',
|
'system.role.actions.edit': 'Edit',
|
||||||
'system.role.actions.delete': 'Delete',
|
'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.enabled': 'Enabled',
|
||||||
'system.role.status.disabled': 'Disabled',
|
'system.role.status.disabled': 'Disabled',
|
||||||
'system.role.category.business': 'Business role',
|
'system.role.category.business': 'Business role',
|
||||||
@ -920,6 +981,13 @@ export default {
|
|||||||
'system.role.permission.uncheckAll': 'Uncheck All',
|
'system.role.permission.uncheckAll': 'Uncheck All',
|
||||||
'system.role.permission.loading': 'Loading permission data...',
|
'system.role.permission.loading': 'Loading permission data...',
|
||||||
'system.role.permission.assignSuccess': 'Permission assigned successfully',
|
'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.deleteSuccess': 'Delete successful',
|
||||||
'system.role.message.updateSuccess': 'Update successful',
|
'system.role.message.updateSuccess': 'Update successful',
|
||||||
'system.role.message.createSuccess': 'Create successful',
|
'system.role.message.createSuccess': 'Create successful',
|
||||||
|
|||||||
@ -145,6 +145,10 @@ export default {
|
|||||||
'menu.system.warehouseAddress': '仓库地址',
|
'menu.system.warehouseAddress': '仓库地址',
|
||||||
'menu.system.batchEmail': '批量发送邮件',
|
'menu.system.batchEmail': '批量发送邮件',
|
||||||
'menu.system.xhsAiAssistant': 'AI 助手',
|
'menu.system.xhsAiAssistant': 'AI 助手',
|
||||||
|
'menu.system.dataPerm': '数据权限',
|
||||||
|
'menu.system.dataPermUsers': '用户数据权限',
|
||||||
|
'menu.system.dataPermTemplates': '权限模板',
|
||||||
|
'menu.system.dataPermAudit': '权限审计',
|
||||||
|
|
||||||
'menu.xhs': '小红书',
|
'menu.xhs': '小红书',
|
||||||
'menu.xhs.startService': '启动服务',
|
'menu.xhs.startService': '启动服务',
|
||||||
@ -776,6 +780,13 @@ export default {
|
|||||||
'system.role.actions.add': '新增角色',
|
'system.role.actions.add': '新增角色',
|
||||||
'system.role.actions.edit': '修改',
|
'system.role.actions.edit': '修改',
|
||||||
'system.role.actions.delete': '删除',
|
'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.enabled': '启用',
|
||||||
'system.role.status.disabled': '禁用',
|
'system.role.status.disabled': '禁用',
|
||||||
'system.role.category.business': '业务角色',
|
'system.role.category.business': '业务角色',
|
||||||
@ -921,6 +932,13 @@ export default {
|
|||||||
'system.role.permission.uncheckAll': '取消全选',
|
'system.role.permission.uncheckAll': '取消全选',
|
||||||
'system.role.permission.loading': '加载权限数据中...',
|
'system.role.permission.loading': '加载权限数据中...',
|
||||||
'system.role.permission.assignSuccess': '权限分配成功',
|
'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.deleteSuccess': '删除成功',
|
||||||
'system.role.message.updateSuccess': '更新成功',
|
'system.role.message.updateSuccess': '更新成功',
|
||||||
'system.role.message.createSuccess': '创建成功',
|
'system.role.message.createSuccess': '创建成功',
|
||||||
@ -1045,6 +1063,57 @@ export default {
|
|||||||
'system.dict.actions.delete': '删除',
|
'system.dict.actions.delete': '删除',
|
||||||
'system.dict.actions.batchImport': '批量导入',
|
'system.dict.actions.batchImport': '批量导入',
|
||||||
'system.dict.actions.batchExport': '批量导出',
|
'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.modalTitle': '批量导入字典',
|
||||||
'system.dict.import.tip':
|
'system.dict.import.tip':
|
||||||
'请先下载模板或 OMS 预置字典,按列填写后上传 Excel(.xlsx/.xls)。同一字典类型下字典编码唯一;已存在的「类型+编码」将自动跳过。',
|
'请先下载模板或 OMS 预置字典,按列填写后上传 Excel(.xlsx/.xls)。同一字典类型下字典编码唯一;已存在的「类型+编码」将自动跳过。',
|
||||||
|
|||||||
@ -109,6 +109,36 @@ const SYSTEM_MANAGEMENT: AppRouteRecordRaw = {
|
|||||||
permission: 'Xhs.Ai.Use',
|
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>
|
<template>
|
||||||
<div class="data-perm-users-container">
|
<div class="rbac-split-layout">
|
||||||
<div class="data-perm-users-card">
|
<CustomLeftMenu
|
||||||
<div class="table-toolbar">
|
class="left-sidebar"
|
||||||
<div class="toolbar-left">
|
:tree-data="userTreeData"
|
||||||
<span class="page-title">{{ t('system.dataPerm.users.title') }}</span>
|
:selected-keys="selectedKeys"
|
||||||
<span class="page-desc">{{ t('system.dataPerm.users.desc') }}</span>
|
v-model:expanded-keys="expandedKeys"
|
||||||
</div>
|
v-model:search-keyword="searchKeyword"
|
||||||
<div class="toolbar-right">
|
: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
|
<a-tree-select
|
||||||
v-model="filterOrganizationId"
|
v-model="filterOrganizationId"
|
||||||
:placeholder="t('system.user.search.organizationPlaceholder')"
|
:placeholder="t('system.user.search.organizationPlaceholder')"
|
||||||
:data="organizationTreeData"
|
:data="organizationTreeData"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 180px"
|
style="width: 200px"
|
||||||
@change="handleSearch"
|
@change="handleSearch"
|
||||||
/>
|
/>
|
||||||
<a-input-search
|
</template>
|
||||||
v-model="searchKeyword"
|
|
||||||
:placeholder="t('system.user.search.accountOrNamePlaceholder')"
|
|
||||||
style="width: 240px"
|
|
||||||
@search="handleSearch"
|
|
||||||
/>
|
|
||||||
<a-button @click="loadData">
|
|
||||||
<template #icon><icon-refresh /></template>
|
|
||||||
</a-button>
|
|
||||||
<PaginationPro
|
|
||||||
:total-count="pagination.total"
|
|
||||||
v-model:current-page="pagination.current"
|
|
||||||
v-model:page-size="pagination.pageSize"
|
|
||||||
@page-change="handlePageChange"
|
|
||||||
@page-size-change="handlePageSizeChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="tableContainerRef" class="list-wrap">
|
<DataPermPanel
|
||||||
<HandsontablePro
|
embedded
|
||||||
:columns="hotColumns"
|
:target-user="selectedUser"
|
||||||
:data="tableRows"
|
@saved="loadData"
|
||||||
:total="pagination.total"
|
@stats-change="onStatsChange"
|
||||||
: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"
|
|
||||||
/>
|
/>
|
||||||
|
</RbacDetailShell>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataPermDrawer v-model:visible="drawerVisible" :target-user="currentUser" @saved="loadData" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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 { getInternalUserList, OrganizationService } from '@/api/rbac'
|
||||||
import type { UserDto, OrganizationTreeDto } from '@/api/rbac'
|
import type { UserDto, OrganizationTreeDto } from '@/api/rbac'
|
||||||
import { hasPermission } from '@/utils/rbac-permission'
|
|
||||||
import { handleApiError } from '@/utils/rbac-error-handler'
|
import { handleApiError } from '@/utils/rbac-error-handler'
|
||||||
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
import { buildFlatLeftMenuTree, findLeftMenuItemByKey } from '@/utils/rbacLeftMenuTree'
|
||||||
import PaginationPro from '@/components/PaginationPro/index.vue'
|
import DataPermPanel from '../components/DataPermPanel.vue'
|
||||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
|
||||||
import DataPermDrawer from '../components/DataPermDrawer.vue'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
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 dataSource = ref<UserDto[]>([])
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const filterOrganizationId = ref<string>()
|
const filterOrganizationId = ref<string>()
|
||||||
const organizationTree = ref<OrganizationTreeDto[]>([])
|
const organizationTree = ref<OrganizationTreeDto[]>([])
|
||||||
const drawerVisible = ref(false)
|
const selectedUser = ref<UserDto | null>(null)
|
||||||
const currentUser = ref<UserDto | null>(null)
|
const expandedKeys = ref(['category-users'])
|
||||||
|
const permCounts = ref({ org: 0, scope: 0, biz: 0, secret: 0, filter: 0 })
|
||||||
const pagination = reactive({ current: 1, pageSize: 30, total: 0 })
|
|
||||||
|
|
||||||
const organizationTreeData = computed(() => {
|
const organizationTreeData = computed(() => {
|
||||||
const convert = (data: OrganizationTreeDto[]): any[] =>
|
const convert = (data: OrganizationTreeDto[]): any[] =>
|
||||||
@ -93,30 +92,72 @@ const organizationTreeData = computed(() => {
|
|||||||
return convert(organizationTree.value)
|
return convert(organizationTree.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hotColumns = computed(() => [
|
const userTreeData = computed(() =>
|
||||||
{ key: 'account', label: t('system.user.table.account'), width: 140 },
|
buildFlatLeftMenuTree('category-users', t('system.dataPerm.users.title'), dataSource.value, {
|
||||||
{ key: 'name', label: t('system.user.table.realName'), width: 120 },
|
titleKey: 'name',
|
||||||
{ key: 'rolesText', label: t('system.user.table.roles'), width: 200 },
|
codeKey: 'account',
|
||||||
])
|
})
|
||||||
|
|
||||||
const tableRows = computed(() =>
|
|
||||||
dataSource.value.map((row) => ({
|
|
||||||
...row,
|
|
||||||
rolesText: (row.roles || []).map((r) => r.name).join('、') || '—',
|
|
||||||
}))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const permIcon =
|
const selectedKeys = computed(() =>
|
||||||
'<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>'
|
selectedUser.value?.id ? [`item-${selectedUser.value.id}`] : []
|
||||||
|
)
|
||||||
|
|
||||||
function actionRenderer(_row: UserDto, rowIndex: number) {
|
const userInitials = computed(() => {
|
||||||
if (!hasPermission('Rbac.DataPerm.Query')) return ''
|
const name = selectedUser.value?.name || selectedUser.value?.account || '?'
|
||||||
return `<span data-action="config" data-row-index="${rowIndex}" style="cursor:pointer;color:var(--app-primary-color,#2D8C8C);display:inline-flex;" title="配置数据权限">${permIcon}</span>`
|
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) {
|
function selectUserFromKey(key: string) {
|
||||||
currentUser.value = row
|
const user = findLeftMenuItemByKey(key, dataSource.value)
|
||||||
drawerVisible.value = true
|
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() {
|
async function loadOrganizationTree() {
|
||||||
@ -128,91 +169,38 @@ async function loadOrganizationTree() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getInternalUserList({
|
const res = await getInternalUserList({
|
||||||
keyword: searchKeyword.value || undefined,
|
keyword: searchKeyword.value || undefined,
|
||||||
organizationId: filterOrganizationId.value,
|
organizationId: filterOrganizationId.value,
|
||||||
page: pagination.current,
|
page: 1,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: 200,
|
||||||
})
|
})
|
||||||
dataSource.value = res?.items || []
|
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) {
|
} catch (e) {
|
||||||
handleApiError(e)
|
handleApiError(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
pagination.current = 1
|
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
onMounted(async () => {
|
||||||
pagination.current = page
|
await loadOrganizationTree()
|
||||||
loadData()
|
await loadData()
|
||||||
}
|
|
||||||
|
|
||||||
function handlePageSizeChange(size: number) {
|
|
||||||
pagination.pageSize = size
|
|
||||||
pagination.current = 1
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadOrganizationTree()
|
|
||||||
loadData()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="less">
|
||||||
.data-perm-users-container {
|
@import '@/components/RbacSplit/rbac-split-layout.less';
|
||||||
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>
|
</style>
|
||||||
|
|||||||
@ -1,79 +1,90 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="role-page">
|
<div class="rbac-split-layout">
|
||||||
<div class="role-page-card">
|
<CustomLeftMenu
|
||||||
<div class="page-header">
|
class="left-sidebar"
|
||||||
|
:tree-data="roleTreeData"
|
||||||
|
:selected-keys="selectedKeys"
|
||||||
|
v-model:expanded-keys="expandedKeys"
|
||||||
|
v-model:search-keyword="searchKeyword"
|
||||||
|
:loading="loading"
|
||||||
|
:search-placeholder="t('system.role.search.placeholder.roleNameOrCode')"
|
||||||
|
:refresh-title="t('system.user.actions.refresh')"
|
||||||
|
:add-title="t('system.role.actions.add')"
|
||||||
|
:delete-label="t('system.role.actions.delete')"
|
||||||
|
:loading-text="t('system.role.loading')"
|
||||||
|
:show-add="hasPermission('Rbac.Roles.Create')"
|
||||||
|
:show-node-actions="hasPermission('Rbac.Roles.Delete')"
|
||||||
|
:show-method="false"
|
||||||
|
:show-map-count="false"
|
||||||
|
:show-node-code="true"
|
||||||
|
:show-pagination="true"
|
||||||
|
:page-size="20"
|
||||||
|
@search="handleSearch"
|
||||||
|
@refresh="loadData"
|
||||||
|
@add="handleAdd"
|
||||||
|
@select="handleRoleSelect"
|
||||||
|
@node-click="handleRoleNodeClick"
|
||||||
|
@delete-node="(node) => handleDelete(node?.id)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="right-panel">
|
||||||
|
<RbacDetailShell
|
||||||
|
:empty="!selectedRole"
|
||||||
|
:empty-title="t('system.role.emptySelect')"
|
||||||
|
:empty-desc="t('system.role.emptyDesc')"
|
||||||
|
:title="selectedRole?.name || ''"
|
||||||
|
:subtitle="selectedRole?.code || ''"
|
||||||
|
:tags="roleTags"
|
||||||
|
:stats="roleStats"
|
||||||
|
:tabs="roleTabs"
|
||||||
|
v-model:active-tab="activeTab"
|
||||||
|
:loading="permissionLoading && activeTab === 'permissions'"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<div class="icon-badge-circle">
|
||||||
|
<icon-user-group />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="hasPermission('Rbac.Roles.Create')"
|
v-if="hasPermission('Rbac.Roles.Update')"
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="handleAdd"
|
:loading="modalLoading"
|
||||||
|
@click="handleSaveRole"
|
||||||
>
|
>
|
||||||
<template #icon><icon-plus /></template>
|
{{ t('system.role.actions.save') }}
|
||||||
{{ t('system.role.actions.add') }}
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="hasPermission('Rbac.Roles.AssignPermissions') && activeTab === 'permissions'"
|
||||||
|
:loading="permissionSaving"
|
||||||
|
@click="handleSavePermissions"
|
||||||
|
>
|
||||||
|
{{ t('system.role.permission.save') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
<a-space>
|
<div v-show="activeTab === 'info'" class="section-card">
|
||||||
<a-button @click="loadData">
|
<h3 class="section-card-title">{{ t('system.role.tab.info') }}</h3>
|
||||||
<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
|
<a-form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="formState"
|
:model="formState"
|
||||||
:rules="formRules"
|
:rules="formRules"
|
||||||
:label-col-props="{ span: 6 }"
|
:label-col-props="{ span: 6 }"
|
||||||
:wrapper-col-props="{ span: 16 }"
|
:wrapper-col-props="{ span: 16 }"
|
||||||
|
class="role-form"
|
||||||
>
|
>
|
||||||
<a-form-item :label="t('system.role.form.name')" field="name">
|
<a-form-item :label="t('system.role.form.name')" field="name">
|
||||||
<a-input v-model="formState.name" :placeholder="t('system.role.form.namePlaceholder')" />
|
<a-input v-model="formState.name" :placeholder="t('system.role.form.namePlaceholder')" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item :label="t('system.role.form.code')" field="code">
|
<a-form-item :label="t('system.role.form.code')" field="code">
|
||||||
<a-input
|
<a-input
|
||||||
v-model="formState.code"
|
v-model="formState.code"
|
||||||
:placeholder="t('system.role.form.codePlaceholder')"
|
:placeholder="t('system.role.form.codePlaceholder')"
|
||||||
:disabled="isEdit"
|
:disabled="!isCreating"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item :label="t('system.role.form.boundWarehouse')" field="boundWarehouseId">
|
<a-form-item :label="t('system.role.form.boundWarehouse')" field="boundWarehouseId">
|
||||||
<a-select
|
<a-select
|
||||||
v-model="formState.boundWarehouseId"
|
v-model="formState.boundWarehouseId"
|
||||||
@ -82,145 +93,88 @@
|
|||||||
:options="warehouseOptions"
|
:options="warehouseOptions"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item v-if="!isCreating" :label="t('system.role.table.createTime')">
|
||||||
|
<span>{{ formatTime(selectedRole?.createdAt) }}</span>
|
||||||
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
|
||||||
|
|
||||||
<!-- 分配权限弹窗 -->
|
|
||||||
<a-modal
|
|
||||||
v-model:visible="permissionModalVisible"
|
|
||||||
:title="t('system.role.modal.assignPermissions')"
|
|
||||||
:confirm-loading="permissionModalLoading"
|
|
||||||
@ok="handlePermissionModalOk"
|
|
||||||
width="800px"
|
|
||||||
>
|
|
||||||
<div class="permission-tree-container">
|
|
||||||
<div class="permission-actions">
|
|
||||||
<a-button size="small" @click="expandAllPermissions">全部展开</a-button>
|
|
||||||
<a-button size="small" @click="collapseAllPermissions">全部收起</a-button>
|
|
||||||
<a-button size="small" @click="checkAllPermissions">全选</a-button>
|
|
||||||
<a-button size="small" @click="uncheckAllPermissions">取消全选</a-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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
|
<a-tree
|
||||||
v-if="permissionTreeData.length > 0"
|
v-if="filteredPermissionTreeData.length > 0"
|
||||||
v-model:checked-keys="selectedPermissions"
|
v-model:checked-keys="selectedPermissions"
|
||||||
v-model:expanded-keys="permissionExpandedKeys"
|
v-model:expanded-keys="permissionExpandedKeys"
|
||||||
checkable
|
checkable
|
||||||
:data="permissionTreeData"
|
:data="filteredPermissionTreeData"
|
||||||
:field-names="{ title: 'name', key: 'code', children: 'children' }"
|
:field-names="{ title: 'name', key: 'code', children: 'children' }"
|
||||||
check-strictly
|
check-strictly
|
||||||
style="max-height: 400px; overflow: auto;"
|
|
||||||
/>
|
/>
|
||||||
<a-spin v-else :loading="true" style="width: 100%; padding: 40px;">
|
<div v-else class="permission-empty">
|
||||||
<div style="text-align: center;">{{ t('system.role.permission.loading') }}</div>
|
{{ permissionKeyword ? t('system.role.permission.noMatch') : t('system.role.permission.loading') }}
|
||||||
</a-spin>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RbacDetailShell>
|
||||||
</div>
|
</div>
|
||||||
</a-modal>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { Message, Modal } from '@arco-design/web-vue'
|
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 { 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 { useWarehouseList } from '@/composables/useWarehouseList'
|
||||||
import { hasPermission } from '@/utils/rbac-permission'
|
import { hasPermission } from '@/utils/rbac-permission'
|
||||||
import { handleApiError } from '@/utils/rbac-error-handler'
|
import { handleApiError } from '@/utils/rbac-error-handler'
|
||||||
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
import { buildFlatLeftMenuTree, findLeftMenuItemByKey } from '@/utils/rbacLeftMenuTree'
|
||||||
import PaginationPro from '@/components/PaginationPro/index.vue'
|
|
||||||
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { tableContainerRef, tableHeight } = useTableContainerHeight()
|
|
||||||
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dataSource = ref<RoleDto[]>([])
|
const dataSource = ref<RoleDto[]>([])
|
||||||
|
const searchKeyword = ref('')
|
||||||
const tableData = computed(() =>
|
const selectedRole = ref<RoleDto | null>(null)
|
||||||
dataSource.value.map((row) => ({
|
const isCreating = ref(false)
|
||||||
...row,
|
const expandedKeys = ref(['category-roles'])
|
||||||
boundWarehouse: getWarehouseNameById(roleWarehouseMap.value[row.id || '']),
|
const activeTab = ref('info')
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
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 formRef = ref()
|
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 }>({
|
const formState = reactive<RoleCreateDto & { boundWarehouseId?: string }>({
|
||||||
name: '',
|
name: '',
|
||||||
@ -233,44 +187,163 @@ const formRules = computed(() => ({
|
|||||||
code: [{ required: true, message: t('system.role.rules.code.required') }],
|
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 permissionTree = ref<PermissionGroupDto[]>([])
|
||||||
const selectedPermissions = ref<string[]>([])
|
const selectedPermissions = ref<string[]>([])
|
||||||
const permissionExpandedKeys = 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 permissionTreeData = computed(() => {
|
||||||
const treeData: PermissionDefinitionDto[] = []
|
const treeData: PermissionDefinitionDto[] = []
|
||||||
permissionTree.value.forEach((group: PermissionGroupDto) => {
|
permissionTree.value.forEach((group: PermissionGroupDto) => {
|
||||||
if (group.permissions && group.permissions.length > 0) {
|
if (group.permissions?.length) {
|
||||||
// 将分组作为父节点,权限作为子节点
|
treeData.push({
|
||||||
const groupNode: PermissionDefinitionDto = {
|
|
||||||
code: group.code || '',
|
code: group.code || '',
|
||||||
name: group.name || '',
|
name: group.name || '',
|
||||||
children: group.permissions || [],
|
children: group.permissions || [],
|
||||||
}
|
})
|
||||||
treeData.push(groupNode)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return treeData
|
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() {
|
async function loadData() {
|
||||||
try {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
try {
|
||||||
const response = await RoleService.getRbacRoleGetList(
|
const response = await RoleService.getRbacRoleGetList(
|
||||||
undefined, // keyword
|
searchKeyword.value || undefined,
|
||||||
pagination.current, // page (从1开始)
|
pagination.current,
|
||||||
pagination.pageSize, // pageSize
|
pagination.pageSize
|
||||||
undefined, // orderBy
|
|
||||||
undefined // descending
|
|
||||||
)
|
)
|
||||||
dataSource.value = response?.items || []
|
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) {
|
} catch (error) {
|
||||||
handleApiError(error)
|
handleApiError(error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -278,71 +351,76 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页变化
|
async function loadPermissionTree() {
|
||||||
function handlePageChange(page: number) {
|
if (permissionTree.value.length > 0) return
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
await RoleService.deleteRbacRoleDelete(id)
|
permissionTree.value = (await PermissionService.getRbacPermissionGetPermissionTree()) || []
|
||||||
Message.success('删除成功')
|
|
||||||
loadData()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error)
|
handleApiError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交表单
|
async function loadRolePermissions(roleId: string) {
|
||||||
async function handleModalOk() {
|
permissionLoading.value = true
|
||||||
try {
|
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
|
modalLoading.value = true
|
||||||
|
|
||||||
if (isEdit.value) {
|
if (isCreating.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 {
|
|
||||||
const createData: RoleCreateDto = {
|
const createData: RoleCreateDto = {
|
||||||
name: formState.name,
|
name: formState.name,
|
||||||
code: formState.code,
|
code: formState.code,
|
||||||
@ -353,224 +431,109 @@ async function handleModalOk() {
|
|||||||
roleWarehouseMap.value[newRoleId] = formState.boundWarehouseId
|
roleWarehouseMap.value[newRoleId] = formState.boundWarehouseId
|
||||||
}
|
}
|
||||||
Message.success(t('system.role.message.createSuccess'))
|
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) {
|
} catch (error: any) {
|
||||||
if (error.fields) {
|
if (error?.fields) return
|
||||||
return
|
|
||||||
}
|
|
||||||
handleApiError(error)
|
handleApiError(error)
|
||||||
} finally {
|
} finally {
|
||||||
modalLoading.value = false
|
modalLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分配权限
|
async function handleSavePermissions() {
|
||||||
async function handleAssignPermissions(record: RoleDto) {
|
if (!selectedRole.value?.id || isCreating.value) return
|
||||||
currentRoleId.value = record.id || ''
|
permissionSaving.value = true
|
||||||
permissionModalVisible.value = true
|
|
||||||
permissionModalLoading.value = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 加载权限树
|
await RoleService.postRbacRoleAssignPermissions(selectedRole.value.id, selectedPermissions.value)
|
||||||
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
|
|
||||||
)
|
|
||||||
Message.success(t('system.role.permission.assignSuccess'))
|
Message.success(t('system.role.permission.assignSuccess'))
|
||||||
permissionModalVisible.value = false
|
|
||||||
loadData()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error)
|
handleApiError(error)
|
||||||
} finally {
|
} finally {
|
||||||
permissionModalLoading.value = false
|
permissionSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 展开所有权限
|
|
||||||
function expandAllPermissions() {
|
function expandAllPermissions() {
|
||||||
const expandAll = (nodes: PermissionDefinitionDto[]): string[] => {
|
permissionExpandedKeys.value = expandAllCodes(filteredPermissionTreeData.value)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收起所有权限
|
|
||||||
function collapseAllPermissions() {
|
function collapseAllPermissions() {
|
||||||
permissionExpandedKeys.value = []
|
permissionExpandedKeys.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全选权限
|
|
||||||
function checkAllPermissions() {
|
function checkAllPermissions() {
|
||||||
const getAllCodes = (nodes: PermissionDefinitionDto[]): string[] => {
|
selectedPermissions.value = expandAllCodes(permissionTreeData.value)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消全选
|
|
||||||
function uncheckAllPermissions() {
|
function uncheckAllPermissions() {
|
||||||
selectedPermissions.value = []
|
selectedPermissions.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化
|
watch(
|
||||||
onMounted(() => {
|
() => selectedRole.value?.id,
|
||||||
loadData()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="less">
|
||||||
.role-page {
|
@import '@/components/RbacSplit/rbac-split-layout.less';
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
.role-form {
|
||||||
padding: 16px 16px 24px;
|
max-width: 640px;
|
||||||
background-color: #f0f3f4;
|
}
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
.permission-section {
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-page-card {
|
.permission-tree-card {
|
||||||
background: #fff;
|
min-height: 320px;
|
||||||
border-radius: 8px;
|
max-height: calc(100vh - 340px);
|
||||||
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;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.permission-actions {
|
.perm-summary {
|
||||||
display: flex;
|
font-size: 13px;
|
||||||
gap: 8px;
|
color: var(--color-text-3);
|
||||||
margin-bottom: 16px;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.permission-empty {
|
||||||
display: flex;
|
padding: 48px 24px;
|
||||||
align-items: center;
|
text-align: center;
|
||||||
justify-content: flex-start;
|
color: var(--color-text-3);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user