diff --git a/frontend/src/features/listings/components/HomeFilters.vue b/frontend/src/features/listings/components/HomeFilters.vue index 31570d8..563c6e1 100644 --- a/frontend/src/features/listings/components/HomeFilters.vue +++ b/frontend/src/features/listings/components/HomeFilters.vue @@ -79,7 +79,6 @@ function closePopover() { :options="publishOptions.insurance_options" :popover-props="filterPopoverProps('insurance')" @update:model-value="updateFilter('insurance', $event)" - @close="closePopover" /> wide?: boolean } -const props = defineProps() +const props = withDefaults(defineProps(), { + modelValue: () => [], +}) const emit = defineEmits<{ - 'update:modelValue': [value: string] + 'update:modelValue': [value: string[]] close: [] }>() -const isActive = computed(() => { - return !!props.modelValue -}) +const selectedValues = computed(() => props.modelValue.filter(Boolean)) + +const isActive = computed(() => selectedValues.value.length > 0) const displayLabel = computed(() => { - return props.modelValue || props.placeholder + const values = selectedValues.value + if (!values.length) return props.placeholder + if (values.length === 1) return values[0] + if (values.length === 2) return values.join('、') + return `${values[0]} 等${values.length}项` }) -function selectOption(value: string) { - emit('update:modelValue', value) - emit('close') +function isSelected(value: string) { + return selectedValues.value.includes(value) +} + +function toggleOption(value: string) { + if (!value) { + emit('update:modelValue', []) + return + } + if (isSelected(value)) { + emit( + 'update:modelValue', + selectedValues.value.filter(item => item !== value) + ) + return + } + emit('update:modelValue', [...selectedValues.value, value]) } @@ -42,16 +62,17 @@ function selectOption(value: string) {
-
diff --git a/frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts b/frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts index 1459b94..7b47d4b 100644 --- a/frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts +++ b/frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts @@ -8,6 +8,8 @@ import { isSameQuery, mergeHomeQuery, readStoredHomeQuery, + joinQueryCSV, + readQueryCSV, readQueryNumber, readQueryString, storeHomeQuery, @@ -26,6 +28,14 @@ describe('useHomeFilterQuery', () => { expect(readQueryNumber(query, 'max_coin')).toBeUndefined() }) + it('应该读写 CSV 多选筛选参数', () => { + expect(readQueryCSV({ insurance: '3*3,2*2,3*3' }, 'insurance')).toEqual(['3*3', '2*2']) + expect(readQueryCSV({ insurance: '3*3' }, 'insurance')).toEqual(['3*3']) + expect(readQueryCSV({}, 'insurance')).toEqual([]) + expect(joinQueryCSV(['3*3', '2*2', '3*3'])).toBe('3*3,2*2') + expect(joinQueryCSV([])).toBeUndefined() + }) + it('应该合并新筛选并移除旧的托管参数', () => { const query = mergeHomeQuery( { keyword: '旧关键词', page: '2', region: '上海' }, diff --git a/frontend/src/features/listings/composables/__tests__/useHomeFilters.spec.ts b/frontend/src/features/listings/composables/__tests__/useHomeFilters.spec.ts index 934a676..95bc038 100644 --- a/frontend/src/features/listings/composables/__tests__/useHomeFilters.spec.ts +++ b/frontend/src/features/listings/composables/__tests__/useHomeFilters.spec.ts @@ -11,6 +11,8 @@ describe('useHomeFilters', () => { const { filters } = useHomeFilters(publishOptions, listings) expect(filters.keyword).toBe('') + expect(filters.region).toEqual([]) + expect(filters.insurance).toEqual([]) expect(filters.minCoin).toBeUndefined() expect(filters.maxCoin).toBeUndefined() }) @@ -23,24 +25,42 @@ describe('useHomeFilters', () => { filters.keyword = '测试' filters.minCoin = 100 - filters.region = '北京' + filters.region = ['北京', '上海'] + filters.insurance = ['3*3', '2*2'] resetFilters() expect(filters.keyword).toBe('') expect(filters.minCoin).toBeUndefined() - expect(filters.region).toBe('') + expect(filters.region).toEqual([]) + expect(filters.insurance).toEqual([]) }) - it('应该正确设置字符串筛选器', () => { + it('应该正确设置字符串多选筛选器', () => { const publishOptions = ref(emptyListingPublishOptions) const listings = ref([]) const { filters, setStringFilter } = useHomeFilters(publishOptions, listings) setStringFilter('region', '上海') + expect(filters.region).toEqual(['上海']) - expect(filters.region).toBe('上海') + setStringFilter('region', ['北京', '上海', '上海']) + expect(filters.region).toEqual(['北京', '上海']) + }) + + it('应该支持切换字符串筛选项', () => { + const publishOptions = ref(emptyListingPublishOptions) + const listings = ref([]) + + const { filters, toggleStringFilter } = useHomeFilters(publishOptions, listings) + + toggleStringFilter('insurance', '3*3') + toggleStringFilter('insurance', '2*2') + expect(filters.insurance).toEqual(['3*3', '2*2']) + + toggleStringFilter('insurance', '3*3') + expect(filters.insurance).toEqual(['2*2']) }) it('应该正确设置范围筛选器', () => { diff --git a/frontend/src/features/listings/composables/useHomeFilterQuery.ts b/frontend/src/features/listings/composables/useHomeFilterQuery.ts index efdb295..a1841de 100644 --- a/frontend/src/features/listings/composables/useHomeFilterQuery.ts +++ b/frontend/src/features/listings/composables/useHomeFilterQuery.ts @@ -48,6 +48,20 @@ export function readQueryNumber(query: LocationQuery | LocationQueryRaw, key: st return Number.isFinite(number) ? number : undefined } +/** 读取 CSV 多选筛选参数,兼容旧版单值与逗号分隔多值。 */ +export function readQueryCSV(query: LocationQuery | LocationQueryRaw, key: string): string[] { + const raw = readQueryString(query, key) + if (!raw) return [] + return [...new Set(raw.split(',').map(item => item.trim()).filter(Boolean))] +} + +/** 将多选数组序列化为 CSV;空数组返回 undefined 以便从 query 中省略。 */ +export function joinQueryCSV(values: string[] | undefined | null): string | undefined { + if (!values?.length) return undefined + const joined = [...new Set(values.map(item => item.trim()).filter(Boolean))].join(',') + return joined || undefined +} + export function mergeHomeQuery( currentQuery: LocationQuery, managedKeys: readonly string[], diff --git a/frontend/src/features/listings/composables/useHomeFilters.ts b/frontend/src/features/listings/composables/useHomeFilters.ts index 9c137a2..2a59584 100644 --- a/frontend/src/features/listings/composables/useHomeFilters.ts +++ b/frontend/src/features/listings/composables/useHomeFilters.ts @@ -23,12 +23,12 @@ export type StringFilterKey = 'insurance' | 'stamina' | 'load' | 'region' | 'ran export interface HomeFilters { keyword: string server: string - region: string - loginMethod: string - rank: string - insurance: string - stamina: string - load: string + region: string[] + loginMethod: string[] + rank: string[] + insurance: string[] + stamina: string[] + load: string[] skinGroup: string skinName: string minCoin: number | undefined @@ -52,12 +52,12 @@ export function useHomeFilters( const filters = reactive({ keyword: '', server: '', - region: '', - loginMethod: '', - rank: '', - insurance: '', - stamina: '', - load: '', + region: [], + loginMethod: [], + rank: [], + insurance: [], + stamina: [], + load: [], skinGroup: '', skinName: '', minCoin: undefined, @@ -111,12 +111,12 @@ export function useHomeFilters( function resetFilters() { filters.keyword = '' filters.server = '' - filters.region = '' - filters.loginMethod = '' - filters.rank = '' - filters.insurance = '' - filters.stamina = '' - filters.load = '' + filters.region = [] + filters.loginMethod = [] + filters.rank = [] + filters.insurance = [] + filters.stamina = [] + filters.load = [] filters.skinGroup = '' filters.skinName = '' filters.minCoin = undefined @@ -133,11 +133,23 @@ export function useHomeFilters( filters.maxFireLevel = undefined } - function setStringFilter(key: StringFilterKey, value: string) { - filters[key] = value + function setStringFilter(key: StringFilterKey, value: string | string[]) { + filters[key] = Array.isArray(value) + ? uniqueOptions(value) + : value.trim() + ? [value.trim()] + : [] closeFilterPopover() } + function toggleStringFilter(key: StringFilterKey, value: string) { + const current = filters[key] + const next = current.includes(value) + ? current.filter(item => item !== value) + : uniqueOptions([...current, value]) + filters[key] = next + } + function setCoinRange(min: number | undefined, max: number | undefined) { filters.minCoin = min filters.maxCoin = max @@ -194,6 +206,7 @@ export function useHomeFilters( skinChipLabel, resetFilters, setStringFilter, + toggleStringFilter, setCoinRange, setPriceRange, setDepositRange, diff --git a/frontend/src/features/listings/composables/useListingQuery.ts b/frontend/src/features/listings/composables/useListingQuery.ts index 234cd8c..fcc1277 100644 --- a/frontend/src/features/listings/composables/useListingQuery.ts +++ b/frontend/src/features/listings/composables/useListingQuery.ts @@ -18,6 +18,10 @@ export function useListingQuery( const hasMoreListings = ref(true) let listingRequestSeq = 0 + function joinCSV(values: string[]) { + return values.filter(Boolean).join(',') || undefined + } + function buildListingQuery(page: number): PublicListingQuery { return { page, @@ -26,12 +30,12 @@ export function useListingQuery( sort: sortBy.value, zone: activeZone.value, server: filters.server, - region: filters.region, - login_method: filters.loginMethod, - rank: filters.rank, - insurance: filters.insurance, - stamina: filters.stamina, - load: filters.load, + region: joinCSV(filters.region), + login_method: joinCSV(filters.loginMethod), + rank: joinCSV(filters.rank), + insurance: joinCSV(filters.insurance), + stamina: joinCSV(filters.stamina), + load: joinCSV(filters.load), skin_group: filters.skinGroup, skin_name: filters.skinName, min_coin: filters.minCoin, diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index c7a00b2..5df492e 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -20,6 +20,8 @@ import { isSameQuery, mergeHomeQuery, readStoredHomeQuery, + joinQueryCSV, + readQueryCSV, readQueryNumber, readQueryString, storeHomeQuery, @@ -170,12 +172,12 @@ function applyRouteQueryToHomeState() { : storedQuery filters.keyword = readQueryString(query, 'keyword') filters.server = readQueryString(query, 'server') - filters.region = readQueryString(query, 'region') - filters.loginMethod = readQueryString(query, 'login_method') - filters.rank = readQueryString(query, 'rank') - filters.insurance = readQueryString(query, 'insurance') - filters.stamina = readQueryString(query, 'stamina') - filters.load = readQueryString(query, 'load') + filters.region = readQueryCSV(query, 'region') + filters.loginMethod = readQueryCSV(query, 'login_method') + filters.rank = readQueryCSV(query, 'rank') + filters.insurance = readQueryCSV(query, 'insurance') + filters.stamina = readQueryCSV(query, 'stamina') + filters.load = readQueryCSV(query, 'load') filters.skinGroup = readQueryString(query, 'skin_group') filters.skinName = readQueryString(query, 'skin_name') filters.minCoin = readQueryNumber(query, 'min_coin') @@ -215,15 +217,15 @@ function buildDesktopHomeQueryValues(): Record { return { keyword: filters.keyword.trim() || undefined, sort: sortBy.value === 'recommended' ? undefined : sortBy.value, - server: filters.server, - region: filters.region, - login_method: filters.loginMethod, - rank: filters.rank, - insurance: filters.insurance, - stamina: filters.stamina, - load: filters.load, - skin_group: filters.skinGroup, - skin_name: filters.skinName, + server: filters.server || undefined, + region: joinQueryCSV(filters.region), + login_method: joinQueryCSV(filters.loginMethod), + rank: joinQueryCSV(filters.rank), + insurance: joinQueryCSV(filters.insurance), + stamina: joinQueryCSV(filters.stamina), + load: joinQueryCSV(filters.load), + skin_group: filters.skinGroup || undefined, + skin_name: filters.skinName || undefined, min_coin: filters.minCoin, max_coin: filters.maxCoin, min_ratio: filters.minRatio, diff --git a/frontend/src/styles/home-filters.css b/frontend/src/styles/home-filters.css index d47f751..e036760 100644 --- a/frontend/src/styles/home-filters.css +++ b/frontend/src/styles/home-filters.css @@ -111,6 +111,28 @@ font-weight: 700; } +.filter-menu button { + display: flex; + align-items: center; + gap: 8px; +} + +.filter-option-check { + width: 14px; + height: 14px; + flex: 0 0 14px; + border: 1.5px solid #cbd5e1; + border-radius: 4px; + background: #ffffff; + box-sizing: border-box; +} + +.filter-option-check.checked { + border-color: #ff6a00; + background: #ff6a00; + box-shadow: inset 0 0 0 2px #ffffff; +} + .range-manual { display: flex; align-items: center;