This commit is contained in:
Your Name
2026-08-07 15:35:02 +08:00
parent 3fc94c4a89
commit 6119fdd767
25 changed files with 2126 additions and 355 deletions
+587 -61
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined, SearchOutlined } from '@ant-design/icons-vue'
import api from '../api'
import { useAuthStore } from '../stores/auth'
import { useIsMobile } from '../composables/useIsMobile'
@@ -11,33 +11,69 @@ const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 520))
const pageCurrent = ref(1)
const pageSize = ref(10)
const searchKeyword = ref('')
const paginatedUsers = computed(() => {
const start = (pageCurrent.value - 1) * pageSize.value
return users.value.slice(start, start + pageSize.value)
})
const FIELD_LABELS = {
username: '用户名',
password: '密码',
display_name: '显示名称',
email: '邮箱',
role: '角色',
max_accounts: '可添加抖音账号数',
is_active: '账号状态',
email_verified: '邮箱验证状态'
}
const paginationConfig = computed(() => ({
current: pageCurrent.value,
pageSize: pageSize.value,
total: users.value.length,
showSizeChanger: !isMobile.value,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total) => `${total}`,
size: isMobile.value ? 'small' : 'default',
onChange: (page, size) => {
pageCurrent.value = page
pageSize.value = size
/** FastAPI 422 detail 可能是字符串,也可能是校验错误对象数组。 */
const formatApiError = (error, fallback = '操作失败') => {
const detail = error?.response?.data?.detail
if (detail == null || detail === '') {
return error?.message || fallback
}
}))
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const parts = detail.map((item) => {
if (typeof item === 'string') return item
if (!item || typeof item !== 'object') return String(item)
const rawField = Array.isArray(item.loc)
? item.loc.filter((part) => part !== 'body' && part !== 'query').join('.')
: ''
const field = FIELD_LABELS[rawField] || rawField
let msg = item.msg || item.message || JSON.stringify(item)
if (/at least 2 characters/i.test(msg)) msg = '至少 2 个字符'
else if (/at least 6 characters/i.test(msg)) msg = '至少 6 位'
else if (/valid email/i.test(msg)) msg = '邮箱格式不正确'
return field ? `${field}${msg}` : msg
}).filter(Boolean)
return parts.length ? parts.join('') : fallback
}
if (typeof detail === 'object') {
return detail.msg || detail.message || JSON.stringify(detail)
}
return String(detail)
}
const auth = useAuthStore()
const activeTab = ref('users')
const users = ref([])
const roles = ref([])
const roleRecords = ref([])
const permissionCatalog = ref({ menus: [], actions: [] })
const loading = ref(false)
const rolesLoading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref('新增用户')
const editingId = ref(null)
const roleModalVisible = ref(false)
const roleModalTitle = ref('新建角色')
const editingRoleCode = ref(null)
const roleSaving = ref(false)
const roleForm = ref({
code: '',
label: '',
description: '',
permissions: []
})
const userForm = ref({
username: '',
@@ -54,26 +90,73 @@ const defaultRegisterMaxAccounts = ref(3)
const emailVerificationRequired = ref(true)
const emailBindingRequired = ref(false)
const isAdminRole = computed(() => userForm.value.role === 'admin')
const roleOptions = ref([
{ value: 'admin', label: '管理员', is_admin: true },
{ value: 'operator', label: '运营', is_admin: false },
{ value: 'viewer', label: '只读', is_admin: false }
])
const isAdminRole = computed(() => {
const hit = roleOptions.value.find((r) => r.value === userForm.value.role)
return !!(hit?.is_admin || userForm.value.role === 'admin')
})
const emailRequiredForRole = computed(
() => emailBindingRequired.value && !isAdminRole.value
)
const roleOptions = ref([
{ value: 'admin', label: '管理员' },
{ value: 'operator', label: '运营' },
{ value: 'viewer', label: '只读' }
])
const rolePermissionsReadonly = computed(
() => editingRoleCode.value === 'admin'
)
const hasEmail = computed(() => !!userForm.value.email?.trim())
const getRoleLabel = (role) =>
roleOptions.value.find((r) => r.value === role)?.label ||
roleRecords.value.find((r) => r.value === role)?.label ||
role
const filteredUsers = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
if (!keyword) return users.value
return users.value.filter((user) => {
const roleLabel = (getRoleLabel(user.role) || '').toLowerCase()
const haystack = [
String(user.id ?? ''),
user.username || '',
user.display_name || '',
user.email || '',
user.role || '',
roleLabel
].join(' ').toLowerCase()
return haystack.includes(keyword)
})
})
const paginatedUsers = computed(() => {
const start = (pageCurrent.value - 1) * pageSize.value
return filteredUsers.value.slice(start, start + pageSize.value)
})
const paginationConfig = computed(() => ({
current: pageCurrent.value,
pageSize: pageSize.value,
total: filteredUsers.value.length,
showSizeChanger: !isMobile.value,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total) => `${total}`,
size: isMobile.value ? 'small' : 'default',
onChange: (page, size) => {
pageCurrent.value = page
pageSize.value = size
}
}))
const fetchUsers = async () => {
loading.value = true
try {
const res = await api.get('/users')
users.value = res.data
} catch (error) {
message.error(error.response?.data?.detail || '获取用户列表失败')
message.error(formatApiError(error, '获取用户列表失败'))
} finally {
loading.value = false
}
@@ -82,15 +165,133 @@ const fetchUsers = async () => {
const fetchRoles = async () => {
try {
const res = await api.get('/auth/roles')
roles.value = res.data.roles
roles.value = res.data.roles || []
if (roles.value.length) {
roleOptions.value = roles.value
roleOptions.value = roles.value.map((r) => ({
value: r.value,
label: r.label,
is_admin: !!r.is_admin
}))
}
} catch {
// keep defaults
}
}
const fetchRoleRecords = async () => {
rolesLoading.value = true
try {
const [rolesRes, catalogRes] = await Promise.all([
api.get('/roles'),
api.get('/roles/catalog')
])
roleRecords.value = rolesRes.data.roles || []
permissionCatalog.value = catalogRes.data || { menus: [], actions: [] }
if (roleRecords.value.length) {
roleOptions.value = roleRecords.value.map((r) => ({
value: r.value,
label: r.label,
is_admin: !!r.is_admin
}))
}
} catch (error) {
message.error(formatApiError(error, '获取角色列表失败'))
} finally {
rolesLoading.value = false
}
}
const openAddRole = () => {
editingRoleCode.value = null
roleModalTitle.value = '新建角色'
roleForm.value = {
code: '',
label: '',
description: '',
permissions: [
'menu.dashboard',
'menu.accounts',
'menu.messages',
'menu.rules',
'menu.help',
'menu.download'
]
}
roleModalVisible.value = true
}
const openEditRole = (record) => {
editingRoleCode.value = record.value
roleModalTitle.value = record.is_admin ? '查看管理员角色' : '编辑角色'
roleForm.value = {
code: record.value,
label: record.label,
description: record.description || '',
permissions: [...(record.permissions || [])]
}
roleModalVisible.value = true
}
const handleSaveRole = async () => {
const code = (roleForm.value.code || '').trim().toLowerCase()
const label = (roleForm.value.label || '').trim()
if (!editingRoleCode.value && !code) {
message.warning('请填写角色码')
return
}
if (!label) {
message.warning('请填写角色名称')
return
}
roleSaving.value = true
try {
if (editingRoleCode.value) {
await api.put(`/roles/${encodeURIComponent(editingRoleCode.value)}`, {
label,
description: roleForm.value.description || null,
permissions: rolePermissionsReadonly.value
? undefined
: roleForm.value.permissions
})
message.success('角色已更新')
} else {
await api.post('/roles', {
code,
label,
description: roleForm.value.description || null,
permissions: roleForm.value.permissions
})
message.success('角色已创建')
}
roleModalVisible.value = false
await Promise.all([fetchRoleRecords(), fetchRoles()])
} catch (error) {
message.error(formatApiError(error, '保存角色失败'))
} finally {
roleSaving.value = false
}
}
const handleDeleteRole = (record) => {
if (record.is_system) {
message.warning('系统内置角色不可删除')
return
}
Modal.confirm({
title: `确定删除角色「${record.label}」吗?`,
okType: 'danger',
onOk: async () => {
try {
await api.delete(`/roles/${encodeURIComponent(record.value)}`)
message.success('角色已删除')
await Promise.all([fetchRoleRecords(), fetchRoles()])
} catch (error) {
message.error(formatApiError(error, '删除角色失败'))
}
}
})
}
const fetchDefaultMaxAccounts = async () => {
try {
const res = await api.get('/settings')
@@ -131,7 +332,9 @@ const openEdit = (record) => {
email_verified: !!record.email_verified,
role: record.role,
is_active: record.is_active,
max_accounts: record.role === 'admin' ? defaultRegisterMaxAccounts.value : resolveAccountLimit(record)
max_accounts: isUnlimitedQuota(record)
? defaultRegisterMaxAccounts.value
: resolveAccountLimit(record)
}
modalVisible.value = true
}
@@ -144,6 +347,8 @@ const onEmailChange = () => {
const handleSave = async () => {
const email = userForm.value.email?.trim() || null
const username = userForm.value.username.trim()
const password = userForm.value.password || ''
if (emailRequiredForRole.value && !email) {
message.warning('当前系统要求非管理员用户必须绑定邮箱')
@@ -151,15 +356,23 @@ const handleSave = async () => {
}
if (!editingId.value) {
if (!userForm.value.username.trim() || !userForm.value.password) {
if (!username || !password) {
message.warning('请填写用户名和密码')
return
}
if (username.length < 2) {
message.warning('用户名至少 2 个字符')
return
}
if (password.length < 6) {
message.warning('密码至少 6 位')
return
}
try {
await api.post('/users', {
username: userForm.value.username.trim(),
password: userForm.value.password,
display_name: userForm.value.display_name || userForm.value.username,
username,
password,
display_name: userForm.value.display_name || username,
role: userForm.value.role,
email: email || undefined,
email_verified: email ? userForm.value.email_verified : true,
@@ -169,11 +382,16 @@ const handleSave = async () => {
modalVisible.value = false
fetchUsers()
} catch (error) {
message.error(error.response?.data?.detail || '创建失败')
message.error(formatApiError(error, '创建失败'))
}
return
}
if (password && password.length < 6) {
message.warning('新密码至少 6 位')
return
}
const payload = {
display_name: userForm.value.display_name,
role: userForm.value.role,
@@ -183,8 +401,8 @@ const handleSave = async () => {
if (email) {
payload.email_verified = userForm.value.email_verified
}
if (userForm.value.password) {
payload.password = userForm.value.password
if (password) {
payload.password = password
}
if (!isAdminRole.value) {
payload.max_accounts = userForm.value.max_accounts
@@ -198,7 +416,7 @@ const handleSave = async () => {
await auth.fetchMe()
}
} catch (error) {
message.error(error.response?.data?.detail || '更新失败')
message.error(formatApiError(error, '更新失败'))
}
}
@@ -207,15 +425,17 @@ const handleDelete = (record) => {
title: `确定删除用户「${record.username}」吗?`,
okType: 'danger',
onOk: async () => {
await api.delete(`/users/${record.id}`)
message.success('已删除')
fetchUsers()
try {
await api.delete(`/users/${record.id}`)
message.success('已删除')
fetchUsers()
} catch (error) {
message.error(formatApiError(error, '删除失败'))
}
}
})
}
const getRoleLabel = (role) => roleOptions.value.find(r => r.value === role)?.label || role
const roleTagColor = (role) => {
if (role === 'admin') return 'purple'
if (role === 'operator') return 'geekblue'
@@ -244,7 +464,15 @@ const emailVerifyColor = (record) => {
return record.email_verified ? 'green' : 'gold'
}
const isUnlimitedQuota = (record) => record.role === 'admin'
const isUnlimitedQuota = (record) =>
!!(record.is_admin || record.role === 'admin' ||
roleOptions.value.find((r) => r.value === record.role)?.is_admin)
watch(activeTab, (tab) => {
if (tab === 'roles' && !roleRecords.value.length) {
fetchRoleRecords()
}
})
const resolveAccountLimit = (record) => {
if (isUnlimitedQuota(record)) return null
@@ -269,13 +497,17 @@ const accountQuotaLabel = (record) => {
return `${total}/${limit}`
}
watch(users, (list) => {
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
watch([filteredUsers, pageSize], () => {
const maxPage = Math.max(1, Math.ceil(filteredUsers.value.length / pageSize.value))
if (pageCurrent.value > maxPage) {
pageCurrent.value = maxPage
}
})
watch(searchKeyword, () => {
pageCurrent.value = 1
})
onMounted(() => {
fetchRoles()
fetchDefaultMaxAccounts()
@@ -291,12 +523,48 @@ onMounted(() => {
<TeamOutlined class="page-title-icon" />
用户与角色管理
</h2>
<p class="subtitle">管理员可创建用户并分配角色实现数据隔离与权限控制</p>
<p class="subtitle">创建用户自定义角色并勾选菜单与操作权限</p>
</div>
<a-button type="primary" class="gradient-btn add-user-btn" @click="openAdd">
<a-button
v-if="activeTab === 'users'"
type="primary"
class="gradient-btn add-user-btn"
@click="openAdd"
>
<template #icon><PlusOutlined /></template>
新增用户
</a-button>
<a-button
v-else
type="primary"
class="gradient-btn add-user-btn"
@click="openAddRole"
>
<template #icon><PlusOutlined /></template>
新建角色
</a-button>
</div>
<a-tabs v-model:activeKey="activeTab" class="users-tabs glass-card">
<a-tab-pane key="users" tab="用户管理" />
<a-tab-pane key="roles" tab="角色设定" />
</a-tabs>
<template v-if="activeTab === 'users'">
<div class="users-toolbar glass-card">
<a-input
v-model:value="searchKeyword"
allow-clear
placeholder="搜索用户名、显示名、邮箱、角色、ID"
class="users-search-input"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<span class="users-toolbar-meta">
{{ searchKeyword.trim() ? `匹配 ${filteredUsers.length} / 共 ${users.length}` : `${users.length}` }} 个用户
</span>
</div>
<!-- 桌面端表格 -->
@@ -435,20 +703,113 @@ onMounted(() => {
</div>
</div>
</div>
<a-empty v-else description="暂无用户" />
<a-empty
v-else
:description="searchKeyword.trim() ? '未找到匹配用户' : '暂无用户'"
/>
</a-spin>
<div v-if="users.length" class="users-mobile-pagination">
<div v-if="filteredUsers.length" class="users-mobile-pagination">
<a-pagination
v-model:current="pageCurrent"
v-model:page-size="pageSize"
:total="users.length"
:total="filteredUsers.length"
:show-size-changer="false"
size="small"
:show-total="(total) => `${total}`"
/>
</div>
</div>
</template>
<div v-else class="glass-card roles-panel">
<a-spin :spinning="rolesLoading">
<a-table
v-if="!isMobile"
:data-source="roleRecords"
row-key="value"
:pagination="false"
>
<a-table-column title="角色码" data-index="value" key="value" :width="140" />
<a-table-column title="名称" data-index="label" key="label" :width="140" />
<a-table-column title="说明" key="description">
<template #default="{ record }">
<span :class="{ 'text-muted': !record.description }">
{{ record.description || '—' }}
</span>
</template>
</a-table-column>
<a-table-column title="类型" key="type" :width="100">
<template #default="{ record }">
<a-tag v-if="record.is_admin" color="purple">超级管理员</a-tag>
<a-tag v-else-if="record.is_system" color="blue">系统</a-tag>
<a-tag v-else color="geekblue">自定义</a-tag>
</template>
</a-table-column>
<a-table-column title="权限数" key="perm_count" :width="90">
<template #default="{ record }">
{{ (record.permissions || []).length }}
</template>
</a-table-column>
<a-table-column title="用户数" data-index="user_count" key="user_count" :width="80" />
<a-table-column title="操作" key="action" :width="160">
<template #default="{ record }">
<a-space>
<a-button type="text" style="color: #c084fc;" @click="openEditRole(record)">
{{ record.is_admin ? '查看' : '编辑' }}
</a-button>
<a-button
v-if="!record.is_system"
type="text"
danger
@click="handleDeleteRole(record)"
>
删除
</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
<div v-else class="user-card-list">
<div v-for="record in roleRecords" :key="record.value" class="user-card glass-card">
<div class="user-card-head">
<div>
<div class="user-card-name">{{ record.label }}</div>
<div class="user-card-display">{{ record.value }}</div>
</div>
<a-tag v-if="record.is_admin" color="purple">超级管理员</a-tag>
<a-tag v-else-if="record.is_system" color="blue">系统</a-tag>
<a-tag v-else color="geekblue">自定义</a-tag>
</div>
<div class="user-card-meta">
<div class="user-card-row">
<span class="user-card-label">权限</span>
<span class="user-card-value">{{ (record.permissions || []).length }} 项</span>
</div>
<div class="user-card-row">
<span class="user-card-label">用户</span>
<span class="user-card-value">{{ record.user_count ?? 0 }}</span>
</div>
</div>
<div class="user-card-actions">
<a-button type="text" class="edit-btn" @click="openEditRole(record)">
{{ record.is_admin ? '查看' : '编辑' }}
</a-button>
<a-button
v-if="!record.is_system"
type="text"
danger
@click="handleDeleteRole(record)"
>
删除
</a-button>
</div>
</div>
<a-empty v-if="!roleRecords.length" description="暂无角色" />
</div>
</a-spin>
</div>
<a-modal
v-model:visible="modalVisible"
@@ -460,10 +821,18 @@ onMounted(() => {
>
<a-form layout="vertical" class="user-form" style="margin-top: 16px;">
<a-form-item v-if="!editingId" label="用户名" required>
<a-input v-model:value="userForm.username" placeholder="登录用户名" />
<a-input
v-model:value="userForm.username"
placeholder="登录用户名至少 2 个字符"
:maxlength="50"
/>
</a-form-item>
<a-form-item :label="editingId ? '新密码(留空不修改)' : '密码'" :required="!editingId">
<a-input-password v-model:value="userForm.password" placeholder="至少 6 " />
<a-input-password
v-model:value="userForm.password"
placeholder="至少 6 "
:maxlength="128"
/>
</a-form-item>
<a-form-item label="显示名称">
<a-input v-model:value="userForm.display_name" placeholder="界面展示名称" />
@@ -511,12 +880,81 @@ onMounted(() => {
</a-form>
</a-modal>
<a-modal
v-model:visible="roleModalVisible"
:title="roleModalTitle"
:width="isMobile ? 'calc(100vw - 32px)' : 720"
:confirm-loading="roleSaving"
@ok="handleSaveRole"
ok-text="保存"
cancel-text="取消"
>
<a-form layout="vertical" class="user-form" style="margin-top: 16px;">
<a-form-item label="角色码" required>
<a-input
v-model:value="roleForm.code"
placeholder="小写字母开头 ops_leader"
:disabled="!!editingRoleCode"
:maxlength="50"
/>
<div class="field-hint">创建后不可修改;仅小写字母、数字、下划线</div>
</a-form-item>
<a-form-item label="显示名称" required>
<a-input v-model:value="roleForm.label" placeholder="界面展示名称" :maxlength="100" />
</a-form-item>
<a-form-item label="说明">
<a-input
v-model:value="roleForm.description"
placeholder="可选描述该角色的职责"
:maxlength="255"
/>
</a-form-item>
<a-alert
v-if="rolePermissionsReadonly"
type="info"
show-icon
message="管理员角色固定拥有全部权限不可取消勾选"
style="margin-bottom: 16px;"
/>
<a-form-item label="菜单权限">
<a-checkbox-group
v-model:value="roleForm.permissions"
:disabled="rolePermissionsReadonly"
class="perm-grid"
>
<a-checkbox
v-for="item in permissionCatalog.menus"
:key="item.code"
:value="item.code"
>
{{ item.label }}
</a-checkbox>
</a-checkbox-group>
</a-form-item>
<a-form-item label="操作权限">
<a-checkbox-group
v-model:value="roleForm.permissions"
:disabled="rolePermissionsReadonly"
class="perm-grid"
>
<a-checkbox
v-for="item in permissionCatalog.actions"
:key="item.code"
:value="item.code"
>
{{ item.label }}
</a-checkbox>
</a-checkbox-group>
</a-form-item>
</a-form>
</a-modal>
<div class="glass-card role-help">
<h3 style="margin-top: 0; color: #fff;">角色权限说明</h3>
<ul class="role-list">
<li><strong>管理员</strong>管理所有抖音账号用户全局规则与系统日志</li>
<li><strong>运营</strong>管理自己创建的抖音账号规则与私信不可见他人数据</li>
<li><strong>只读</strong>仅查看自己账号的数据不可修改或发送</li>
<li><strong>管理员</strong>全局数据范围 + 全部权限不可删除</li>
<li><strong>运营 / 只读 / 自定义角色</strong>仅能访问自己的账号数据菜单与操作由勾选权限决定</li>
<li>自定义角色可在角色设定中新建并分配给用户</li>
</ul>
</div>
</div>
@@ -562,6 +1000,70 @@ onMounted(() => {
line-height: 1.5;
}
.users-tabs {
padding: 8px 16px 0;
margin-bottom: 16px;
}
.users-tabs:hover,
.users-toolbar:hover,
.table-card:hover,
.roles-panel:hover,
.role-help:hover {
transform: none;
}
.users-tabs :deep(.ant-tabs-nav) {
margin-bottom: 0;
}
.users-tabs :deep(.ant-tabs-tab) {
font-size: 0.95rem;
font-weight: 500;
padding: 12px 4px;
}
.roles-panel {
padding: 16px;
margin-bottom: 16px;
overflow: hidden;
}
.perm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px 14px;
width: 100%;
}
.perm-grid :deep(.ant-checkbox-wrapper) {
color: #e5e7eb !important;
margin-left: 0 !important;
line-height: 1.45;
}
.users-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 16px 20px;
margin-bottom: 16px;
}
.users-search-input {
flex: 1;
min-width: 220px;
max-width: 420px;
}
.users-toolbar-meta {
color: var(--text-secondary);
font-size: 0.88rem;
white-space: nowrap;
}
.gradient-btn {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
border: none !important;
@@ -614,19 +1116,20 @@ onMounted(() => {
}
.users-page :deep(.ant-table-thead > tr > th) {
background: rgba(255, 255, 255, 0.03) !important;
color: var(--text-secondary) !important;
border-bottom: 1px solid var(--border-light) !important;
background: rgba(255, 255, 255, 0.05) !important;
color: #d1d5db !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
font-weight: 600;
}
.users-page :deep(.ant-table-tbody > tr > td) {
background: transparent !important;
border-bottom: 1px solid var(--border-light) !important;
color: var(--text-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
color: #f3f4f6;
}
.users-page :deep(.ant-table-tbody > tr:hover > td) {
background: rgba(170, 59, 255, 0.05) !important;
background: rgba(170, 59, 255, 0.08) !important;
}
.user-card-head {
@@ -699,7 +1202,20 @@ onMounted(() => {
}
.edit-btn {
color: #c084fc !important;
color: #d8b4fe !important;
}
.edit-btn:hover {
color: #f3e8ff !important;
}
.users-page :deep(.ant-btn-dangerous.ant-btn-text) {
color: #fca5a5 !important;
}
.users-page :deep(.ant-btn-dangerous.ant-btn-text:hover) {
color: #fecaca !important;
background: rgba(239, 68, 68, 0.12) !important;
}
.users-mobile-pagination {
@@ -753,6 +1269,16 @@ onMounted(() => {
width: 100%;
}
.users-toolbar {
padding: 12px 16px;
margin-bottom: 12px;
}
.users-search-input {
max-width: none;
width: 100%;
}
.role-help {
margin-top: 16px !important;
padding: 16px !important;