2026-02-10 17:29:54 +08:00

690 lines
17 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">
<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 &amp; 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>