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;