556 lines
17 KiB
Vue
556 lines
17 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { RouterLink, useRouter } from 'vue-router'
|
||
import { showToast } from 'vant'
|
||
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
||
|
||
import { ensureSupportChat } from '@/features/chats/api/chats'
|
||
import {
|
||
emptyListingPublishOptions,
|
||
type ListingPublishOptions,
|
||
} from '@/features/listings/api/listingOptions'
|
||
import {
|
||
fetchListingsPage,
|
||
type Listing,
|
||
type PublicListingQuery,
|
||
} from '@/features/listings/api/listings'
|
||
import {
|
||
defaultHomeAnnouncements,
|
||
defaultHomeBanners,
|
||
fetchMobileHomeConfig,
|
||
type HomeBannerSlide,
|
||
} from '@/features/listings/api/homeConfig'
|
||
import MobileHomeFilterSheet, { type FilterSection } from './MobileHomeFilterSheet.vue'
|
||
import {
|
||
getListingChips,
|
||
getListingDisplayPrice,
|
||
getListingSubtitle,
|
||
getListingTitle,
|
||
formatListingCode,
|
||
getLoginMethod,
|
||
getServerRegion,
|
||
hasAcceleratedSaleRatio,
|
||
hasGiftResources,
|
||
} from '@/utils/listingDisplay'
|
||
import { useSessionStore } from '@/stores/session'
|
||
|
||
const router = useRouter()
|
||
const session = useSessionStore()
|
||
const loading = ref(false)
|
||
const loadingMore = ref(false)
|
||
const loadFailed = ref(false)
|
||
const listings = ref<Listing[]>([])
|
||
const totalListings = ref(0)
|
||
const currentPage = ref(1)
|
||
const hasMoreListings = ref(true)
|
||
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
|
||
const sortOpen = ref(false)
|
||
const activeSort = ref('comprehensive')
|
||
const filterOpen = ref(false)
|
||
const selectedFilters = ref<Record<string, string[]>>({})
|
||
const rangeFilters = ref<Record<string, { min: string; max: string }>>({})
|
||
const refreshing = ref(false)
|
||
const searchValue = ref('')
|
||
const announcements = ref<string[]>(defaultHomeAnnouncements)
|
||
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
|
||
const supportLoading = ref(false)
|
||
const mobilePageSize = 10
|
||
let listingRequestSeq = 0
|
||
|
||
const sortOptions = [
|
||
{ key: 'comprehensive', label: '综合排序' },
|
||
{ key: 'published', label: '发布时间' },
|
||
{ key: 'awmDesc', label: 'AWM数量' },
|
||
{ key: 'priceAsc', label: '价格最低' },
|
||
{ key: 'priceDesc', label: '价格最高' },
|
||
]
|
||
|
||
const rangePresets: Record<string, Array<{ label: string; min: string; max: string }>> = {
|
||
coin: [
|
||
{ label: '50-100', min: '50', max: '100' },
|
||
{ label: '100-200', min: '100', max: '200' },
|
||
{ label: '200-300', min: '200', max: '300' },
|
||
{ label: '300-500', min: '300', max: '500' },
|
||
{ label: '500以上', min: '500', max: '' },
|
||
],
|
||
resource_awmAmmo: [
|
||
{ label: '0-20', min: '0', max: '20' },
|
||
{ label: '20-50', min: '20', max: '50' },
|
||
{ label: '50-100', min: '50', max: '100' },
|
||
{ label: '100-200', min: '100', max: '200' },
|
||
{ label: '200以上', min: '200', max: '' },
|
||
],
|
||
}
|
||
|
||
const activeSortLabel = computed(
|
||
() => sortOptions.find(option => option.key === activeSort.value)?.label || '综合排序'
|
||
)
|
||
|
||
async function handleSupportClick() {
|
||
if (!session.isLoggedIn) {
|
||
router.push({ path: '/m/login', query: { redirect: router.currentRoute.value.fullPath } })
|
||
return
|
||
}
|
||
if (supportLoading.value) return
|
||
supportLoading.value = true
|
||
try {
|
||
const chat = await ensureSupportChat()
|
||
router.push(`/m/chats/${chat.id}`)
|
||
} catch {
|
||
showToast({ message: '联系客服失败,请稍后重试', icon: 'cross' })
|
||
} finally {
|
||
supportLoading.value = false
|
||
}
|
||
}
|
||
|
||
const serverFilterOptions = computed(() =>
|
||
uniqueOptions(publishOptions.value.server_options.map(item => item.trim()).filter(Boolean))
|
||
)
|
||
|
||
const loginMethodFilterOptions = computed(() =>
|
||
uniqueOptions(publishOptions.value.login_method_options.map(item => item.trim()).filter(Boolean))
|
||
)
|
||
|
||
const filterSections = computed<FilterSection[]>(() => [
|
||
{
|
||
key: 'price',
|
||
title: '价格区间',
|
||
type: 'range',
|
||
unit: '元',
|
||
minPlaceholder: '最低价',
|
||
maxPlaceholder: '最高价',
|
||
},
|
||
{
|
||
key: 'coin',
|
||
title: '哈夫币数量',
|
||
type: 'range',
|
||
unit: 'M',
|
||
minPlaceholder: '最低',
|
||
maxPlaceholder: '最高',
|
||
},
|
||
{ key: 'server', title: '区服', type: 'chips', options: serverFilterOptions.value },
|
||
{ key: 'login', title: '上号方式', type: 'chips', options: loginMethodFilterOptions.value },
|
||
{
|
||
key: 'insurance',
|
||
title: '保险',
|
||
type: 'chips',
|
||
options: publishOptions.value.insurance_options,
|
||
},
|
||
{ key: 'stamina', title: '体力', type: 'chips', options: publishOptions.value.level_options },
|
||
{ key: 'load', title: '负重', type: 'chips', options: publishOptions.value.level_options },
|
||
...publishOptions.value.quantity_items.map(item => ({
|
||
key: `resource_${item.key}`,
|
||
title: item.label,
|
||
type: 'range' as const,
|
||
unit: parseQuantityUnit(item.price),
|
||
minPlaceholder: '最低',
|
||
maxPlaceholder: '最高',
|
||
})),
|
||
...publishOptions.value.skin_groups.map(group => ({
|
||
key: group.key,
|
||
title: group.title,
|
||
type: 'chips' as const,
|
||
options: group.options,
|
||
})),
|
||
{
|
||
key: 'secretKd',
|
||
title: '绝密KD',
|
||
type: 'range',
|
||
minPlaceholder: '最低',
|
||
maxPlaceholder: '最高',
|
||
},
|
||
{ key: 'rank', title: '段位', type: 'chips', options: publishOptions.value.rank_options },
|
||
{
|
||
key: 'deposit',
|
||
title: '押金',
|
||
type: 'range',
|
||
unit: '元',
|
||
minPlaceholder: '最低',
|
||
maxPlaceholder: '最高',
|
||
},
|
||
])
|
||
|
||
const activeFilterCount = computed(() => {
|
||
const chipCount = Object.values(selectedFilters.value).reduce(
|
||
(sum, values) => sum + values.length,
|
||
0
|
||
)
|
||
const rangeCount = Object.values(rangeFilters.value).filter(
|
||
range => range.min || range.max
|
||
).length
|
||
return chipCount + rangeCount
|
||
})
|
||
|
||
const displayListings = computed(() => {
|
||
return listings.value
|
||
})
|
||
|
||
onMounted(() => {
|
||
loadListings()
|
||
loadHomeConfig()
|
||
window.addEventListener('scroll', handleWindowScroll, { passive: true })
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('scroll', handleWindowScroll)
|
||
})
|
||
|
||
watch(
|
||
() => listingQuerySignature(),
|
||
() => {
|
||
loadListings(true)
|
||
}
|
||
)
|
||
|
||
async function loadListings(reset = true) {
|
||
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return
|
||
const requestSeq = ++listingRequestSeq
|
||
if (reset) {
|
||
loading.value = true
|
||
currentPage.value = 1
|
||
hasMoreListings.value = true
|
||
}
|
||
loadingMore.value = true
|
||
loadFailed.value = false
|
||
try {
|
||
const page = await fetchListingsPage(buildListingQuery(currentPage.value))
|
||
if (requestSeq !== listingRequestSeq) return
|
||
listings.value = reset ? page.items : [...listings.value, ...page.items]
|
||
totalListings.value = page.total
|
||
hasMoreListings.value = listings.value.length < page.total
|
||
currentPage.value = page.page + 1
|
||
requestAnimationFrame(handleWindowScroll)
|
||
} catch {
|
||
if (reset) {
|
||
listings.value = []
|
||
totalListings.value = 0
|
||
hasMoreListings.value = false
|
||
loadFailed.value = true
|
||
}
|
||
} finally {
|
||
if (requestSeq === listingRequestSeq) {
|
||
loading.value = false
|
||
loadingMore.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadHomeConfig() {
|
||
try {
|
||
const config = await fetchMobileHomeConfig()
|
||
announcements.value = config.announcements
|
||
bannerSlides.value = config.banners
|
||
publishOptions.value = config.publish_options
|
||
} catch {
|
||
announcements.value = defaultHomeAnnouncements
|
||
bannerSlides.value = defaultHomeBanners
|
||
publishOptions.value = emptyListingPublishOptions
|
||
}
|
||
}
|
||
|
||
async function onRefresh() {
|
||
refreshing.value = true
|
||
try {
|
||
const [, nextHomeConfig] = await Promise.all([loadListings(true), fetchMobileHomeConfig()])
|
||
announcements.value = nextHomeConfig.announcements
|
||
bannerSlides.value = nextHomeConfig.banners
|
||
publishOptions.value = nextHomeConfig.publish_options
|
||
showToast({ message: '刷新成功', icon: 'passed' })
|
||
} catch {
|
||
// 静默处理
|
||
} finally {
|
||
refreshing.value = false
|
||
}
|
||
}
|
||
|
||
function openFilters() {
|
||
sortOpen.value = false
|
||
filterOpen.value = true
|
||
}
|
||
|
||
function toggleSortPanel() {
|
||
sortOpen.value = !sortOpen.value
|
||
}
|
||
|
||
function selectSort(sortKey: string) {
|
||
activeSort.value = sortKey
|
||
sortOpen.value = false
|
||
}
|
||
|
||
function clearFilters() {
|
||
selectedFilters.value = {}
|
||
rangeFilters.value = {}
|
||
searchValue.value = ''
|
||
}
|
||
|
||
function buildListingQuery(page: number): PublicListingQuery {
|
||
const query: PublicListingQuery = {
|
||
page,
|
||
page_size: mobilePageSize,
|
||
keyword: searchValue.value.trim(),
|
||
sort: activeSort.value,
|
||
}
|
||
const skinGroups: string[] = []
|
||
const skinNames: string[] = []
|
||
for (const [key, values] of Object.entries(selectedFilters.value)) {
|
||
const value = values.filter(Boolean).join(',')
|
||
if (!value) continue
|
||
if (key === 'server') query.server = value
|
||
else if (key === 'login') query.login_method = value
|
||
else if (key === 'insurance') query.insurance = value
|
||
else if (key === 'stamina') query.stamina = value
|
||
else if (key === 'load') query.load = value
|
||
else if (key === 'rank') query.rank = value
|
||
else if (isSkinGroupKey(key)) {
|
||
skinGroups.push(key)
|
||
skinNames.push(...values)
|
||
}
|
||
}
|
||
if (skinGroups.length) query.skin_group = skinGroups.join(',')
|
||
if (skinNames.length) query.skin_name = skinNames.join(',')
|
||
|
||
for (const [key, range] of Object.entries(rangeFilters.value)) {
|
||
if (!range.min && !range.max) continue
|
||
const min = parseOptionalNumber(range.min)
|
||
const max = parseOptionalNumber(range.max)
|
||
if (key === 'price') {
|
||
query.min_price = min
|
||
query.max_price = max
|
||
} else if (key === 'coin') {
|
||
query.min_coin = min
|
||
query.max_coin = max
|
||
} else if (key === 'secretKd') {
|
||
query.min_secret_kd = min
|
||
query.max_secret_kd = max
|
||
} else if (key === 'deposit') {
|
||
query.min_deposit = min
|
||
query.max_deposit = max
|
||
} else if (key.startsWith('resource_')) {
|
||
const resourceKey = key.replace('resource_', '')
|
||
query[`resource_${resourceKey}_min`] = min
|
||
query[`resource_${resourceKey}_max`] = max
|
||
}
|
||
}
|
||
return query
|
||
}
|
||
|
||
function listingQuerySignature() {
|
||
return JSON.stringify(buildListingQuery(1))
|
||
}
|
||
|
||
function parseOptionalNumber(value: string) {
|
||
if (value === '') return undefined
|
||
const number = Number(value)
|
||
return Number.isFinite(number) ? number : undefined
|
||
}
|
||
|
||
function handleWindowScroll() {
|
||
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 360) return
|
||
loadListings(false)
|
||
}
|
||
|
||
function isSkinGroupKey(key: string) {
|
||
return publishOptions.value.skin_groups.some(group => group.key === key)
|
||
}
|
||
|
||
function parseQuantityUnit(price: string) {
|
||
const unit = price.split('/')[1]?.trim()
|
||
return unit || undefined
|
||
}
|
||
|
||
function uniqueOptions(values: string[]) {
|
||
return [...new Set(values.map(item => item.trim()).filter(Boolean))]
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<main class="mobile-shell">
|
||
<!-- ========== Hero 区域:顶部搜索与公告 ========== -->
|
||
<section class="mobile-hero">
|
||
<div class="mobile-topbar">
|
||
<div class="mobile-brand">
|
||
<span class="mobile-logo">锤</span>
|
||
<div>
|
||
<strong>大锤商行</strong>
|
||
<small>哈夫币租号</small>
|
||
</div>
|
||
</div>
|
||
<van-search
|
||
v-model="searchValue"
|
||
shape="round"
|
||
placeholder="搜编号 / 区服 / 段位"
|
||
class="home-search"
|
||
/>
|
||
<button
|
||
class="mobile-service"
|
||
type="button"
|
||
:disabled="supportLoading"
|
||
@click="handleSupportClick"
|
||
>
|
||
{{ supportLoading ? '接入中' : '客服' }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 防骗提示卡片(不用 van-notice-bar) -->
|
||
<div class="fraud-tip">
|
||
<van-icon name="warning-o" :size="16" color="#b8860b" />
|
||
<span class="fraud-dot"></span>
|
||
<van-swipe
|
||
class="announcement-swipe"
|
||
vertical
|
||
:autoplay="3200"
|
||
:show-indicators="false"
|
||
touchable
|
||
>
|
||
<van-swipe-item v-for="item in announcements" :key="item">
|
||
<span>{{ item }}</span>
|
||
</van-swipe-item>
|
||
</van-swipe>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ========== Content 区域 ========== -->
|
||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||
<section class="mobile-content">
|
||
<!-- Banner 轮播 -->
|
||
<van-swipe class="banner-swipe" :autoplay="3600" lazy-render>
|
||
<van-swipe-item v-for="slide in bannerSlides" :key="slide.title || slide.image_url">
|
||
<div
|
||
class="mobile-banner"
|
||
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
|
||
>
|
||
<img
|
||
v-if="slide.image_url"
|
||
class="banner-image"
|
||
:src="slide.image_url"
|
||
:alt="slide.title || slide.eyebrow || '首页轮播图'"
|
||
/>
|
||
<div>
|
||
<p v-if="slide.eyebrow">{{ slide.eyebrow }}</p>
|
||
<h1 v-if="slide.title">{{ slide.title }}</h1>
|
||
<span v-if="slide.pill">{{ slide.pill }}</span>
|
||
</div>
|
||
<div v-if="slide.badge" class="banner-badge">{{ slide.badge }}</div>
|
||
</div>
|
||
</van-swipe-item>
|
||
</van-swipe>
|
||
|
||
<div class="list-toolbar">
|
||
<button type="button" class="sort-entry" @click="toggleSortPanel">
|
||
<span>{{ activeSortLabel }}</span>
|
||
<van-icon :name="sortOpen ? 'arrow-up' : 'arrow-down'" :size="14" />
|
||
</button>
|
||
<button type="button" class="filter-entry" @click="openFilters">
|
||
<van-icon name="filter-o" :size="16" />
|
||
<span>筛选</span>
|
||
<em v-if="activeFilterCount">{{ activeFilterCount }}</em>
|
||
</button>
|
||
</div>
|
||
<div v-if="sortOpen" class="sort-panel">
|
||
<button
|
||
v-for="option in sortOptions"
|
||
:key="option.key"
|
||
type="button"
|
||
:class="{ active: activeSort === option.key }"
|
||
@click="selectSort(option.key)"
|
||
>
|
||
<span>{{ option.label }}</span>
|
||
<van-icon v-if="activeSort === option.key" name="success" :size="18" />
|
||
</button>
|
||
</div>
|
||
<div class="result-count">
|
||
<strong>{{ totalListings }}</strong>
|
||
<span>个可租账号</span>
|
||
</div>
|
||
|
||
<!-- 加载/错误状态 -->
|
||
<van-loading v-if="loading" class="state-loading" size="24px" vertical>
|
||
正在加载优质账号...
|
||
</van-loading>
|
||
<van-notice-bar
|
||
v-else-if="loadFailed"
|
||
left-icon="info-o"
|
||
color="#6b7a90"
|
||
background="transparent"
|
||
text="接口暂不可用,请稍后刷新。"
|
||
/>
|
||
<van-empty
|
||
v-else-if="displayListings.length === 0"
|
||
image="search"
|
||
description="没有符合条件的账号"
|
||
>
|
||
<van-button size="small" type="primary" @click="clearFilters"> 重置条件 </van-button>
|
||
</van-empty>
|
||
|
||
<!-- 列表卡片:全宽上下布局 -->
|
||
<div class="mobile-list">
|
||
<RouterLink
|
||
v-for="item in displayListings"
|
||
:key="item.id"
|
||
class="mobile-card"
|
||
:to="`/m/listings/${item.id}`"
|
||
>
|
||
<div class="card-cover">
|
||
<img
|
||
v-if="item.cover_url"
|
||
:src="item.cover_url"
|
||
:alt="getListingTitle(item)"
|
||
loading="lazy"
|
||
decoding="async"
|
||
/>
|
||
<span v-else>图</span>
|
||
<div
|
||
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
|
||
class="card-cover-labels"
|
||
>
|
||
<em v-if="hasGiftResources(item)">有赠送</em>
|
||
<em v-if="hasAcceleratedSaleRatio(item)">特惠</em>
|
||
</div>
|
||
</div>
|
||
<div class="card-main">
|
||
<div class="card-title-row">
|
||
<h2>{{ getListingTitle(item) }}</h2>
|
||
</div>
|
||
<div class="mobile-listing-no">编号 {{ formatListingCode(item) }}</div>
|
||
<p class="card-subtitle">{{ getListingSubtitle(item) }}</p>
|
||
<div class="card-badges-row">
|
||
<span class="server-badge">{{ getServerRegion(item) }}</span>
|
||
<span v-if="getLoginMethod(item)" class="server-badge">
|
||
{{ getLoginMethod(item) }}
|
||
</span>
|
||
</div>
|
||
<div class="card-footer">
|
||
<div class="price-col">
|
||
<strong>¥{{ getListingDisplayPrice(item) }}</strong>
|
||
<span class="rent-sub">押金¥{{ item.deposit_amount }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card-chip-row">
|
||
<span v-for="chip in getListingChips(item)" :key="`${item.id}-${chip.label}`">
|
||
{{ chip.label }}:{{ chip.value }}
|
||
</span>
|
||
</div>
|
||
</RouterLink>
|
||
</div>
|
||
<div v-if="!loading && displayListings.length" class="mobile-load-state">
|
||
<span v-if="loadingMore">正在加载更多账号...</span>
|
||
<span v-else-if="!hasMoreListings">已经到底了</span>
|
||
</div>
|
||
</section>
|
||
</van-pull-refresh>
|
||
|
||
<MobileHomeFilterSheet
|
||
v-model:show="filterOpen"
|
||
v-model:selected-filters="selectedFilters"
|
||
v-model:range-filters="rangeFilters"
|
||
:sections="filterSections"
|
||
:range-presets="rangePresets"
|
||
/>
|
||
|
||
<MobileBottomNav />
|
||
</main>
|
||
</template>
|
||
|
||
<style scoped src="./MobileHomeView.css"></style>
|