- PC端交互:HomeView 排序按钮支持点击切换升降序翻转(比例、哈夫币、AWM数量、合并价格为单个价格项),并带有高低方向箭头(▲/▼)高亮指示 - 移动端交互:MobileHomeView 菜单提供清晰的双向文案(如「比例从高到低 / 比例从低到高」),且点击当前选中项时支持自动翻转方向,顶部工具栏标题紧凑对齐 - 后端排序:public_filter 与 public_query 完整支持 ratioDesc/ratioAsc、priceAsc/priceDesc、coinDesc/coinAsc、awmDesc/awmAsc - 验证方式:新增并运行 TestSortPublicListingsByRatio 包含升序/降序多断言,全量运行 ./scripts/check.sh 门禁全绿通过
1281 lines
42 KiB
Vue
1281 lines
42 KiB
Vue
<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 { defaultRangePresets } from '@/features/listings/composables/useFilterOptions'
|
||
import { mobileFiltersToHomeFilters } from '@/features/listings/composables/useHomeFilters'
|
||
import { buildListingQueryFromFilters } from '@/features/listings/composables/useListingQuery'
|
||
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 bannerPreviewVisible = ref(false)
|
||
const bannerPreviewIndex = ref(0)
|
||
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
|
||
|
||
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 = [
|
||
{ 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 = defaultRangePresets
|
||
|
||
const activeSortLabel = computed(
|
||
() => sortOptions.find(option => option.key === activeSort.value)?.shortLabel || '综合推荐'
|
||
)
|
||
|
||
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.length > 0
|
||
? 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: 'IP地区', 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: 'dailyLoss',
|
||
title: '日消耗',
|
||
type: 'range',
|
||
unit: 'M/天',
|
||
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
|
||
}
|
||
|
||
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, open => {
|
||
document.body.style.overflow = open ? 'hidden' : ''
|
||
})
|
||
|
||
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) {
|
||
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
|
||
}
|
||
|
||
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 homeFilters = mobileFiltersToHomeFilters(
|
||
selectedFilters.value,
|
||
rangeFilters.value,
|
||
searchValue.value,
|
||
publishOptions.value.skin_groups.map(group => group.key)
|
||
)
|
||
return buildListingQueryFromFilters(homeFilters, {
|
||
page,
|
||
pageSize: mobilePageSize,
|
||
sort: activeSort.value,
|
||
zone: activeZone.value,
|
||
})
|
||
}
|
||
|
||
function filterQuerySignature() {
|
||
return JSON.stringify({
|
||
sort: activeSort.value,
|
||
zone: activeZone.value,
|
||
selected: selectedFilters.value,
|
||
ranges: rangeFilters.value,
|
||
})
|
||
}
|
||
|
||
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 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
|
||
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)"
|
||
>
|
||
<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="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 class="banner-lightbox-frame">
|
||
<img :src="bannerPreviewImages[bannerPreviewIndex]" alt="轮播图预览" />
|
||
</div>
|
||
<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>
|