2026-02-26 16:10:20 +08:00

1342 lines
34 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">
{{ item.label }}
</v-tab>
</v-tabs>
<div class="home-category-layer1-actions">
<v-btn
icon
variant="text"
size="small"
class="home-category-action-btn"
:aria-label="t('common.search')"
@click="expandSearch"
>
<v-icon size="20">mdi-magnify</v-icon>
</v-btn>
<v-btn
icon
variant="text"
size="small"
class="home-category-action-btn"
:aria-label="t('common.filter')"
>
<v-icon size="20">mdi-filter-outline</v-icon>
</v-btn>
</div>
</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="18">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="16">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">
<button
v-for="item in categoryLayers[1]"
:key="item.id"
type="button"
class="home-category-icon-item"
:class="{ 'home-category-icon-item--active': layerActiveValues[1] === item.id }"
@click="onCategorySelect(1, item.id)"
>
<v-icon
size="24"
class="home-category-icon"
:color="resolveCategoryIconColor(item) ?? 'grey'"
>
{{ item.icon || DEFAULT_CATEGORY_ICON }}
</v-icon>
<span class="home-category-icon-label">{{ item.label }}</span>
</button>
</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">
{{ 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"
@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>
<footer class="home-footer">
<div class="footer-inner">
<div class="footer-top">
<div class="footer-brand">
<div class="footer-logo">
<span class="logo-mark">M</span>
<span class="logo-text">Polymarket</span>
</div>
<p class="footer-slogan">The World's Largest Prediction Market™</p>
</div>
<div class="footer-links-row">
<div class="footer-col">
<h4 class="footer-col-title">Support & Social</h4>
<ul class="footer-link-list">
<li><a href="#">Contact us</a></li>
<li><a href="#">Learn</a></li>
<li><a href="#">X (Twitter)</a></li>
<li><a href="#">Instagram</a></li>
<li><a href="#">Discord</a></li>
<li><a href="#">TikTok</a></li>
<li><a href="#">News</a></li>
</ul>
</div>
<div class="footer-col">
<h4 class="footer-col-title">Polymarket</h4>
<ul class="footer-link-list">
<li><a href="#">Accuracy</a></li>
<li><a href="#">Activity</a></li>
<li><a href="#">Leaderboard</a></li>
<li><a href="#">Rewards</a></li>
<li><a href="#">Press</a></li>
<li><a href="#">Careers</a></li>
<li><a href="#">APIs</a></li>
</ul>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="footer-legal-links">
<a href="#">Adventure One QSS Inc. © 2026</a>
<span class="sep">/</span>
<a href="#">Privacy</a>
<span class="sep">/</span>
<a href="#">Terms of Use</a>
<span class="sep">/</span>
<a href="#">Help Center</a>
<span class="sep">/</span>
<a href="#">Docs</a>
</div>
<div class="footer-lang-social">
<v-select
v-model="footerLang"
:items="['English']"
density="compact"
hide-details
variant="outlined"
class="footer-lang-select"
/>
<div class="footer-social-icons">
<v-icon size="20">mdi-email-outline</v-icon>
<v-icon size="20">mdi-twitter</v-icon>
<v-icon size="20">mdi-instagram</v-icon>
<v-icon size="20">mdi-discord</v-icon>
<v-icon size="20">mdi-music</v-icon>
</div>
</div>
</div>
<p class="footer-disclaimer">
Polymarket operates globally through separate legal entities. Polymarket US is operated by
QCX LLC d/b/a Polymarket US, a CFTC-regulated Designated Contract Market. This
international platform is not regulated by the CFTC and operates independently. Trading
involves substantial risk of loss. See our
<a href="#">Terms of Service &amp; Privacy Policy</a>.
</p>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'Home' })
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, computed } 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 {
DEFAULT_CATEGORY_ICON,
enrichWithIcons,
getPmTagMain,
MOCK_CATEGORY_TREE,
resolveCategoryIconColor,
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'
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
}
/** 当前选中分类的 tag 筛选(取最后选中的层级,用于 API tagId/tagSlug */
const activeTagFilter = computed(() => {
const ids = layerActiveValues.value
if (ids.length === 0) return { tagId: undefined as number | undefined, tagSlug: undefined as string | undefined }
const lastId = ids[ids.length - 1]
const root = filterVisible(categoryTree.value)
const node = lastId ? findNodeById(root, lastId) : undefined
if (!node) return { tagId: undefined, tagSlug: undefined }
const tagId = /^\d+$/.test(node.id) ? parseInt(node.id, 10) : undefined
return { tagId, tagSlug: node.slug || undefined }
})
/** 当前展示的层级数据:[[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 footerLang = ref('English')
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
} | null>(null)
const scrollRef = ref<HTMLElement | null>(null)
function onCardOpenTrade(
side: 'yes' | 'no',
market?: { id: string; title: string; marketId?: string; yesLabel?: string; noLabel?: string },
) {
tradeDialogSide.value = side
tradeDialogMarket.value = market ?? null
tradeDialogOpen.value = true
}
const toastStore = useToastStore()
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 chance = 50
const yesPrice = Math.min(1, Math.max(0, chance / 100))
const 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 { tagId, tagSlug } = activeTagFilter.value
try {
const res = await getPmEventPublic({
page,
pageSize: PAGE_SIZE,
keyword: kw || undefined,
tagId,
tagSlug,
})
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 = []
console.warn('getPmEventList failed', e)
}
}
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 checkScrollLoad() {
if (loadingMore.value || eventList.value.length === 0 || noMoreEvents.value) return
const { scrollY, innerHeight } = window
const scrollHeight = document.documentElement.scrollHeight
if (scrollHeight - scrollY - innerHeight < SCROLL_LOAD_THRESHOLD) loadMore()
}
onMounted(() => {
/** 分类树就绪后加载列表(确保 activeTagFilter 已计算,与下拉刷新参数一致) */
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 {
getPmTagMain()
.then((res) => {
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)
}
initCategorySelection()
loadEventListAfterCategoryReady()
})
.catch(() => {
categoryTree.value = enrichWithIcons(MOCK_CATEGORY_TREE)
initCategorySelection()
loadEventListAfterCategoryReady()
})
}
nextTick(() => {
const sentinel = sentinelRef.value
if (sentinel) {
observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting) return
loadMore()
},
{ root: null, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 },
)
observer.observe(sentinel)
}
window.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
}
window.removeEventListener('scroll', checkScrollLoad)
}
// keep-alive 时离开页面不会触发 onUnmounted需在 onDeactivated 移除监听,否则详情页滚动到底会误触发 loadMore
onDeactivated(removeScrollListeners)
onUnmounted(removeScrollListeners)
// 从详情页返回时重新注册监听(仅当 observer 已被 removeScrollListeners 清空时)
onActivated(() => {
nextTick(() => {
const sentinel = sentinelRef.value
if (sentinel && !observer) {
observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting) return
loadMore()
},
{ root: null, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 },
)
observer.observe(sentinel)
window.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;
}
.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%;
}
.pull-to-refresh-inner {
min-height: 100%;
}
/* 不设固定高度与 overflow列表随页面窗口滚动便于 Vue Router scrollBehavior 自动恢复位置 */
.home-list-scroll {
width: 100%;
overflow-x: hidden;
}
/* 列数由 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;
}
/* 第一层:置顶、全宽 */
.home-category-layer1-wrap {
width: 100vw;
max-width: 100vw;
margin-left: calc(50% - 50vw);
position: sticky;
top: 64px;
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;
}
.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-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: 12px 16px;
}
.home-category-icon-row {
display: flex;
flex-wrap: nowrap;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
scrollbar-width: none;
}
.home-category-icon-row::-webkit-scrollbar {
display: none;
}
.home-category-icon-item {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 56px;
padding: 8px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 12px;
cursor: pointer;
transition:
background-color 0.2s,
color 0.2s;
}
.home-category-icon-item:hover {
background-color: rgba(0, 0, 0, 0.04);
color: #334155;
}
.home-category-icon-item--active {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
.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 {
padding: 12px 16px 0;
}
.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--compact :deep(.v-tab) {
min-height: 40px;
font-size: 14px;
}
.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;
}
}
/* Footer */
.home-page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.home-footer {
width: 100%;
background-color: #374151;
color: rgba(255, 255, 255, 0.85);
margin-top: auto;
padding: 48px 24px 32px;
}
.footer-inner {
max-width: 1200px;
margin: 0 auto;
}
.footer-top {
display: flex;
flex-wrap: wrap;
gap: 48px;
padding-bottom: 32px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.footer-brand {
flex-shrink: 0;
}
.footer-logo {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.logo-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-weight: 700;
font-size: 18px;
border-radius: 6px;
}
.logo-text {
font-size: 1.25rem;
font-weight: 600;
color: #fff;
}
.footer-slogan {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
.footer-links-row {
display: flex;
gap: 64px;
flex-wrap: wrap;
}
.footer-col-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.9);
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.footer-link-list {
list-style: none;
margin: 0;
padding: 0;
}
.footer-link-list li {
margin-bottom: 6px;
}
.footer-link-list a {
color: rgba(255, 255, 255, 0.75);
text-decoration: none;
font-size: 0.875rem;
}
.footer-link-list a:hover {
color: #fff;
}
.footer-bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 24px;
padding-bottom: 24px;
}
.footer-legal-links {
font-size: 0.8125rem;
}
.footer-legal-links a {
color: rgba(255, 255, 255, 0.75);
text-decoration: none;
}
.footer-legal-links a:hover {
color: #fff;
}
.footer-legal-links .sep {
margin: 0 8px;
color: rgba(255, 255, 255, 0.4);
}
.footer-lang-social {
display: flex;
align-items: center;
gap: 16px;
}
.footer-lang-select {
max-width: 120px;
}
.footer-lang-select :deep(.v-field) {
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 0.875rem;
}
.footer-social-icons {
display: flex;
gap: 12px;
color: rgba(255, 255, 255, 0.8);
}
.footer-social-icons .v-icon {
cursor: pointer;
}
.footer-social-icons .v-icon:hover {
color: #fff;
}
.footer-disclaimer {
font-size: 0.75rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.6);
margin: 0;
max-width: 720px;
}
.footer-disclaimer a {
color: rgba(255, 255, 255, 0.85);
text-decoration: underline;
}
.footer-disclaimer a:hover {
color: #fff;
}
@media (max-width: 600px) {
.home-footer {
padding: 32px 16px 24px;
}
.footer-top {
flex-direction: column;
gap: 32px;
padding-bottom: 24px;
}
.footer-links-row {
gap: 32px;
}
.footer-bottom {
flex-direction: column;
align-items: flex-start;
}
}
</style>