mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00: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>
|
<template>
|
||||||
<div class="min-w-0 w-full space-y-2">
|
<!-- 窄屏两列网格铺满宽度,sm 起恢复单行流式排布 -->
|
||||||
<div class="grid grid-cols-2 items-center gap-2 sm:flex sm:flex-wrap">
|
<div class="grid w-full grid-cols-2 gap-3 sm:flex sm:w-auto sm:flex-wrap sm:items-center">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
:model-value="searchQuery"
|
:model-value="searchQuery"
|
||||||
:placeholder="t('admin.accounts.searchAccounts')"
|
: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)"
|
@update:model-value="$emit('update:searchQuery', $event)"
|
||||||
@search="$emit('change')"
|
@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.platform" class="min-w-0 sm:w-40" :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')" />
|
<Select :model-value="filters.type" class="min-w-0 sm:w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
|
||||||
<button
|
<Select :model-value="filters.status" class="min-w-0 sm:w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
|
||||||
type="button"
|
<Select :model-value="filters.privacy_mode" class="min-w-0 sm:w-48" :options="privacyOpts" @update:model-value="updatePrivacyMode" @change="$emit('change')" />
|
||||||
class="btn btn-secondary col-span-2 min-w-0 w-full sm:w-auto"
|
<Select :model-value="filters.group" class="col-span-2 min-w-0 sm:w-40" :options="gOpts" @update:model-value="updateGroup" @change="$emit('change')" />
|
||||||
: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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 type { AdminGroup } from '@/types'
|
||||||
import { CONCRETE_PLATFORM_OPTIONS } from '@/constants/platforms'
|
import { CONCRETE_PLATFORM_OPTIONS } from '@/constants/platforms'
|
||||||
const props = defineProps<{ searchQuery: string; filters: Record<string, any>; groups?: AdminGroup[] }>()
|
const props = defineProps<{ searchQuery: string; filters: Record<string, any>; groups?: AdminGroup[] }>()
|
||||||
const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
|
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 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 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 }) }
|
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',
|
editAccount: 'Edit Account',
|
||||||
deleteAccount: 'Delete Account',
|
deleteAccount: 'Delete Account',
|
||||||
searchAccounts: 'Search accounts...',
|
searchAccounts: 'Search accounts...',
|
||||||
moreFilters: 'More filters',
|
|
||||||
moreFiltersActive: 'More filters ({count} active)',
|
|
||||||
notes: 'Notes',
|
notes: 'Notes',
|
||||||
notesPlaceholder: 'Enter notes',
|
notesPlaceholder: 'Enter notes',
|
||||||
notesHint: 'Notes are optional',
|
notesHint: 'Notes are optional',
|
||||||
|
|||||||
@@ -78,8 +78,6 @@ export default {
|
|||||||
editAccount: '编辑账号',
|
editAccount: '编辑账号',
|
||||||
deleteAccount: '删除账号',
|
deleteAccount: '删除账号',
|
||||||
searchAccounts: '搜索账号...',
|
searchAccounts: '搜索账号...',
|
||||||
moreFilters: '更多筛选',
|
|
||||||
moreFiltersActive: '更多筛选(已启用 {count} 项)',
|
|
||||||
notes: '备注',
|
notes: '备注',
|
||||||
notesPlaceholder: '请输入备注',
|
notesPlaceholder: '请输入备注',
|
||||||
notesHint: '备注可选',
|
notesHint: '备注可选',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<template #filters>
|
<template #filters>
|
||||||
<div class="flex flex-wrap-reverse items-start justify-between gap-3">
|
<div class="flex flex-wrap-reverse items-start justify-between gap-3">
|
||||||
<AccountTableFilters
|
<AccountTableFilters
|
||||||
class="lg:w-auto lg:flex-1"
|
|
||||||
v-model:searchQuery="params.search"
|
v-model:searchQuery="params.search"
|
||||||
:filters="params"
|
:filters="params"
|
||||||
:groups="groups"
|
:groups="groups"
|
||||||
|
|||||||
Reference in New Issue
Block a user