1152 lines
30 KiB
Vue
1152 lines
30 KiB
Vue
<template>
|
||
<div class="home-page">
|
||
<!-- 第一层:单行紧凑布局,tabs + 搜索/筛选图标 -->
|
||
<div v-if="categoryLayers.length > 0" class="home-category-layer1-wrap">
|
||
<div class="home-category-layer1-row">
|
||
<v-tabs
|
||
:model-value="layerActiveValues[0]"
|
||
class="home-tab-bar home-tab-bar--inline"
|
||
@update:model-value="onCategorySelect(0, $event)"
|
||
>
|
||
<v-tab v-for="item in categoryLayers[0]" :key="item.id" :value="item.id" :ripple="false">
|
||
{{ item.label }}
|
||
</v-tab>
|
||
</v-tabs>
|
||
</div>
|
||
<!-- 搜索展开时:浮层输入框 + 历史记录 -->
|
||
<transition name="home-search-overlay">
|
||
<div v-if="searchExpanded" class="home-search-overlay">
|
||
<div class="home-search-overlay-inner">
|
||
<v-text-field
|
||
ref="searchInputRef"
|
||
v-model="searchKeyword"
|
||
density="compact"
|
||
hide-details
|
||
:placeholder="t('home.searchPlaceholder')"
|
||
prepend-inner-icon="mdi-magnify"
|
||
variant="outlined"
|
||
class="home-search-overlay-field"
|
||
@blur="onSearchBlur"
|
||
@keydown.enter="onSearchSubmit"
|
||
/>
|
||
<v-btn
|
||
icon
|
||
variant="text"
|
||
size="small"
|
||
class="home-search-close-btn"
|
||
:aria-label="t('common.collapse')"
|
||
@click="collapseSearch"
|
||
>
|
||
<v-icon size="22">mdi-close</v-icon>
|
||
</v-btn>
|
||
</div>
|
||
<div v-if="searchHistoryList.length > 0" class="home-search-history">
|
||
<div class="home-search-history-header">
|
||
<span class="home-search-history-title">{{ t('home.searchHistory') }}</span>
|
||
<v-btn
|
||
variant="text"
|
||
size="x-small"
|
||
color="primary"
|
||
class="home-search-history-clear"
|
||
@click="searchHistory.clearAll"
|
||
>
|
||
{{ t('common.clear') }}
|
||
</v-btn>
|
||
</div>
|
||
<ul class="home-search-history-list">
|
||
<li
|
||
v-for="(item, idx) in searchHistoryList"
|
||
:key="`${item}-${idx}`"
|
||
class="home-search-history-item"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="home-search-history-text"
|
||
@click="selectHistoryItem(item)"
|
||
>
|
||
{{ item }}
|
||
</button>
|
||
<v-btn
|
||
icon
|
||
variant="text"
|
||
size="x-small"
|
||
class="home-search-history-delete"
|
||
:aria-label="t('common.delete')"
|
||
@click.stop="searchHistory.remove(idx)"
|
||
>
|
||
<v-icon size="20">mdi-close</v-icon>
|
||
</v-btn>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
<v-container fluid class="home-container">
|
||
<!-- 第二三层:随内容滚动,回顶部时与列表第一行一起出现 -->
|
||
<div v-if="categoryLayers.length >= 2" class="home-category-layers-23-scroll">
|
||
<div class="home-category-layer home-category-layer--icon">
|
||
<div class="home-category-icon-row">
|
||
<v-chip
|
||
v-for="item in categoryLayers[1]"
|
||
:key="item.id"
|
||
class="home-category-icon-item"
|
||
:color="layerActiveValues[1] === item.id ? 'primary' : undefined"
|
||
:variant="layerActiveValues[1] === item.id ? 'tonal' : 'outlined'"
|
||
size="small"
|
||
@click="onCategorySelect(1, item.id)"
|
||
>
|
||
<span class="home-category-icon-label">{{ item.label }}</span>
|
||
</v-chip>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="categoryLayers.length >= 3"
|
||
class="home-category-layer home-category-layer--third"
|
||
>
|
||
<v-tabs
|
||
:model-value="layerActiveValues[2]"
|
||
class="home-tab-bar home-tab-bar--compact"
|
||
@update:model-value="onCategorySelect(2, $event)"
|
||
>
|
||
<v-tab
|
||
v-for="item in categoryLayers[2]"
|
||
:key="item.id"
|
||
:value="item.id"
|
||
:ripple="false"
|
||
>
|
||
{{ item.label }}
|
||
</v-tab>
|
||
</v-tabs>
|
||
</div>
|
||
</div>
|
||
<!-- 可滚动容器作为 v-pull-to-refresh 的父元素,组件据此判断 scrollTop 仅在顶部时才响应下拉 -->
|
||
<div ref="scrollRef" class="home-list-scroll">
|
||
<v-pull-to-refresh class="pull-to-refresh" @load="onRefresh">
|
||
<div class="pull-to-refresh-inner">
|
||
<div ref="listRef" class="home-list" :style="gridListStyle">
|
||
<MarketCard
|
||
v-for="card in eventList"
|
||
:key="card.id"
|
||
:id="card.id"
|
||
:slug="card.slug"
|
||
:market-title="card.marketTitle"
|
||
:chance-value="card.chanceValue"
|
||
:market-info="card.marketInfo"
|
||
:image-url="card.imageUrl"
|
||
:category="card.category"
|
||
:expires-at="card.expiresAt"
|
||
:display-type="card.displayType"
|
||
:outcomes="card.outcomes"
|
||
:yes-label="card.yesLabel"
|
||
:no-label="card.noLabel"
|
||
:is-new="card.isNew"
|
||
:market-id="card.marketId"
|
||
:clob-token-ids="card.clobTokenIds"
|
||
:yes-price="card.yesPrice"
|
||
:no-price="card.noPrice"
|
||
@open-trade="onCardOpenTrade"
|
||
/>
|
||
<div v-if="eventList.length === 0 && !loadingMore" class="home-list-empty">
|
||
暂无数据
|
||
</div>
|
||
</div>
|
||
<div v-if="eventList.length > 0" class="load-more-footer">
|
||
<div ref="sentinelRef" class="load-more-sentinel" aria-hidden="true" />
|
||
<div v-if="loadingMore" class="load-more-indicator">
|
||
<v-progress-circular indeterminate size="24" width="2" />
|
||
<span>{{ t('common.loading') }}</span>
|
||
</div>
|
||
<div v-else-if="noMoreEvents" class="no-more-tip">没有更多了</div>
|
||
<v-btn
|
||
v-else
|
||
class="load-more-btn"
|
||
variant="outlined"
|
||
color="primary"
|
||
:disabled="loadingMore"
|
||
@click="loadMore"
|
||
>
|
||
{{ t('home.loadMore') }}
|
||
</v-btn>
|
||
</div>
|
||
</div>
|
||
</v-pull-to-refresh>
|
||
</div>
|
||
|
||
<!-- PC:对话框;手机:底部 sheet,直接显示交易表单 -->
|
||
<v-dialog
|
||
v-if="!isMobile"
|
||
v-model="tradeDialogOpen"
|
||
max-width="420"
|
||
scrollable
|
||
content-class="trade-dialog trade-dialog--bare"
|
||
transition="dialog-transition"
|
||
@click:outside="tradeDialogOpen = false"
|
||
>
|
||
<TradeComponent
|
||
:key="`trade-${tradeDialogMarket?.id}-${tradeDialogSide}`"
|
||
:market="homeTradeMarketPayload"
|
||
:initial-option="tradeDialogSide"
|
||
@order-success="onOrderSuccess"
|
||
/>
|
||
</v-dialog>
|
||
<v-bottom-sheet v-else v-model="tradeDialogOpen" content-class="trade-bottom-sheet">
|
||
<TradeComponent
|
||
:key="`trade-${tradeDialogMarket?.id}-${tradeDialogSide}`"
|
||
:market="homeTradeMarketPayload"
|
||
:initial-option="tradeDialogSide"
|
||
embedded-in-sheet
|
||
@order-success="onOrderSuccess"
|
||
/>
|
||
</v-bottom-sheet>
|
||
</v-container>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
/** 进入页面时是否自动展开搜索(供 /search 路由使用) */
|
||
initialSearchExpanded?: boolean
|
||
}>(),
|
||
{ initialSearchExpanded: false },
|
||
)
|
||
|
||
defineOptions({ name: 'Home' })
|
||
import {
|
||
ref,
|
||
onMounted,
|
||
onUnmounted,
|
||
onActivated,
|
||
onDeactivated,
|
||
nextTick,
|
||
computed,
|
||
watch,
|
||
} from 'vue'
|
||
import { useDisplay } from 'vuetify'
|
||
import MarketCard from '../components/MarketCard.vue'
|
||
import TradeComponent from '../components/TradeComponent.vue'
|
||
import {
|
||
getPmEventPublic,
|
||
mapEventItemToCard,
|
||
getEventListCache,
|
||
setEventListCache,
|
||
clearEventListCache,
|
||
type EventCardItem,
|
||
} from '../api/event'
|
||
import {
|
||
enrichWithIcons,
|
||
getPmTagMain,
|
||
MOCK_CATEGORY_TREE,
|
||
type CategoryTreeNode,
|
||
} from '../api/category'
|
||
import { USE_MOCK_CATEGORY } from '../config/mock'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useSearchHistory } from '../composables/useSearchHistory'
|
||
import { useToastStore } from '../stores/toast'
|
||
import { useLocaleStore } from '../stores/locale'
|
||
|
||
const { mobile } = useDisplay()
|
||
const { t } = useI18n()
|
||
const searchHistory = useSearchHistory()
|
||
const searchHistoryList = computed(() => searchHistory.list.value)
|
||
const isMobile = computed(() => mobile.value)
|
||
|
||
/** 分类树(顶层) */
|
||
const categoryTree = ref<CategoryTreeNode[]>([])
|
||
/** 每层选中的 id,[layer0, layer1?, layer2?] */
|
||
const layerActiveValues = ref<string[]>([])
|
||
/** 第三层搜索框是否展开 */
|
||
const searchExpanded = ref(false)
|
||
/** 搜索关键词 */
|
||
const searchKeyword = ref('')
|
||
const searchInputRef = ref<{ focus: () => void } | null>(null)
|
||
|
||
function expandSearch() {
|
||
searchExpanded.value = true
|
||
nextTick(() => {
|
||
const el = searchInputRef.value as { focus?: () => void } | null
|
||
el?.focus?.()
|
||
})
|
||
}
|
||
|
||
function collapseSearch() {
|
||
searchExpanded.value = false
|
||
searchKeyword.value = ''
|
||
if (activeSearchKeyword.value) {
|
||
activeSearchKeyword.value = ''
|
||
clearEventListCache()
|
||
eventPage.value = 1
|
||
loadEvents(1, false, '')
|
||
}
|
||
}
|
||
|
||
function onSearchBlur() {
|
||
setTimeout(() => {
|
||
if (!searchKeyword.value.trim()) searchExpanded.value = false
|
||
}, 100)
|
||
}
|
||
|
||
function onSearchSubmit() {
|
||
const k = searchKeyword.value.trim()
|
||
if (k) {
|
||
searchHistory.add(k)
|
||
doSearch(k)
|
||
}
|
||
}
|
||
|
||
function selectHistoryItem(item: string) {
|
||
searchKeyword.value = item
|
||
searchHistory.add(item)
|
||
doSearch(item)
|
||
}
|
||
|
||
function doSearch(keyword: string) {
|
||
activeSearchKeyword.value = keyword
|
||
clearEventListCache()
|
||
eventPage.value = 1
|
||
loadEvents(1, false, keyword)
|
||
}
|
||
|
||
/** 过滤:仅当 forceShow 明确为 false 时不显示,其他(null/undefined/true 等)均显示;排除 forceHide 的节点 */
|
||
function filterVisible(nodes: CategoryTreeNode[] | undefined): CategoryTreeNode[] {
|
||
if (!nodes?.length) return []
|
||
return nodes.filter((n) => n.forceShow !== false && !n.forceHide)
|
||
}
|
||
|
||
/** 第三层区块标题:取当前选中的第一层节点的 sectionTitle 或 label */
|
||
const selectedLayer0SectionTitle = computed(() => {
|
||
const root = filterVisible(categoryTree.value)
|
||
const id = layerActiveValues.value[0]
|
||
const node = id ? root.find((n) => n.id === id) : root[0]
|
||
return node?.sectionTitle ?? node?.label ?? ''
|
||
})
|
||
|
||
/** 从树中递归查找节点 */
|
||
function findNodeById(nodes: CategoryTreeNode[], id: string): CategoryTreeNode | undefined {
|
||
for (const n of nodes) {
|
||
if (n.id === id) return n
|
||
const found = n.children ? findNodeById(n.children, id) : undefined
|
||
if (found) return found
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
/** 当前选中分类的 tagIds:收集所有选中层级节点的 tagId 数组(含父级),用于事件筛选 */
|
||
const activeTagIds = computed(() => {
|
||
const activeIds = layerActiveValues.value
|
||
const tagIdSet = new Set<number>()
|
||
|
||
// 遍历每一层选中的节点,收集所有 tagIds(含父级)
|
||
let currentNodes = filterVisible(categoryTree.value)
|
||
for (let i = 0; i < activeIds.length; i++) {
|
||
const selectedId = activeIds[i]
|
||
if (!selectedId) continue
|
||
|
||
const node = currentNodes.find((n) => n.id === selectedId)
|
||
if (node?.tagIds && node.tagIds.length > 0) {
|
||
// 合并当前选中节点的 tagIds
|
||
node.tagIds.forEach((id) => tagIdSet.add(id))
|
||
}
|
||
|
||
// 进入下一层
|
||
currentNodes = filterVisible(node?.children)
|
||
}
|
||
|
||
return Array.from(tagIdSet)
|
||
})
|
||
|
||
/** 当前展示的层级数据:[[layer0], [layer1]?, [layer2]?] */
|
||
const categoryLayers = computed(() => {
|
||
const root = filterVisible(categoryTree.value)
|
||
if (root.length === 0) return []
|
||
|
||
const layers: CategoryTreeNode[][] = [root]
|
||
const active = layerActiveValues.value
|
||
|
||
let currentNodes = root
|
||
for (let i = 0; i < 2; i++) {
|
||
const selectedId = active[i]
|
||
const node = selectedId ? currentNodes.find((n) => n.id === selectedId) : currentNodes[0]
|
||
const children = filterVisible(node?.children)
|
||
if (children.length === 0) break
|
||
layers.push(children)
|
||
currentNodes = children
|
||
}
|
||
return layers
|
||
})
|
||
|
||
/** 分类选中时:若有 children 则展开下一层并默认选中第一个,并重新加载列表 */
|
||
function onCategorySelect(layerIndex: number, selectedId: string) {
|
||
const nextValues = [...layerActiveValues.value]
|
||
nextValues[layerIndex] = selectedId
|
||
nextValues.length = layerIndex + 1
|
||
|
||
const layers = categoryLayers.value
|
||
const layer = layers[layerIndex]
|
||
const node = layer?.find((n) => n.id === selectedId)
|
||
const children = filterVisible(node?.children)
|
||
|
||
const firstChild = children[0]
|
||
if (firstChild && layerIndex < 2) {
|
||
nextValues.push(firstChild.id)
|
||
}
|
||
layerActiveValues.value = nextValues
|
||
|
||
clearEventListCache()
|
||
eventPage.value = 1
|
||
loadEvents(1, false)
|
||
}
|
||
|
||
/** 初始化分类选中:默认选中第一个,若有 children 则递归展开 */
|
||
function initCategorySelection() {
|
||
const root = filterVisible(categoryTree.value)
|
||
if (root.length === 0) return
|
||
|
||
const values: string[] = []
|
||
let current = root
|
||
for (let i = 0; i < 3; i++) {
|
||
const first = current[0]
|
||
if (!first) break
|
||
values.push(first.id)
|
||
const children = filterVisible(first.children)
|
||
if (children.length === 0) break
|
||
current = children
|
||
}
|
||
layerActiveValues.value = values
|
||
}
|
||
|
||
const PAGE_SIZE = 10
|
||
|
||
/** 接口返回的列表(已映射为卡片所需结构) */
|
||
const eventList = ref<EventCardItem[]>([])
|
||
/** 当前页码(从 1 开始) */
|
||
const eventPage = ref(1)
|
||
/** 接口返回的 total */
|
||
const eventTotal = ref(0)
|
||
const eventPageSize = ref(PAGE_SIZE)
|
||
const loadingMore = ref(false)
|
||
|
||
const noMoreEvents = computed(() => {
|
||
if (eventList.value.length === 0) return false
|
||
return (
|
||
eventList.value.length >= eventTotal.value ||
|
||
eventPage.value * eventPageSize.value >= eventTotal.value
|
||
)
|
||
})
|
||
|
||
const tradeDialogOpen = ref(false)
|
||
const tradeDialogSide = ref<'yes' | 'no'>('yes')
|
||
const tradeDialogMarket = ref<{
|
||
id: string
|
||
title: string
|
||
marketId?: string
|
||
clobTokenIds?: string[]
|
||
yesLabel?: string
|
||
noLabel?: string
|
||
yesPrice?: number
|
||
noPrice?: number
|
||
} | null>(null)
|
||
const scrollRef = ref<HTMLElement | null>(null)
|
||
|
||
function onCardOpenTrade(
|
||
side: 'yes' | 'no',
|
||
market?: {
|
||
id: string
|
||
title: string
|
||
marketId?: string
|
||
yesLabel?: string
|
||
noLabel?: string
|
||
yesPrice?: number
|
||
noPrice?: number
|
||
clobTokenIds?: string[]
|
||
},
|
||
) {
|
||
tradeDialogSide.value = side
|
||
tradeDialogMarket.value = market ?? null
|
||
tradeDialogOpen.value = true
|
||
}
|
||
|
||
const toastStore = useToastStore()
|
||
const localeStore = useLocaleStore()
|
||
|
||
/** 加载分类树(接口或 mock),完成后初始化选中并触发事件列表加载 */
|
||
async function loadCategory() {
|
||
function loadEventListAfterCategoryReady() {
|
||
const cached = getEventListCache()
|
||
if (cached && cached.list.length > 0) {
|
||
eventList.value = cached.list
|
||
eventPage.value = cached.page
|
||
eventTotal.value = cached.total
|
||
eventPageSize.value = cached.pageSize
|
||
} else {
|
||
loadEvents(1, false)
|
||
}
|
||
}
|
||
|
||
if (USE_MOCK_CATEGORY) {
|
||
categoryTree.value = enrichWithIcons(MOCK_CATEGORY_TREE)
|
||
initCategorySelection()
|
||
loadEventListAfterCategoryReady()
|
||
} else {
|
||
try {
|
||
const res = await getPmTagMain()
|
||
if (res.code === 0 || res.code === 200) {
|
||
const data = res.data
|
||
categoryTree.value =
|
||
Array.isArray(data) && data.length > 0 ? data : enrichWithIcons(MOCK_CATEGORY_TREE)
|
||
} else {
|
||
categoryTree.value = enrichWithIcons(MOCK_CATEGORY_TREE)
|
||
}
|
||
} catch {
|
||
categoryTree.value = enrichWithIcons(MOCK_CATEGORY_TREE)
|
||
}
|
||
initCategorySelection()
|
||
loadEventListAfterCategoryReady()
|
||
}
|
||
}
|
||
|
||
// 监听语言切换,语言变化时重新请求分类并加载事件列表
|
||
watch(
|
||
() => localeStore.currentLocale,
|
||
() => {
|
||
clearEventListCache()
|
||
eventPage.value = 1
|
||
loadCategory()
|
||
},
|
||
)
|
||
|
||
function onOrderSuccess() {
|
||
tradeDialogOpen.value = false
|
||
toastStore.show(t('toast.orderSuccess'))
|
||
}
|
||
|
||
/** 传给 TradeComponent 的 market(Home 弹窗/底部栏),供 Split、下单等使用 */
|
||
const homeTradeMarketPayload = computed(() => {
|
||
const m = tradeDialogMarket.value
|
||
if (!m) return undefined
|
||
const marketId = m.marketId ?? m.id
|
||
const yesPrice =
|
||
m.yesPrice != null && Number.isFinite(m.yesPrice) ? Math.min(1, Math.max(0, m.yesPrice)) : 0.5
|
||
const noPrice =
|
||
m.noPrice != null && Number.isFinite(m.noPrice)
|
||
? Math.min(1, Math.max(0, m.noPrice))
|
||
: 1 - yesPrice
|
||
const outcomes =
|
||
m.yesLabel != null || m.noLabel != null ? [m.yesLabel ?? 'Yes', m.noLabel ?? 'No'] : undefined
|
||
return { marketId, yesPrice, noPrice, title: m.title, clobTokenIds: m.clobTokenIds, outcomes }
|
||
})
|
||
|
||
const sentinelRef = ref<HTMLElement | null>(null)
|
||
let observer: IntersectionObserver | null = null
|
||
let resizeObserver: ResizeObserver | null = null
|
||
|
||
const SCROLL_LOAD_THRESHOLD = 280
|
||
|
||
/** 卡片最小宽度(与 MarketCard 一致),用于动态计算列数 */
|
||
const CARD_MIN_WIDTH = 310
|
||
const GRID_GAP = 20
|
||
|
||
const listRef = ref<HTMLElement | null>(null)
|
||
const gridColumns = ref(1)
|
||
|
||
const gridListStyle = computed(() => ({
|
||
gridTemplateColumns: `repeat(${gridColumns.value}, 1fr)`,
|
||
}))
|
||
|
||
function updateGridColumns() {
|
||
const el = listRef.value
|
||
if (!el) return
|
||
const width = el.getBoundingClientRect().width
|
||
const n = Math.floor((width + GRID_GAP) / (CARD_MIN_WIDTH + GRID_GAP))
|
||
gridColumns.value = Math.max(1, n)
|
||
}
|
||
|
||
/** 当前生效的搜索关键词(用于分页加载) */
|
||
const activeSearchKeyword = ref('')
|
||
|
||
/** 请求事件列表并追加或覆盖到 eventList(公开接口,无需鉴权);成功后会更新内存缓存 */
|
||
async function loadEvents(page: number, append: boolean, keyword?: string) {
|
||
const kw = keyword !== undefined ? keyword : activeSearchKeyword.value
|
||
const tagIds = activeTagIds.value
|
||
try {
|
||
const res = await getPmEventPublic({
|
||
page,
|
||
pageSize: PAGE_SIZE,
|
||
keyword: kw || undefined,
|
||
tagIds: tagIds.length > 0 ? tagIds : undefined,
|
||
})
|
||
if (res.code !== 0 && res.code !== 200) {
|
||
throw new Error(res.msg || '请求失败')
|
||
}
|
||
const data = res.data
|
||
if (!data?.list || !Array.isArray(data.list)) {
|
||
if (!append) eventList.value = []
|
||
return
|
||
}
|
||
const mapped = data.list.map((item) => mapEventItemToCard(item))
|
||
eventTotal.value = data.total ?? 0
|
||
eventPageSize.value = data.pageSize && data.pageSize > 0 ? data.pageSize : PAGE_SIZE
|
||
eventPage.value = data.page ?? page
|
||
if (append) {
|
||
eventList.value = [...eventList.value, ...mapped]
|
||
} else {
|
||
eventList.value = mapped
|
||
}
|
||
setEventListCache({
|
||
list: eventList.value,
|
||
page: eventPage.value,
|
||
total: eventTotal.value,
|
||
pageSize: eventPageSize.value,
|
||
})
|
||
} catch (e) {
|
||
if (!append) eventList.value = []
|
||
}
|
||
}
|
||
|
||
function onRefresh({ done }: { done: () => void }) {
|
||
clearEventListCache()
|
||
eventPage.value = 1
|
||
loadEvents(1, false, activeSearchKeyword.value).finally(() => done())
|
||
}
|
||
|
||
function loadMore() {
|
||
if (loadingMore.value || noMoreEvents.value || eventList.value.length === 0) return
|
||
loadingMore.value = true
|
||
const nextPage = eventPage.value + 1
|
||
loadEvents(nextPage, true).finally(() => {
|
||
loadingMore.value = false
|
||
})
|
||
}
|
||
|
||
function getMainScrollEl(): Element | null {
|
||
return document.querySelector('[data-main-scroll]')
|
||
}
|
||
|
||
function checkScrollLoad() {
|
||
if (loadingMore.value || eventList.value.length === 0 || noMoreEvents.value) return
|
||
const el = getMainScrollEl()
|
||
if (!el) return
|
||
const { scrollTop, scrollHeight, clientHeight } = el
|
||
if (scrollHeight - scrollTop - clientHeight < SCROLL_LOAD_THRESHOLD) loadMore()
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (props.initialSearchExpanded) expandSearch()
|
||
loadCategory()
|
||
nextTick(() => {
|
||
const scrollEl = getMainScrollEl()
|
||
const sentinel = sentinelRef.value
|
||
if (sentinel && scrollEl) {
|
||
observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (!entries[0]?.isIntersecting) return
|
||
loadMore()
|
||
},
|
||
{ root: scrollEl, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 },
|
||
)
|
||
observer.observe(sentinel)
|
||
}
|
||
scrollEl?.addEventListener('scroll', checkScrollLoad, { passive: true })
|
||
const listEl = listRef.value
|
||
if (listEl) {
|
||
updateGridColumns()
|
||
resizeObserver = new ResizeObserver(updateGridColumns)
|
||
resizeObserver.observe(listEl)
|
||
}
|
||
})
|
||
})
|
||
|
||
function removeScrollListeners() {
|
||
const sentinel = sentinelRef.value
|
||
if (observer && sentinel) observer.unobserve(sentinel)
|
||
observer = null
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect()
|
||
resizeObserver = null
|
||
}
|
||
getMainScrollEl()?.removeEventListener('scroll', checkScrollLoad)
|
||
}
|
||
|
||
// keep-alive 时离开页面不会触发 onUnmounted,需在 onDeactivated 移除监听,否则详情页滚动到底会误触发 loadMore
|
||
onDeactivated(removeScrollListeners)
|
||
onUnmounted(removeScrollListeners)
|
||
|
||
// 从详情页返回时重新注册监听(仅当 observer 已被 removeScrollListeners 清空时)
|
||
onActivated(() => {
|
||
nextTick(() => {
|
||
const scrollEl = getMainScrollEl()
|
||
const sentinel = sentinelRef.value
|
||
if (sentinel && scrollEl && !observer) {
|
||
observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (!entries[0]?.isIntersecting) return
|
||
loadMore()
|
||
},
|
||
{ root: scrollEl, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 },
|
||
)
|
||
observer.observe(sentinel)
|
||
scrollEl.addEventListener('scroll', checkScrollLoad, { passive: true })
|
||
}
|
||
const listEl = listRef.value
|
||
if (listEl && !resizeObserver) {
|
||
updateGridColumns()
|
||
resizeObserver = new ResizeObserver(updateGridColumns)
|
||
resizeObserver.observe(listEl)
|
||
}
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* fluid 后无断点 max-width,用自定义 max-width 让列表在 2137px 等宽屏下能算到 6 列 */
|
||
.home-container {
|
||
min-height: 100vh;
|
||
max-width: 2560px;
|
||
margin-left: auto;
|
||
margin-right: auto;
|
||
padding: 0 8px !important;
|
||
}
|
||
|
||
.home-header {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.home-title {
|
||
font-size: 3rem;
|
||
font-weight: bold;
|
||
color: #0066cc;
|
||
margin: 0;
|
||
}
|
||
|
||
.pull-to-refresh {
|
||
width: 100%;
|
||
margin-top: 8px;
|
||
min-height: 100%;
|
||
padding: 8px;
|
||
}
|
||
|
||
.pull-to-refresh-inner {
|
||
min-height: 100%;
|
||
}
|
||
|
||
/* 不设固定高度与 overflow,列表随页面(窗口)滚动,便于 Vue Router scrollBehavior 自动恢复位置 */
|
||
.home-list-scroll {
|
||
width: 100%;
|
||
overflow-x: visible;
|
||
}
|
||
|
||
/* 列数由 JS 根据容器宽度与 CARD_MIN_WIDTH 连续计算,避免断点导致 6→4 跳变 */
|
||
.home-list {
|
||
width: 100%;
|
||
display: grid;
|
||
gap: 20px;
|
||
}
|
||
|
||
.home-list-empty {
|
||
grid-column: 1 / -1;
|
||
text-align: center;
|
||
color: #6b7280;
|
||
font-size: 14px;
|
||
padding: 48px 16px;
|
||
}
|
||
|
||
.home-list > * {
|
||
min-width: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.load-more-footer {
|
||
min-height: 56px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
}
|
||
|
||
.load-more-sentinel {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 8px;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
}
|
||
|
||
.load-more-indicator,
|
||
.no-more-tip {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 16px;
|
||
color: #666;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.load-more-btn {
|
||
text-transform: none;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.trade-dialog--bare :deep(.v-overlay__content) {
|
||
padding: 0;
|
||
overflow: visible;
|
||
}
|
||
.trade-dialog--bare :deep(.v-card) {
|
||
box-shadow: none;
|
||
}
|
||
|
||
.trade-bottom-sheet :deep(.v-overlay__content) {
|
||
padding: 0;
|
||
}
|
||
|
||
.home-subtitle {
|
||
margin-bottom: 40px;
|
||
}
|
||
|
||
.home-subtitle p {
|
||
font-size: 1.2rem;
|
||
color: #666666;
|
||
margin: 0;
|
||
}
|
||
|
||
.home-tabs {
|
||
margin-top: 40px;
|
||
}
|
||
|
||
/* 第一层:置顶、全宽;sticky 参照 app-main-scroll,top:0 贴容器顶(即 app-bar 下方) */
|
||
.home-category-layer1-wrap {
|
||
width: 100vw;
|
||
max-width: 100vw;
|
||
margin-left: calc(50% - 50vw);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
background-color: white;
|
||
/* box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); */
|
||
}
|
||
|
||
.home-category-layer1-row {
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 48px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
.home-tab-bar--inline {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.home-tab-bar--inline :deep(.v-tabs) {
|
||
min-height: 48px;
|
||
}
|
||
|
||
.home-tab-bar--inline :deep(.v-tab) {
|
||
min-height: 48px;
|
||
}
|
||
|
||
.home-category-layer1-actions {
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
gap: 0;
|
||
}
|
||
|
||
/* 搜索浮层:绝对定位,遮挡第二三层,不顶开布局 */
|
||
.home-search-overlay {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 11;
|
||
padding: 8px 12px 12px;
|
||
background-color: white;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
.home-search-overlay-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.home-search-overlay-field {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.home-search-close-btn {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.home-search-overlay-field :deep(.v-field) {
|
||
background-color: #f8fafc;
|
||
}
|
||
.home-search-overlay-field :deep(.v-field__prepend-inner .v-icon) {
|
||
font-size: 22px;
|
||
}
|
||
|
||
.home-search-overlay-enter-active,
|
||
.home-search-overlay-leave-active {
|
||
transition:
|
||
opacity 0.15s ease,
|
||
transform 0.15s ease;
|
||
}
|
||
|
||
.home-search-overlay-enter-from,
|
||
.home-search-overlay-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
.home-search-history {
|
||
margin-top: 8px;
|
||
padding-top: 8px;
|
||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
.home-search-history-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.home-search-history-title {
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
}
|
||
|
||
.home-search-history-clear {
|
||
min-width: auto;
|
||
padding: 0 4px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.home-search-history-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
max-height: 160px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.home-search-history-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
.home-search-history-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.home-search-history-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 0;
|
||
border: none;
|
||
background: none;
|
||
font-size: 14px;
|
||
color: #1e293b;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.home-search-history-text:hover {
|
||
color: rgb(var(--v-theme-primary));
|
||
}
|
||
|
||
.home-search-history-delete {
|
||
flex-shrink: 0;
|
||
min-width: 28px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.home-search-history-delete:hover {
|
||
color: #ef4444;
|
||
}
|
||
|
||
/* 第二三层:随内容滚动,全宽 */
|
||
.home-category-layers-23-scroll {
|
||
width: 100vw;
|
||
max-width: 100vw;
|
||
margin-left: calc(50% - 50vw);
|
||
margin-bottom: 0;
|
||
background-color: white;
|
||
}
|
||
|
||
.home-category-layer {
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.home-category-layer:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.home-category-layer--text :deep(.v-tabs) {
|
||
min-height: 48px;
|
||
}
|
||
|
||
.home-category-layer--icon {
|
||
padding: 6px 16px;
|
||
}
|
||
|
||
.home-category-icon-row {
|
||
display: flex;
|
||
flex-wrap: nowrap;
|
||
gap: 6px;
|
||
overflow-x: auto;
|
||
padding-bottom: 2px;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.home-category-icon-row::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
.home-category-icon-item {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 未选中:outlined 描边改为灰色(不改文字颜色) */
|
||
.home-category-icon-item.v-chip--variant-outlined {
|
||
border-color: rgba(0, 0, 0, 0.28) !important;
|
||
}
|
||
|
||
.home-category-icon {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.home-category-icon-placeholder {
|
||
display: block;
|
||
width: 24px;
|
||
height: 24px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.home-category-icon-label {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.home-category-layer--third {
|
||
/* 让第三层高度更紧凑:接近 2 倍字体高度 */
|
||
padding: 2px 16px;
|
||
}
|
||
|
||
.home-category-action-btn {
|
||
min-width: 36px;
|
||
}
|
||
|
||
.home-tab-bar {
|
||
position: relative;
|
||
top: auto;
|
||
left: auto;
|
||
transform: none;
|
||
width: 100%;
|
||
background-color: transparent;
|
||
margin-bottom: 0;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.home-tab-bar :deep(.v-tab__slider),
|
||
.home-tab-bar :deep(.v-tabs-slider) {
|
||
display: none !important;
|
||
}
|
||
|
||
/* 覆写 Vuetify density 默认的 tabs 高度变量(在 v-tabs 根元素上) */
|
||
:deep(.v-tabs--density-default.home-tab-bar) {
|
||
--v-tabs-height: unset !important;
|
||
}
|
||
|
||
.home-tab-bar :deep(.v-tab--selected) {
|
||
font-weight: 700;
|
||
}
|
||
|
||
.home-tab-bar :deep(.v-ripple__container) {
|
||
display: none !important;
|
||
}
|
||
|
||
/* 去掉鼠标悬停(hover)视觉效果:禁用按钮 overlay/伪元素 */
|
||
.home-tab-bar :deep(.v-btn__overlay),
|
||
.home-tab-bar :deep(.v-btn__underlay) {
|
||
display: none !important;
|
||
}
|
||
.home-tab-bar :deep(.v-tab:hover) {
|
||
background-color: transparent !important;
|
||
}
|
||
|
||
.home-tab-bar :deep(.v-tab) {
|
||
min-width: unset !important;
|
||
}
|
||
|
||
.home-tab-bar--compact :deep(.v-tab) {
|
||
min-height: 28px;
|
||
height: 28px;
|
||
line-height: 28px;
|
||
font-size: 14px;
|
||
padding-left: 10px;
|
||
padding-right: 10px;
|
||
}
|
||
|
||
/* 第三层 tabs:收敛容器高度,避免底部多余留白 */
|
||
.home-tab-bar--compact :deep(.v-tabs) {
|
||
min-height: 28px !important;
|
||
height: 28px !important;
|
||
}
|
||
.home-tab-bar--compact :deep(.v-slide-group__container) {
|
||
min-height: 28px !important;
|
||
height: 28px !important;
|
||
}
|
||
.home-tab-bar--compact :deep(.v-slide-group__content) {
|
||
align-items: center;
|
||
}
|
||
|
||
.home-card {
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
padding: 24px;
|
||
text-align: center;
|
||
}
|
||
|
||
.home-card-text {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.home-btn {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.market-card-container {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: 20px;
|
||
padding: 20px 0;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
}
|
||
|
||
/* Mobile view */
|
||
@media (max-width: 600px) {
|
||
.market-card-container {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* 页面布局:flex 列 */
|
||
.home-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 100vh;
|
||
}
|
||
</style>
|