fix: keep account table on compact list responses

This commit is contained in:
Ritel-T
2026-09-03 21:20:09 +09:00
parent 994192ef4f
commit db15e0090a
2 changed files with 295 additions and 16 deletions
+45 -16
View File
@@ -304,7 +304,7 @@
/>
</template>
<template #cell-groups="{ row }">
<AccountGroupsCell :groups="row.groups" :max-display="4" />
<AccountGroupsCell :groups="accountGroupsForRow(row)" :max-display="4" />
</template>
<template #header-usage="{ column }">
<div class="flex items-center">
@@ -532,7 +532,7 @@ import { extractApiErrorMessage } from '@/utils/apiError'
import { sanitizeUrl } from '@/utils/url'
import { getFloatingPanelPosition } from '@/utils/floatingPanel'
import { formatMultiplier } from '@/utils/formatters'
import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, AccountUsageInfo, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
import type { Account, AccountListItem, AccountPlatform, AccountSchedulerGroupScore, AccountType, AccountUsageInfo, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel, UpstreamBillingProbeSnapshot } from '@/types'
const { t } = useI18n()
const appStore = useAppStore()
@@ -540,6 +540,12 @@ const authStore = useAuthStore()
const proxies = ref<AccountProxy[]>([])
const groups = ref<AdminGroup[]>([])
const groupsByID = computed(() => new Map(groups.value.map(group => [group.id, group])))
const accountGroupsForRow = (account: Pick<AccountListItem, 'group_ids'>): AdminGroup[] => {
const groupIDs = account.group_ids ?? []
if (groupIDs.length === 0) return []
return groupIDs.map(id => groupsByID.value.get(id)).filter((group): group is AdminGroup => Boolean(group))
}
const accountTableRef = ref<HTMLElement | null>(null)
const dataTableRef = ref<InstanceType<typeof DataTable> | null>(null)
type AccountBulkEditTarget =
@@ -1070,7 +1076,7 @@ const {
debouncedReload: baseDebouncedReload,
handlePageChange: baseHandlePageChange,
handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({
} = useTableLoader<AccountListItem, any>({
fetchFn: adminAPI.accounts.list,
initialParams: {
platform: '',
@@ -1079,6 +1085,7 @@ const {
privacy_mode: '',
group: '',
search: '',
lite: '1',
include_scheduler_score: shouldIncludeSchedulerScore() ? '1' : '0',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
@@ -1099,7 +1106,7 @@ const {
toggleVisible,
selectVisible: selectCurrentPage,
batchUpdate
} = useTableSelection<Account>({
} = useTableSelection<AccountListItem>({
rows: accounts,
getId: (account) => account.id
})
@@ -1142,8 +1149,6 @@ const resetAutoRefreshCache = () => {
upstreamBillingRateETag.value = null
}
const isFirstLoad = ref(true)
type AccountLoadOptions = {
refreshTodayStats?: boolean
}
@@ -1154,14 +1159,8 @@ const load = async (options: AccountLoadOptions = {}) => {
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = false
if (isFirstLoad.value) {
requestParams.lite = '1'
}
requestParams.lite = '1'
await baseLoad()
if (isFirstLoad.value) {
isFirstLoad.value = false
delete requestParams.lite
}
if (options.refreshTodayStats !== false) await refreshTodayStatsBatch()
}
@@ -1822,7 +1821,27 @@ const cols = computed(() =>
)
)
const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
const accountDetailLoading = new Set<number>()
const loadAccountDetails = async (account: Pick<AccountListItem, 'id'>): Promise<Account | null> => {
if (accountDetailLoading.has(account.id)) return null
accountDetailLoading.add(account.id)
try {
return await adminAPI.accounts.getById(account.id)
} catch (error) {
console.error('Failed to load account details:', error)
appStore.showError(extractApiErrorMessage(error, t('common.error')))
return null
} finally {
accountDetailLoading.delete(account.id)
}
}
const handleEdit = async (a: AccountListItem) => {
const account = await loadAccountDetails(a)
if (!account) return
edAcc.value = account
showEdit.value = true
}
const openMenu = (a: Account, e: MouseEvent) => {
menu.acc = a
@@ -2333,8 +2352,18 @@ const accountExportStepUp = useStepUp()
const closeTestModal = () => { showTest.value = false; testingAcc.value = null }
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = null }
const handleTest = (a: Account) => { testingAcc.value = a; showTest.value = true }
const handleViewStats = (a: Account) => { statsAcc.value = a; showStats.value = true }
const handleTest = async (a: AccountListItem) => {
const account = await loadAccountDetails(a)
if (!account) return
testingAcc.value = account
showTest.value = true
}
const handleViewStats = async (a: AccountListItem) => {
const account = await loadAccountDetails(a)
if (!account) return
statsAcc.value = account
showStats.value = true
}
const handleSchedule = async (a: Account) => {
scheduleAcc.value = a
scheduleModelOptions.value = []
@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import AccountsView from '../AccountsView.vue'
import AccountActionMenu from '@/components/admin/account/AccountActionMenu.vue'
const {
listAccounts,
listWithEtag,
getById,
getBatchTodayStats,
getUpstreamBillingProbeSettings,
getAllProxies,
getAllGroups,
showError
} = vi.hoisted(() => ({
listAccounts: vi.fn(),
listWithEtag: vi.fn(),
getById: vi.fn(),
getBatchTodayStats: vi.fn(),
getUpstreamBillingProbeSettings: vi.fn(),
getAllProxies: vi.fn(),
getAllGroups: vi.fn(),
showError: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
list: listAccounts,
getById,
listWithEtag,
getBatchTodayStats,
getUpstreamBillingProbeSettings,
delete: vi.fn(),
batchClearError: vi.fn(),
batchRefresh: vi.fn(),
toggleSchedulable: vi.fn()
},
proxies: { getAll: getAllProxies },
groups: { getAll: getAllGroups }
}
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showError, showSuccess: vi.fn(), showInfo: vi.fn() })
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({ token: 'test-token', isSimpleMode: false })
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return { ...actual, useI18n: () => ({ t: (key: string) => key }) }
})
const DataTableStub = defineComponent({
props: { data: { type: Array, default: () => [] } },
template: `
<div>
<div v-for="row in data" :key="row.id">
<slot name="cell-groups" :row="row" />
<slot name="cell-actions" :row="row" />
</div>
</div>
`
})
const AccountGroupsCellStub = defineComponent({
props: { groups: { type: Array, default: () => [] } },
template: '<span data-test="account-groups">{{ groups.map(group => group.name).join(",") }}</span>'
})
const EditAccountModalStub = defineComponent({
props: { show: Boolean, account: { type: Object, default: null } },
template: '<div data-test="edit-account">{{ show ? account?.name : "" }}</div>'
})
const AccountTestModalStub = defineComponent({
props: { show: Boolean, account: { type: Object, default: null } },
template: '<div data-test="test-account">{{ show ? account?.name : "" }}</div>'
})
const AccountStatsModalStub = defineComponent({
props: { show: Boolean, account: { type: Object, default: null } },
template: '<div data-test="stats-account">{{ show ? account?.name : "" }}</div>'
})
function mountView() {
return mount(AccountsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: { template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>' },
DataTable: DataTableStub,
AccountTableActions: { template: '<div><slot name="after" /></div>' },
AccountTableFilters: true,
AccountBulkActionsBar: true,
Pagination: true,
ConfirmDialog: true,
AccountActionMenu: true,
ImportDataModal: true,
ReAuthAccountModal: true,
AccountTestModal: AccountTestModalStub,
AccountStatsModal: AccountStatsModalStub,
ScheduledTestsPanel: true,
SyncFromCrsModal: true,
TempUnschedStatusModal: true,
ErrorPassthroughRulesModal: true,
TLSFingerprintProfilesModal: true,
CreateAccountModal: true,
EditAccountModal: EditAccountModalStub,
BulkEditAccountModal: true,
PlatformTypeBadge: true,
AccountCapacityCell: true,
AccountStatusIndicator: true,
AccountTodayStatsCell: true,
AccountGroupsCell: AccountGroupsCellStub,
AccountUsageCell: true,
UpstreamBillingRateCell: true,
HelpTooltip: true,
Icon: true,
Teleport: true
}
}
})
}
const listRow = {
id: 42,
name: 'compact row',
platform: 'openai',
type: 'oauth',
status: 'active',
schedulable: true,
concurrency: 2,
priority: 1,
group_ids: [7],
extra: {},
credentials: {}
}
const fullAccount = {
...listRow,
groups: [{ id: 7, name: 'codex', platform: 'openai' }],
account_groups: [{ account_id: 42, group_id: 7 }],
credentials: { api_key: 'redacted' },
extra: { detail_only: true }
}
describe('admin AccountsView lite account list', () => {
beforeEach(() => {
localStorage.clear()
listAccounts.mockReset().mockResolvedValue({ items: [listRow], total: 1, page: 1, page_size: 20, pages: 1 })
listWithEtag.mockReset().mockResolvedValue({ notModified: true, etag: 'compact-etag', data: null })
getById.mockReset().mockResolvedValue(fullAccount)
getBatchTodayStats.mockReset().mockResolvedValue({ stats: {} })
getUpstreamBillingProbeSettings.mockReset().mockResolvedValue({ enabled: true })
getAllProxies.mockReset().mockResolvedValue([])
getAllGroups.mockReset().mockResolvedValue([{ id: 7, name: 'codex', platform: 'openai' }])
showError.mockReset()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('keeps lite=1 on the initial list request', async () => {
const wrapper = mountView()
await flushPromises()
expect(listAccounts).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({ lite: '1' }),
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
wrapper.unmount()
})
it('maps group_ids through the group catalog for the table cell', async () => {
const wrapper = mountView()
await flushPromises()
expect(wrapper.get('[data-test="account-groups"]').text()).toBe('codex')
wrapper.unmount()
})
it('keeps lite=1 on automatic ETag refreshes', async () => {
vi.useFakeTimers()
vi.spyOn(document, 'hidden', 'get').mockReturnValue(false)
localStorage.setItem('account-auto-refresh', JSON.stringify({ enabled: true, interval_seconds: 5 }))
const wrapper = mountView()
await flushPromises()
await vi.advanceTimersByTimeAsync(6000)
await flushPromises()
expect(listWithEtag).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({ lite: '1' }),
expect.objectContaining({ etag: null })
)
wrapper.unmount()
})
it('loads the full account by id before opening edit, test, and stats actions', async () => {
const wrapper = mountView()
await flushPromises()
const editButton = wrapper.findAll('button').find(button => button.text().includes('common.edit'))
expect(editButton).toBeTruthy()
await editButton!.trigger('click')
await flushPromises()
expect(getById).toHaveBeenCalledWith(42)
expect(wrapper.get('[data-test="edit-account"]').text()).toBe('compact row')
const menu = wrapper.findComponent(AccountActionMenu)
menu.vm.$emit('test', listRow)
await flushPromises()
expect(getById).toHaveBeenCalledTimes(2)
expect(wrapper.get('[data-test="test-account"]').text()).toBe('compact row')
menu.vm.$emit('stats', listRow)
await flushPromises()
expect(getById).toHaveBeenCalledTimes(3)
expect(wrapper.get('[data-test="stats-account"]').text()).toBe('compact row')
wrapper.unmount()
})
it('shows an error and keeps the modal closed when detail loading fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
getById.mockRejectedValueOnce(new Error('detail failed'))
const wrapper = mountView()
await flushPromises()
const editButton = wrapper.findAll('button').find(button => button.text().includes('common.edit'))
await editButton!.trigger('click')
await flushPromises()
expect(showError).toHaveBeenCalledWith('detail failed')
expect(wrapper.get('[data-test="edit-account"]').text()).toBe('')
consoleError.mockRestore()
wrapper.unmount()
})
})