Files
hfb_sys/frontend/src/features/listings/views/MobileHomeView.vue
T

884 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { ensureSupportChat } from '@/features/chats/api/chats'
import { formatCent, formatMoney } from '@/shared/utils/money'
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 {
clearStoredHomeQuery,
decodeHomeQueryJson,
encodeHomeQueryJson,
hasHomeQueryValues,
isSameQuery,
mergeHomeQuery,
mobileHomeFilterStorageKey,
mobileHomeFilterQueryKeys,
readStoredHomeQuery,
readQueryString,
storeHomeQuery,
type HomeQueryValue,
} from '@/features/listings/composables/useHomeFilterQuery'
import {
getListingChips,
getListingDisplayPrice,
getListingTitle,
getDailyLoss,
getRatioValue,
formatListingCode,
formatEstimatedRentalDuration,
getLoginMethod,
getServerRegion,
assetRegions,
hasAcceleratedSaleRatio,
hasGiftResources,
} from '@/shared/utils/listingDisplay'
import { useSessionStore } from '@/stores/session'
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const loading = ref(false)
const loadingMore = ref(false)
const loadFailed = ref(false)
const listings = ref<Listing[]>([])
const totalListings = ref(0)
const zoneCounts = ref<Record<string, number>>({})
const currentPage = ref(1)
const hasMoreListings = ref(true)
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const sortOpen = ref(false)
const activeSort = ref('recommended')
const activeZone = ref('all')
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 showBackTop = ref(false)
const mobilePageSize = 10
let listingRequestSeq = 0
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
const sortOptions = [
{ key: 'recommended', label: '综合推荐' },
{ key: 'coinDesc', label: '哈夫币' },
{ key: 'awmDesc', label: 'AWM数量' },
{ key: 'priceAsc', label: '价格最低' },
{ key: 'priceDesc', label: '价格最高' },
{ key: 'published', label: '最新发布' },
]
const zoneOptions = [
{ key: 'all', label: '全部', hint: '当前可租' },
{ key: 'sale', label: '特惠', hint: '价格更划算' },
{ key: 'gift', label: '赠送', hint: '含赠送物品' },
{ key: 'night', label: '夜间', hint: '0-8点可上号' },
{ key: 'password', label: '账密', hint: '交接更快' },
{ key: 'highCoin', label: '高币', hint: '100M以上' },
]
const rangePresets: Record<string, Array<{ label: string; min: string; max: string }>> = {
price: [
{ label: '0-50', min: '0', max: '50' },
{ label: '50-100', min: '50', max: '100' },
{ label: '100-200', min: '100', max: '200' },
{ label: '200以上', min: '200', max: '' },
],
coin: [
{ label: '0-100', min: '0', max: '100' },
{ label: '100-300', min: '100', max: '300' },
{ label: '300-500', min: '300', max: '500' },
{ label: '500以上', min: '500', max: '' },
],
ratio: [
{ label: '0-20', min: '0', max: '20' },
{ label: '20-30', min: '20', max: '30' },
{ label: '30-40', min: '30', max: '40' },
{ label: '40以上', min: '40', max: '' },
],
deposit: [
{ label: '0-50', min: '0', max: '50' },
{ label: '50-100', min: '50', max: '100' },
{ label: '100-200', min: '100', max: '200' },
{ label: '200以上', min: '200', max: '' },
],
total: [
{ label: '0-500', min: '0', max: '500' },
{ label: '500-1000', min: '500', max: '1000' },
{ label: '1000-2000', min: '1000', max: '2000' },
{ label: '2000-5000', min: '2000', max: '5000' },
],
fireLevel: [
{ label: '38-50', min: '38', max: '50' },
{ label: '50-60', min: '50', max: '60' },
{ label: '60-70', min: '60', max: '70' },
{ label: '70以上', min: '70', 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 regionFilterOptions = computed(() =>
uniqueOptions([
...publishOptions.value.region_options,
...listings.value.flatMap(item => assetRegions(item)),
])
)
const filterSections = computed<FilterSection[]>(() => [
{
key: 'price',
title: '价格区间',
type: 'range',
unit: '元',
minPlaceholder: '最低价',
maxPlaceholder: '最高价',
},
{
key: 'coin',
title: '哈夫币数量',
type: 'range',
unit: 'M',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
{
key: 'ratio',
title: '比例',
type: 'range',
unit: 'w/元',
minPlaceholder: '最低比例',
maxPlaceholder: '最高比例',
},
{ key: 'server', title: '区服', type: 'chips', options: serverFilterOptions.value },
{ key: 'region', title: '资产地区', type: 'chips', options: regionFilterOptions.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: 'fireLevel',
title: '等级',
type: 'range',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
{ key: 'rank', title: '段位', type: 'chips', options: publishOptions.value.rank_options },
{
key: 'deposit',
title: '押金',
type: 'range',
unit: '元',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
{
key: 'total',
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
})
const visibleZoneOptions = computed(() =>
zoneOptions.map(zone => ({
...zone,
count: zoneCount(zone.key),
}))
)
applyRouteQueryToMobileState()
onMounted(() => {
loadListings()
loadHomeConfig()
window.addEventListener('scroll', handleWindowScroll, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleWindowScroll)
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
})
watch(
() => filterQuerySignature(),
() => {
syncMobileHomeQuery()
loadListings(true)
}
)
watch(
() => route.query,
() => {
applyRouteQueryToMobileState()
}
)
watch(searchValue, () => {
syncMobileHomeQuery()
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => {
loadListings(true)
}, 300)
})
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
zoneCounts.value = page.zone_counts
hasMoreListings.value = listings.value.length < page.total
currentPage.value = page.page + 1
requestAnimationFrame(handleWindowScroll)
} catch {
if (reset) {
listings.value = []
totalListings.value = 0
zoneCounts.value = {}
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 selectZone(zoneKey: string) {
activeZone.value = zoneKey
sortOpen.value = false
}
function clearFilters() {
clearStoredHomeQuery(mobileHomeFilterStorageKey)
selectedFilters.value = {}
rangeFilters.value = {}
searchValue.value = ''
activeZone.value = 'all'
syncMobileHomeQuery()
}
function buildListingQuery(page: number): PublicListingQuery {
const query: PublicListingQuery = {
page,
page_size: mobilePageSize,
keyword: searchValue.value.trim(),
sort: activeSort.value,
zone: activeZone.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 === 'region') query.region = 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 === 'ratio') {
query.min_ratio = min
query.max_ratio = 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 === 'total') {
query.min_total = min
query.max_total = max
} else if (key === 'fireLevel') {
query.min_fire_level = min
query.max_fire_level = max
} else if (key.startsWith('resource_')) {
const resourceKey = key.replace('resource_', '')
query[`resource_${resourceKey}_min`] = min
query[`resource_${resourceKey}_max`] = max
}
}
return query
}
function filterQuerySignature() {
return JSON.stringify({
sort: activeSort.value,
zone: activeZone.value,
selected: selectedFilters.value,
ranges: rangeFilters.value,
})
}
function parseOptionalNumber(value: string) {
if (value === '') return undefined
const number = Number(value)
return Number.isFinite(number) ? number : undefined
}
function applyRouteQueryToMobileState() {
const storedQuery = readStoredHomeQuery(mobileHomeFilterStorageKey)
const query = hasHomeQueryValues(route.query, mobileHomeFilterQueryKeys)
? route.query
: storedQuery
searchValue.value = readQueryString(query, 'keyword')
activeSort.value = readQueryString(query, 'sort') || 'recommended'
activeZone.value = readQueryString(query, 'zone') || 'all'
selectedFilters.value = normalizeSelectedFilters(
decodeHomeQueryJson<Record<string, unknown>>(query, 'filters', {})
)
rangeFilters.value = normalizeRangeFilters(
decodeHomeQueryJson<Record<string, unknown>>(query, 'ranges', {})
)
}
function syncMobileHomeQuery() {
const query = mergeHomeQuery(route.query, mobileHomeFilterQueryKeys, buildMobileHomeQueryValues())
if (hasHomeQueryValues(query, mobileHomeFilterQueryKeys)) {
storeHomeQuery(mobileHomeFilterStorageKey, query)
}
if (isSameQuery(route.query, query)) return
router.replace({ path: route.path, query })
}
function buildMobileHomeQueryValues(): Record<string, HomeQueryValue> {
return {
keyword: searchValue.value.trim() || undefined,
sort: activeSort.value === 'recommended' ? undefined : activeSort.value,
zone: activeZone.value === 'all' ? undefined : activeZone.value,
filters: encodeHomeQueryJson(normalizeSelectedFilters(selectedFilters.value)),
ranges: encodeHomeQueryJson(normalizeRangeFilters(rangeFilters.value)),
}
}
function normalizeSelectedFilters(value: Record<string, unknown>) {
const normalized: Record<string, string[]> = {}
for (const [key, items] of Object.entries(value)) {
if (!Array.isArray(items)) continue
const filters = items.map(item => String(item).trim()).filter(Boolean)
if (filters.length) normalized[key] = filters
}
return normalized
}
function normalizeRangeFilters(value: Record<string, unknown>) {
const normalized: Record<string, { min: string; max: string }> = {}
for (const [key, range] of Object.entries(value)) {
if (!range || typeof range !== 'object' || Array.isArray(range)) continue
const record = range as Record<string, unknown>
const min = record.min === undefined ? '' : String(record.min).trim()
const max = record.max === undefined ? '' : String(record.max).trim()
if (min || max) normalized[key] = { min, max }
}
return normalized
}
function handleWindowScroll() {
showBackTop.value = window.scrollY > 520
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 360) return
loadListings(false)
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
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))]
}
function zoneCount(key: string) {
if (key === 'all') return zoneCounts.value.all ?? totalListings.value
return zoneCounts.value[key] ?? 0
}
function getAccessBadgeMeta(value: string) {
const text = value.trim()
if (text.includes('微信')) return { icon: 'wechat', tone: 'wechat' }
if (text.toUpperCase().includes('QQ')) return { icon: 'qq', tone: 'qq' }
if (text.includes('扫码')) return { icon: 'scan', tone: 'scan' }
if (text.includes('账号') || text.includes('密码')) return { icon: 'lock', tone: 'password' }
if (text.includes('Steam')) return { icon: 'desktop-o', tone: 'steam' }
return { icon: 'bookmark-o', tone: 'default' }
}
function getMobileRatioText(item: Listing) {
const ratio = getRatioValue(item)
if (ratio <= 0) return ''
const formatted = Number.isInteger(ratio) ? String(ratio) : ratio.toFixed(1)
return `1元=${formatted}w哈夫币`
}
function chipTone(label: string) {
if (label.includes('哈夫币')) return 'coin'
if (label.includes('保险')) return 'insurance'
if (label.includes('体力')) return 'stamina'
if (label.includes('负重')) return 'load'
if (label.includes('段位')) return 'rank'
if (label.includes('AWM')) return 'weapon'
if (label.includes('总资产')) return 'asset'
if (label.includes('方便')) return 'time'
return 'default'
}
syncMobileHomeQuery()
</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>
<button
class="mobile-service"
type="button"
:disabled="supportLoading"
@click="handleSupportClick"
>
{{ supportLoading ? '接入中' : '客服' }}
</button>
</div>
<RouterLink class="add-home-strip" to="/m/add-home-guide" aria-label="查看添加到手机桌面教程">
<span class="add-home-mark">
<van-icon name="home-o" :size="18" />
</span>
<span class="add-home-copy">
<strong>添加到手机桌面</strong>
<small>下次像 App 一样快速打开</small>
</span>
<span class="add-home-cta">去添加</span>
</RouterLink>
<van-search
v-model="searchValue"
shape="round"
placeholder="搜编号 / 区服 / 段位"
class="home-search"
/>
<!-- 防骗提示卡片不用 van-notice-bar -->
<RouterLink class="fraud-tip" to="/m/announcements">
<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>
<van-icon class="fraud-more" name="arrow" :size="14" />
</RouterLink>
</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 class="banner-copy">
<p v-if="slide.eyebrow">{{ slide.eyebrow }}</p>
<h1 v-if="slide.title">{{ slide.title }}</h1>
<span v-if="slide.pill" class="banner-pill">{{ slide.pill }}</span>
</div>
<div v-if="slide.badge" class="banner-badge">{{ slide.badge }}</div>
</div>
</van-swipe-item>
</van-swipe>
<div class="zone-strip" aria-label="账号专区">
<button
v-for="zone in visibleZoneOptions"
:key="zone.key"
type="button"
class="zone-pill"
:class="{ active: activeZone === zone.key }"
@click="selectZone(zone.key)"
>
<strong>{{ zone.label }}</strong>
<span>{{ zone.count }}</span>
<small>{{ zone.hint }}</small>
</button>
</div>
<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"
class="mobile-empty"
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="card-meta-row">
<div class="mobile-listing-no">{{ formatListingCode(item) }}</div>
<div
v-if="getDailyLoss(item) || formatEstimatedRentalDuration(item) !== '--'"
class="rental-meta-badge"
>
<span v-if="getDailyLoss(item)">消耗 {{ getDailyLoss(item) }}/</span>
<span
v-if="getDailyLoss(item) && formatEstimatedRentalDuration(item) !== '--'"
class="meta-separator"
>
·
</span>
<span v-if="formatEstimatedRentalDuration(item) !== '--'">
租期 {{ formatEstimatedRentalDuration(item) }}
</span>
</div>
</div>
<div class="card-action-row">
<p v-if="getMobileRatioText(item)" class="card-subtitle">
{{ getMobileRatioText(item) }}
</p>
<div class="card-badges-row">
<span
class="server-badge"
:class="`tone-${getAccessBadgeMeta(getServerRegion(item)).tone}`"
>
<van-icon :name="getAccessBadgeMeta(getServerRegion(item)).icon" :size="13" />
{{ getServerRegion(item) }}
</span>
<span
v-if="getLoginMethod(item)"
class="server-badge"
:class="`tone-${getAccessBadgeMeta(getLoginMethod(item)).tone}`"
>
<van-icon :name="getAccessBadgeMeta(getLoginMethod(item)).icon" :size="13" />
{{ getLoginMethod(item) }}
</span>
</div>
</div>
<div class="card-footer">
<div class="price-col">
<strong>¥{{ formatMoney(getListingDisplayPrice(item)) }}</strong>
<span class="rent-sub">押金 ¥{{ formatCent(item.deposit_amount_cent) }}</span>
</div>
</div>
</div>
<div class="card-chip-row">
<span
v-for="chip in getListingChips(item)"
:key="`${item.id}-${chip.label}`"
:class="`tone-${chipTone(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"
@reset="clearFilters"
/>
<button
v-if="showBackTop"
class="back-top-button"
type="button"
aria-label="回到顶部"
@click="scrollToTop"
>
<van-icon name="arrow-up" :size="20" />
</button>
<footer class="home-footer">
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
class="home-footer-icp"
>湘ICP备2025146668号</a>
<span class="home-footer-divider">|</span>
<span class="home-footer-copyright">版权所有 ©2026 大锤网络游戏有限公司</span>
</footer>
<MobileBottomNav />
</main>
</template>
<style scoped src="./MobileHomeView.css"></style>