feat(listings): 排序交互升级为双向翻转并对齐双端体验

- PC端交互:HomeView 排序按钮支持点击切换升降序翻转(比例、哈夫币、AWM数量、合并价格为单个价格项),并带有高低方向箭头(▲/▼)高亮指示
- 移动端交互:MobileHomeView 菜单提供清晰的双向文案(如「比例从高到低 / 比例从低到高」),且点击当前选中项时支持自动翻转方向,顶部工具栏标题紧凑对齐
- 后端排序:public_filter 与 public_query 完整支持 ratioDesc/ratioAsc、priceAsc/priceDesc、coinDesc/coinAsc、awmDesc/awmAsc
- 验证方式:新增并运行 TestSortPublicListingsByRatio 包含升序/降序多断言,全量运行 ./scripts/check.sh 门禁全绿通过
This commit is contained in:
yml2213
2026-10-10 17:44:27 +08:00
parent 180a21c048
commit 5b2f36ac36
5 changed files with 183 additions and 24 deletions
@@ -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", "":
@@ -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) {
@@ -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:
+123 -13
View File
@@ -69,15 +69,78 @@ const awRecycleDialogVisible = ref(false)
const cooperationFeedback = ref<CooperationFeedbackConfig>({ ...defaultCooperationFeedbackConfig })
const cooperationFeedbackDialogVisible = ref(false)
const publishOptions = ref<ListingPublishOptions>(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) {
/>
<div class="list-actions sort-bar" role="group" aria-label="排序方式">
<button
v-for="item in sortOptions"
:key="item.value"
v-for="item in sortButtons"
:key="item.key"
type="button"
class="sort-chip"
:class="{ active: sortBy === item.value }"
@click="sortBy = item.value"
:class="{ active: isSortActive(item) }"
@click="handleSortClick(item)"
>
{{ item.label }}
<span>{{ item.label }}</span>
<span v-if="item.hasDirection" class="sort-arrows" aria-hidden="true">
<i class="arrow-up" :class="{ active: getSortDirection(item) === 'asc' }" />
<i class="arrow-down" :class="{ active: getSortDirection(item) === 'desc' }" />
</span>
</button>
</div>
</div>
@@ -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;
@@ -123,14 +123,23 @@ const mobilePageSize = 10
let listingRequestSeq = 0
let searchDebounceTimer: ReturnType<typeof setTimeout> | 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<string, string> = {
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
}