mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user