优化筛选, 增加记住筛选功能
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { LocationQuery } from 'vue-router'
|
||||||
|
import {
|
||||||
|
decodeHomeQueryJson,
|
||||||
|
encodeHomeQueryJson,
|
||||||
|
isSameQuery,
|
||||||
|
mergeHomeQuery,
|
||||||
|
readQueryNumber,
|
||||||
|
readQueryString,
|
||||||
|
} from '@/features/listings/composables/useHomeFilterQuery'
|
||||||
|
|
||||||
|
describe('useHomeFilterQuery', () => {
|
||||||
|
it('应该从路由 query 中读取字符串和数字', () => {
|
||||||
|
const query: LocationQuery = {
|
||||||
|
keyword: ['测试账号'],
|
||||||
|
min_coin: '100',
|
||||||
|
max_coin: 'abc',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(readQueryString(query, 'keyword')).toBe('测试账号')
|
||||||
|
expect(readQueryNumber(query, 'min_coin')).toBe(100)
|
||||||
|
expect(readQueryNumber(query, 'max_coin')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应该合并新筛选并移除旧的托管参数', () => {
|
||||||
|
const query = mergeHomeQuery(
|
||||||
|
{ keyword: '旧关键词', page: '2', region: '上海' },
|
||||||
|
['keyword', 'region', 'sort'],
|
||||||
|
{ keyword: '新关键词', sort: undefined }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(query).toEqual({
|
||||||
|
page: '2',
|
||||||
|
keyword: '新关键词',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应该判断 query 是否语义一致', () => {
|
||||||
|
expect(isSameQuery({ keyword: '测试', empty: '' }, { empty: undefined, keyword: '测试' })).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应该安全编码和恢复移动端动态筛选', () => {
|
||||||
|
const filters = { server: ['微信区'], operatorRed: ['红皮肤'] }
|
||||||
|
const encoded = encodeHomeQueryJson(filters)
|
||||||
|
|
||||||
|
expect(encoded).toBe(JSON.stringify(filters))
|
||||||
|
expect(decodeHomeQueryJson({ filters: encoded }, 'filters', {})).toEqual(filters)
|
||||||
|
expect(decodeHomeQueryJson({ filters: '{' }, 'filters', { server: [] })).toEqual({
|
||||||
|
server: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { LocationQuery, LocationQueryRaw } from 'vue-router'
|
||||||
|
|
||||||
|
export const desktopHomeFilterQueryKeys = [
|
||||||
|
'keyword',
|
||||||
|
'sort',
|
||||||
|
'zone',
|
||||||
|
'server',
|
||||||
|
'region',
|
||||||
|
'login_method',
|
||||||
|
'rank',
|
||||||
|
'insurance',
|
||||||
|
'stamina',
|
||||||
|
'load',
|
||||||
|
'skin_group',
|
||||||
|
'skin_name',
|
||||||
|
'min_coin',
|
||||||
|
'max_coin',
|
||||||
|
'min_price',
|
||||||
|
'max_price',
|
||||||
|
'min_deposit',
|
||||||
|
'max_deposit',
|
||||||
|
'min_total',
|
||||||
|
'max_total',
|
||||||
|
'min_fire_level',
|
||||||
|
'max_fire_level',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const mobileHomeFilterQueryKeys = ['keyword', 'sort', 'zone', 'filters', 'ranges'] as const
|
||||||
|
|
||||||
|
export type HomeQueryValue = string | number | undefined
|
||||||
|
|
||||||
|
export function readQueryString(query: LocationQuery, key: string) {
|
||||||
|
const value = query[key]
|
||||||
|
if (Array.isArray(value)) return String(value[0] || '')
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readQueryNumber(query: LocationQuery, key: string) {
|
||||||
|
const value = readQueryString(query, key)
|
||||||
|
if (value === '') return undefined
|
||||||
|
const number = Number(value)
|
||||||
|
return Number.isFinite(number) ? number : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeHomeQuery(
|
||||||
|
currentQuery: LocationQuery,
|
||||||
|
managedKeys: readonly string[],
|
||||||
|
nextValues: Record<string, HomeQueryValue>
|
||||||
|
): LocationQueryRaw {
|
||||||
|
const managedKeySet = new Set(managedKeys)
|
||||||
|
const nextQuery: LocationQueryRaw = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(currentQuery)) {
|
||||||
|
if (!managedKeySet.has(key)) nextQuery[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(nextValues)) {
|
||||||
|
if (value === undefined || value === '') continue
|
||||||
|
nextQuery[key] = String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameQuery(left: LocationQuery, right: LocationQueryRaw) {
|
||||||
|
return stableQuerySignature(left) === stableQuerySignature(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeHomeQueryJson(value: unknown) {
|
||||||
|
if (!hasMeaningfulValue(value)) return undefined
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeHomeQueryJson<T>(query: LocationQuery, key: string, fallback: T): T {
|
||||||
|
const raw = readQueryString(query, key)
|
||||||
|
if (!raw) return fallback
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return parsed && typeof parsed === 'object' ? (parsed as T) : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableQuerySignature(query: LocationQuery | LocationQueryRaw) {
|
||||||
|
const entries: Array<[string, string | string[]]> = Object.entries(query)
|
||||||
|
.filter(([, value]) => value !== undefined && value !== '')
|
||||||
|
.map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
Array.isArray(value) ? value.map(item => String(item ?? '')) : String(value ?? ''),
|
||||||
|
])
|
||||||
|
|
||||||
|
return JSON.stringify(entries.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulValue(value: unknown): boolean {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
if (Array.isArray(value)) return value.length > 0
|
||||||
|
|
||||||
|
return Object.values(value).some(item => {
|
||||||
|
if (Array.isArray(item)) return item.length > 0
|
||||||
|
if (!item || typeof item !== 'object') return item !== '' && item !== undefined
|
||||||
|
return Object.values(item).some(nested => nested !== '' && nested !== undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,6 +12,14 @@ import {
|
|||||||
type ListingPublishOptions,
|
type ListingPublishOptions,
|
||||||
} from '@/features/listings/api/listingOptions'
|
} from '@/features/listings/api/listingOptions'
|
||||||
import { useHomeFilters } from '@/features/listings/composables/useHomeFilters'
|
import { useHomeFilters } from '@/features/listings/composables/useHomeFilters'
|
||||||
|
import {
|
||||||
|
desktopHomeFilterQueryKeys,
|
||||||
|
isSameQuery,
|
||||||
|
mergeHomeQuery,
|
||||||
|
readQueryNumber,
|
||||||
|
readQueryString,
|
||||||
|
type HomeQueryValue,
|
||||||
|
} from '@/features/listings/composables/useHomeFilterQuery'
|
||||||
import { useListingQuery } from '@/features/listings/composables/useListingQuery'
|
import { useListingQuery } from '@/features/listings/composables/useListingQuery'
|
||||||
import HomeAnnouncement from '../components/HomeAnnouncement.vue'
|
import HomeAnnouncement from '../components/HomeAnnouncement.vue'
|
||||||
import HomeBanner from '../components/HomeBanner.vue'
|
import HomeBanner from '../components/HomeBanner.vue'
|
||||||
@@ -43,7 +51,7 @@ const {
|
|||||||
computed(() => listings.value)
|
computed(() => listings.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
filters.keyword = routeKeyword()
|
applyRouteQueryToHomeState()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
@@ -56,9 +64,16 @@ const {
|
|||||||
} = useListingQuery(filters, sortBy, activeZone)
|
} = useListingQuery(filters, sortBy, activeZone)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.keyword,
|
() => route.query,
|
||||||
() => {
|
() => {
|
||||||
filters.keyword = routeKeyword()
|
applyRouteQueryToHomeState()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => desktopHomeStateSignature(),
|
||||||
|
() => {
|
||||||
|
syncHomeQuery()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,19 +154,74 @@ function handleResetFilters() {
|
|||||||
resetFilters()
|
resetFilters()
|
||||||
activeZone.value = 'all'
|
activeZone.value = 'all'
|
||||||
sortBy.value = 'recommended'
|
sortBy.value = 'recommended'
|
||||||
router.replace({
|
syncHomeQuery()
|
||||||
path: '/',
|
|
||||||
query: {
|
|
||||||
...route.query,
|
|
||||||
keyword: undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function routeKeyword() {
|
function applyRouteQueryToHomeState() {
|
||||||
const keyword = route.query.keyword
|
const query = route.query
|
||||||
if (Array.isArray(keyword)) return String(keyword[0] || '')
|
filters.keyword = readQueryString(query, 'keyword')
|
||||||
return typeof keyword === 'string' ? 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.skinGroup = readQueryString(query, 'skin_group')
|
||||||
|
filters.skinName = readQueryString(query, 'skin_name')
|
||||||
|
filters.minCoin = readQueryNumber(query, 'min_coin')
|
||||||
|
filters.maxCoin = readQueryNumber(query, 'max_coin')
|
||||||
|
filters.minPrice = readQueryNumber(query, 'min_price')
|
||||||
|
filters.maxPrice = readQueryNumber(query, 'max_price')
|
||||||
|
filters.minDeposit = readQueryNumber(query, 'min_deposit')
|
||||||
|
filters.maxDeposit = readQueryNumber(query, 'max_deposit')
|
||||||
|
filters.minTotal = readQueryNumber(query, 'min_total')
|
||||||
|
filters.maxTotal = readQueryNumber(query, 'max_total')
|
||||||
|
filters.minFireLevel = readQueryNumber(query, 'min_fire_level')
|
||||||
|
filters.maxFireLevel = readQueryNumber(query, 'max_fire_level')
|
||||||
|
sortBy.value = readQueryString(query, 'sort') || 'recommended'
|
||||||
|
activeZone.value = readQueryString(query, 'zone') || 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
function desktopHomeStateSignature() {
|
||||||
|
return JSON.stringify(buildDesktopHomeQueryValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHomeQuery() {
|
||||||
|
const query = mergeHomeQuery(
|
||||||
|
route.query,
|
||||||
|
desktopHomeFilterQueryKeys,
|
||||||
|
buildDesktopHomeQueryValues()
|
||||||
|
)
|
||||||
|
if (isSameQuery(route.query, query)) return
|
||||||
|
router.replace({ path: '/', query })
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDesktopHomeQueryValues(): Record<string, HomeQueryValue> {
|
||||||
|
return {
|
||||||
|
keyword: filters.keyword.trim() || undefined,
|
||||||
|
sort: sortBy.value === 'recommended' ? undefined : sortBy.value,
|
||||||
|
zone: activeZone.value === 'all' ? undefined : activeZone.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,
|
||||||
|
min_coin: filters.minCoin,
|
||||||
|
max_coin: filters.maxCoin,
|
||||||
|
min_price: filters.minPrice,
|
||||||
|
max_price: filters.maxPrice,
|
||||||
|
min_deposit: filters.minDeposit,
|
||||||
|
max_deposit: filters.maxDeposit,
|
||||||
|
min_total: filters.minTotal,
|
||||||
|
max_total: filters.maxTotal,
|
||||||
|
min_fire_level: filters.minFireLevel,
|
||||||
|
max_fire_level: filters.maxFireLevel,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadHome()
|
loadHome()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
||||||
|
|
||||||
@@ -22,6 +22,15 @@ import {
|
|||||||
type HomeBannerSlide,
|
type HomeBannerSlide,
|
||||||
} from '@/features/listings/api/homeConfig'
|
} from '@/features/listings/api/homeConfig'
|
||||||
import MobileHomeFilterSheet, { type FilterSection } from './MobileHomeFilterSheet.vue'
|
import MobileHomeFilterSheet, { type FilterSection } from './MobileHomeFilterSheet.vue'
|
||||||
|
import {
|
||||||
|
decodeHomeQueryJson,
|
||||||
|
encodeHomeQueryJson,
|
||||||
|
isSameQuery,
|
||||||
|
mergeHomeQuery,
|
||||||
|
mobileHomeFilterQueryKeys,
|
||||||
|
readQueryString,
|
||||||
|
type HomeQueryValue,
|
||||||
|
} from '@/features/listings/composables/useHomeFilterQuery'
|
||||||
import {
|
import {
|
||||||
getListingChips,
|
getListingChips,
|
||||||
getListingDisplayPrice,
|
getListingDisplayPrice,
|
||||||
@@ -38,6 +47,7 @@ import {
|
|||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
@@ -255,6 +265,8 @@ const visibleZoneOptions = computed(() =>
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
applyRouteQueryToMobileState()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadListings()
|
loadListings()
|
||||||
loadHomeConfig()
|
loadHomeConfig()
|
||||||
@@ -269,11 +281,20 @@ onBeforeUnmount(() => {
|
|||||||
watch(
|
watch(
|
||||||
() => filterQuerySignature(),
|
() => filterQuerySignature(),
|
||||||
() => {
|
() => {
|
||||||
|
syncMobileHomeQuery()
|
||||||
loadListings(true)
|
loadListings(true)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
() => {
|
||||||
|
applyRouteQueryToMobileState()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
watch(searchValue, () => {
|
watch(searchValue, () => {
|
||||||
|
syncMobileHomeQuery()
|
||||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||||
searchDebounceTimer = setTimeout(() => {
|
searchDebounceTimer = setTimeout(() => {
|
||||||
loadListings(true)
|
loadListings(true)
|
||||||
@@ -367,6 +388,7 @@ function clearFilters() {
|
|||||||
rangeFilters.value = {}
|
rangeFilters.value = {}
|
||||||
searchValue.value = ''
|
searchValue.value = ''
|
||||||
activeZone.value = 'all'
|
activeZone.value = 'all'
|
||||||
|
syncMobileHomeQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildListingQuery(page: number): PublicListingQuery {
|
function buildListingQuery(page: number): PublicListingQuery {
|
||||||
@@ -443,6 +465,57 @@ function parseOptionalNumber(value: string) {
|
|||||||
return Number.isFinite(number) ? number : undefined
|
return Number.isFinite(number) ? number : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyRouteQueryToMobileState() {
|
||||||
|
const query = route.query
|
||||||
|
searchValue.value = readQueryString(query, 'keyword')
|
||||||
|
activeSort.value = readQueryString(query, 'sort') || 'recommended'
|
||||||
|
activeZone.value = readQueryString(query, 'zone') || 'all'
|
||||||
|
selectedFilters.value = normalizeSelectedFilters(
|
||||||
|
decodeHomeQueryJson<Record<string, unknown>>(query, 'filters', {})
|
||||||
|
)
|
||||||
|
rangeFilters.value = normalizeRangeFilters(
|
||||||
|
decodeHomeQueryJson<Record<string, unknown>>(query, 'ranges', {})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMobileHomeQuery() {
|
||||||
|
const query = mergeHomeQuery(route.query, mobileHomeFilterQueryKeys, buildMobileHomeQueryValues())
|
||||||
|
if (isSameQuery(route.query, query)) return
|
||||||
|
router.replace({ path: route.path, query })
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMobileHomeQueryValues(): Record<string, HomeQueryValue> {
|
||||||
|
return {
|
||||||
|
keyword: searchValue.value.trim() || undefined,
|
||||||
|
sort: activeSort.value === 'recommended' ? undefined : activeSort.value,
|
||||||
|
zone: activeZone.value === 'all' ? undefined : activeZone.value,
|
||||||
|
filters: encodeHomeQueryJson(normalizeSelectedFilters(selectedFilters.value)),
|
||||||
|
ranges: encodeHomeQueryJson(normalizeRangeFilters(rangeFilters.value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSelectedFilters(value: Record<string, unknown>) {
|
||||||
|
const normalized: Record<string, string[]> = {}
|
||||||
|
for (const [key, items] of Object.entries(value)) {
|
||||||
|
if (!Array.isArray(items)) continue
|
||||||
|
const filters = items.map(item => String(item).trim()).filter(Boolean)
|
||||||
|
if (filters.length) normalized[key] = filters
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRangeFilters(value: Record<string, unknown>) {
|
||||||
|
const normalized: Record<string, { min: string; max: string }> = {}
|
||||||
|
for (const [key, range] of Object.entries(value)) {
|
||||||
|
if (!range || typeof range !== 'object' || Array.isArray(range)) continue
|
||||||
|
const record = range as Record<string, unknown>
|
||||||
|
const min = record.min === undefined ? '' : String(record.min).trim()
|
||||||
|
const max = record.max === undefined ? '' : String(record.max).trim()
|
||||||
|
if (min || max) normalized[key] = { min, max }
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
function handleWindowScroll() {
|
function handleWindowScroll() {
|
||||||
showBackTop.value = window.scrollY > 520
|
showBackTop.value = window.scrollY > 520
|
||||||
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 360) return
|
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 360) return
|
||||||
|
|||||||
Reference in New Issue
Block a user