175 lines
11 KiB
Vue
175 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import { LockKeyhole, RotateCcw, Save, ShieldCheck } from '@lucide/vue'
|
||
import { useTenantAdministrationStore } from '@/modules/tenant-management/store/tenantAdministration'
|
||
import { REQUIRED_TENANT_PERMISSION_CODES, TENANT_PERMISSION_GROUPS } from '@/modules/tenant-management/contracts'
|
||
import { useAuthStore } from '@/modules/auth/public'
|
||
|
||
const store = useTenantAdministrationStore()
|
||
const auth = useAuthStore()
|
||
const route = useRoute()
|
||
const initialRoleId = typeof route.query.role === 'string' && store.roles.some((role) => role.id === route.query.role) ? route.query.role : store.roles[0]?.id ?? ''
|
||
const selectedRoleId = ref(initialRoleId)
|
||
const selectedCodes = ref<string[]>([])
|
||
const savedCodes = ref<string[]>([])
|
||
const loading = ref(false)
|
||
const loadError = ref('')
|
||
const saving = ref(false)
|
||
|
||
const selectedRole = computed(() => store.roles.find((role) => role.id === selectedRoleId.value))
|
||
const editable = computed(() => Boolean(auth.hasPermission('company:permission:assign') && selectedRole.value && !selectedRole.value.builtin && selectedRole.value.status === 'ACTIVE'))
|
||
const hasChanges = computed(() => [...selectedCodes.value].sort().join('|') !== [...savedCodes.value].sort().join('|'))
|
||
const requiredPermissionCodes = new Set<string>(REQUIRED_TENANT_PERMISSION_CODES)
|
||
|
||
function isRequiredPermission(code: string) {
|
||
return requiredPermissionCodes.has(code)
|
||
}
|
||
|
||
function loadRolePermissions() {
|
||
const codes = selectedRole.value?.permissionCodes ?? []
|
||
selectedCodes.value = [...codes]
|
||
savedCodes.value = [...codes]
|
||
}
|
||
|
||
watch(selectedRoleId, loadRolePermissions, { immediate: true })
|
||
|
||
async function loadRoles() {
|
||
loading.value = true
|
||
loadError.value = ''
|
||
try {
|
||
await store.loadRoles()
|
||
if (!store.roles.some((role) => role.id === selectedRoleId.value)) selectedRoleId.value = store.roles[0]?.id ?? ''
|
||
loadRolePermissions()
|
||
} catch (error) {
|
||
loadError.value = error instanceof Error ? error.message : '角色列表加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => void loadRoles())
|
||
|
||
function groupCodes(groupKey: string) {
|
||
return TENANT_PERMISSION_GROUPS.find((group) => group.key === groupKey)?.items.map((item) => item.code) ?? []
|
||
}
|
||
|
||
function groupChecked(groupKey: string) {
|
||
const codes = groupCodes(groupKey)
|
||
return codes.length > 0 && codes.every((code) => selectedCodes.value.includes(code))
|
||
}
|
||
|
||
function groupIndeterminate(groupKey: string) {
|
||
const codes = groupCodes(groupKey)
|
||
const count = codes.filter((code) => selectedCodes.value.includes(code)).length
|
||
return count > 0 && count < codes.length
|
||
}
|
||
|
||
function toggleGroup(groupKey: string, checked: boolean) {
|
||
const codes = groupCodes(groupKey)
|
||
if (checked) selectedCodes.value = [...new Set([...selectedCodes.value, ...codes])]
|
||
else selectedCodes.value = selectedCodes.value.filter((code) => !codes.includes(code) || isRequiredPermission(code))
|
||
}
|
||
|
||
function resetPermissions() {
|
||
selectedCodes.value = [...savedCodes.value]
|
||
}
|
||
|
||
async function savePermissions() {
|
||
if (!selectedRole.value || !editable.value || saving.value) return
|
||
saving.value = true
|
||
try {
|
||
await store.setRolePermissions(selectedRole.value.id, selectedCodes.value)
|
||
savedCodes.value = [...selectedCodes.value]
|
||
ElMessage.success('角色菜单及操作权限已保存')
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section v-loading="loading" class="permissions-page">
|
||
<div v-if="loadError" class="client-card permission-load-error" role="alert">
|
||
<div><strong>角色权限加载失败</strong><span>{{ loadError }}</span></div>
|
||
<el-button :loading="loading" @click="loadRoles">重新加载</el-button>
|
||
</div>
|
||
<template v-else-if="store.roles.length">
|
||
<aside class="client-card role-selector" aria-label="选择角色">
|
||
<button v-for="role in store.roles" :key="role.id" type="button" :class="{ active: role.id === selectedRoleId }" @click="selectedRoleId = role.id">
|
||
<span class="role-select-icon"><LockKeyhole v-if="role.builtin" :size="17" /><ShieldCheck v-else :size="17" /></span>
|
||
<span><strong>{{ role.name }}</strong><small>{{ role.code }}</small></span>
|
||
<el-tag :type="role.status === 'ACTIVE' ? 'success' : 'info'" effect="plain" size="small">{{ role.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag>
|
||
</button>
|
||
</aside>
|
||
|
||
<div class="permission-content">
|
||
<div class="permission-actions" aria-label="权限操作"><el-button :disabled="!editable || !hasChanges || saving" @click="resetPermissions"><RotateCcw :size="16" />撤销修改</el-button><el-button type="primary" :loading="saving" :disabled="!editable || !hasChanges || saving" @click="savePermissions"><Save :size="16" />保存权限</el-button></div>
|
||
|
||
<div v-if="selectedRole?.builtin" class="permission-notice"><LockKeyhole :size="18" /><span>当前角色为系统内置角色,权限由平台统一维护,不允许修改。</span></div>
|
||
<div v-else-if="selectedRole?.status === 'INACTIVE'" class="permission-notice warning"><span>该角色已停用,请先启用角色后再调整权限。</span></div>
|
||
<div v-else class="permission-notice"><ShieldCheck :size="18" /><span>保存时会全量替换该角色的权限,请确认所有需要保留的菜单和操作均已勾选。</span></div>
|
||
|
||
<div class="permission-groups">
|
||
<article v-for="group in TENANT_PERMISSION_GROUPS" :key="group.key" class="client-card permission-group" :class="{ 'permission-group--single': group.items.length === 1 }">
|
||
<header>
|
||
<el-checkbox :model-value="groupChecked(group.key)" :indeterminate="groupIndeterminate(group.key)" :disabled="!editable || group.items.every((item) => isRequiredPermission(item.code))" @change="toggleGroup(group.key, Boolean($event))"><strong>{{ group.label }}</strong></el-checkbox>
|
||
<span>{{ group.items.filter((item) => selectedCodes.includes(item.code)).length }} / {{ group.items.length }}</span>
|
||
</header>
|
||
<el-checkbox-group v-model="selectedCodes" :disabled="!editable">
|
||
<el-checkbox v-for="item in group.items" :key="item.code" :value="item.code" :disabled="isRequiredPermission(item.code)" class="permission-item" :class="{ selected: selectedCodes.includes(item.code) }">
|
||
<span class="permission-copy"><strong>{{ item.label }}</strong><small>{{ item.description }}</small><code>{{ item.code }}</code></span>
|
||
</el-checkbox>
|
||
</el-checkbox-group>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="client-card permission-empty">当前企业暂无可用角色,创建角色后才能查看和分配权限。</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.permissions-page { display: grid; grid-template-columns: 250px minmax(0, 1fr); align-items: start; gap: 14px; }
|
||
.permission-load-error, .permission-empty { grid-column: 1 / -1; min-height: 170px; }
|
||
.permission-load-error { display: flex; align-items: center; justify-content: center; gap: 20px; }
|
||
.permission-load-error > div { display: flex; max-width: 520px; flex-direction: column; gap: 6px; }
|
||
.permission-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||
.permission-empty { display: grid; place-items: center; color: var(--muted); font-size: 12px; text-align: center; }
|
||
.role-selector { position: sticky; top: 84px; padding: 12px; }
|
||
.role-selector > button { display: flex; width: 100%; min-height: 58px; align-items: center; gap: 9px; margin: 3px 0; padding: 9px; border: 0; border-radius: 8px; background: transparent; color: var(--navy); cursor: pointer; text-align: left; }
|
||
.role-selector > button:hover, .role-selector > button.active { background: #edf6f4; }
|
||
.role-selector > button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||
.role-selector > button.active { color: var(--primary); }
|
||
.role-select-icon { display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; background: #f0f5f4; }
|
||
.role-selector > button > span:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
|
||
.role-selector strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.role-selector small { color: var(--muted); font-size: 9px; }
|
||
.permission-content { display: flex; min-width: 0; flex-direction: column; gap: 12px; }
|
||
.permission-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||
.permission-notice { display: flex; align-items: center; gap: 9px; padding: 12px 15px; border: 1px solid #cfe1dc; background: #edf6f4; color: #45615d; font-size: 11px; }
|
||
.permission-notice.warning { border-color: #f1d4a8; background: #fff7ed; color: #9a5a09; }
|
||
.permission-groups { display: grid; gap: 12px; }
|
||
.permission-group { padding: 0; overflow: hidden; }
|
||
.permission-group > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); background: #fafcfc; }
|
||
.permission-group > header > span { color: var(--muted); font-size: 11px; }
|
||
.permission-group :deep(.el-checkbox-group) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
.permission-group--single :deep(.el-checkbox-group) { grid-template-columns: minmax(0, 1fr); }
|
||
.permission-item { display: flex; width: 100%; min-height: 104px; align-items: flex-start; margin: 0; padding: 16px 18px; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||
.permission-item:nth-child(odd) { border-right: 1px solid var(--border); }
|
||
.permission-item:last-child:nth-child(odd) { grid-column: 1 / -1; border-right: 0; }
|
||
.permission-item.selected { background: #f5faf9; }
|
||
.permission-item :deep(.el-checkbox__input) { flex: 0 0 auto; margin-top: 3px; }
|
||
.permission-item :deep(.el-checkbox__label) { display: flex; min-width: 0; flex: 1; padding-left: 10px; white-space: normal; }
|
||
.permission-copy { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
|
||
.permission-copy strong { color: var(--navy); font-size: 13px; line-height: 1.35; }
|
||
.permission-copy small { color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||
.permission-copy code { overflow-wrap: anywhere; color: #86939a; font-size: 10px; line-height: 1.35; }
|
||
@media (max-width: 900px) { .permissions-page { grid-template-columns: 1fr; } .role-selector { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||
@media (max-width: 720px) { .permission-actions { width: 100%; } .permission-actions .el-button { flex: 1; } .permission-group :deep(.el-checkbox-group) { grid-template-columns: 1fr; } .permission-item:nth-child(odd) { border-right: 0; } }
|
||
@media (max-width: 480px) { .role-selector { grid-template-columns: 1fr; } }
|
||
</style>
|