690 lines
17 KiB
Vue
690 lines
17 KiB
Vue
<template>
|
||
<div class="home-page">
|
||
<v-container fluid class="home-container">
|
||
<v-row justify="center" align="center" class="home-tabs">
|
||
<v-tabs v-model="activeTab" class="home-tab-bar">
|
||
<v-tab value="overview">Market Overview</v-tab>
|
||
<v-tab value="trending">Trending</v-tab>
|
||
<v-tab value="portfolio">Portfolio</v-tab>
|
||
</v-tabs>
|
||
</v-row>
|
||
<!-- 可滚动容器作为 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"
|
||
: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"
|
||
@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>加载中...</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"
|
||
>
|
||
加载更多
|
||
</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}`"
|
||
:initial-option="tradeDialogSide"
|
||
/>
|
||
</v-dialog>
|
||
<v-bottom-sheet
|
||
v-else
|
||
v-model="tradeDialogOpen"
|
||
content-class="trade-bottom-sheet"
|
||
>
|
||
<TradeComponent
|
||
:key="`trade-${tradeDialogMarket?.id}-${tradeDialogSide}`"
|
||
:initial-option="tradeDialogSide"
|
||
embedded-in-sheet
|
||
/>
|
||
</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 & Privacy Policy</a>.
|
||
</p>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
defineOptions({ name: 'Home' })
|
||
import { ref, onMounted, onUnmounted, 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'
|
||
|
||
const { mobile } = useDisplay()
|
||
const isMobile = computed(() => mobile.value)
|
||
|
||
const activeTab = ref('overview')
|
||
|
||
const PAGE_SIZE = 10
|
||
|
||
/** 接口返回的列表(已映射为卡片所需结构) */
|
||
const eventList = ref<EventCardItem[]>([])
|
||
/** 当前页码(0-based) */
|
||
const eventPage = ref(0)
|
||
/** 接口返回的 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 + 1) * 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 } | null>(null)
|
||
const scrollRef = ref<HTMLElement | null>(null)
|
||
|
||
function onCardOpenTrade(side: 'yes' | 'no', market?: { id: string; title: string }) {
|
||
tradeDialogSide.value = side
|
||
tradeDialogMarket.value = market ?? null
|
||
tradeDialogOpen.value = true
|
||
}
|
||
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)
|
||
}
|
||
|
||
/** 请求事件列表并追加或覆盖到 eventList(公开接口,无需鉴权);成功后会更新内存缓存 */
|
||
async function loadEvents(page: number, append: boolean) {
|
||
try {
|
||
const res = await getPmEventPublic(
|
||
{ page, pageSize: PAGE_SIZE }
|
||
)
|
||
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 = 0
|
||
loadEvents(0, false).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(() => {
|
||
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(0, false)
|
||
}
|
||
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)
|
||
}
|
||
})
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
const sentinel = sentinelRef.value
|
||
if (observer && sentinel) observer.unobserve(sentinel)
|
||
observer = null
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect()
|
||
resizeObserver = null
|
||
}
|
||
window.removeEventListener('scroll', checkScrollLoad)
|
||
})
|
||
</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-tab-bar {
|
||
position: fixed;
|
||
top: 64px; /* Adjust based on app bar height */
|
||
left: 0;
|
||
transform: none;
|
||
width: 100%;
|
||
z-index: 10;
|
||
background-color: white;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.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>
|