Files
client-web/src/modules/tenant-management/views/PermissionsView.vue
T
2026-08-04 10:31:53 +08:00

175 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>