Files
xh-medical-sign-web/clinical-web/src/views/management/users/index.vue
T

1016 lines
28 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 { ElMessage } from 'element-plus'
import { getCampuses, getDepartments } from '@/api/management/organization'
import { getPermissions } from '@/api/management/permissions'
import {
getRolePermissions,
getRoleSummaries,
getRoles,
updateRolePermissions,
} from '@/api/management/roles'
import {
commitUserImport,
createUser,
deleteUser,
getUserDetail,
getUserImportJob,
getUsers,
isUserMockEnabled,
previewUserImport,
resetUserPassword,
updateUserManagement,
} from '@/api/management/users'
import type {
BackendDataScope,
BackendUserStatus,
ApiPermissionRecord,
CampusRecord,
DepartmentRecord,
RoleRecord,
UserImportJobResponseDto,
UserImportPreviewResponseDto,
UserRecord as ApiUserRecord,
} from '@/api/management/types'
import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import RoleSummaryCards from './components/RoleSummaryCards.vue'
import UserTable from './components/UserTable.vue'
import UserEditorDialog from './components/UserEditorDialog.vue'
import UserImportDialog from './components/UserImportDialog.vue'
import UserPasswordResetDialog from './components/UserPasswordResetDialog.vue'
import RoleApiPermissionPanel from './components/RoleApiPermissionPanel.vue'
import { roleColors, roleSummaries, users } from './mock'
import type { RoleSummary, UserEditorForm, UserTableRow } from './types'
const appStore = useAppStore()
const loginStore = useLoginStore()
const loading = ref(true)
const keyword = ref('')
const roleRows = ref<RoleSummary[]>([])
const roleOptions = ref<RoleRecord[]>([])
const userRows = ref<UserTableRow[]>([])
const roleColorMap = ref<Record<string, string>>(roleColors)
const userPage = ref(1)
const userPageSize = ref(20)
const userTotal = ref(0)
const campuses = ref<CampusRecord[]>([])
const departments = ref<DepartmentRecord[]>([])
const editorVisible = ref(false)
const editorInitial = ref<UserEditorForm | null>(null)
const editorSaving = ref(false)
const disablingUserId = ref<string | null>(null)
const passwordResetVisible = ref(false)
const passwordResetTarget = ref<UserTableRow | null>(null)
const passwordResetSaving = ref(false)
const importVisible = ref(false)
const importLoading = ref(false)
const importPreview = ref<UserImportPreviewResponseDto | null>(null)
const importJob = ref<UserImportJobResponseDto | null>(null)
const apiPermissions = ref<ApiPermissionRecord[]>([])
const selectedRoleId = ref('')
const selectedRolePermissionIds = ref<string[]>([])
const rolePermissionVersion = ref<number | undefined>()
const apiPermissionLoading = ref(false)
const apiPermissionSaving = ref(false)
const apiPermissionError = ref('')
const rolePermissionDrawerVisible = ref(false)
let userRequestId = 0
const canEditUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:update'),
)
const canCreateUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:create'),
)
const canDeactivateUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:disable'),
)
const canResetPasswords = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:password:reset'),
)
const canImportUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:import'),
)
const canViewRoleApiPermissions = computed(
() =>
!isUserMockEnabled &&
loginStore.hasPermission(['system:permission:query', 'system:role:permission:query']),
)
const canEditRoleApiPermissions = computed(
() => !isUserMockEnabled && loginStore.hasPermission('system:role:permission:update'),
)
const mockCampuses: CampusRecord[] = [
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
{ id: 'mock-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
{ id: 'mock-west', code: 'WEST', name: '西院区', status: 'ENABLED', sortNo: 3 },
]
const mockDepartments: DepartmentRecord[] = [
'医务处',
'放射科',
'心内科',
'骨科',
'健康管理中心',
'信息科',
'纪检监察室',
].map((name, index) => ({
id: `mock-department-${index + 1}`,
campusId: index === 3 || index === 4 ? 'mock-east' : 'mock-main',
code: `MOCK-${index + 1}`,
name,
status: 'ENABLED',
sortNo: index + 1,
}))
const visibleUsers = computed(() => {
if (!isUserMockEnabled) {
return userRows.value
}
return userRows.value.filter((user) => {
const matchesCampus = user.campus === appStore.selectedCampus
const normalizedKeyword = keyword.value.trim().toLowerCase()
const matchesKeyword =
!normalizedKeyword ||
[user.name, user.employeeNo].join(' ').toLowerCase().includes(normalizedKeyword)
return matchesCampus && matchesKeyword
})
})
async function loadUsers() {
const requestId = ++userRequestId
loading.value = true
try {
if (isUserMockEnabled) {
campuses.value = mockCampuses.map((campus) => ({ ...campus }))
departments.value = mockDepartments.map((department) => ({ ...department }))
roleRows.value = roleSummaries.map((role) => ({ ...role }))
roleOptions.value = roleSummaries.map((role) => ({
id: role.id,
code: role.id,
name: role.name,
description: role.description,
color: role.color,
userCount: role.userCount ?? undefined,
status: 'ENABLED' as const,
}))
userRows.value = users.map((user) => ({ ...user }))
roleColorMap.value = roleColors
userTotal.value = userRows.value.length
return
}
const [rolesResult, campusesResult, departmentsResult] = await Promise.allSettled([
getRoles({ page: 1, pageSize: 200 }),
getCampuses(),
getDepartments(),
])
const campusRecords = campusesResult.status === 'fulfilled' ? campusesResult.value : []
const selectedCampusId = campusRecords.find(
(campus) => campus.name === appStore.selectedCampus,
)?.id
const [usersResult, roleSummaryResult] = await Promise.allSettled([
getUsers({
page: userPage.value,
pageSize: userPageSize.value,
keyword: keyword.value.trim() || undefined,
status: 'all',
campusId: selectedCampusId,
}),
getRoleSummaries(selectedCampusId),
])
if (usersResult.status === 'rejected') {
throw usersResult.reason
}
if (requestId !== userRequestId) {
return
}
const campusNames = new Map(
campusesResult.status === 'fulfilled'
? campusesResult.value.map((campus) => [campus.id, campus.name])
: [],
)
const departmentNames = new Map(
departmentsResult.status === 'fulfilled'
? departmentsResult.value.map((department) => [department.id, department.name])
: [],
)
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
const apiUsers = usersResult.value.records
userRows.value = apiUsers.map((user) => mapUserRow(user, campusNames, departmentNames))
userTotal.value = usersResult.value.total
const roles = rolesResult.status === 'fulfilled' ? rolesResult.value : []
const summaryRoles = roleSummaryResult.status === 'fulfilled' ? roleSummaryResult.value : roles
roleOptions.value = roles.length ? roles : summaryRoles
roleRows.value = summaryRoles.map((role) => mapRoleSummary(role))
roleColorMap.value = Object.fromEntries(
roleOptions.value.map((role) => [role.name, role.color ?? '#8a9aa3']),
)
if (rolesResult.status === 'rejected' || roleSummaryResult.status === 'rejected') {
ElMessage.warning('角色列表或人数统计加载不完整,部分角色信息可能暂不可用')
}
if (!apiPermissions.value.length) {
void loadApiPermissionCatalog()
}
} catch {
if (requestId !== userRequestId) {
return
}
roleRows.value = []
roleOptions.value = []
userRows.value = []
userTotal.value = 0
ElMessage.error('用户与角色加载失败,请稍后重试')
} finally {
if (requestId === userRequestId) {
loading.value = false
}
}
}
async function loadSelectedRolePermissions() {
if (!selectedRoleId.value) {
selectedRolePermissionIds.value = []
rolePermissionVersion.value = undefined
return
}
const response = await getRolePermissions(selectedRoleId.value)
selectedRolePermissionIds.value = response.permissions.map((permission) => permission.id)
rolePermissionVersion.value = response.version
}
async function loadApiPermissionCatalog() {
if (isUserMockEnabled || !canViewRoleApiPermissions.value || apiPermissionLoading.value) {
return
}
apiPermissionLoading.value = true
apiPermissionError.value = ''
try {
const result = await getPermissions({ page: 1, pageSize: 200 })
apiPermissions.value = result.records
if (!roleOptions.value.some((role) => role.id === selectedRoleId.value)) {
selectedRoleId.value = roleOptions.value[0]?.id ?? ''
}
await loadSelectedRolePermissions()
} catch {
apiPermissionError.value = 'API 权限目录或角色权限加载失败,请检查当前账号权限'
selectedRolePermissionIds.value = []
rolePermissionVersion.value = undefined
} finally {
apiPermissionLoading.value = false
}
}
async function selectRoleForApiPermissions(roleId: string) {
if (!canViewRoleApiPermissions.value) {
return
}
selectedRoleId.value = roleId
apiPermissionLoading.value = true
apiPermissionError.value = ''
try {
await loadSelectedRolePermissions()
} catch {
selectedRolePermissionIds.value = []
rolePermissionVersion.value = undefined
apiPermissionError.value = '该角色的 API 权限加载失败,请稍后重试'
} finally {
apiPermissionLoading.value = false
}
}
function toggleApiPermission(permissionId: string) {
if (selectedRolePermissionIds.value.includes(permissionId)) {
selectedRolePermissionIds.value = selectedRolePermissionIds.value.filter(
(id) => id !== permissionId,
)
return
}
selectedRolePermissionIds.value = [...selectedRolePermissionIds.value, permissionId]
}
async function saveRoleApiPermissions() {
if (!canEditRoleApiPermissions.value || !selectedRoleId.value || apiPermissionSaving.value) {
return
}
const disabledPermission = apiPermissions.value.find(
(permission) =>
selectedRolePermissionIds.value.includes(permission.id) && permission.status === 'DISABLED',
)
if (disabledPermission) {
ElMessage.warning(`权限“${disabledPermission.name}”已停用,请取消勾选后再保存`)
return
}
apiPermissionSaving.value = true
try {
const response = await updateRolePermissions(selectedRoleId.value, {
permissionIds: selectedRolePermissionIds.value,
expectedVersion: rolePermissionVersion.value,
})
selectedRolePermissionIds.value = response.permissions.map((permission) => permission.id)
rolePermissionVersion.value = response.version
ElMessage.success('角色 API 权限已保存')
} catch {
ElMessage.error('角色 API 权限保存失败,请稍后重试')
} finally {
apiPermissionSaving.value = false
}
}
function mapUserRow(
user: ApiUserRecord,
campusNames: Map<string, string>,
departmentNames: Map<string, string>,
): UserTableRow {
const dataScopeLabels: Record<string, string> = {
ALL: '全院',
CAMPUS: '院区',
DEPARTMENT: '本科室',
READ_ONLY_ALL: '全院只读',
}
return {
id: user.id,
name: user.name,
account: user.account ?? '',
employeeNo: user.employeeNo ?? '—',
phone: user.phone ?? '',
email: user.email ?? '',
campus:
campusNames.get(user.campusId ?? '') ??
user.campusName ??
(user.campusId ? '未知院区' : '未指定院区'),
campusId: user.campusId,
department: departmentNames.get(user.departmentId ?? '') ?? user.department ?? '未指定科室',
departmentId: user.departmentId,
role: user.role,
roleNames: user.roleNames,
roleIds: user.roleIds,
dataScope: dataScopeLabels[user.dataScope ?? ''] ?? user.dataScope ?? '—',
status: user.status,
version: user.version,
}
}
function isMaskedContactValue(value: string | null | undefined) {
return Boolean(value?.includes('*'))
}
function mapRoleSummary(role: RoleRecord): RoleSummary {
return {
id: role.id,
name: role.name,
color: role.color ?? '#8a9aa3',
description: role.description,
userCount: role.userCount ?? null,
}
}
function showAddUserMessage() {
if (!canCreateUsers.value) {
return
}
editorInitial.value = null
editorVisible.value = true
}
function showImportMessage() {
if (!canImportUsers.value) {
return
}
if (isUserMockEnabled) {
ElMessage.info('批量导入需要切换到真实接口模式(VITE_USE_MOCK=false)')
return
}
importPreview.value = null
importJob.value = null
importVisible.value = true
}
async function editUser(row: UserTableRow) {
if (!canEditUsers.value) {
return
}
try {
const user = isUserMockEnabled
? userRows.value.find((item) => item.id === row.id)
: await getUserDetail(row.id)
if (!user) {
ElMessage.error('用户不存在或已被删除')
return
}
editorInitial.value = {
id: user.id,
username: user.account ?? '',
password: '',
displayName: user.name,
employeeNo: user.employeeNo ?? '',
phone: user.phone ?? '',
phoneMasked: isMaskedContactValue(user.phone),
email: user.email ?? '',
emailMasked: isMaskedContactValue(user.email),
campusId:
user.campusId ?? campuses.value.find((campus) => campus.name === row.campus)?.id ?? '',
departmentId:
user.departmentId ??
departments.value.find((department) => department.name === row.department)?.id ??
'',
status: user.status,
dataScope: toBackendDataScope(user.dataScope),
roleIds: user.roleIds ?? [],
clearPhone: false,
clearEmail: false,
version: user.version,
}
editorVisible.value = true
} catch {
ElMessage.error('用户详情加载失败,请稍后重试')
}
}
function resetPassword(user: UserTableRow) {
if (!canResetPasswords.value) {
return
}
if (isUserMockEnabled) {
ElMessage.info('密码重置需要切换到真实接口模式(VITE_USE_MOCK=false)')
return
}
passwordResetTarget.value = user
passwordResetVisible.value = true
}
async function handlePasswordReset(payload: {
newPassword: string
forceChangeOnNextLogin: boolean
}) {
const target = passwordResetTarget.value
if (!target) {
return
}
passwordResetSaving.value = true
try {
await resetUserPassword(target.id, payload)
passwordResetVisible.value = false
passwordResetTarget.value = null
ElMessage.success(`用户“${target.name}”的密码已重置`)
} catch {
ElMessage.error('密码重置失败,请稍后重试')
} finally {
passwordResetSaving.value = false
}
}
async function handleImportPreview(payload: { file: File; campusId?: string }) {
importLoading.value = true
importPreview.value = null
importJob.value = null
try {
importPreview.value = await previewUserImport(payload.file, payload.campusId)
} catch {
ElMessage.error('用户文件校验失败,请检查文件格式和内容')
} finally {
importLoading.value = false
}
}
async function handleImportCommit() {
const preview = importPreview.value
if (!preview || preview.invalidRows > 0) {
return
}
importLoading.value = true
try {
const job = await commitUserImport({ importId: preview.importId })
importJob.value = job
try {
importJob.value = await getUserImportJob(job.jobId)
} catch {
// 提交已经成功,任务状态查询失败不影响本次导入结果。
}
ElMessage.success('用户导入任务已提交')
await loadUsers()
} catch {
ElMessage.error('用户导入提交失败,请稍后重试')
} finally {
importLoading.value = false
}
}
function searchUsers() {
if (isUserMockEnabled) {
return
}
userPage.value = 1
void loadUsers()
}
function changeUserPage(nextPage: number) {
const pageCount = Math.max(Math.ceil(userTotal.value / userPageSize.value), 1)
if (isUserMockEnabled || nextPage < 1 || nextPage > pageCount || nextPage === userPage.value) {
return
}
userPage.value = nextPage
void loadUsers()
}
async function deactivateUser(row: UserTableRow) {
if (
!canDeactivateUsers.value ||
row.status !== 'enabled' ||
!window.confirm(`确定停用用户“${row.name}”吗?`)
) {
return
}
disablingUserId.value = row.id
try {
if (isUserMockEnabled) {
const target = userRows.value.find((user) => user.id === row.id)
if (target) {
target.status = 'disabled'
}
} else {
await deleteUser(row.id)
await loadUsers()
}
ElMessage.success(`用户“${row.name}”已停用`)
} catch {
ElMessage.error('用户停用失败,请稍后重试')
} finally {
disablingUserId.value = null
}
}
function toBackendDataScope(value: string | undefined) {
const map: Record<string, string> = {
全院: 'ALL',
院区: 'CAMPUS',
本科室: 'DEPARTMENT',
全院只读: 'READ_ONLY_ALL',
}
return (map[value ?? ''] ?? value ?? 'DEPARTMENT') as BackendDataScope
}
function toBackendUserStatus(value: UserTableRow['status']): BackendUserStatus {
const map: Record<UserTableRow['status'], BackendUserStatus> = {
enabled: 'ENABLED',
disabled: 'DISABLED',
locked: 'LOCKED',
}
return map[value]
}
function getRoleNames(roleIds: string[]) {
return roleIds
.map((roleId) => roleOptions.value.find((role) => role.id === roleId)?.name)
.filter((name): name is string => Boolean(name))
}
function getCampusName(id: string) {
return campuses.value.find((campus) => campus.id === id)?.name ?? '未指定院区'
}
function getDepartmentName(id: string) {
return departments.value.find((department) => department.id === id)?.name ?? '未指定科室'
}
async function saveUser(form: UserEditorForm) {
editorSaving.value = true
try {
if (isUserMockEnabled) {
const row: UserTableRow = {
id: form.id ?? `mock-user-${Date.now()}`,
name: form.displayName,
account: form.username,
employeeNo: form.employeeNo || '—',
phone: form.phone,
email: form.email,
campus: getCampusName(form.campusId),
campusId: form.campusId,
department: getDepartmentName(form.departmentId),
departmentId: form.departmentId || null,
role: getRoleNames(form.roleIds).join('、') || '未分配角色',
roleNames: getRoleNames(form.roleIds),
roleIds: [...form.roleIds],
dataScope: form.dataScope,
status: form.status,
version: form.version,
}
const index = userRows.value.findIndex((item) => item.id === row.id)
if (index === -1) {
userRows.value.unshift(row)
} else {
userRows.value[index] = row
}
} else if (form.id) {
await updateUserManagement(form.id, {
displayName: form.displayName,
employeeNo: form.employeeNo || undefined,
...(form.phoneMasked
? {}
: form.clearPhone
? { clearPhone: true }
: { phone: form.phone || undefined }),
...(form.emailMasked
? {}
: form.clearEmail
? { clearEmail: true }
: { email: form.email || undefined }),
campusId: form.campusId,
departmentId: form.departmentId || null,
status: toBackendUserStatus(form.status),
dataScope: form.dataScope,
roleIds: form.roleIds,
expectedVersion: form.version,
})
await loadUsers()
} else {
await createUser({
username: form.username,
password: form.password,
displayName: form.displayName,
employeeNo: form.employeeNo || undefined,
phone: form.phone || undefined,
email: form.email || undefined,
campusId: form.campusId,
departmentId: form.departmentId || null,
status: toBackendUserStatus(form.status),
dataScope: form.dataScope,
roleIds: form.roleIds,
})
await loadUsers()
}
editorVisible.value = false
ElMessage.success(form.id ? '用户已更新' : '用户已创建')
} catch {
ElMessage.error(form.id ? '用户更新失败,请稍后重试' : '用户创建失败,请稍后重试')
} finally {
editorSaving.value = false
}
}
watch(
() => appStore.selectedCampus,
() => {
if (!isUserMockEnabled) {
userPage.value = 1
void loadUsers()
}
},
)
onMounted(loadUsers)
function openRolePermissionDrawer(role: RoleSummary) {
if (!canViewRoleApiPermissions.value) {
return
}
rolePermissionDrawerVisible.value = true
void selectRoleForApiPermissions(role.id)
}
</script>
<template>
<section class="users-page">
<div v-if="loading" class="loading-card" aria-label="正在加载用户与角色">
<span v-for="index in 4" :key="index" />
</div>
<template v-else>
<RoleSummaryCards
:rows="roleRows"
:interactive="canViewRoleApiPermissions"
:selected-role-id="selectedRoleId"
@select="openRolePermissionDrawer"
/>
<section class="page-card">
<div class="card-heading">
<h2>用户列表</h2>
<span class="heading-spacer" />
<input
v-model="keyword"
class="user-search"
type="search"
placeholder="搜索姓名 / 工号"
aria-label="搜索姓名或工号"
@keydown.enter.prevent="searchUsers"
/>
<button type="button" class="table-button" @click="searchUsers">查询</button>
<button
v-if="canCreateUsers"
type="button"
class="table-button"
@click="showAddUserMessage"
>
+新增用户
</button>
<button
v-if="canImportUsers"
type="button"
class="table-button"
@click="showImportMessage"
>
批量导入
</button>
</div>
<UserTable
:rows="visibleUsers"
:role-colors="roleColorMap"
:disabling-id="disablingUserId"
:can-edit="canEditUsers"
:can-reset-password="canResetPasswords"
:can-deactivate="canDeactivateUsers"
@edit="editUser"
@reset-password="resetPassword"
@deactivate="deactivateUser"
/>
<div v-if="!isUserMockEnabled && userTotal" class="table-pagination">
<span>共 {{ userTotal }} 条</span>
<button
type="button"
class="table-button"
:disabled="userPage <= 1 || loading"
@click="changeUserPage(userPage - 1)"
>
上一页
</button>
<span>第 {{ userPage }} / {{ Math.max(Math.ceil(userTotal / userPageSize), 1) }} 页</span>
<button
type="button"
class="table-button"
:disabled="userPage >= Math.ceil(userTotal / userPageSize) || loading"
@click="changeUserPage(userPage + 1)"
>
下一页
</button>
</div>
</section>
<Teleport to="body">
<div
v-if="rolePermissionDrawerVisible && canViewRoleApiPermissions"
class="role-permission-mask"
@click.self="rolePermissionDrawerVisible = false"
>
<aside class="role-permission-drawer" role="dialog" aria-modal="true">
<RoleApiPermissionPanel
:roles="roleOptions"
:permissions="apiPermissions"
:selected-role-id="selectedRoleId"
:selected-permission-ids="selectedRolePermissionIds"
:loading="apiPermissionLoading"
:saving="apiPermissionSaving"
:error="apiPermissionError"
:can-edit="canEditRoleApiPermissions"
drawer
@select-role="selectRoleForApiPermissions"
@toggle-permission="toggleApiPermission"
@save="saveRoleApiPermissions"
@close="rolePermissionDrawerVisible = false"
/>
</aside>
</div>
</Teleport>
</template>
<UserEditorDialog
:visible="editorVisible"
:initial="editorInitial"
:campuses="campuses"
:departments="departments"
:roles="roleOptions"
:saving="editorSaving"
@close="editorVisible = false"
@save="saveUser"
/>
<UserPasswordResetDialog
:visible="passwordResetVisible"
:user-name="passwordResetTarget?.name ?? ''"
:saving="passwordResetSaving"
@close="passwordResetVisible = false"
@reset="handlePasswordReset"
/>
<UserImportDialog
:visible="importVisible"
:campuses="campuses"
:default-campus-id="campuses.find((campus) => campus.name === appStore.selectedCampus)?.id"
:preview="importPreview"
:job="importJob"
:loading="importLoading"
@close="importVisible = false"
@preview="handleImportPreview"
@commit="handleImportCommit"
/>
</section>
</template>
<style scoped>
.users-page {
width: 100%;
max-width: 1440px;
margin: 0 auto;
}
.page-card {
padding: 16px 18px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.card-heading {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 14px;
}
.card-heading h2 {
margin: 0;
color: var(--ink);
font-size: 14.5px;
line-height: 1.4;
}
.heading-spacer {
flex: 1;
}
.user-search {
width: 180px;
padding: 6px 10px;
color: var(--ink);
font-size: 12.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.user-search:focus {
outline: none;
border-color: var(--brand);
}
.table-button {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.table-button:hover {
background: var(--brand-l);
}
.table-pagination {
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
padding-top: 14px;
color: var(--mut);
font-size: 12px;
}
.table-pagination .table-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.role-permission-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
justify-content: flex-end;
background: rgb(9 40 52 / 45%);
}
.role-permission-drawer {
width: min(760px, 96vw);
height: 100%;
overflow: hidden;
background: var(--card);
box-shadow: var(--sh-modal);
animation: slide-in 0.18s ease-out;
}
.loading-card {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.loading-card span {
display: block;
height: 116px;
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
background-size: 400% 100%;
border-radius: 10px;
box-shadow: var(--sh);
animation: skeleton-shimmer 1.4s ease infinite;
}
@keyframes skeleton-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@keyframes slide-in {
from {
transform: translateX(12px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@media (max-width: 900px) {
.card-heading {
align-items: flex-start;
flex-wrap: wrap;
}
.heading-spacer {
display: none;
}
.user-search {
flex: 1;
min-width: 180px;
}
}
@media (max-width: 600px) {
.loading-card {
grid-template-columns: 1fr;
}
}
</style>