Merge pull request #4648 from creamtea47/codex/fix-mobile-account-actions-menu

fix: 修复账号管理移动端更多操作菜单溢出
This commit is contained in:
Wesley Liddick
2026-07-22 09:36:54 +08:00
committed by GitHub
4 changed files with 221 additions and 78 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { getFloatingPanelPosition } from '@/utils/floatingPanel'
describe('getFloatingPanelPosition', () => {
it('移动端使用视口安全边距,不再从靠左按钮向屏幕外展开', () => {
const position = getFloatingPanelPosition(
{ top: 160, right: 148, bottom: 200 },
393,
844
)
expect(position).toMatchObject({
top: 208,
bottom: null,
left: 16,
width: 320
})
expect(position.left + position.width).toBeLessThanOrEqual(393 - 16)
})
it('桌面端与按钮右侧对齐', () => {
const position = getFloatingPanelPosition(
{ top: 100, right: 1000, bottom: 140 },
1280,
900
)
expect(position.left).toBe(680)
expect(position.width).toBe(320)
})
it('按钮下方空间不足时改为向上展开', () => {
const position = getFloatingPanelPosition(
{ top: 700, right: 1000, bottom: 740 },
1280,
800
)
expect(position.top).toBeNull()
expect(position.bottom).toBe(108)
expect(position.maxHeight).toBe(560)
})
})
+56
View File
@@ -0,0 +1,56 @@
export interface FloatingPanelPosition {
top: number | null
bottom: number | null
left: number
width: number
maxHeight: number
}
export interface FloatingPanelOptions {
viewportPadding?: number
gap?: number
maxWidth?: number
maxHeightRatio?: number
mobileBreakpoint?: number
minComfortableHeight?: number
}
/**
* 计算挂载到 body 的浮层位置,避免触发按钮靠近视口边缘时浮层被挤到屏幕外。
*/
export const getFloatingPanelPosition = (
triggerRect: Pick<DOMRect, 'top' | 'right' | 'bottom'>,
viewportWidth: number,
viewportHeight: number,
options: FloatingPanelOptions = {}
): FloatingPanelPosition => {
const viewportPadding = options.viewportPadding ?? 16
const gap = options.gap ?? 8
const maxWidth = options.maxWidth ?? 320
const maxHeightRatio = options.maxHeightRatio ?? 0.7
const mobileBreakpoint = options.mobileBreakpoint ?? 768
const minComfortableHeight = options.minComfortableHeight ?? 240
const availableWidth = Math.max(0, viewportWidth - viewportPadding * 2)
const width = Math.min(maxWidth, availableWidth)
const left = viewportWidth < mobileBreakpoint
? viewportPadding
: Math.max(
viewportPadding,
Math.min(triggerRect.right - width, viewportWidth - width - viewportPadding)
)
const preferredMaxHeight = Math.max(0, Math.floor(viewportHeight * maxHeightRatio))
const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - gap - viewportPadding)
const spaceAbove = Math.max(0, triggerRect.top - gap - viewportPadding)
const openAbove = spaceBelow < Math.min(minComfortableHeight, preferredMaxHeight) && spaceAbove > spaceBelow
const maxHeight = Math.min(preferredMaxHeight, openAbove ? spaceAbove : spaceBelow)
return {
top: openAbove ? null : triggerRect.bottom + gap,
bottom: openAbove ? viewportHeight - triggerRect.top + gap : null,
left,
width,
maxHeight
}
}
+122 -78
View File
@@ -65,95 +65,98 @@
<!-- More Tools Dropdown -->
<div class="relative" ref="accountToolsDropdownRef">
<button
@click="
showAccountToolsDropdown = !showAccountToolsDropdown;
showAutoRefreshDropdown = false
"
ref="accountToolsTriggerRef"
@click="toggleAccountToolsDropdown"
class="btn btn-secondary px-2 md:px-3"
:title="t('admin.accounts.moreActions')"
:aria-expanded="showAccountToolsDropdown"
>
<Icon name="more" size="sm" class="md:mr-1.5" />
<span class="hidden md:inline">{{ t('admin.accounts.moreActions') }}</span>
<Icon name="chevronDown" size="xs" class="ml-1 hidden md:inline" />
</button>
<div
v-if="showAccountToolsDropdown"
class="absolute right-0 z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] origin-top-right overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-dark-700 dark:bg-dark-800"
>
<div class="max-h-[70vh] overflow-y-auto p-2">
<div class="px-2 py-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{{ t('admin.accounts.dataActions') }}
<Teleport to="body">
<div
v-if="showAccountToolsDropdown"
class="fixed z-[9999] origin-top-right overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-dark-700 dark:bg-dark-800"
:style="accountToolsDropdownStyle"
@click.stop
>
<div class="overflow-y-auto p-2" :style="{ maxHeight: `${accountToolsDropdownPosition.maxHeight}px` }">
<div class="px-2 py-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{{ t('admin.accounts.dataActions') }}
</div>
</div>
</div>
<button class="account-tools-menu-item" @click="openSyncFromCrs">
<span class="account-tools-menu-icon bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300">
<Icon name="sync" size="sm" />
</span>
<span class="flex-1 text-left">{{ t('admin.accounts.syncFromCrs') }}</span>
</button>
<button class="account-tools-menu-item" @click="openImportData">
<span class="account-tools-menu-icon bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300">
<Icon name="upload" size="sm" />
</span>
<span class="flex-1 text-left">{{ t('admin.accounts.dataImport') }}</span>
</button>
<button class="account-tools-menu-item" @click="openExportDataDialogFromMenu">
<span class="account-tools-menu-icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-300">
<Icon name="download" size="sm" />
</span>
<span class="flex-1 text-left">
{{ selIds.length ? t('admin.accounts.dataExportSelected') : t('admin.accounts.dataExport') }}
</span>
<span
v-if="selIds.length"
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
>
{{ t('admin.accounts.selectedCount', { count: selIds.length }) }}
</span>
</button>
<div class="my-2 border-t border-gray-100 dark:border-dark-700"></div>
<div class="px-2 py-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{{ t('admin.accounts.toolActions') }}
</div>
</div>
<button class="account-tools-menu-item" @click="openErrorPassthrough">
<span class="account-tools-menu-icon bg-amber-50 text-amber-600 dark:bg-amber-900/30 dark:text-amber-300">
<Icon name="shield" size="sm" />
</span>
<span class="flex-1 text-left">{{ t('admin.errorPassthrough.title') }}</span>
</button>
<button class="account-tools-menu-item" @click="openTLSFingerprintProfiles">
<span class="account-tools-menu-icon bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-200">
<Icon name="lock" size="sm" />
</span>
<span class="flex-1 text-left">{{ t('admin.tlsFingerprintProfiles.title') }}</span>
</button>
<div class="my-2 border-t border-gray-100 dark:border-dark-700"></div>
<div class="px-2 py-2">
<div class="flex items-center justify-between gap-3">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{{ t('admin.accounts.viewColumns') }}
<button class="account-tools-menu-item" @click="openSyncFromCrs">
<span class="account-tools-menu-icon bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300">
<Icon name="sync" size="sm" />
</span>
<Icon name="grid" size="sm" class="text-gray-400" />
</div>
</div>
<div class="grid grid-cols-1 gap-1">
<button
v-for="col in toggleableColumns"
:key="col.key"
@click="toggleColumn(col.key)"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
>
<span class="truncate">{{ col.label }}</span>
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
<span class="flex-1 text-left">{{ t('admin.accounts.syncFromCrs') }}</span>
</button>
<button class="account-tools-menu-item" @click="openImportData">
<span class="account-tools-menu-icon bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300">
<Icon name="upload" size="sm" />
</span>
<span class="flex-1 text-left">{{ t('admin.accounts.dataImport') }}</span>
</button>
<button class="account-tools-menu-item" @click="openExportDataDialogFromMenu">
<span class="account-tools-menu-icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-300">
<Icon name="download" size="sm" />
</span>
<span class="flex-1 text-left">
{{ selIds.length ? t('admin.accounts.dataExportSelected') : t('admin.accounts.dataExport') }}
</span>
<span
v-if="selIds.length"
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
>
{{ t('admin.accounts.selectedCount', { count: selIds.length }) }}
</span>
</button>
<div class="my-2 border-t border-gray-100 dark:border-dark-700"></div>
<div class="px-2 py-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{{ t('admin.accounts.toolActions') }}
</div>
</div>
<button class="account-tools-menu-item" @click="openErrorPassthrough">
<span class="account-tools-menu-icon bg-amber-50 text-amber-600 dark:bg-amber-900/30 dark:text-amber-300">
<Icon name="shield" size="sm" />
</span>
<span class="flex-1 text-left">{{ t('admin.errorPassthrough.title') }}</span>
</button>
<button class="account-tools-menu-item" @click="openTLSFingerprintProfiles">
<span class="account-tools-menu-icon bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-200">
<Icon name="lock" size="sm" />
</span>
<span class="flex-1 text-left">{{ t('admin.tlsFingerprintProfiles.title') }}</span>
</button>
<div class="my-2 border-t border-gray-100 dark:border-dark-700"></div>
<div class="px-2 py-2">
<div class="flex items-center justify-between gap-3">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{{ t('admin.accounts.viewColumns') }}
</span>
<Icon name="grid" size="sm" class="text-gray-400" />
</div>
</div>
<div class="grid grid-cols-1 gap-1">
<button
v-for="col in toggleableColumns"
:key="col.key"
@click="toggleColumn(col.key)"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
>
<span class="truncate">{{ col.label }}</span>
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
</button>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
</AccountTableActions>
@@ -507,6 +510,7 @@ import { formatDateTime, formatRelativeTime } from '@/utils/format'
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
import { extractApiErrorMessage } from '@/utils/apiError'
import { sanitizeUrl } from '@/utils/url'
import { getFloatingPanelPosition } from '@/utils/floatingPanel'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
const { t } = useI18n()
@@ -594,6 +598,20 @@ useIntervalFn(() => { upstreamBillingNow.value = Date.now() }, 60_000)
// Account tools dropdown
const showAccountToolsDropdown = ref(false)
const accountToolsDropdownRef = ref<HTMLElement | null>(null)
const accountToolsTriggerRef = ref<HTMLElement | null>(null)
const accountToolsDropdownPosition = reactive({
top: null as number | null,
bottom: null as number | null,
left: 16,
width: 320,
maxHeight: 0
})
const accountToolsDropdownStyle = computed(() => ({
top: accountToolsDropdownPosition.top == null ? 'auto' : `${accountToolsDropdownPosition.top}px`,
bottom: accountToolsDropdownPosition.bottom == null ? 'auto' : `${accountToolsDropdownPosition.bottom}px`,
left: `${accountToolsDropdownPosition.left}px`,
width: `${accountToolsDropdownPosition.width}px`
}))
const hiddenColumns = reactive<Set<string>>(new Set())
const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'scheduler_score', 'rate_multiplier']
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
@@ -1176,6 +1194,25 @@ const closeAccountToolsDropdown = () => {
showAccountToolsDropdown.value = false
}
const updateAccountToolsDropdownPosition = () => {
const trigger = accountToolsTriggerRef.value
if (!trigger) return
const position = getFloatingPanelPosition(
trigger.getBoundingClientRect(),
document.documentElement.clientWidth || window.innerWidth,
window.innerHeight
)
Object.assign(accountToolsDropdownPosition, position)
}
const toggleAccountToolsDropdown = () => {
const nextVisible = !showAccountToolsDropdown.value
showAutoRefreshDropdown.value = false
if (nextVisible) updateAccountToolsDropdownPosition()
showAccountToolsDropdown.value = nextVisible
}
const openSyncFromCrs = () => {
closeAccountToolsDropdown()
showSync.value = true
@@ -2033,9 +2070,14 @@ const proxyExpiryText = (p: AccountProxy): string => {
return params ? t(key, params) : t(key)
}
// 滚动时关闭操作菜单(不关闭列设置下拉菜单)
// 表格滚动时关闭行操作菜单,并让顶部工具菜单继续贴紧触发按钮。
const handleScroll = () => {
menu.show = false
if (showAccountToolsDropdown.value) updateAccountToolsDropdownPosition()
}
const handleViewportResize = () => {
if (showAccountToolsDropdown.value) updateAccountToolsDropdownPosition()
}
// 点击外部关闭顶部下拉菜单
@@ -2060,6 +2102,7 @@ onMounted(async () => {
console.error('Failed to load proxies/groups:', error)
}
window.addEventListener('scroll', handleScroll, true)
window.addEventListener('resize', handleViewportResize)
document.addEventListener('click', handleClickOutside)
if (autoRefreshEnabled.value) {
@@ -2072,6 +2115,7 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener('resize', handleViewportResize)
document.removeEventListener('click', handleClickOutside)
})
</script>