diff --git a/frontend/src/features/listings/components/HomeFilters.vue b/frontend/src/features/listings/components/HomeFilters.vue index 563c6e1..ec2ae44 100644 --- a/frontend/src/features/listings/components/HomeFilters.vue +++ b/frontend/src/features/listings/components/HomeFilters.vue @@ -1,18 +1,23 @@ diff --git a/frontend/src/features/listings/composables/__tests__/useHomeFilters.spec.ts b/frontend/src/features/listings/composables/__tests__/useHomeFilters.spec.ts index 95bc038..107ccc6 100644 --- a/frontend/src/features/listings/composables/__tests__/useHomeFilters.spec.ts +++ b/frontend/src/features/listings/composables/__tests__/useHomeFilters.spec.ts @@ -27,6 +27,7 @@ describe('useHomeFilters', () => { filters.minCoin = 100 filters.region = ['北京', '上海'] filters.insurance = ['3*3', '2*2'] + filters.skinNames = ['凌霄成卫', '坠星者'] resetFilters() @@ -34,6 +35,21 @@ describe('useHomeFilters', () => { expect(filters.minCoin).toBeUndefined() expect(filters.region).toEqual([]) expect(filters.insurance).toEqual([]) + expect(filters.skinNames).toEqual([]) + }) + + it('应该支持皮肤多选切换', () => { + const publishOptions = ref(emptyListingPublishOptions) + const listings = ref([]) + + const { filters, toggleSkinName } = useHomeFilters(publishOptions, listings) + + toggleSkinName('凌霄成卫') + toggleSkinName('坠星者') + expect(filters.skinNames).toEqual(['凌霄成卫', '坠星者']) + + toggleSkinName('凌霄成卫') + expect(filters.skinNames).toEqual(['坠星者']) }) it('应该正确设置字符串多选筛选器', () => { diff --git a/frontend/src/features/listings/composables/useFilterOptions.ts b/frontend/src/features/listings/composables/useFilterOptions.ts index b3453cc..43e85a8 100644 --- a/frontend/src/features/listings/composables/useFilterOptions.ts +++ b/frontend/src/features/listings/composables/useFilterOptions.ts @@ -1,41 +1,55 @@ export const coinRangeOptions = [ - { label: '全部区间', min: undefined, max: undefined }, - { label: '0-100', min: 0, max: 100 }, - { label: '100-300', min: 100, max: 300 }, - { label: '300-500', min: 300, max: 500 }, - { label: '500+', min: 500, max: undefined }, + { label: '全部', min: undefined, max: undefined }, + { label: '15-50', min: 15, max: 50 }, + { label: '50-100', min: 50, max: 100 }, + { label: '100-150', min: 100, max: 150 }, + { label: '150-200', min: 150, max: 200 }, + { label: '200-250', min: 200, max: 250 }, + { label: '250-300', min: 250, max: 300 }, + { label: '300以上', min: 300, max: undefined }, ] export const ratioRangeOptions = [ - { label: '全部比例', min: undefined, max: undefined }, - { label: '0-20', min: 0, max: 20 }, - { label: '20-30', min: 20, max: 30 }, - { label: '30-40', min: 30, max: 40 }, - { label: '40+', min: 40, max: undefined }, + { label: '全部', min: undefined, max: undefined }, + { label: '≤30', min: undefined, max: 30 }, + { label: '30-35', min: 30, max: 35 }, + { label: '35-40', min: 35, max: 40 }, + { label: '40-45', min: 40, max: 45 }, + { label: '45+', min: 45, max: undefined }, ] export const moneyRangeOptions = [ - { label: '全部区间', min: undefined, max: undefined }, + { label: '全部', min: undefined, max: undefined }, { label: '0-50', min: 0, max: 50 }, { label: '50-100', min: 50, max: 100 }, - { label: '100-200', min: 100, max: 200 }, - { label: '200+', min: 200, max: undefined }, + { label: '100-150', min: 100, max: 150 }, + { label: '150-200', min: 150, max: 200 }, + { label: '200以上', min: 200, max: undefined }, +] + +/** 押金区间:去掉 0-50 */ +export const depositRangeOptions = [ + { label: '全部', min: undefined, max: undefined }, + { label: '50-100', min: 50, max: 100 }, + { label: '100-150', min: 100, max: 150 }, + { label: '150-200', min: 150, max: 200 }, + { label: '200以上', min: 200, max: undefined }, ] export const totalRangeOptions = [ - { label: '全部区间', min: undefined, max: undefined }, + { label: '全部', min: undefined, max: undefined }, { label: '0-500', min: 0, max: 500 }, { label: '500-1000', min: 500, max: 1000 }, { label: '1000-2000', min: 1000, max: 2000 }, { label: '2000-5000', min: 2000, max: 5000 }, + { label: '5000+', min: 5000, max: undefined }, ] +/** 等级最高 60 */ export const fireLevelRangeOptions = [ - { label: '全部等级', min: undefined, max: undefined }, + { label: '全部', min: undefined, max: undefined }, { label: '38-50', min: 38, max: 50 }, { label: '50-60', min: 50, max: 60 }, - { label: '60-70', min: 60, max: 70 }, - { label: '70+', min: 70, max: undefined }, ] export function rangeLabel(min: number | undefined, max: number | undefined, fallback: string) { diff --git a/frontend/src/features/listings/composables/useHomeFilters.ts b/frontend/src/features/listings/composables/useHomeFilters.ts index a7c7a81..9a3205a 100644 --- a/frontend/src/features/listings/composables/useHomeFilters.ts +++ b/frontend/src/features/listings/composables/useHomeFilters.ts @@ -3,6 +3,15 @@ import type { ListingPublishOptions } from '../api/listingOptions' import type { Listing } from '../api/listings' import { assetRegions } from '@/shared/utils/listingDisplay' +/** PC 默认区优先展示的皮肤组:刀皮 / 红皮 */ +export const defaultSkinGroupDefs = [ + { key: 'melee', label: '刀皮' }, + { key: 'operatorRed', label: '红皮' }, +] as const + +/** 高级筛选:枪皮(武器皮肤) */ +export const advancedSkinGroupDefs = [{ key: 'weapon', label: '枪皮' }] as const + export type FilterPopoverKey = | 'insurance' | 'stamina' @@ -29,8 +38,8 @@ export interface HomeFilters { insurance: string[] stamina: string[] load: string[] - skinGroup: string - skinName: string + /** 多选具体皮肤名(刀皮/红皮等) */ + skinNames: string[] minCoin: number | undefined maxCoin: number | undefined minRatio: number | undefined @@ -45,6 +54,12 @@ export interface HomeFilters { maxFireLevel: number | undefined } +export interface SkinFilterGroup { + key: string + title: string + options: string[] +} + export function useHomeFilters( publishOptions: Ref, listings: Ref @@ -58,8 +73,7 @@ export function useHomeFilters( insurance: [], stamina: [], load: [], - skinGroup: '', - skinName: '', + skinNames: [], minCoin: undefined, maxCoin: undefined, minRatio: undefined, @@ -75,6 +89,7 @@ export function useHomeFilters( }) const activeFilterPopover = ref('') + const advancedFiltersOpen = ref(false) const regionOptions = computed(() => uniqueOptions([ @@ -89,19 +104,52 @@ export function useHomeFilters( ) ) - const skinFilterGroups = computed(() => { - const preferred = ['operatorRed', 'operatorGold'] - return preferred - .map(key => publishOptions.value.skin_groups.find(group => group.key === key)) - .filter((group): group is ListingPublishOptions['skin_groups'][number] => Boolean(group)) + function resolveSkinGroups( + defs: ReadonlyArray<{ key: string; label: string }> + ): SkinFilterGroup[] { + const groups: SkinFilterGroup[] = [] + for (const def of defs) { + const group = publishOptions.value.skin_groups.find(item => item.key === def.key) + if (!group) continue + groups.push({ + key: def.key, + title: def.label, + options: uniqueOptions(group.options || []), + }) + } + return groups + } + + /** 默认区刀皮 / 红皮(展示全部选项,支持多选) */ + const skinFilterGroups = computed((): SkinFilterGroup[] => + resolveSkinGroups(defaultSkinGroupDefs) + ) + + /** 高级区枪皮 */ + const advancedSkinFilterGroups = computed((): SkinFilterGroup[] => + resolveSkinGroups(advancedSkinGroupDefs) + ) + + const selectedSkinGroups = computed(() => { + if (!filters.skinNames.length) return [] as string[] + const keys = new Set() + for (const group of [...skinFilterGroups.value, ...advancedSkinFilterGroups.value]) { + if (group.options.some(name => filters.skinNames.includes(name))) { + keys.add(group.key) + } + } + return [...keys] }) - const skinChipLabel = computed(() => { - if (filters.skinName) return filters.skinName - if (filters.skinGroup) { - return skinFilterGroups.value.find(group => group.key === filters.skinGroup)?.title || '皮肤' - } - return '皮肤' + const advancedActiveCount = computed(() => { + let count = 0 + if (filters.region.length) count += 1 + if (filters.rank.length) count += 1 + if (filters.minPrice !== undefined || filters.maxPrice !== undefined) count += 1 + if (filters.minDeposit !== undefined || filters.maxDeposit !== undefined) count += 1 + if (filters.minTotal !== undefined || filters.maxTotal !== undefined) count += 1 + if (filters.minFireLevel !== undefined || filters.maxFireLevel !== undefined) count += 1 + return count }) function uniqueOptions(values: string[]) { @@ -117,8 +165,7 @@ export function useHomeFilters( filters.insurance = [] filters.stamina = [] filters.load = [] - filters.skinGroup = '' - filters.skinName = '' + filters.skinNames = [] filters.minCoin = undefined filters.maxCoin = undefined filters.minRatio = undefined @@ -146,11 +193,23 @@ export function useHomeFilters( filters[key] = next } + function toggleSkinName(name: string) { + const current = filters.skinNames + filters.skinNames = current.includes(name) + ? current.filter(item => item !== name) + : uniqueOptions([...current, name]) + } + function setCoinRange(min: number | undefined, max: number | undefined) { filters.minCoin = min filters.maxCoin = max } + function setRatioRange(min: number | undefined, max: number | undefined) { + filters.minRatio = min + filters.maxRatio = max + } + function setPriceRange(min: number | undefined, max: number | undefined) { filters.minPrice = min filters.maxPrice = max @@ -171,14 +230,19 @@ export function useHomeFilters( filters.maxFireLevel = max } - function setSkinFilter(group: string, name = '') { - filters.skinGroup = group - filters.skinName = name - } - - function resetSkinFilter() { - filters.skinGroup = '' - filters.skinName = '' + /** 区间 chip:再点同一档则清空 */ + function toggleRange( + currentMin: number | undefined, + currentMax: number | undefined, + min: number | undefined, + max: number | undefined, + apply: (min: number | undefined, max: number | undefined) => void + ) { + if (currentMin === min && currentMax === max) { + apply(undefined, undefined) + return + } + apply(min, max) } function setFilterPopover(key: FilterPopoverKey, visible: boolean) { @@ -196,20 +260,24 @@ export function useHomeFilters( return { filters, activeFilterPopover, + advancedFiltersOpen, + advancedActiveCount, regionOptions, loginMethodOptions, skinFilterGroups, - skinChipLabel, + advancedSkinFilterGroups, + selectedSkinGroups, resetFilters, setStringFilter, toggleStringFilter, + toggleSkinName, setCoinRange, + setRatioRange, setPriceRange, setDepositRange, setTotalRange, setFireLevelRange, - setSkinFilter, - resetSkinFilter, + toggleRange, setFilterPopover, closeFilterPopover, } diff --git a/frontend/src/features/listings/composables/useListingQuery.ts b/frontend/src/features/listings/composables/useListingQuery.ts index fcc1277..8226e63 100644 --- a/frontend/src/features/listings/composables/useListingQuery.ts +++ b/frontend/src/features/listings/composables/useListingQuery.ts @@ -36,8 +36,8 @@ export function useListingQuery( insurance: joinCSV(filters.insurance), stamina: joinCSV(filters.stamina), load: joinCSV(filters.load), - skin_group: filters.skinGroup, - skin_name: filters.skinName, + // 多选皮肤名;后端 skin_name CSV 为 OR 匹配 + skin_name: joinCSV(filters.skinNames), min_coin: filters.minCoin, max_coin: filters.maxCoin, min_ratio: filters.minRatio, diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index 2b6ae5e..bd91454 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -39,20 +39,26 @@ const announcements = ref(defaultHomeAnnouncements) const banners = ref(defaultHomeBanners) const publishOptions = ref(emptyListingPublishOptions) const sortBy = ref('recommended') +const sortOptions = [ + { value: 'recommended', label: '综合推荐' }, + { value: 'coinDesc', label: '哈夫币' }, + { value: 'awmDesc', label: 'AWM数量' }, + { value: 'priceAsc', label: '价格最低' }, + { value: 'priceDesc', label: '价格最高' }, +] as const const activeZone = ref('all') const route = useRoute() const router = useRouter() const { filters, - activeFilterPopover, regionOptions, loginMethodOptions, skinFilterGroups, - skinChipLabel, + advancedSkinFilterGroups, + advancedFiltersOpen, + advancedActiveCount, resetFilters, - setFilterPopover, - closeFilterPopover, } = useHomeFilters( publishOptions, computed(() => listings.value) @@ -167,9 +173,10 @@ function handleResetFilters() { function applyRouteQueryToHomeState() { const storedQuery = readStoredHomeQuery(desktopHomeFilterStorageKey) - const query = hasHomeQueryValues(route.query, desktopHomeFilterQueryKeys) - ? route.query - : storedQuery + const routeHasManaged = hasHomeQueryValues(route.query, desktopHomeFilterQueryKeys) + // 路由有托管参数时以路由为准;否则回退 session。 + // 注意:综合推荐时 sort 不在 URL,若路由仍有其它筛选参数,也不要用 storage 覆盖 sort。 + const query = routeHasManaged ? route.query : storedQuery filters.keyword = readQueryString(query, 'keyword') filters.server = readQueryString(query, 'server') filters.region = readQueryCSV(query, 'region') @@ -178,8 +185,8 @@ function applyRouteQueryToHomeState() { 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') + // 兼容旧单值 skin_name / 新 CSV 多选 + filters.skinNames = readQueryCSV(query, 'skin_name') filters.minCoin = readQueryNumber(query, 'min_coin') filters.maxCoin = readQueryNumber(query, 'max_coin') filters.minRatio = readQueryNumber(query, 'min_ratio') @@ -192,6 +199,7 @@ function applyRouteQueryToHomeState() { filters.maxTotal = readQueryNumber(query, 'max_total') filters.minFireLevel = readQueryNumber(query, 'min_fire_level') filters.maxFireLevel = readQueryNumber(query, 'max_fire_level') + // 无 sort 参数即综合推荐(默认) sortBy.value = readQueryString(query, 'sort') || 'recommended' // zone 不从 query/storage 恢复,由用户实时点击决定,不参与筛选持久化 } @@ -206,8 +214,12 @@ function syncHomeQuery() { desktopHomeFilterQueryKeys, buildDesktopHomeQueryValues() ) + // 综合推荐不写 sort;若当前无任何筛选条件,必须清空 storage, + // 否则会回退读到旧的 sort=coinDesc 等,导致无法选回「综合推荐」。 if (hasHomeQueryValues(query, desktopHomeFilterQueryKeys)) { storeHomeQuery(desktopHomeFilterStorageKey, query) + } else { + clearStoredHomeQuery(desktopHomeFilterStorageKey) } if (isSameQuery(route.query, query)) return router.replace({ path: '/', query }) @@ -224,8 +236,7 @@ function buildDesktopHomeQueryValues(): Record { insurance: joinQueryCSV(filters.insurance), stamina: joinQueryCSV(filters.stamina), load: joinQueryCSV(filters.load), - skin_group: filters.skinGroup || undefined, - skin_name: filters.skinName || undefined, + skin_name: joinQueryCSV(filters.skinNames), min_coin: filters.minCoin, max_coin: filters.maxCoin, min_ratio: filters.minRatio, @@ -273,12 +284,12 @@ loadHome() :region-options="regionOptions" :login-method-options="loginMethodOptions" :skin-filter-groups="skinFilterGroups" - :skin-chip-label="skinChipLabel" - :active-filter-popover="activeFilterPopover" + :advanced-skin-filter-groups="advancedSkinFilterGroups" + :advanced-open="advancedFiltersOpen" + :advanced-active-count="advancedActiveCount" @update:filters="updateFilters" + @update:advanced-open="advancedFiltersOpen = $event" @reset="handleResetFilters" - @set-filter-popover="setFilterPopover" - @close-filter-popover="closeFilterPopover" />
@@ -286,14 +297,17 @@ loadHome()

Account Zone

账号专区

-
- - 综合推荐 - 哈夫币 - AWM数量 - 价格最低 - 价格最高 - +
+
@@ -401,6 +415,8 @@ loadHome() display: flex; justify-content: space-between; align-items: flex-end; + gap: 16px; + flex-wrap: wrap; } .zone-head { @@ -425,6 +441,43 @@ loadHome() color: #17233d; } +.sort-bar { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 4px; + border-radius: 12px; + background: #f3f5f9; +} + +.sort-chip { + min-height: 32px; + padding: 0 14px; + border: none; + border-radius: 9px; + background: transparent; + color: #64748b; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease; +} + +.sort-chip:hover { + color: #334155; + background: rgba(255, 255, 255, 0.7); +} + +.sort-chip.active { + background: #ffffff; + color: #ff6a00; + box-shadow: 0 1px 4px rgba(23, 35, 61, 0.08); +} + .enhanced-desktop-list { display: grid; gap: 16px; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 28d2b2e..8b2d8f5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -14,9 +14,14 @@ import { sellerRoutes } from './sellerRoutes' const router = createRouter({ history: createWebHistory(), routes: [...adminRoutes, ...mobileRoutes, ...publicRoutes, ...accountRoutes, ...sellerRoutes], - scrollBehavior(_to, _from, savedPosition) { + scrollBehavior(to, from, savedPosition) { // 浏览器前进/后退用历史滚动;移动端首页列表在内部容器滚动,不依赖 window if (savedPosition) return savedPosition + // 同路径仅改 query(筛选/排序)时保持当前滚动,避免点筛选被弹回顶部 + // from.matched 为空表示首次进入,仍滚到顶部 + if (to.path === from.path && from.matched.length > 0) { + return false + } return { left: 0, top: 0 } }, }) diff --git a/frontend/src/styles/home-filters.css b/frontend/src/styles/home-filters.css index e036760..0db21a1 100644 --- a/frontend/src/styles/home-filters.css +++ b/frontend/src/styles/home-filters.css @@ -1,41 +1,183 @@ /* 筛选器相关样式 */ .horizontal-filter-card { - padding: 24px; + padding: 20px 22px 16px; border: 1px solid #eef1f5; border-radius: 16px; background: #ffffff; box-shadow: 0 4px 12px rgba(23, 35, 61, 0.03); } +.quick-filter-card { + padding-bottom: 12px; +} + .filter-header { display: flex; gap: 16px; justify-content: space-between; align-items: center; - margin-bottom: 20px; + margin-bottom: 14px; } .filter-title { display: flex; align-items: center; - gap: 12px; + gap: 10px; +} + +.filter-title-bar { + display: inline-block; + flex: 0 0 auto; + width: 4px; + height: 16px; + border-radius: 999px; + background: #ff6a00 !important; + box-shadow: 0 0 0 1px rgba(255, 106, 0, 0.12); } .filter-title strong { - font-size: 18px; + font-size: 15px; font-weight: 800; color: #17233d; } .filter-title span { - padding: 4px 10px; - border-radius: 6px; + padding: 2px 8px; + border-radius: 999px; background: #f1f5f9; - font-size: 13px; + font-size: 12px; font-weight: 700; color: #64748b; } +/* PC 点击式快速筛选 */ +.quick-filter-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 28px; +} + +.filter-field { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +.filter-field-full { + grid-column: 1 / -1; +} + +.filter-field label { + padding-top: 6px; + font-size: 13px; + font-weight: 700; + color: #334155; + line-height: 1.3; + white-space: nowrap; +} + +.pill-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 28px; + align-items: center; +} + +.pill-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 4px 12px; + border: none; + border-radius: 999px; + background: #f3f5f9; + color: #475569; + font-size: 12px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} + +.pill-chip:hover { + background: #e8edf5; + color: #1e293b; +} + +.pill-chip.active { + background: #fff1e6; + color: #ff6a00; + box-shadow: inset 0 0 0 1px #ffd0a8; +} + +.pill-empty { + font-size: 12px; + color: #94a3b8; +} + +.advanced-toggle-row { + display: flex; + justify-content: center; + margin-top: 14px; + padding-top: 4px; +} + +.advanced-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: none; + background: transparent; + color: #64748b; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.advanced-toggle:hover { + color: #ff6a00; +} + +.advanced-toggle em { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: #ff6a00; + color: #fff; + font-size: 11px; + font-style: normal; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +.advanced-caret { + display: inline-block; + transition: transform 0.15s; + font-size: 12px; +} + +.advanced-caret.open { + transform: rotate(180deg); +} + +.advanced-filter-panel { + margin-top: 12px; + padding-top: 14px; + border-top: 1px dashed #e5eaf1; +} + +@media (max-width: 960px) { + .quick-filter-grid { + grid-template-columns: 1fr; + } +} + .filter-chip-row { display: flex; flex-wrap: wrap;