2026-03-22 11:23:48 +08:00

1173 lines
31 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.

<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="eventListLoading" class="home-list-empty home-list-loading">
<v-progress-circular indeterminate size="40" width="2" />
<span>{{ t('common.loading') }}</span>
</div>
<div v-else-if="eventList.length === 0 && !loadingMore" class="home-list-empty">
{{ t('common.noData') }}
</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">{{ t('home.noMore') }}</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 eventListLoading = 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() {
const cached = getEventListCache()
const hasCachedEvents = cached && cached.list.length > 0
if (!hasCachedEvents) eventListLoading.value = true
function loadEventListAfterCategoryReady() {
if (hasCachedEvents) {
eventList.value = cached!.list
eventPage.value = cached!.page
eventTotal.value = cached!.total
eventPageSize.value = cached!.pageSize
eventListLoading.value = false
} 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 的 marketHome 弹窗/底部栏),供 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
if (!append) eventListLoading.value = true
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 = []
} finally {
if (!append) eventListLoading.value = false
}
}
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-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.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-scrolltop: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>