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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user