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
+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
}