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
|
return a.PriceCent > b.PriceCent
|
||||||
case "coinDesc":
|
case "coinDesc":
|
||||||
return a.HafCoinAmount > b.HafCoinAmount
|
return a.HafCoinAmount > b.HafCoinAmount
|
||||||
|
case "coinAsc":
|
||||||
|
return a.HafCoinAmount < b.HafCoinAmount
|
||||||
case "ratioDesc", "ratio":
|
case "ratioDesc", "ratio":
|
||||||
aRatio := ratioFromListing(a)
|
aRatio := ratioFromListing(a)
|
||||||
bRatio := ratioFromListing(b)
|
bRatio := ratioFromListing(b)
|
||||||
@@ -124,6 +126,13 @@ func sortPublicListings(items []ListingDTO, sortKey string) {
|
|||||||
return aAmmo > bAmmo
|
return aAmmo > bAmmo
|
||||||
}
|
}
|
||||||
return a.HafCoinAmount > b.HafCoinAmount
|
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":
|
case "published":
|
||||||
return publicRecentLess(a, b)
|
return publicRecentLess(a, b)
|
||||||
case "recommended", "comprehensive", "":
|
case "recommended", "comprehensive", "":
|
||||||
|
|||||||
@@ -208,13 +208,25 @@ func TestSortPublicListingsByRatio(t *testing.T) {
|
|||||||
|
|
||||||
sortPublicListings(items, "ratioDesc")
|
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}
|
expectedIDs := []uint64{2, 4, 3, 1}
|
||||||
for i, expected := range expectedIDs {
|
for i, expected := range expectedIDs {
|
||||||
if items[i].ID != expected {
|
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]))
|
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) {
|
func TestIsNightAvailableSummaryUsesEarlyMorningResponse(t *testing.T) {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func canListPublicWithSQL(query PublicListQuery) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch query.Sort {
|
switch query.Sort {
|
||||||
case "", "published", "recommended", "comprehensive", "priceAsc", "priceDesc", "coinDesc":
|
case "", "published", "recommended", "comprehensive", "priceAsc", "priceDesc", "coinDesc", "coinAsc":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
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")
|
return db.Order("l.price_cent DESC, l.published_at DESC, l.id DESC")
|
||||||
case "coinDesc":
|
case "coinDesc":
|
||||||
return db.Order("a.haf_coin_amount DESC, l.published_at DESC, l.id DESC")
|
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":
|
case "published":
|
||||||
return db.Order("l.published_at DESC, l.id DESC")
|
return db.Order("l.published_at DESC, l.id DESC")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -69,15 +69,78 @@ const awRecycleDialogVisible = ref(false)
|
|||||||
const cooperationFeedback = ref<CooperationFeedbackConfig>({ ...defaultCooperationFeedbackConfig })
|
const cooperationFeedback = ref<CooperationFeedbackConfig>({ ...defaultCooperationFeedbackConfig })
|
||||||
const cooperationFeedbackDialogVisible = ref(false)
|
const cooperationFeedbackDialogVisible = ref(false)
|
||||||
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
|
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
|
||||||
|
interface SortButtonItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
hasDirection?: boolean
|
||||||
|
defaultSort?: string
|
||||||
|
altSort?: string
|
||||||
|
}
|
||||||
|
|
||||||
const sortBy = ref('recommended')
|
const sortBy = ref('recommended')
|
||||||
const sortOptions = [
|
const sortButtons: SortButtonItem[] = [
|
||||||
{ value: 'recommended', label: '综合推荐' },
|
{ key: 'recommended', label: '综合推荐' },
|
||||||
{ value: 'ratioDesc', label: '比例排序' },
|
{
|
||||||
{ value: 'coinDesc', label: '哈夫币' },
|
key: 'ratio',
|
||||||
{ value: 'awmDesc', label: 'AWM数量' },
|
label: '比例',
|
||||||
{ value: 'priceAsc', label: '价格最低' },
|
hasDirection: true,
|
||||||
{ value: 'priceDesc', label: '价格最高' },
|
defaultSort: 'ratioDesc',
|
||||||
] as const
|
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 activeZone = ref('all')
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -577,14 +640,18 @@ if (!restored) {
|
|||||||
/>
|
/>
|
||||||
<div class="list-actions sort-bar" role="group" aria-label="排序方式">
|
<div class="list-actions sort-bar" role="group" aria-label="排序方式">
|
||||||
<button
|
<button
|
||||||
v-for="item in sortOptions"
|
v-for="item in sortButtons"
|
||||||
:key="item.value"
|
:key="item.key"
|
||||||
type="button"
|
type="button"
|
||||||
class="sort-chip"
|
class="sort-chip"
|
||||||
:class="{ active: sortBy === item.value }"
|
:class="{ active: isSortActive(item) }"
|
||||||
@click="sortBy = item.value"
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -761,6 +828,9 @@ if (!restored) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sort-chip {
|
.sort-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -787,6 +857,46 @@ if (!restored) {
|
|||||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
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 {
|
.enhanced-desktop-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|||||||
@@ -123,14 +123,23 @@ const mobilePageSize = 10
|
|||||||
let listingRequestSeq = 0
|
let listingRequestSeq = 0
|
||||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const sortOptions = [
|
interface MobileSortOption {
|
||||||
{ key: 'recommended', label: '综合推荐' },
|
key: string
|
||||||
{ key: 'ratioDesc', label: '比例排序' },
|
label: string
|
||||||
{ key: 'coinDesc', label: '哈夫币' },
|
shortLabel: string
|
||||||
{ key: 'awmDesc', label: 'AWM数量' },
|
}
|
||||||
{ key: 'priceAsc', label: '价格最低' },
|
|
||||||
{ key: 'priceDesc', label: '价格最高' },
|
const sortOptions: MobileSortOption[] = [
|
||||||
{ key: 'published', label: '最新发布' },
|
{ 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 = [
|
const zoneOptions = [
|
||||||
@@ -146,7 +155,7 @@ const zoneOptions = [
|
|||||||
const rangePresets = defaultRangePresets
|
const rangePresets = defaultRangePresets
|
||||||
|
|
||||||
const activeSortLabel = computed(
|
const activeSortLabel = computed(
|
||||||
() => sortOptions.find(option => option.key === activeSort.value)?.label || '综合排序'
|
() => sortOptions.find(option => option.key === activeSort.value)?.shortLabel || '综合推荐'
|
||||||
)
|
)
|
||||||
|
|
||||||
async function handleSupportClick() {
|
async function handleSupportClick() {
|
||||||
@@ -552,6 +561,23 @@ function toggleSortPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectSort(sortKey: string) {
|
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
|
activeSort.value = sortKey
|
||||||
sortOpen.value = false
|
sortOpen.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user