fix(mobile): 优化移动端表格、操作栏和弹窗显示
**问题描述**: - 表格在移动端显示列过多,需要横向滚动,内容被截断 - 顶部操作栏按钮拥挤,占用过多空间 - 弹窗表单在小屏幕上布局不合理 - "更多"操作菜单定位错误,位置过高或超出屏幕 - 滚动页面时菜单不会自动关闭,与卡片分离 **解决方案**: 1. **DataTable 组件 - 移动端卡片视图** - 在 < 768px 时自动切换到卡片布局 - 每个表格行渲染为独立卡片,所有字段清晰可见 - 操作按钮在卡片底部,触摸目标足够大 - 支持深色模式,包含加载和空状态 - 自动应用于所有使用 DataTable 的管理页面 2. **UsersView 顶部操作栏优化** - 移动端:搜索框全宽 + 次要按钮显示为图标 + 创建按钮突出 - 桌面端:保持原有布局(图标 + 文字) - 使用响应式 Tailwind classes 3. **UserCreateModal 弹窗优化** - 余额/并发数字段:移动端单列,桌面端双列 - 弹窗边距:移动端 8px,桌面端 16px 4. **操作菜单定位修复** - UsersView: 移动端菜单居中对齐按钮,智能定位 - AccountsView: 移动端菜单优先显示在按钮下方 - 所有情况下确保菜单不超出屏幕边界 - 添加滚动监听,滚动时自动关闭菜单 **影响范围**: - 所有使用 DataTable 的管理页面(8 个页面)自动获得移动端卡片视图 - 用户管理和账号管理页面的操作菜单定位优化 - 创建用户弹窗的响应式布局优化 **技术要点**: - 使用 Tailwind 响应式断点(md:, sm:) - 触摸目标 ≥ 44px - 完整支持深色模式 - 向后兼容,桌面端保持原有布局
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
<label class="input-label">{{ t('admin.users.username') }}</label>
|
||||
<input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
|
||||
<input v-model.number="form.balance" type="number" step="any" class="input" />
|
||||
|
||||
@@ -1,7 +1,68 @@
|
||||
<template>
|
||||
<div class="md:hidden space-y-3">
|
||||
<template v-if="loading">
|
||||
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||||
<div class="space-y-3">
|
||||
<div v-for="column in columns.filter(c => c.key !== 'actions')" :key="column.key" class="flex justify-between">
|
||||
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
|
||||
<div class="h-8 w-full animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!data || data.length === 0">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-dark-700 dark:bg-dark-900">
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center">
|
||||
<Icon
|
||||
name="inbox"
|
||||
size="xl"
|
||||
class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
|
||||
/>
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ t('empty.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(row, index) in sortedData"
|
||||
:key="resolveRowKey(row, index)"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="column in columns.filter(c => c.key !== 'actions')"
|
||||
:key="column.key"
|
||||
class="flex items-start justify-between gap-4"
|
||||
>
|
||||
<span class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400">
|
||||
{{ column.label }}
|
||||
</span>
|
||||
<div class="text-right text-sm text-gray-900 dark:text-gray-100">
|
||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
|
||||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
|
||||
<slot name="cell-actions" :row="row" :value="row['actions']" :expanded="actionsExpanded"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="tableWrapperRef"
|
||||
class="table-wrapper"
|
||||
class="table-wrapper hidden md:block"
|
||||
:class="{
|
||||
'actions-expanded': actionsExpanded,
|
||||
'is-scrollable': isScrollable
|
||||
@@ -277,7 +338,10 @@ const sortedData = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 检查第一列是否为勾选列
|
||||
const hasActionsColumn = computed(() => {
|
||||
return props.columns.some(column => column.key === 'actions')
|
||||
})
|
||||
|
||||
const hasSelectColumn = computed(() => {
|
||||
return props.columns.length > 0 && props.columns[0].key === 'select'
|
||||
})
|
||||
|
||||
@@ -345,7 +345,7 @@
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-50;
|
||||
@apply bg-black/50 backdrop-blur-sm;
|
||||
@apply flex items-center justify-center p-4;
|
||||
@apply flex items-center justify-center p-2 sm:p-4;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -202,7 +202,56 @@ const cols = computed(() => {
|
||||
})
|
||||
|
||||
const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
|
||||
const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top: e.clientY, left: e.clientX - 200 }; menu.show = true }
|
||||
const openMenu = (a: Account, e: MouseEvent) => {
|
||||
menu.acc = a
|
||||
|
||||
const target = e.currentTarget as HTMLElement
|
||||
if (target) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
const menuWidth = 200
|
||||
const menuHeight = 240
|
||||
const padding = 8
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
let left, top
|
||||
|
||||
if (viewportWidth < 768) {
|
||||
// 居中显示,水平位置
|
||||
left = Math.max(padding, Math.min(
|
||||
rect.left + rect.width / 2 - menuWidth / 2,
|
||||
viewportWidth - menuWidth - padding
|
||||
))
|
||||
|
||||
// 优先显示在按钮下方
|
||||
top = rect.bottom + 4
|
||||
|
||||
// 如果下方空间不够,显示在上方
|
||||
if (top + menuHeight > viewportHeight - padding) {
|
||||
top = rect.top - menuHeight - 4
|
||||
// 如果上方也不够,就贴在视口顶部
|
||||
if (top < padding) {
|
||||
top = padding
|
||||
}
|
||||
}
|
||||
} else {
|
||||
left = Math.max(padding, Math.min(
|
||||
e.clientX - menuWidth,
|
||||
viewportWidth - menuWidth - padding
|
||||
))
|
||||
top = e.clientY
|
||||
if (top + menuHeight > viewportHeight - padding) {
|
||||
top = viewportHeight - menuHeight - padding
|
||||
}
|
||||
}
|
||||
|
||||
menu.pos = { top, left }
|
||||
} else {
|
||||
menu.pos = { top: e.clientY, left: e.clientX - 200 }
|
||||
}
|
||||
|
||||
menu.show = true
|
||||
}
|
||||
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
|
||||
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
|
||||
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
|
||||
@@ -360,5 +409,14 @@ const isExpired = (value: number | null) => {
|
||||
return value * 1000 <= Date.now()
|
||||
}
|
||||
|
||||
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } })
|
||||
// 滚动时关闭菜单
|
||||
const handleScroll = () => {
|
||||
menu.show = false
|
||||
}
|
||||
|
||||
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) }; window.addEventListener('scroll', handleScroll, true) })
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
<TablePageLayout>
|
||||
<!-- Single Row: Search, Filters, and Actions -->
|
||||
<template #filters>
|
||||
<div class="flex w-full flex-wrap-reverse items-center justify-between gap-4">
|
||||
<div class="flex w-full flex-col gap-3 md:flex-row md:flex-wrap-reverse md:items-center md:justify-between md:gap-4">
|
||||
<!-- Left: Search + Active Filters -->
|
||||
<div class="flex min-w-[280px] flex-1 flex-wrap content-start items-center gap-3">
|
||||
<div class="flex min-w-[280px] flex-1 flex-wrap content-start items-center gap-3 md:order-1">
|
||||
<!-- Search Box -->
|
||||
<div class="relative w-full sm:w-64">
|
||||
<div class="relative w-full md:w-64">
|
||||
<Icon
|
||||
name="search"
|
||||
size="md"
|
||||
@@ -100,109 +100,119 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: Actions and Settings -->
|
||||
<div class="ml-auto flex max-w-full flex-wrap items-center justify-end gap-3">
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="loadUsers"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<!-- Filter Settings Dropdown -->
|
||||
<div class="relative" ref="filterDropdownRef">
|
||||
<div class="flex w-full items-center justify-between gap-2 md:order-2 md:ml-auto md:max-w-full md:flex-wrap md:justify-end md:gap-3">
|
||||
<!-- Mobile: Secondary buttons (icon only) -->
|
||||
<div class="flex items-center gap-2 md:contents">
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="showFilterDropdown = !showFilterDropdown"
|
||||
class="btn btn-secondary"
|
||||
@click="loadUsers"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="filter" size="sm" class="mr-1.5" />
|
||||
{{ t('admin.users.filterSettings') }}
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showFilterDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<!-- Built-in filters -->
|
||||
<!-- Filter Settings Dropdown -->
|
||||
<div class="relative" ref="filterDropdownRef">
|
||||
<button
|
||||
v-for="filter in builtInFilters"
|
||||
:key="filter.key"
|
||||
@click="toggleBuiltInFilter(filter.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
@click="showFilterDropdown = !showFilterDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.filterSettings')"
|
||||
>
|
||||
<span>{{ filter.name }}</span>
|
||||
<Icon
|
||||
v-if="visibleFilters.has(filter.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<Icon name="filter" size="sm" class="md:mr-1.5" />
|
||||
<span class="hidden md:inline">{{ t('admin.users.filterSettings') }}</span>
|
||||
</button>
|
||||
<!-- Divider if custom attributes exist -->
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="filterableAttributes.length > 0"
|
||||
class="my-1 border-t border-gray-100 dark:border-dark-700"
|
||||
></div>
|
||||
<!-- Custom attribute filters -->
|
||||
<button
|
||||
v-for="attr in filterableAttributes"
|
||||
:key="attr.id"
|
||||
@click="toggleAttributeFilter(attr)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
v-if="showFilterDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<span>{{ attr.name }}</span>
|
||||
<Icon
|
||||
v-if="visibleFilters.has(`attr_${attr.id}`)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
<!-- Built-in filters -->
|
||||
<button
|
||||
v-for="filter in builtInFilters"
|
||||
:key="filter.key"
|
||||
@click="toggleBuiltInFilter(filter.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<span>{{ filter.name }}</span>
|
||||
<Icon
|
||||
v-if="visibleFilters.has(filter.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
<!-- Divider if custom attributes exist -->
|
||||
<div
|
||||
v-if="filterableAttributes.length > 0"
|
||||
class="my-1 border-t border-gray-100 dark:border-dark-700"
|
||||
></div>
|
||||
<!-- Custom attribute filters -->
|
||||
<button
|
||||
v-for="attr in filterableAttributes"
|
||||
:key="attr.id"
|
||||
@click="toggleAttributeFilter(attr)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<span>{{ attr.name }}</span>
|
||||
<Icon
|
||||
v-if="visibleFilters.has(`attr_${attr.id}`)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.columnSettings')"
|
||||
>
|
||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon
|
||||
v-if="isColumnVisible(col.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Attributes Config Button -->
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary"
|
||||
@click="showAttributesModal = true"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.attributes.configButton')"
|
||||
>
|
||||
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
{{ t('admin.users.columnSettings') }}
|
||||
<Icon name="cog" size="sm" class="md:mr-1.5" />
|
||||
<span class="hidden md:inline">{{ t('admin.users.attributes.configButton') }}</span>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon
|
||||
v-if="isColumnVisible(col.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Attributes Config Button -->
|
||||
<button @click="showAttributesModal = true" class="btn btn-secondary">
|
||||
<Icon name="cog" size="sm" class="mr-1.5" />
|
||||
{{ t('admin.users.attributes.configButton') }}
|
||||
</button>
|
||||
<!-- Create User Button -->
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
|
||||
<!-- Create User Button (full width on mobile, auto width on desktop) -->
|
||||
<button @click="showCreateModal = true" class="btn btn-primary flex-1 md:flex-initial">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
{{ t('admin.users.createUser') }}
|
||||
</button>
|
||||
@@ -757,15 +767,29 @@ const openActionMenu = (user: User) => {
|
||||
const padding = 8
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const left = Math.min(
|
||||
Math.max(rect.right - menuWidth, padding),
|
||||
Math.max(viewportWidth - menuWidth - padding, padding)
|
||||
)
|
||||
let top = rect.bottom + 4
|
||||
if (top + menuHeight > viewportHeight - padding) {
|
||||
top = Math.max(rect.top - menuHeight - 4, padding)
|
||||
|
||||
let left, top
|
||||
|
||||
if (viewportWidth < 768) {
|
||||
left = Math.max(padding, Math.min(
|
||||
rect.left + rect.width / 2 - menuWidth / 2,
|
||||
viewportWidth - menuWidth - padding
|
||||
))
|
||||
top = rect.top - menuHeight - 4
|
||||
if (top < padding) {
|
||||
top = rect.bottom + 4
|
||||
}
|
||||
} else {
|
||||
left = Math.min(
|
||||
Math.max(rect.right - menuWidth, padding),
|
||||
Math.max(viewportWidth - menuWidth - padding, padding)
|
||||
)
|
||||
top = rect.bottom + 4
|
||||
if (top + menuHeight > viewportHeight - padding) {
|
||||
top = Math.max(rect.top - menuHeight - 4, padding)
|
||||
}
|
||||
}
|
||||
// Position menu near the trigger, clamped to viewport
|
||||
|
||||
menuPosition.value = {
|
||||
top,
|
||||
left
|
||||
@@ -1054,16 +1078,24 @@ const closeBalanceModal = () => {
|
||||
showBalanceModal.value = false
|
||||
balanceUser.value = null
|
||||
}
|
||||
|
||||
// 滚动时关闭菜单
|
||||
const handleScroll = () => {
|
||||
closeActionMenu()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAttributeDefinitions()
|
||||
loadSavedFilters()
|
||||
loadSavedColumns()
|
||||
loadUsers()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
clearTimeout(searchTimeout)
|
||||
abortController?.abort()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user