From 55326b7d068381d972efa8ba5f644e94564d929d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 13 Jun 2026 15:46:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=AD=9B=E9=80=89,=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=AE=B0=E4=BD=8F=E7=AD=9B=E9=80=89=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/useHomeFilterQuery.spec.ts | 54 +++++++++ .../composables/useHomeFilterQuery.ts | 105 ++++++++++++++++++ .../src/features/listings/views/HomeView.vue | 98 +++++++++++++--- .../listings/views/MobileHomeView.vue | 75 ++++++++++++- 4 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts create mode 100644 frontend/src/features/listings/composables/useHomeFilterQuery.ts diff --git a/frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts b/frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts new file mode 100644 index 0000000..319ad7b --- /dev/null +++ b/frontend/src/features/listings/composables/__tests__/useHomeFilterQuery.spec.ts @@ -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: [], + }) + }) +}) diff --git a/frontend/src/features/listings/composables/useHomeFilterQuery.ts b/frontend/src/features/listings/composables/useHomeFilterQuery.ts new file mode 100644 index 0000000..0ccfb46 --- /dev/null +++ b/frontend/src/features/listings/composables/useHomeFilterQuery.ts @@ -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 +): 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(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) + }) +} diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index 52bd506..ba58be6 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -12,6 +12,14 @@ import { type ListingPublishOptions, } from '@/features/listings/api/listingOptions' 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 HomeAnnouncement from '../components/HomeAnnouncement.vue' import HomeBanner from '../components/HomeBanner.vue' @@ -43,7 +51,7 @@ const { computed(() => listings.value) ) -filters.keyword = routeKeyword() +applyRouteQueryToHomeState() const { loading, @@ -56,9 +64,16 @@ const { } = useListingQuery(filters, sortBy, activeZone) watch( - () => route.query.keyword, + () => route.query, () => { - filters.keyword = routeKeyword() + applyRouteQueryToHomeState() + } +) + +watch( + () => desktopHomeStateSignature(), + () => { + syncHomeQuery() } ) @@ -139,19 +154,74 @@ function handleResetFilters() { resetFilters() activeZone.value = 'all' sortBy.value = 'recommended' - router.replace({ - path: '/', - query: { - ...route.query, - keyword: undefined, - }, - }) + syncHomeQuery() } -function routeKeyword() { - const keyword = route.query.keyword - if (Array.isArray(keyword)) return String(keyword[0] || '') - return typeof keyword === 'string' ? keyword : '' +function applyRouteQueryToHomeState() { + const query = route.query + 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.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 { + 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() diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue index 216c3ff..c7a4310 100644 --- a/frontend/src/features/listings/views/MobileHomeView.vue +++ b/frontend/src/features/listings/views/MobileHomeView.vue @@ -1,6 +1,6 @@