Merge pull request #6798 from savvym/codex/fix-account-menu-overflow

fix(frontend): 修复账号操作菜单在视口底部被截断
This commit is contained in:
Wesley Liddick
2026-09-08 20:08:53 +08:00
committed by GitHub
5 changed files with 234 additions and 69 deletions
@@ -1,11 +1,12 @@
<template>
<Teleport to="body">
<div v-if="show && position">
<div v-if="show && anchorRect">
<!-- Backdrop: click anywhere outside to close -->
<div class="fixed inset-0 z-[9998]" @click="emit('close')"></div>
<div
class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
:style="{ top: position.top + 'px', left: position.left + 'px' }"
ref="menuRef"
class="action-menu-content fixed z-[9999] w-52 overflow-y-auto overscroll-contain rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
:style="menuStyle"
@click.stop
>
<div class="py-1">
@@ -62,14 +63,48 @@
</template>
<script setup lang="ts">
import { computed, watch, onUnmounted } from 'vue'
import { computed, ref, watch, onUnmounted } from 'vue'
import { useResizeObserver, useWindowSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
const props = defineProps<{ show: boolean; account: Account | null; anchorRect: DOMRect | null }>()
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'duplicate', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy', 'create-spark-shadow'])
const { t } = useI18n()
const menuRef = ref<HTMLElement | null>(null)
const { width: viewportWidth, height: viewportHeight } = useWindowSize()
const viewportPadding = 8
const menuPosition = ref({ top: viewportPadding, left: viewportPadding })
const menuStyle = computed(() => ({
top: `${menuPosition.value.top}px`,
left: `${menuPosition.value.left}px`,
maxWidth: `${Math.max(0, viewportWidth.value - viewportPadding * 2)}px`,
maxHeight: `${Math.max(0, viewportHeight.value - viewportPadding * 2)}px`
}))
const updatePosition = () => {
if (!menuRef.value || !props.anchorRect) return
const { width, height } = menuRef.value.getBoundingClientRect()
const anchor = props.anchorRect
const gap = 4
const maxTop = viewportHeight.value - height - viewportPadding
const top = anchor.bottom + gap <= maxTop
? anchor.bottom + gap
: anchor.top - height - gap
const left = viewportWidth.value < 768
? anchor.left + anchor.width / 2 - width / 2
: anchor.right - width
menuPosition.value.top = Math.max(viewportPadding, Math.min(top, maxTop))
menuPosition.value.left = Math.max(viewportPadding, Math.min(left, viewportWidth.value - width - viewportPadding))
}
// Measure after rendering; menu items and translated labels can change its size.
watch([menuRef, () => props.anchorRect, viewportWidth, viewportHeight], updatePosition, { flush: 'post' })
useResizeObserver(menuRef, updatePosition)
const canDuplicate = computed(() => {
if (!props.account || props.account.parent_account_id != null) return false
return ['apikey', 'upstream', 'bedrock', 'service_account'].includes(props.account.type)
@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import AccountActionMenu from '../AccountActionMenu.vue'
import type { Account } from '@/types'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
const account = {
id: 1,
platform: 'openai',
type: 'oauth',
status: 'active',
rate_limit_reset_at: '2099-01-01T00:00:00Z'
} as Account
let menuHeight: number
let resizeMenu: ResizeObserverCallback
const getMenu = () => document.body.querySelector<HTMLElement>('.action-menu-content')!
const getTop = () => Number.parseFloat(getMenu().style.top)
const getLeft = () => Number.parseFloat(getMenu().style.left)
function setViewport(width: number, height: number) {
vi.stubGlobal('innerWidth', width)
vi.stubGlobal('innerHeight', height)
window.dispatchEvent(new Event('resize'))
}
async function mountMenu(anchorRect = new DOMRect(950, 720, 32, 24)) {
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, anchorRect },
global: { stubs: { Icon: true } }
})
await flushPromises()
return wrapper
}
enableAutoUnmount(afterEach)
describe('AccountActionMenu viewport positioning', () => {
beforeEach(() => {
menuHeight = 305
setViewport(1024, 768)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (!this.classList.contains('action-menu-content')) return new DOMRect()
return new DOMRect(0, 0, Math.min(208, window.innerWidth - 16), Math.min(menuHeight, window.innerHeight - 16))
})
vi.stubGlobal('ResizeObserver', class {
constructor(callback: ResizeObserverCallback) { resizeMenu = callback }
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
})
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('measures a long OAuth menu and opens above the last row', async () => {
await mountMenu()
expect(getTop()).toBe(720 - 305 - 4)
expect(getLeft()).toBe(982 - 208)
expect(getTop() + menuHeight).toBeLessThanOrEqual(window.innerHeight - 8)
expect(getMenu().textContent).toContain('admin.accounts.recoverState')
})
it('opens below the trigger when the complete menu fits', async () => {
await mountMenu(new DOMRect(500, 100, 32, 24))
expect(getTop()).toBe(128)
expect(getLeft()).toBe(324)
})
it('centers a mobile menu on its trigger and flips above the last row', async () => {
setViewport(390, 600)
await mountMenu(new DOMRect(179, 550, 32, 24))
expect(getTop()).toBe(241)
expect(getLeft()).toBe(91)
})
it.each([0, 370])('keeps mobile menus within the horizontal margins at x=%i', async (x) => {
setViewport(390, 600)
await mountMenu(new DOMRect(x, 100, 24, 24))
expect(getLeft()).toBeGreaterThanOrEqual(8)
expect(getLeft() + 208).toBeLessThanOrEqual(382)
})
it('constrains an oversized menu and preserves the recover-state action', async () => {
setViewport(180, 220)
const wrapper = await mountMenu(new DOMRect(150, 180, 24, 24))
expect(getTop()).toBe(8)
expect(getLeft()).toBe(8)
expect(getMenu().style.maxHeight).toBe('204px')
expect(getMenu().style.maxWidth).toBe('164px')
expect(getMenu().classList.contains('overflow-y-auto')).toBe(true)
expect(getMenu().classList.contains('overscroll-contain')).toBe(true)
const recover = Array.from(getMenu().querySelectorAll('button'))
.find(button => button.textContent?.includes('admin.accounts.recoverState'))!
recover.click()
expect(wrapper.emitted('recover-state')).toEqual([[account]])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('repositions when menu content grows', async () => {
await mountMenu()
menuHeight = 420
resizeMenu([], {} as ResizeObserver)
await flushPromises()
expect(getTop()).toBe(296)
})
it('repositions and updates size limits after a viewport resize', async () => {
await mountMenu()
setViewport(320, 240)
await flushPromises()
expect(getTop()).toBe(8)
expect(getLeft()).toBe(104)
expect(getMenu().style.maxHeight).toBe('224px')
expect(getMenu().style.maxWidth).toBe('304px')
})
it('remeasures after reopening on another row', async () => {
const wrapper = await mountMenu()
await wrapper.setProps({ show: false })
expect(getMenu()).toBeNull()
await wrapper.setProps({ show: true, anchorRect: new DOMRect(400, 100, 32, 24) })
await flushPromises()
expect(getTop()).toBe(128)
expect(getLeft()).toBe(224)
})
it('still closes on Escape and backdrop clicks', async () => {
const wrapper = await mountMenu()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
const backdrop = getMenu().previousElementSibling as HTMLElement
backdrop.click()
expect(wrapper.emitted('close')).toHaveLength(2)
})
})
@@ -42,7 +42,7 @@ function makeAccount(overrides: Partial<Account>): Account {
}
}
const position = { top: 100, left: 100 }
const anchorRect = new DOMRect(100, 100, 24, 24)
// AccountActionMenu uses <Teleport to="body">; content is rendered in document.body, not in wrapper.
const getBodyText = () => document.body.textContent ?? ''
@@ -52,7 +52,7 @@ describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('普通账号显示「复制账号」按钮', () => {
const account = makeAccount({ platform: 'anthropic', type: 'apikey', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
props: { show: true, account, anchorRect },
attachTo: document.body,
})
expect(getBodyText()).toContain('admin.accounts.duplicateAccount')
@@ -62,7 +62,7 @@ describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('影子账号隐藏「复制账号」按钮', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: 42 })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
props: { show: true, account, anchorRect },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.duplicateAccount')
@@ -72,7 +72,7 @@ describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it.each(['oauth', 'setup-token'] as const)('%s 账号隐藏「复制账号」按钮,避免共享可轮换令牌', (type) => {
const account = makeAccount({ platform: 'openai', type, parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
props: { show: true, account, anchorRect },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.duplicateAccount')
@@ -82,7 +82,7 @@ describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('点击「复制账号」触发 duplicate 事件并携带 account', async () => {
const account = makeAccount({ platform: 'anthropic', type: 'apikey', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
props: { show: true, account, anchorRect },
attachTo: document.body,
})
@@ -101,7 +101,7 @@ describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('OpenAI OAuth 母账号(无 parent_account_id)显示「创建 spark 影子」按钮', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
props: { show: true, account, anchorRect },
attachTo: document.body,
})
expect(getBodyText()).toContain('admin.accounts.createSparkShadow')
@@ -111,7 +111,7 @@ describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('影子账号(parent_account_id 非 null)隐藏「创建 spark 影子」按钮', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: 42 })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
props: { show: true, account, anchorRect },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.createSparkShadow')
@@ -121,7 +121,7 @@ describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('非 OpenAI 账号隐藏「创建 spark 影子」按钮', () => {
const account = makeAccount({ platform: 'antigravity', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
props: { show: true, account, anchorRect },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.createSparkShadow')
@@ -131,7 +131,7 @@ describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('影子账号隐藏凭据/隐私类操作(重授权/刷新token/隐私)— 外审 G4', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: 42 })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
props: { show: true, account, anchorRect },
attachTo: document.body,
})
const body = getBodyText()
@@ -144,7 +144,7 @@ describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('普通 OpenAI OAuth 母账号仍显示凭据/隐私类操作', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
props: { show: true, account, anchorRect },
attachTo: document.body,
})
const body = getBodyText()
@@ -156,7 +156,7 @@ describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('点击按钮触发 create-spark-shadow 事件并携带 account', async () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
props: { show: true, account, anchorRect },
attachTo: document.body,
})
+5 -49
View File
@@ -456,7 +456,7 @@
<AccountTestModal :show="showTest" :account="testingAcc" @close="closeTestModal" />
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
<ScheduledTestsPanel :show="showSchedulePanel" :account-id="scheduleAcc?.id ?? null" :model-options="scheduleModelOptions" @close="closeSchedulePanel" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @duplicate="handleDuplicateAccount" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" @create-spark-shadow="handleCreateSparkShadow" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :anchor-rect="menu.anchorRect" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @duplicate="handleDuplicateAccount" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" @create-spark-shadow="handleCreateSparkShadow" />
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
<BulkEditAccountModal
@@ -614,7 +614,7 @@ const showSchedulePanel = ref(false)
const scheduleAcc = ref<Account | null>(null)
const scheduleModelOptions = ref<SelectOption[]>([])
const togglingSchedulable = ref<number | null>(null)
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
const menu = reactive<{show:boolean, acc:Account|null, anchorRect:DOMRect|null}>({ show: false, acc: null, anchorRect: null })
const exportingData = ref(false)
const probingUpstreamBilling = reactive(new Set<number>())
const upstreamBillingProbeGloballyEnabled = ref<boolean | undefined>(undefined)
@@ -1844,53 +1844,8 @@ const handleEdit = async (a: AccountListItem) => {
}
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: number
let top: number
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.anchorRect = target.getBoundingClientRect()
menu.show = true
}
const toggleSelectAllVisible = (event: Event) => {
@@ -2538,7 +2493,8 @@ const proxyExpiryText = (p: AccountProxy): string => {
}
// 表格滚动时关闭行操作菜单,并让顶部工具菜单继续贴紧触发按钮。
const handleScroll = () => {
const handleScroll = (event: Event) => {
if (event.target instanceof Element && event.target.closest('.action-menu-content')) return
menu.show = false
if (showAccountToolsDropdown.value) updateAccountToolsDropdownPosition()
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import AccountsView from '../AccountsView.vue'
@@ -88,8 +88,9 @@ const AccountStatsModalStub = defineComponent({
template: '<div data-test="stats-account">{{ show ? account?.name : "" }}</div>'
})
function mountView() {
function mountView(stubActionMenu = true) {
return mount(AccountsView, {
attachTo: document.body,
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
@@ -100,7 +101,7 @@ function mountView() {
AccountBulkActionsBar: true,
Pagination: true,
ConfirmDialog: true,
AccountActionMenu: true,
AccountActionMenu: stubActionMenu,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: AccountTestModalStub,
@@ -122,7 +123,7 @@ function mountView() {
UpstreamBillingRateCell: true,
HelpTooltip: true,
Icon: true,
Teleport: true
Teleport: stubActionMenu
}
}
})
@@ -189,6 +190,27 @@ describe('admin AccountsView lite account list', () => {
wrapper.unmount()
})
it('keeps the action menu open during internal scrolling but closes it on table scrolling', async () => {
const wrapper = mountView(false)
await flushPromises()
const trigger = wrapper.findAll('button').find(button => button.text() === 'common.more')!
await trigger.trigger('click')
const menu = new DOMWrapper(document.body.querySelector('.action-menu-content')!)
menu.element.dispatchEvent(new Event('scroll'))
await flushPromises()
expect(wrapper.findComponent(AccountActionMenu).props('show')).toBe(true)
menu.get('button').element.dispatchEvent(new Event('scroll'))
await flushPromises()
expect(wrapper.findComponent(AccountActionMenu).props('show')).toBe(true)
wrapper.getComponent(DataTableStub).element.dispatchEvent(new Event('scroll'))
await flushPromises()
expect(wrapper.findComponent(AccountActionMenu).props('show')).toBe(false)
wrapper.unmount()
})
it('keeps lite=1 on automatic ETag refreshes', async () => {
vi.useFakeTimers()
vi.spyOn(document, 'hidden', 'get').mockReturnValue(false)