diff --git a/docs/screenshots/mobile-account-actions-menu.png b/docs/screenshots/mobile-account-actions-menu.png new file mode 100644 index 0000000000..f09684a612 Binary files /dev/null and b/docs/screenshots/mobile-account-actions-menu.png differ diff --git a/frontend/src/utils/__tests__/floatingPanel.spec.ts b/frontend/src/utils/__tests__/floatingPanel.spec.ts new file mode 100644 index 0000000000..0a5a4624fd --- /dev/null +++ b/frontend/src/utils/__tests__/floatingPanel.spec.ts @@ -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) + }) +}) diff --git a/frontend/src/utils/floatingPanel.ts b/frontend/src/utils/floatingPanel.ts new file mode 100644 index 0000000000..01ee00f101 --- /dev/null +++ b/frontend/src/utils/floatingPanel.ts @@ -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, + 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 + } +} diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 0dbbdc987b..ef3f2b6678 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -65,95 +65,98 @@
-
-
-
-
- {{ t('admin.accounts.dataActions') }} + +
+
+
+
+ {{ t('admin.accounts.dataActions') }} +
-
- - - - -
-
-
- {{ t('admin.accounts.toolActions') }} -
-
- - - -
-
-
- - {{ t('admin.accounts.viewColumns') }} +
-
-
- + + + +
+
+
+ {{ t('admin.accounts.toolActions') }} +
+
+ + + +
+
+
+ + {{ t('admin.accounts.viewColumns') }} + + +
+
+
+ +
-
+
@@ -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(null) +const accountToolsTriggerRef = ref(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>(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) })