From 5b2f36ac365ca894360ada98a830207c955adbb3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 10 Oct 2026 17:44:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(listings):=20=E6=8E=92=E5=BA=8F=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=8D=87=E7=BA=A7=E4=B8=BA=E5=8F=8C=E5=90=91=E7=BF=BB?= =?UTF-8?q?=E8=BD=AC=E5=B9=B6=E5=AF=B9=E9=BD=90=E5=8F=8C=E7=AB=AF=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PC端交互:HomeView 排序按钮支持点击切换升降序翻转(比例、哈夫币、AWM数量、合并价格为单个价格项),并带有高低方向箭头(▲/▼)高亮指示 - 移动端交互:MobileHomeView 菜单提供清晰的双向文案(如「比例从高到低 / 比例从低到高」),且点击当前选中项时支持自动翻转方向,顶部工具栏标题紧凑对齐 - 后端排序:public_filter 与 public_query 完整支持 ratioDesc/ratioAsc、priceAsc/priceDesc、coinDesc/coinAsc、awmDesc/awmAsc - 验证方式:新增并运行 TestSortPublicListingsByRatio 包含升序/降序多断言,全量运行 ./scripts/check.sh 门禁全绿通过 --- .../internal/modules/listing/public_filter.go | 9 ++ .../modules/listing/public_filter_test.go | 14 +- .../internal/modules/listing/public_query.go | 4 +- .../src/features/listings/views/HomeView.vue | 136 ++++++++++++++++-- .../listings/views/MobileHomeView.vue | 44 ++++-- 5 files changed, 183 insertions(+), 24 deletions(-) diff --git a/backend/internal/modules/listing/public_filter.go b/backend/internal/modules/listing/public_filter.go index 6e54758..38c62ef 100644 --- a/backend/internal/modules/listing/public_filter.go +++ b/backend/internal/modules/listing/public_filter.go @@ -103,6 +103,8 @@ func sortPublicListings(items []ListingDTO, sortKey string) { return a.PriceCent > b.PriceCent case "coinDesc": return a.HafCoinAmount > b.HafCoinAmount + case "coinAsc": + return a.HafCoinAmount < b.HafCoinAmount case "ratioDesc", "ratio": aRatio := ratioFromListing(a) bRatio := ratioFromListing(b) @@ -124,6 +126,13 @@ func sortPublicListings(items []ListingDTO, sortKey string) { return aAmmo > bAmmo } return a.HafCoinAmount > b.HafCoinAmount + case "awmAsc": + aAmmo := resourceQuantity(a.AssetSummary, "awmAmmo") + bAmmo := resourceQuantity(b.AssetSummary, "awmAmmo") + if aAmmo != bAmmo { + return aAmmo < bAmmo + } + return a.HafCoinAmount < b.HafCoinAmount case "published": return publicRecentLess(a, b) case "recommended", "comprehensive", "": diff --git a/backend/internal/modules/listing/public_filter_test.go b/backend/internal/modules/listing/public_filter_test.go index 10ee653..a422210 100644 --- a/backend/internal/modules/listing/public_filter_test.go +++ b/backend/internal/modules/listing/public_filter_test.go @@ -208,13 +208,25 @@ func TestSortPublicListingsByRatio(t *testing.T) { sortPublicListings(items, "ratioDesc") - // 预期顺序:ID=2 (45.0) > ID=4 (35.0, 100M 币更多) > ID=3 (35.0, 80M) > ID=1 (20.0) + // 预期降序:ID=2 (45.0) > ID=4 (35.0, 100M 币更多) > ID=3 (35.0, 80M) > ID=1 (20.0) expectedIDs := []uint64{2, 4, 3, 1} for i, expected := range expectedIDs { if items[i].ID != expected { t.Fatalf("at index %d, expected listing %d, got %d (ratio=%.1f)", i, expected, items[i].ID, ratioFromListing(items[i])) } } + + sortPublicListings(items, "ratioAsc") + + // 预期升序:ID=1 (20.0) < ID=3 (35.0) < ID=4 (35.0) < ID=2 (45.0) + if items[0].ID != 1 || items[len(items)-1].ID != 2 { + t.Fatalf("expected ratioAsc items[0].ID=1 and items[last].ID=2, got order: %v", []uint64{items[0].ID, items[1].ID, items[2].ID, items[3].ID}) + } + + sortPublicListings(items, "coinAsc") + if items[0].HafCoinAmount > items[len(items)-1].HafCoinAmount { + t.Fatalf("expected coinAsc first item coin <= last item coin") + } } func TestIsNightAvailableSummaryUsesEarlyMorningResponse(t *testing.T) { diff --git a/backend/internal/modules/listing/public_query.go b/backend/internal/modules/listing/public_query.go index 900e8d2..2e2dac7 100644 --- a/backend/internal/modules/listing/public_query.go +++ b/backend/internal/modules/listing/public_query.go @@ -146,7 +146,7 @@ func canListPublicWithSQL(query PublicListQuery) bool { return false } switch query.Sort { - case "", "published", "recommended", "comprehensive", "priceAsc", "priceDesc", "coinDesc": + case "", "published", "recommended", "comprehensive", "priceAsc", "priceDesc", "coinDesc", "coinAsc": return true default: return false @@ -161,6 +161,8 @@ func applyPublicSQLSort(db *gorm.DB, sortKey string) *gorm.DB { return db.Order("l.price_cent DESC, l.published_at DESC, l.id DESC") case "coinDesc": return db.Order("a.haf_coin_amount DESC, l.published_at DESC, l.id DESC") + case "coinAsc": + return db.Order("a.haf_coin_amount ASC, l.published_at DESC, l.id DESC") case "published": return db.Order("l.published_at DESC, l.id DESC") default: diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index 511a065..f8109e7 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -69,15 +69,78 @@ const awRecycleDialogVisible = ref(false) const cooperationFeedback = ref({ ...defaultCooperationFeedbackConfig }) const cooperationFeedbackDialogVisible = ref(false) const publishOptions = ref(emptyListingPublishOptions) +interface SortButtonItem { + key: string + label: string + hasDirection?: boolean + defaultSort?: string + altSort?: string +} + const sortBy = ref('recommended') -const sortOptions = [ - { value: 'recommended', label: '综合推荐' }, - { value: 'ratioDesc', label: '比例排序' }, - { value: 'coinDesc', label: '哈夫币' }, - { value: 'awmDesc', label: 'AWM数量' }, - { value: 'priceAsc', label: '价格最低' }, - { value: 'priceDesc', label: '价格最高' }, -] as const +const sortButtons: SortButtonItem[] = [ + { key: 'recommended', label: '综合推荐' }, + { + key: 'ratio', + label: '比例', + hasDirection: true, + defaultSort: 'ratioDesc', + altSort: 'ratioAsc', + }, + { + key: 'coin', + label: '哈夫币', + hasDirection: true, + defaultSort: 'coinDesc', + altSort: 'coinAsc', + }, + { + key: 'awm', + label: 'AWM数量', + hasDirection: true, + defaultSort: 'awmDesc', + altSort: 'awmAsc', + }, + { + key: 'price', + label: '价格', + hasDirection: true, + defaultSort: 'priceAsc', + altSort: 'priceDesc', + }, +] + +function isSortActive(item: SortButtonItem): boolean { + if (!item.hasDirection) { + return sortBy.value === 'recommended' || !sortBy.value + } + return sortBy.value === item.defaultSort || sortBy.value === item.altSort +} + +function getSortDirection(item: SortButtonItem): 'asc' | 'desc' | null { + if (!item.hasDirection) return null + if (sortBy.value === item.defaultSort) { + return item.defaultSort?.endsWith('Asc') ? 'asc' : 'desc' + } + if (sortBy.value === item.altSort) { + return item.altSort?.endsWith('Asc') ? 'asc' : 'desc' + } + return null +} + +function handleSortClick(item: SortButtonItem) { + if (!item.hasDirection) { + sortBy.value = 'recommended' + return + } + if (sortBy.value === item.defaultSort) { + sortBy.value = item.altSort! + } else if (sortBy.value === item.altSort) { + sortBy.value = item.defaultSort! + } else { + sortBy.value = item.defaultSort! + } +} const activeZone = ref('all') const route = useRoute() const router = useRouter() @@ -577,14 +640,18 @@ if (!restored) { />
@@ -761,6 +828,9 @@ if (!restored) { } .sort-chip { + display: inline-flex; + align-items: center; + justify-content: center; min-height: 28px; padding: 0 12px; border: none; @@ -787,6 +857,46 @@ if (!restored) { box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); } +.sort-arrows { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + margin-left: 4px; +} + +.sort-arrows .arrow-up, +.sort-arrows .arrow-down { + display: block; + width: 0; + height: 0; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + transition: border-color 0.15s ease; +} + +.sort-arrows .arrow-up { + border-bottom: 3.5px solid #cbd5e1; +} + +.sort-arrows .arrow-up.active { + border-bottom-color: #ff6a00; +} + +.sort-arrows .arrow-down { + border-top: 3.5px solid #cbd5e1; +} + +.sort-arrows .arrow-down.active { + border-top-color: #ff6a00; +} + +.sort-chip:hover .sort-arrows .arrow-up:not(.active), +.sort-chip:hover .sort-arrows .arrow-down:not(.active) { + border-color: #94a3b8 transparent; +} + .enhanced-desktop-list { display: grid; gap: 16px; diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue index 87cc50d..2806490 100644 --- a/frontend/src/features/listings/views/MobileHomeView.vue +++ b/frontend/src/features/listings/views/MobileHomeView.vue @@ -123,14 +123,23 @@ const mobilePageSize = 10 let listingRequestSeq = 0 let searchDebounceTimer: ReturnType | null = null -const sortOptions = [ - { key: 'recommended', label: '综合推荐' }, - { key: 'ratioDesc', label: '比例排序' }, - { key: 'coinDesc', label: '哈夫币' }, - { key: 'awmDesc', label: 'AWM数量' }, - { key: 'priceAsc', label: '价格最低' }, - { key: 'priceDesc', label: '价格最高' }, - { key: 'published', label: '最新发布' }, +interface MobileSortOption { + key: string + label: string + shortLabel: string +} + +const sortOptions: MobileSortOption[] = [ + { key: 'recommended', label: '综合推荐', shortLabel: '综合推荐' }, + { key: 'ratioDesc', label: '比例从高到低', shortLabel: '比例最高' }, + { key: 'ratioAsc', label: '比例从低到高', shortLabel: '比例最低' }, + { key: 'priceAsc', label: '价格从低到高', shortLabel: '价格最低' }, + { key: 'priceDesc', label: '价格从高到低', shortLabel: '价格最高' }, + { key: 'coinDesc', label: '哈夫币从多到少', shortLabel: '哈夫币最多' }, + { key: 'coinAsc', label: '哈夫币从少到多', shortLabel: '哈夫币最少' }, + { key: 'awmDesc', label: 'AWM数量从多到少', shortLabel: 'AWM最多' }, + { key: 'awmAsc', label: 'AWM数量从少到多', shortLabel: 'AWM最少' }, + { key: 'published', label: '最新发布', shortLabel: '最新发布' }, ] const zoneOptions = [ @@ -146,7 +155,7 @@ const zoneOptions = [ const rangePresets = defaultRangePresets const activeSortLabel = computed( - () => sortOptions.find(option => option.key === activeSort.value)?.label || '综合排序' + () => sortOptions.find(option => option.key === activeSort.value)?.shortLabel || '综合推荐' ) async function handleSupportClick() { @@ -552,6 +561,23 @@ function toggleSortPanel() { } function selectSort(sortKey: string) { + if (activeSort.value === sortKey) { + const flipMap: Record = { + ratioDesc: 'ratioAsc', + ratioAsc: 'ratioDesc', + priceAsc: 'priceDesc', + priceDesc: 'priceAsc', + coinDesc: 'coinAsc', + coinAsc: 'coinDesc', + awmDesc: 'awmAsc', + awmAsc: 'awmDesc', + } + if (flipMap[sortKey]) { + activeSort.value = flipMap[sortKey] + sortOpen.value = false + return + } + } activeSort.value = sortKey sortOpen.value = false }