Merge pull request #289 from IanShaw027/fix/mobile-ui

fix(mobile): 优化移动端表格、操作栏和弹窗显示
This commit is contained in:
Wesley Liddick
2026-01-15 11:31:16 +08:00
committed by GitHub
5 changed files with 284 additions and 131 deletions

View File

@@ -25,7 +25,7 @@
<label class="input-label">{{ t('admin.users.username') }}</label> <label class="input-label">{{ t('admin.users.username') }}</label>
<input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" /> <input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label> <label class="input-label">{{ t('admin.users.columns.balance') }}</label>
<input v-model.number="form.balance" type="number" step="any" class="input" /> <input v-model.number="form.balance" type="number" step="any" class="input" />

View File

@@ -1,7 +1,68 @@
<template> <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 <div
ref="tableWrapperRef" ref="tableWrapperRef"
class="table-wrapper" class="table-wrapper hidden md:block"
:class="{ :class="{
'actions-expanded': actionsExpanded, 'actions-expanded': actionsExpanded,
'is-scrollable': isScrollable 'is-scrollable': isScrollable
@@ -284,7 +345,10 @@ const sortedData = computed(() => {
}) })
}) })
// 检查第一列是否为勾选列 const hasActionsColumn = computed(() => {
return props.columns.some(column => column.key === 'actions')
})
const hasSelectColumn = computed(() => { const hasSelectColumn = computed(() => {
return props.columns.length > 0 && props.columns[0].key === 'select' return props.columns.length > 0 && props.columns[0].key === 'select'
}) })

View File

@@ -345,7 +345,7 @@
.modal-overlay { .modal-overlay {
@apply fixed inset-0 z-50; @apply fixed inset-0 z-50;
@apply bg-black/50 backdrop-blur-sm; @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 { .modal-content {

View File

@@ -125,7 +125,7 @@
</template> </template>
<script setup lang="ts"> <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 { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -208,7 +208,56 @@ const cols = computed(() => {
}) })
const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true } 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 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 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) } } 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) } }
@@ -366,5 +415,14 @@ const isExpired = (value: number | null) => {
return value * 1000 <= Date.now() 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> </script>

View File

@@ -3,11 +3,11 @@
<TablePageLayout> <TablePageLayout>
<!-- Single Row: Search, Filters, and Actions --> <!-- Single Row: Search, Filters, and Actions -->
<template #filters> <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 --> <!-- 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 --> <!-- Search Box -->
<div class="relative w-full sm:w-64"> <div class="relative w-full md:w-64">
<Icon <Icon
name="search" name="search"
size="md" size="md"
@@ -100,109 +100,119 @@
</div> </div>
<!-- Right: Actions and Settings --> <!-- Right: Actions and Settings -->
<div class="ml-auto flex max-w-full flex-wrap items-center justify-end gap-3"> <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">
<!-- Refresh Button --> <!-- Mobile: Secondary buttons (icon only) -->
<button <div class="flex items-center gap-2 md:contents">
@click="loadUsers" <!-- Refresh Button -->
: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">
<button <button
@click="showFilterDropdown = !showFilterDropdown" @click="loadUsers"
class="btn btn-secondary" :disabled="loading"
class="btn btn-secondary px-2 md:px-3"
:title="t('common.refresh')"
> >
<Icon name="filter" size="sm" class="mr-1.5" /> <Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
{{ t('admin.users.filterSettings') }}
</button> </button>
<!-- Dropdown menu --> <!-- Filter Settings Dropdown -->
<div <div class="relative" ref="filterDropdownRef">
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 -->
<button <button
v-for="filter in builtInFilters" @click="showFilterDropdown = !showFilterDropdown"
:key="filter.key" class="btn btn-secondary px-2 md:px-3"
@click="toggleBuiltInFilter(filter.key)" :title="t('admin.users.filterSettings')"
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 name="filter" size="sm" class="md:mr-1.5" />
<Icon <span class="hidden md:inline">{{ t('admin.users.filterSettings') }}</span>
v-if="visibleFilters.has(filter.key)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button> </button>
<!-- Divider if custom attributes exist --> <!-- Dropdown menu -->
<div <div
v-if="filterableAttributes.length > 0" v-if="showFilterDropdown"
class="my-1 border-t border-gray-100 dark:border-dark-700" 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"
></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> <!-- Built-in filters -->
<Icon <button
v-if="visibleFilters.has(`attr_${attr.id}`)" v-for="filter in builtInFilters"
name="check" :key="filter.key"
size="sm" @click="toggleBuiltInFilter(filter.key)"
class="text-primary-500" 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"
:stroke-width="2" >
/> <span>{{ filter.name }}</span>
</button> <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>
</div> <!-- Column Settings Dropdown -->
<!-- Column Settings Dropdown --> <div class="relative" ref="columnDropdownRef">
<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 <button
@click="showColumnDropdown = !showColumnDropdown" @click="showAttributesModal = true"
class="btn btn-secondary" 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"> <Icon name="cog" size="sm" class="md:mr-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" /> <span class="hidden md:inline">{{ t('admin.users.attributes.configButton') }}</span>
</svg>
{{ t('admin.users.columnSettings') }}
</button> </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> </div>
<!-- Attributes Config Button -->
<button @click="showAttributesModal = true" class="btn btn-secondary"> <!-- Create User Button (full width on mobile, auto width on desktop) -->
<Icon name="cog" size="sm" class="mr-1.5" /> <button @click="showCreateModal = true" class="btn btn-primary flex-1 md:flex-initial">
{{ t('admin.users.attributes.configButton') }}
</button>
<!-- Create User Button -->
<button @click="showCreateModal = true" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" /> <Icon name="plus" size="md" class="mr-2" />
{{ t('admin.users.createUser') }} {{ t('admin.users.createUser') }}
</button> </button>
@@ -362,8 +372,7 @@
<!-- More Actions Menu Trigger --> <!-- More Actions Menu Trigger -->
<button <button
:ref="(el) => setActionButtonRef(row.id, el)" @click="openActionMenu(row, $event)"
@click="openActionMenu(row)"
class="action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white" class="action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
:class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }" :class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
> >
@@ -475,7 +484,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
@@ -735,42 +744,56 @@ let abortController: AbortController | null = null
// Action Menu State // Action Menu State
const activeMenuId = ref<number | null>(null) const activeMenuId = ref<number | null>(null)
const menuPosition = ref<{ top: number; left: number } | null>(null) const menuPosition = ref<{ top: number; left: number } | null>(null)
const actionButtonRefs = ref<Map<number, HTMLElement>>(new Map())
const setActionButtonRef = (userId: number, el: Element | ComponentPublicInstance | null) => { const openActionMenu = (user: User, e: MouseEvent) => {
if (el instanceof HTMLElement) {
actionButtonRefs.value.set(userId, el)
} else {
actionButtonRefs.value.delete(userId)
}
}
const openActionMenu = (user: User) => {
if (activeMenuId.value === user.id) { if (activeMenuId.value === user.id) {
closeActionMenu() closeActionMenu()
} else { } else {
const buttonEl = actionButtonRefs.value.get(user.id) const target = e.currentTarget as HTMLElement
if (buttonEl) { if (!target) {
const rect = buttonEl.getBoundingClientRect() closeActionMenu()
const menuWidth = 192 return
const menuHeight = 240 }
const padding = 8
const viewportWidth = window.innerWidth const rect = target.getBoundingClientRect()
const viewportHeight = window.innerHeight const menuWidth = 200
const left = Math.min( const menuHeight = 240
Math.max(rect.right - menuWidth, padding), const padding = 8
Math.max(viewportWidth - menuWidth - padding, padding) const viewportWidth = window.innerWidth
) const viewportHeight = window.innerHeight
let top = rect.bottom + 4
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) { if (top + menuHeight > viewportHeight - padding) {
top = Math.max(rect.top - menuHeight - 4, padding) top = rect.top - menuHeight - 4
// 如果上方也不够,就贴在视口顶部
if (top < padding) {
top = padding
}
} }
// Position menu near the trigger, clamped to viewport } else {
menuPosition.value = { left = Math.max(padding, Math.min(
top, e.clientX - menuWidth,
left viewportWidth - menuWidth - padding
))
top = e.clientY
if (top + menuHeight > viewportHeight - padding) {
top = viewportHeight - menuHeight - padding
} }
} }
menuPosition.value = { top, left }
activeMenuId.value = user.id activeMenuId.value = user.id
} }
} }
@@ -1054,16 +1077,24 @@ const closeBalanceModal = () => {
showBalanceModal.value = false showBalanceModal.value = false
balanceUser.value = null balanceUser.value = null
} }
// 滚动时关闭菜单
const handleScroll = () => {
closeActionMenu()
}
onMounted(async () => { onMounted(async () => {
await loadAttributeDefinitions() await loadAttributeDefinitions()
loadSavedFilters() loadSavedFilters()
loadSavedColumns() loadSavedColumns()
loadUsers() loadUsers()
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
window.addEventListener('scroll', handleScroll, true)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
window.removeEventListener('scroll', handleScroll, true)
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
abortController?.abort() abortController?.abort()
}) })