revert(accounts): restore inline account filters (#7853)

Revert 679a7186f ("feat(accounts): compact account search and filters"),
which moved the type / privacy / group filters behind a collapsed
"More filters" panel on /admin/accounts.

Polish on top of the revert:
- Widen the privacy select (w-48) so "全部Privacy状态" / "All Privacy States"
  no longer truncates.
- Narrow the search input to w-52 so all six controls fit on one row at 1440px.
- Below sm, lay the filters out as a two-column grid that fills the width
  instead of fixed-width selects with a ragged edge.
This commit is contained in:
shaw
2026-10-15 20:44:29 +08:00
parent 698d142468
commit c2cca510f2
5 changed files with 15 additions and 191 deletions
@@ -1,49 +1,27 @@
<template>
<div class="min-w-0 w-full space-y-2">
<div class="grid grid-cols-2 items-center gap-2 sm:flex sm:flex-wrap">
<!-- 窄屏两列网格铺满宽度,sm 起恢复单行流式排布 -->
<div class="grid w-full grid-cols-2 gap-3 sm:flex sm:w-auto sm:flex-wrap sm:items-center">
<SearchInput
:model-value="searchQuery"
:placeholder="t('admin.accounts.searchAccounts')"
class="col-span-2 min-w-0 w-full sm:w-56"
class="col-span-2 sm:w-52"
@update:model-value="$emit('update:searchQuery', $event)"
@search="$emit('change')"
/>
<Select :model-value="filters.platform" :aria-label="t('admin.accounts.allPlatforms')" class="min-w-0 w-full sm:w-36" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
<Select :model-value="filters.status" :aria-label="t('admin.accounts.allStatus')" class="min-w-0 w-full sm:w-36" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
<button
type="button"
class="btn btn-secondary col-span-2 min-w-0 w-full sm:w-auto"
:aria-expanded="moreFiltersOpen"
:aria-controls="moreFiltersId"
:aria-label="activeSecondaryCount ? t('admin.accounts.moreFiltersActive', { count: activeSecondaryCount }) : t('admin.accounts.moreFilters')"
@click="moreFiltersOpen = !moreFiltersOpen"
>
{{ t('admin.accounts.moreFilters') }}
<span v-if="activeSecondaryCount" aria-hidden="true" class="rounded-full bg-primary-100 px-2 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">{{ activeSecondaryCount }}</span>
</button>
</div>
<div v-show="moreFiltersOpen" :id="moreFiltersId" class="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-3">
<Select :model-value="filters.type" :aria-label="t('admin.accounts.allTypes')" class="min-w-0 w-full" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
<Select :model-value="filters.privacy_mode" :aria-label="t('admin.accounts.allPrivacyModes')" class="min-w-0 w-full" :options="privacyOpts" @update:model-value="updatePrivacyMode" @change="$emit('change')" />
<Select :model-value="filters.group" :aria-label="t('admin.accounts.allGroups')" class="min-w-0 w-full" :options="gOpts" @update:model-value="updateGroup" @change="$emit('change')" />
</div>
<Select :model-value="filters.platform" class="min-w-0 sm:w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
<Select :model-value="filters.type" class="min-w-0 sm:w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
<Select :model-value="filters.status" class="min-w-0 sm:w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
<Select :model-value="filters.privacy_mode" class="min-w-0 sm:w-48" :options="privacyOpts" @update:model-value="updatePrivacyMode" @change="$emit('change')" />
<Select :model-value="filters.group" class="col-span-2 min-w-0 sm:w-40" :options="gOpts" @update:model-value="updateGroup" @change="$emit('change')" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, useId } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
import type { AdminGroup } from '@/types'
import { CONCRETE_PLATFORM_OPTIONS } from '@/constants/platforms'
const props = defineProps<{ searchQuery: string; filters: Record<string, any>; groups?: AdminGroup[] }>()
const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
const moreFiltersOpen = ref(false)
const moreFiltersId = useId()
const activeSecondaryCount = computed(() =>
['type', 'privacy_mode', 'group'].filter(key => {
const value = props.filters[key]
return value !== '' && value !== null && value !== undefined
}).length
)
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
@@ -1,149 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import AccountTableFilters from '../AccountTableFilters.vue'
import Select from '@/components/common/Select.vue'
import SearchInput from '@/components/common/SearchInput.vue'
import { CONCRETE_PLATFORM_OPTIONS } from '@/constants/platforms'
import type { AdminGroup } from '@/types'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string, params?: { count: number }) => params ? `${key} (${params.count})` : key
})
}
})
const defaultFilters = () => ({
platform: '', type: '', status: '', privacy_mode: '', group: '',
search: 'existing search', lite: '1', sort_by: 'priority', sort_order: 'asc'
})
function mountFilters(filters: Record<string, unknown> = defaultFilters()) {
return mount(AccountTableFilters, {
attachTo: document.body,
props: {
searchQuery: 'existing search', filters,
groups: [{ id: 42, name: 'A very long group name' } as AdminGroup]
},
global: { stubs: { Teleport: true } }
})
}
enableAutoUnmount(afterEach)
afterEach(() => {
vi.useRealTimers()
document.body.innerHTML = ''
})
describe('AccountTableFilters', () => {
it('toggles an accessible secondary panel without emitting changes or clearing filters', async () => {
const filters = { ...defaultFilters(), type: 'bedrock', privacy_mode: '__unset__', group: 'ungrouped' }
const wrapper = mountFilters(filters)
const toggle = wrapper.get('button[aria-controls]')
const panel = wrapper.get(`[id="${toggle.attributes('aria-controls')}"]`)
expect(toggle.attributes('type')).toBe('button')
expect(toggle.attributes('aria-expanded')).toBe('false')
expect(panel.isVisible()).toBe(false)
expect(wrapper.findAllComponents(Select).filter(select => select.isVisible())).toHaveLength(2)
await toggle.trigger('click')
expect(toggle.attributes('aria-expanded')).toBe('true')
expect(panel.isVisible()).toBe(true)
expect(wrapper.findAllComponents(Select).every(select => select.isVisible())).toBe(true)
await toggle.trigger('click')
expect(panel.isVisible()).toBe(false)
await toggle.trigger('click')
expect(wrapper.props('filters')).toEqual(filters)
expect(wrapper.emitted('update:filters')).toBeUndefined()
expect(wrapper.emitted('change')).toBeUndefined()
wrapper.unmount()
})
it('indicates active secondary filters while collapsed and reacts to external resets', async () => {
const wrapper = mountFilters({ ...defaultFilters(), platform: 'openai', status: 'active' })
const toggle = wrapper.get('button[aria-controls]')
expect(toggle.find('span').exists()).toBe(false)
for (const [filters, count] of [
[{ type: 'oauth', privacy_mode: '', group: '' }, 1],
[{ type: 'setup-token', privacy_mode: '__unset__', group: 'ungrouped' }, 3],
[{ type: '', privacy_mode: 'training_off', group: '42' }, 2]
] as const) {
await wrapper.setProps({ filters: { ...defaultFilters(), ...filters } })
expect(toggle.get('span').text()).toBe(String(count))
expect(toggle.attributes('aria-label')).toBe(`admin.accounts.moreFiltersActive (${count})`)
expect(toggle.attributes('aria-expanded')).toBe('false')
}
await wrapper.setProps({ filters: { type: null, privacy_mode: undefined, group: '' } })
expect(toggle.find('span').exists()).toBe(false)
expect(toggle.attributes('aria-label')).toBe('admin.accounts.moreFilters')
wrapper.unmount()
})
it('preserves every option and emits a merged filter snapshot plus change for every selection', async () => {
const filters = { ...defaultFilters(), platform: 'openai', type: 'oauth', status: 'active', privacy_mode: '__unset__', group: '42' }
const wrapper = mountFilters(filters)
await wrapper.get('button[aria-controls]').trigger('click')
const expectedOptions = [
['platform', ['', ...CONCRETE_PLATFORM_OPTIONS.map(option => option.value)]],
['status', ['', 'active', 'inactive', 'error', 'rate_limited', 'temp_unschedulable', 'unschedulable']],
['type', ['', 'oauth', 'setup-token', 'apikey', 'bedrock']],
['privacy_mode', ['', '__unset__', 'training_off', 'training_set_cf_blocked', 'training_set_failed']],
['group', ['', 'ungrouped', '42']]
] as const
let changes = 0
for (const [index, [key, values]] of expectedOptions.entries()) {
const select = wrapper.findAllComponents(Select)[index]
expect(select.props('options').map(option => option.value)).toEqual(values)
expect(select.props('modelValue')).toBe(filters[key])
expect(select.get('button').attributes('aria-label')).not.toBe('Select option')
for (const [optionIndex, value] of values.entries()) {
await select.get('button').trigger('click')
await select.findAll('[role="option"]')[optionIndex].trigger('click')
changes += 1
expect(wrapper.emitted('update:filters')?.at(-1)).toEqual([{ ...filters, [key]: value }])
expect(wrapper.emitted('change')).toHaveLength(changes)
expect(wrapper.props('filters')).toEqual(filters)
}
}
expect(wrapper.findAllComponents(Select)[4].props('options')[2].label).toBe('A very long group name')
wrapper.unmount()
})
it('preserves immediate search updates and the debounced search change event', async () => {
vi.useFakeTimers()
const wrapper = mountFilters()
const search = wrapper.getComponent(SearchInput)
expect(search.props('modelValue')).toBe('existing search')
expect(search.props('placeholder')).toBe('admin.accounts.searchAccounts')
await search.get('input').setValue('new search')
expect(wrapper.emitted('update:searchQuery')).toEqual([['new search']])
expect(wrapper.emitted('change')).toBeUndefined()
await vi.advanceTimersByTimeAsync(300)
expect(wrapper.emitted('change')).toEqual([[]])
expect(wrapper.emitted('update:filters')).toBeUndefined()
wrapper.unmount()
})
it('uses shrinkable, wrapping controls and unique panel IDs for multiple instances', () => {
const wrapper = mountFilters()
expect(wrapper.classes()).toContain('min-w-0')
expect(wrapper.get('.grid').classes()).toEqual(expect.arrayContaining(['grid-cols-2', 'sm:flex-wrap']))
for (const select of wrapper.findAllComponents(Select)) {
expect(select.classes()).toEqual(expect.arrayContaining(['min-w-0', 'w-full']))
expect(select.classes()).not.toContain('w-40')
}
const pair = mount(defineComponent({
components: { AccountTableFilters },
setup: () => ({ filters: defaultFilters() }),
template: '<div><AccountTableFilters search-query="" :filters="filters" /><AccountTableFilters search-query="" :filters="filters" /></div>'
}))
const toggles = pair.findAll('button[aria-controls]')
expect(toggles[0].attributes('aria-controls')).not.toBe(toggles[1].attributes('aria-controls'))
wrapper.unmount()
pair.unmount()
})
})
@@ -79,8 +79,6 @@ export default {
editAccount: 'Edit Account',
deleteAccount: 'Delete Account',
searchAccounts: 'Search accounts...',
moreFilters: 'More filters',
moreFiltersActive: 'More filters ({count} active)',
notes: 'Notes',
notesPlaceholder: 'Enter notes',
notesHint: 'Notes are optional',
@@ -78,8 +78,6 @@ export default {
editAccount: '编辑账号',
deleteAccount: '删除账号',
searchAccounts: '搜索账号...',
moreFilters: '更多筛选',
moreFiltersActive: '更多筛选(已启用 {count} 项)',
notes: '备注',
notesPlaceholder: '请输入备注',
notesHint: '备注可选',
@@ -4,7 +4,6 @@
<template #filters>
<div class="flex flex-wrap-reverse items-start justify-between gap-3">
<AccountTableFilters
class="lg:w-auto lg:flex-1"
v-model:searchQuery="params.search"
:filters="params"
:groups="groups"