mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:28:39 +08:00
fix: 修复账号管理移动端更多操作菜单溢出
This commit is contained in:
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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user