Files
hfb_sys/frontend/src/features/listings/views/MobileHomeView.vue
T
yml2213andCursor bd502bfa63 fix(listings): 手机超宽轮播改为可横滑展示和大图预览
分站海报比例很宽,整图塞进轮播会缩成一条,点开预览也看不清。首页按固定高度横滑查看,预览用大图灯箱代替默认图片预览。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 15:02:37 +08:00

1409 lines
46 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, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, RouterLink, useRoute, useRouter } from 'vue-router'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { ensureSupportChat, resolveSupportScene } from '@/features/chats/api/chats'
import { formatCent, formatMoney } from '@/shared/utils/money'
import {
emptyListingPublishOptions,
type ListingPublishOptions,
} from '@/features/listings/api/listingOptions'
import {
fetchListingsPage,
type PublicListingItem,
type PublicListingQuery,
} from '@/features/listings/api/listings'
import {
defaultAWRecycleConfig,
defaultCooperationFeedbackConfig,
defaultHomeAnnouncements,
defaultHomeBanners,
fetchMobileHomeConfig,
type AWRecycleConfig,
type CooperationFeedbackConfig,
type HomeBannerSlide,
} from '@/features/listings/api/homeConfig'
import { showToast } from 'vant'
import AuthImage from '@/shared/components/business/AuthImage.vue'
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,
getDailyLoss,
getRatioValue,
formatListingCode,
formatEstimatedRentalDuration,
getLoginMethod,
getServerRegion,
assetRegions,
hasAcceleratedSaleRatio,
hasGiftResources,
isTopTierInsurance,
getCoinWan,
formatHafCoinM,
readAssetString,
getListingSkinGroups,
getListingResources,
} from '@/shared/utils/listingDisplay'
import { buildMobileHomeListSignature, useMobileHomeCacheStore } from '@/stores/mobileHomeCache'
import { mapDistributionHomeBanners, useDistributionSiteStore } from '@/stores/distributionSite'
import { useSessionStore } from '@/stores/session'
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const siteStore = useDistributionSiteStore()
const homeCache = useMobileHomeCacheStore()
const scrollEl = ref<HTMLElement | null>(null)
const loading = ref(false)
const loadingMore = ref(false)
const loadFailed = ref(false)
const listings = ref<PublicListingItem[]>([])
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 displayAnnouncements = computed(() => {
if (siteStore.isDistribution) {
return [
`【${siteStore.siteName}】精选三角洲账号:官方验号,安全秒交接,售后有保障!`,
...announcements.value,
]
}
return announcements.value
})
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
const displayBanners = computed<HomeBannerSlide[]>(() => {
if (siteStore.isDistribution && siteStore.banners.length > 0) {
return mapDistributionHomeBanners(siteStore.banners, siteStore.siteName)
}
return bannerSlides.value
})
const bannerSwipeRef = ref<{ prev: () => void; next: () => void } | null>(null)
const bannerPreviewVisible = ref(false)
const bannerPreviewIndex = ref(0)
const bannerPreviewScroller = ref<HTMLElement | null>(null)
const bannerPreviewImages = computed(() =>
displayBanners.value
.map(slide => slide.image_url?.trim() || '')
.filter((url): url is string => Boolean(url))
)
const awRecycle = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig })
const awRecyclePreviewVisible = ref(false)
const cooperationFeedback = ref<CooperationFeedbackConfig>({ ...defaultCooperationFeedbackConfig })
const cooperationFeedbackPreviewVisible = ref(false)
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-2点可响应上号' },
{ key: 'password', label: '账密', hint: '交接更快' },
{ key: 'highCoin', label: '高币', hint: '100M以上' },
]
const rangePresets: Record<string, Array<{ label: string; min: string; max: string }>> = {
price: [
{ 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: '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: '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 scene = resolveSupportScene(route.path)
const chat = await ensureSupportChat(scene)
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()
function currentListSignature() {
return buildMobileHomeListSignature({
searchValue: searchValue.value,
activeSort: activeSort.value,
activeZone: activeZone.value,
selectedFilters: selectedFilters.value,
rangeFilters: rangeFilters.value,
})
}
function persistListCache() {
homeCache.saveList({
signature: currentListSignature(),
listings: listings.value.slice(),
totalListings: totalListings.value,
zoneCounts: { ...zoneCounts.value },
currentPage: currentPage.value,
hasMoreListings: hasMoreListings.value,
})
}
function persistScrollTop() {
if (!scrollEl.value) return
homeCache.saveScrollTop(scrollEl.value.scrollTop)
}
function restoreFromCache() {
const signature = currentListSignature()
if (!homeCache.matchList(signature)) return false
// 拷贝快照,避免页面内变更直接写穿 store
listings.value = homeCache.listings.slice()
totalListings.value = homeCache.totalListings
zoneCounts.value = { ...homeCache.zoneCounts }
currentPage.value = homeCache.currentPage
hasMoreListings.value = homeCache.hasMoreListings
loadFailed.value = false
loading.value = false
loadingMore.value = false
return true
}
function restoreHomeConfigFromCache() {
if (!homeCache.homeConfigLoaded) return false
announcements.value = homeCache.announcements.slice()
bannerSlides.value = homeCache.bannerSlides.map(slide => ({ ...slide }))
publishOptions.value = homeCache.publishOptions
return true
}
function restoreScrollPosition() {
const top = homeCache.scrollTop
const apply = () => {
if (scrollEl.value) scrollEl.value.scrollTop = top
}
apply()
nextTick(() => {
apply()
requestAnimationFrame(() => {
apply()
requestAnimationFrame(() => {
apply()
handleListScroll()
})
})
})
}
onMounted(() => {
if (restoredList) {
restoreScrollPosition()
}
})
onBeforeRouteLeave(() => {
persistScrollTop()
})
onBeforeUnmount(() => {
persistScrollTop()
document.body.style.overflow = ''
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
})
watch(
() => filterQuerySignature(),
() => {
// 筛选弹层打开时只改本地 state,避免每个按键写 URL/storage 后被旧缓存回写
if (filterOpen.value) return
applyFilterStateAndReload()
}
)
watch(filterOpen, (open, wasOpen) => {
// 关闭弹层时再统一同步 URL 并刷新列表
if (wasOpen && !open) {
applyFilterStateAndReload()
}
})
watch(
() => route.query,
() => {
applyRouteQueryToMobileState()
}
)
watch(searchValue, () => {
syncMobileHomeQuery()
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => {
homeCache.clearList()
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
persistListCache()
if (reset) {
homeCache.saveScrollTop(0)
if (scrollEl.value) scrollEl.value.scrollTop = 0
}
requestAnimationFrame(handleListScroll)
} catch {
if (reset) {
listings.value = []
totalListings.value = 0
zoneCounts.value = {}
hasMoreListings.value = false
loadFailed.value = true
homeCache.clearList()
}
} 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
awRecycle.value = config.aw_recycle
cooperationFeedback.value = config.cooperation_feedback
publishOptions.value = config.publish_options
homeCache.saveHomeConfig({
announcements: config.announcements,
bannerSlides: config.banners,
publishOptions: config.publish_options,
})
} catch {
announcements.value = defaultHomeAnnouncements
bannerSlides.value = defaultHomeBanners
awRecycle.value = { ...defaultAWRecycleConfig }
cooperationFeedback.value = { ...defaultCooperationFeedbackConfig }
publishOptions.value = emptyListingPublishOptions
}
}
function openAWRecycle() {
if (!awRecycle.value.image_url) {
showToast({ message: '回收说明图片尚未配置', icon: 'warning-o' })
return
}
awRecyclePreviewVisible.value = true
}
function openCooperationFeedback() {
if (!cooperationFeedback.value.image_url) {
showToast({ message: '合作与反馈图片尚未配置', icon: 'warning-o' })
return
}
cooperationFeedbackPreviewVisible.value = true
}
let posterDragged = false
function centerWideImage(event: Event) {
const img = event.target as HTMLImageElement
const scroller = img.parentElement
if (!scroller) return
scroller.scrollLeft = Math.max(0, (scroller.scrollWidth - scroller.clientWidth) / 2)
}
function onPosterTouchStart() {
posterDragged = false
}
function onPosterTouchMove() {
posterDragged = true
}
function onPosterClick(slide: HomeBannerSlide) {
if (posterDragged) {
posterDragged = false
return
}
openBannerPreview(slide)
}
function stepBanner(step: number) {
if (step < 0) bannerSwipeRef.value?.prev()
else bannerSwipeRef.value?.next()
}
function centerBannerPreview() {
const scroller = bannerPreviewScroller.value
if (!scroller) return
scroller.scrollLeft = Math.max(0, (scroller.scrollWidth - scroller.clientWidth) / 2)
}
function openBannerPreview(slide: HomeBannerSlide) {
const url = slide.image_url?.trim()
if (!url) return
const startPosition = bannerPreviewImages.value.indexOf(url)
if (startPosition < 0) return
bannerPreviewIndex.value = startPosition
bannerPreviewVisible.value = true
}
function closeBannerPreview() {
bannerPreviewVisible.value = false
}
function shiftBannerPreview(step: number) {
const total = bannerPreviewImages.value.length
if (total <= 1) return
bannerPreviewIndex.value = (bannerPreviewIndex.value + step + total) % total
}
watch([bannerPreviewVisible, bannerPreviewIndex], async ([open]) => {
document.body.style.overflow = open ? 'hidden' : ''
if (!open) return
await nextTick()
centerBannerPreview()
})
function hasBannerCopy(slide: HomeBannerSlide) {
return Boolean(slide.eyebrow?.trim() || slide.title?.trim() || slide.pill?.trim())
}
async function onRefresh() {
refreshing.value = true
try {
homeCache.clearList()
const [, nextHomeConfig] = await Promise.all([loadListings(true), fetchMobileHomeConfig()])
announcements.value = nextHomeConfig.announcements
bannerSlides.value = nextHomeConfig.banners
awRecycle.value = nextHomeConfig.aw_recycle
cooperationFeedback.value = nextHomeConfig.cooperation_feedback
publishOptions.value = nextHomeConfig.publish_options
homeCache.saveHomeConfig({
announcements: nextHomeConfig.announcements,
bannerSlides: nextHomeConfig.banners,
publishOptions: 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
const nextSearch = readQueryString(query, 'keyword')
const nextSort = readQueryString(query, 'sort') || 'recommended'
const nextZone = readQueryString(query, 'zone') || 'all'
const nextFilters = normalizeSelectedFilters(
decodeHomeQueryJson<Record<string, unknown>>(query, 'filters', {})
)
const nextRanges = normalizeRangeFilters(
decodeHomeQueryJson<Record<string, unknown>>(query, 'ranges', {})
)
// 内容未变时避免赋值,防止 URL 回写时误触发列表刷新
if (
searchValue.value === nextSearch &&
activeSort.value === nextSort &&
activeZone.value === nextZone &&
JSON.stringify(selectedFilters.value) === JSON.stringify(nextFilters) &&
JSON.stringify(rangeFilters.value) === JSON.stringify(nextRanges)
) {
return
}
searchValue.value = nextSearch
activeSort.value = nextSort
activeZone.value = nextZone
selectedFilters.value = nextFilters
rangeFilters.value = nextRanges
}
function applyFilterStateAndReload() {
syncMobileHomeQuery()
// 筛选变化时清空旧列表缓存,避免返回时命中过期数据
homeCache.clearList()
loadListings(true)
}
function syncMobileHomeQuery() {
const query = mergeHomeQuery(route.query, mobileHomeFilterQueryKeys, buildMobileHomeQueryValues())
if (hasHomeQueryValues(query, mobileHomeFilterQueryKeys)) {
storeHomeQuery(mobileHomeFilterStorageKey, query)
} else {
// 筛选被清空时必须清掉 storage,否则 URL 变空会回退读出旧区间(最后一位删不掉)
clearStoredHomeQuery(mobileHomeFilterStorageKey)
}
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 handleListScroll() {
const el = scrollEl.value
if (!el) return
homeCache.saveScrollTop(el.scrollTop)
showBackTop.value = el.scrollTop > 520
if (el.scrollTop + el.clientHeight < el.scrollHeight - 360) return
loadListings(false)
}
function scrollToTop() {
scrollEl.value?.scrollTo({ top: 0, behavior: 'smooth' })
homeCache.saveScrollTop(0)
}
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' }
}
type Listing = PublicListingItem
function getItemHafCoin(item: Listing) {
return formatHafCoinM(getCoinWan(item))
}
function getItemInsurance(item: Listing) {
return readAssetString(item, 'season_insurance') || '--'
}
function getItemRatioText(item: Listing) {
const val = getRatioValue(item)
if (val <= 0) return null
return `1元=${val.toFixed(0)}w`
}
function getItemSecondaryChips(item: Listing) {
return getListingChips(item).filter(
c => c.label !== '哈夫币' && c.label !== '保险格数' && c.label !== 'AWM'
)
}
function getItemSkinPreview(item: Listing) {
const groups = getListingSkinGroups(item)
const list: Array<{ name: string; type: string }> = []
for (const group of groups) {
for (const opt of group.options) {
list.push({ name: opt, type: group.key })
if (list.length >= 3) return list
}
}
return list
}
function getItemResourcePreview(item: Listing) {
const resources = getListingResources(item)
return resources.slice(0, 2)
}
syncMobileHomeQuery()
// 同步恢复列表和配置快照,确保首帧 DOM 即包含完整卡片列表,避免高度塌陷和滚动闪跳
const restoredList = restoreFromCache()
if (!restoredList) {
loadListings()
}
if (!restoreHomeConfigFromCache()) {
loadHomeConfig()
}
</script>
<template>
<main class="mobile-shell">
<div ref="scrollEl" class="mobile-scroll" @scroll.passive="handleListScroll">
<!-- ========== Hero 区域:顶部搜索与公告 ========== -->
<section class="mobile-hero">
<div class="mobile-topbar">
<div class="mobile-brand">
<span class="mobile-logo">{{
siteStore.isDistribution ? siteStore.siteName.slice(0, 1) : '锤'
}}</span>
<div>
<div class="mobile-brand-title-row">
<strong>{{ siteStore.siteName }}</strong>
</div>
<small>{{
siteStore.isDistribution ? siteStore.storefrontText.tagline : '哈夫币租号'
}}</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 displayAnnouncements" :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">
<!-- 商城移动端专属认证卡片 -->
<div v-if="siteStore.isDistribution" class="mobile-dist-card">
<div class="dist-card-top">
<span class="dist-card-badge">{{ siteStore.storefrontText.hero_badge }}</span>
<strong class="dist-card-name">{{ siteStore.coBrandName }}</strong>
</div>
<p class="dist-card-sub">{{ siteStore.storefrontText.card_sub }}</p>
</div>
<!-- Banner 轮播 -->
<van-swipe
ref="bannerSwipeRef"
class="banner-swipe"
:class="{
'is-poster': displayBanners.some(slide => slide.image_url && !hasBannerCopy(slide)),
}"
:autoplay="3600"
lazy-render
>
<van-swipe-item v-for="slide in displayBanners" :key="slide.image_url || slide.title">
<div
class="mobile-banner"
:class="[
`tone-${slide.tone}`,
{
'has-image': slide.image_url,
'has-copy': hasBannerCopy(slide),
'is-poster': !!slide.image_url && !hasBannerCopy(slide),
'is-previewable': !!slide.image_url,
},
]"
@click="openBannerPreview(slide)"
>
<div
v-if="slide.image_url && !hasBannerCopy(slide)"
class="poster-scroller"
@touchstart.stop="onPosterTouchStart"
@touchmove.stop="onPosterTouchMove"
@click.stop="onPosterClick(slide)"
>
<img
class="banner-image"
:src="slide.image_url"
:alt="slide.title || '首页轮播图'"
@load="centerWideImage"
/>
</div>
<img
v-else-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
v-if="
displayBanners.length > 1 &&
displayBanners.some(slide => slide.image_url && !hasBannerCopy(slide))
"
class="banner-step-row"
>
<button type="button" @click="stepBanner(-1)">上一张</button>
<button type="button" @click="stepBanner(1)">下一张</button>
</div>
<div class="biz-entry-grid" aria-label="业务入口">
<RouterLink class="biz-entry" to="/">
<strong>租号大厅</strong>
<small>高哈夫币 · 随租随玩</small>
</RouterLink>
<RouterLink v-if="!siteStore.isDistribution" class="biz-entry" to="/crash">
<strong>撞车</strong>
<small>选购商品 · 一键找客服</small>
<em class="biz-entry-badge">NEW</em>
</RouterLink>
<button
v-if="awRecycle.enabled"
type="button"
class="biz-entry aw-recycle"
@click="openAWRecycle"
>
<strong>{{ awRecycle.title }}</strong>
<small>{{ awRecycle.subtitle }}</small>
</button>
<button
v-if="cooperationFeedback.enabled"
type="button"
class="biz-entry cooperation-feedback"
@click="openCooperationFeedback"
>
<strong>{{ cooperationFeedback.title }}</strong>
<small>{{ cooperationFeedback.subtitle }}</small>
</button>
</div>
<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>
<!-- 列表卡片:现代结构化卡片(对齐PC首页体验) -->
<div class="mobile-list">
<RouterLink
v-for="item in displayListings"
:key="item.id"
class="mobile-card"
:to="`/m/listings/${item.id}`"
>
<!-- 顶部精简元信息栏 -->
<div class="mobile-card-header">
<div class="header-tags-left">
<span class="code-badge">{{ formatListingCode(item) }}</span>
<span
v-if="getServerRegion(item)"
class="server-badge"
:class="`tone-${getAccessBadgeMeta(getServerRegion(item)).tone}`"
>
<van-icon :name="getAccessBadgeMeta(getServerRegion(item)).icon" :size="11" />
<span>{{ getServerRegion(item) }}</span>
</span>
<span
v-if="getLoginMethod(item)"
class="server-badge"
:class="`tone-${getAccessBadgeMeta(getLoginMethod(item)).tone}`"
>
<van-icon :name="getAccessBadgeMeta(getLoginMethod(item)).icon" :size="11" />
<span>{{ getLoginMethod(item) }}</span>
</span>
</div>
<div class="header-tags-right">
<span v-if="formatEstimatedRentalDuration(item) !== '--'" class="aux-pill rental">
可租 {{ formatEstimatedRentalDuration(item) }}
</span>
<span v-if="getDailyLoss(item)" class="aux-pill loss">
耗 {{ getDailyLoss(item) }}/天
</span>
</div>
</div>
<!-- 中部:封面 + 核心资产与高光展台 -->
<div class="mobile-card-body">
<!-- 左侧封面 -->
<div class="mobile-card-media">
<img
v-if="item.cover_url || item.screenshot_urls?.[0]"
:src="item.cover_url || item.screenshot_urls?.[0]"
:alt="formatListingCode(item)"
loading="lazy"
decoding="async"
/>
<div v-else class="media-empty">HFB</div>
<div
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
class="media-badges"
>
<span v-if="hasAcceleratedSaleRatio(item)" class="badge sale">特惠</span>
<span v-if="hasGiftResources(item)" class="badge gift">有赠送</span>
</div>
<div v-if="getRatioValue(item) > 0" class="media-ratio">
比例 1:{{ getRatioValue(item).toFixed(1) }}
</div>
</div>
<!-- 右侧信息流 -->
<div class="mobile-card-info">
<!-- 核心资产三大微卡 (对齐PC,3*3橙底高光) -->
<div class="card-core-assets">
<div class="core-asset-card coin-card">
<span class="c-label">哈夫币</span>
<strong class="c-val orange">{{ getItemHafCoin(item) }}</strong>
</div>
<div
class="core-asset-card slot-card"
:class="{ 'top-insurance': isTopTierInsurance(getItemInsurance(item)) }"
>
<span class="c-label">保险</span>
<strong
class="c-val"
:class="isTopTierInsurance(getItemInsurance(item)) ? 'orange' : 'slate'"
>{{ getItemInsurance(item) }}</strong
>
</div>
<div v-if="getItemRatioText(item)" class="core-asset-card ratio-card">
<span class="c-label">币比</span>
<strong class="c-val amber">{{ getItemRatioText(item) }}</strong>
</div>
</div>
<!-- 高光皮肤与仓库物资微胶囊 -->
<div
v-if="getItemSkinPreview(item).length || getItemResourcePreview(item).length"
class="card-highlights"
>
<span
v-for="skin in getItemSkinPreview(item)"
:key="skin.name"
class="mobile-skin-pill"
:class="`skin-type-${skin.type}`"
>
<i class="quality-dot" />
<span>{{ skin.name }}</span>
</span>
<span
v-for="res in getItemResourcePreview(item)"
:key="res.key"
class="mobile-res-pill"
>
<span class="r-label">{{ res.label }}</span>
<strong class="r-qty">{{ res.quantity }}</strong>
<em v-if="res.mode === '赠送'" class="r-gift">送</em>
</span>
</div>
<!-- 价格与立即租用行动区 -->
<div class="card-price-action">
<div class="price-wrap">
<div class="price-main">
<small>¥</small>
<strong>{{ formatMoney(getListingDisplayPrice(item)) }}</strong>
</div>
<span class="deposit-text">
押金
{{
item.deposit_amount_cent > 0
? `¥${formatCent(item.deposit_amount_cent)}`
: '免押'
}}
</span>
</div>
<div class="rent-cta-btn">
<span>去租用</span>
<van-icon name="arrow" :size="10" />
</div>
</div>
</div>
</div>
<!-- 底部:过滤后的次要规格流 (无多余重复) -->
<div v-if="getItemSecondaryChips(item).length" class="card-specs-row">
<span
v-for="chip in getItemSecondaryChips(item).slice(0, 5)"
:key="chip.label"
class="spec-chip"
>
<span class="k">{{ chip.label }}</span>
<strong class="v">{{ chip.value }}</strong>
</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>
<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 {{ siteStore.siteName }}</span>
</footer>
</div>
<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>
<van-popup
v-model:show="awRecyclePreviewVisible"
position="center"
round
closeable
teleport="body"
class="aw-recycle-popup"
:style="{ width: 'min(92vw, 420px)', maxHeight: '86vh' }"
>
<div class="aw-recycle-preview">
<header class="aw-recycle-preview-head">
<h3>{{ awRecycle.title }}</h3>
</header>
<div class="aw-recycle-preview-body">
<AuthImage
v-if="awRecycle.image_url"
:source="awRecycle.image_url"
:alt="awRecycle.title"
fit="contain"
image-class="aw-recycle-preview-img"
/>
<van-empty v-else description="暂无展示图片" />
</div>
</div>
</van-popup>
<van-popup
v-model:show="cooperationFeedbackPreviewVisible"
position="center"
round
closeable
teleport="body"
class="aw-recycle-popup"
:style="{ width: 'min(92vw, 420px)', maxHeight: '86vh' }"
>
<div class="aw-recycle-preview">
<header class="aw-recycle-preview-head">
<h3>{{ cooperationFeedback.title }}</h3>
</header>
<div class="aw-recycle-preview-body">
<AuthImage
v-if="cooperationFeedback.image_url"
:source="cooperationFeedback.image_url"
:alt="cooperationFeedback.title"
fit="contain"
image-class="aw-recycle-preview-img"
/>
<van-empty v-else description="暂无展示图片" />
</div>
</div>
</van-popup>
<MobileBottomNav />
<Teleport to="body">
<div
v-if="bannerPreviewVisible"
class="banner-lightbox"
role="dialog"
aria-modal="true"
aria-label="轮播图预览"
>
<button class="banner-lightbox-close" type="button" @click="closeBannerPreview">
关闭
</button>
<p class="banner-lightbox-count">
{{ bannerPreviewIndex + 1 }}/{{ bannerPreviewImages.length }}
</p>
<div ref="bannerPreviewScroller" class="banner-lightbox-scroller">
<img
:src="bannerPreviewImages[bannerPreviewIndex]"
alt="轮播图预览"
@load="centerBannerPreview"
/>
</div>
<p class="banner-lightbox-hint">左右滑动查看完整海报</p>
<div v-if="bannerPreviewImages.length > 1" class="banner-lightbox-nav">
<button type="button" @click="shiftBannerPreview(-1)">上一张</button>
<button type="button" @click="shiftBannerPreview(1)">下一张</button>
</div>
</div>
</Teleport>
</main>
</template>
<style scoped src="./MobileHomeView.css"></style>