新增:多Market卡片UI

This commit is contained in:
ivan 2026-02-10 17:29:54 +08:00
parent f0c1be71cb
commit a572fdfa99
6 changed files with 615 additions and 116 deletions

View File

@ -56,7 +56,11 @@ const currentRoute = computed(() => {
</template>
</v-app-bar>
<v-main>
<router-view />
<router-view v-slot="{ Component }">
<keep-alive :include="['Home']">
<component :is="Component" />
</keep-alive>
</router-view>
</v-main>
</v-app>
</template>

View File

@ -49,11 +49,18 @@ export interface PmEventListItem {
[key: string]: unknown
}
/** 对应 definitions polymarket.PmMarket 常用字段outcomePrices 首项为 Yes 价格 */
/**
* definitions polymarket.PmMarket
* - outcomes: 选项展示文案 ["Yes", "No"] ["Up", "Down"] outcomePrices
* - outcomePrices: 各选项价格 Yes/Up
*/
export interface PmEventMarketItem {
ID?: number
question?: string
slug?: string
/** 选项展示文案,与 outcomePrices 顺序一致 */
outcomes?: string[]
/** 各选项价格outcomes[0] 对应 outcomePrices[0] */
outcomePrices?: string[] | number[]
endDate?: string
volume?: number
@ -137,7 +144,23 @@ export async function findPmEvent(
return get<PmEventDetailResponse>('/PmEvent/findPmEvent', { ID: id }, config)
}
/** 首页卡片项(与 mapEventItemToCard 返回结构一致,用于缓存) */
/** 多选项卡片中单个选项(用于左右滑动切换) */
export interface EventCardOutcome {
title: string
/** 第一选项概率(来自 outcomePrices[0] */
chanceValue: number
/** 第一选项按钮文案(来自 outcomes[0],如 Yes / Up */
yesLabel?: string
/** 第二选项按钮文案(来自 outcomes[1],如 No / Down */
noLabel?: string
/** 可选,用于交易时区分 market */
marketId?: string
}
/**
* mapEventItemToCard
* displayTypesingle = Yes/Nomulti =
*/
export interface EventCardItem {
id: string
marketTitle: string
@ -146,6 +169,15 @@ export interface EventCardItem {
imageUrl: string
category: string
expiresAt: string
/** 展示类型:单一二元 或 多选项滑动 */
displayType: 'single' | 'multi'
/** 多选项时每个选项的标题与概率,按顺序滑动展示 */
outcomes?: EventCardOutcome[]
/** 单一类型时可选按钮文案,如 "Up"/"Down" */
yesLabel?: string
noLabel?: string
/** 是否显示 NEW 角标 */
isNew?: boolean
}
/** 内存缓存:列表数据,切换页面时复用,下拉刷新时清空 */
@ -173,23 +205,31 @@ export function clearEventListCache(): void {
eventListCache = null
}
function marketChance(market: PmEventMarketItem): number {
const raw = market?.outcomePrices?.[0]
if (raw == null) return 17
const yesPrice = parseFloat(String(raw))
if (!Number.isFinite(yesPrice)) return 17
return Math.min(100, Math.max(0, Math.round(yesPrice * 100)))
}
/**
* list MarketCard
* IDid, titlemarketTitle, imageimageUrl, volumemarketInfo,
* outcomePrices[0]chanceValue, series[0].titlecategory, endDateexpiresAt
* - market marketdisplayType single
* - marketsdisplayType multioutcomes +
*/
export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
const id = String(item.ID ?? '')
const marketTitle = item.title ?? ''
const imageUrl = item.image ?? item.icon ?? ''
const markets = item.markets ?? []
const multi = markets.length > 1
let chanceValue = 17
const market = item.markets?.[0]
if (market?.outcomePrices?.[0] != null) {
const yesPrice = parseFloat(String(market.outcomePrices[0]))
if (Number.isFinite(yesPrice)) {
chanceValue = Math.min(100, Math.max(0, Math.round(yesPrice * 100)))
}
const firstMarket = markets[0]
if (firstMarket?.outcomePrices?.[0] != null) {
chanceValue = marketChance(firstMarket)
}
let marketInfo = '$0 Vol.'
@ -216,6 +256,16 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
const category = item.series?.[0]?.title ?? item.tags?.[0]?.label ?? ''
const outcomes: EventCardOutcome[] | undefined = multi
? markets.map((m) => ({
title: m.question ?? '',
chanceValue: marketChance(m),
yesLabel: m.outcomes?.[0] ?? 'Yes',
noLabel: m.outcomes?.[1] ?? 'No',
marketId: m.ID != null ? String(m.ID) : undefined,
}))
: undefined
return {
id,
marketTitle,
@ -224,5 +274,10 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
imageUrl,
category,
expiresAt,
displayType: multi ? 'multi' : 'single',
outcomes,
yesLabel: firstMarket?.outcomes?.[0] ?? 'Yes',
noLabel: firstMarket?.outcomes?.[1] ?? 'No',
isNew: item.new === true,
}
}

115
src/api/mockEventList.ts Normal file
View File

@ -0,0 +1,115 @@
import type { PmEventListItem } from './event'
/**
*
* - Up/DownYes/No
* - markets
*/
export const MOCK_EVENT_LIST: PmEventListItem[] = [
// 1. 单一 market按钮文案为 Up/Down测试动态 outcomes
{
ID: 9001,
title: 'S&P 500 (SPX) Opens Up or Down on February 10?',
slug: 'spx-up-down-feb10',
ticker: 'SPX',
image: '',
icon: '',
volume: 11000,
endDate: '2026-02-10T21:00:00.000Z',
new: true,
markets: [
{
ID: 90011,
question: 'S&P 500 (SPX) Opens Up or Down on February 10?',
outcomes: ['Up', 'Down'],
outcomePrices: [0.48, 0.52],
},
],
series: [{ ID: 1, title: 'Economy', ticker: 'ECON' }],
tags: [{ label: 'Markets', slug: 'markets' }],
},
// 2. 多个 markets测试左右滑动轮播NFL Champion 多支队伍)
{
ID: 9002,
title: 'NFL Champion 2027',
slug: 'nfl-champion-2027',
ticker: 'NFL27',
image: '',
icon: '',
volume: 196000,
endDate: '2027-02-15T00:00:00.000Z',
new: true,
markets: [
{
ID: 90021,
question: 'Seattle Seahawks',
outcomes: ['Yes', 'No'],
outcomePrices: [0.12, 0.88],
},
{
ID: 90022,
question: 'Buffalo Bills',
outcomes: ['Yes', 'No'],
outcomePrices: [0.08, 0.92],
},
{
ID: 90023,
question: 'Kansas City Chiefs',
outcomes: ['Yes', 'No'],
outcomePrices: [0.18, 0.82],
},
],
series: [{ ID: 2, title: 'Sports', ticker: 'SPORT' }],
tags: [{ label: 'NFL', slug: 'nfl' }],
},
// 3. 单一 marketYes/No带 + NEW
{
ID: 9003,
title: 'Will Trump pardon Ghislaine Maxwell by end of 2026?',
slug: 'trump-pardon-maxwell-2026',
ticker: 'POL',
image: '',
icon: '',
volume: 361000,
endDate: '2026-12-31T23:59:59.000Z',
new: false,
markets: [
{
ID: 90031,
question: 'Will Trump pardon Ghislaine Maxwell by end of 2026?',
outcomes: ['Yes', 'No'],
outcomePrices: [0.09, 0.91],
},
],
series: [{ ID: 3, title: 'Politics', ticker: 'POL' }],
tags: [{ label: 'Politics', slug: 'politics' }],
},
// 4. 两个 markets不同 outcomes 文案(混合 Yes/No
{
ID: 9004,
title: 'Which team wins the 2026 World Cup?',
slug: 'world-cup-2026-winner',
ticker: 'FIFA',
image: '',
icon: '',
volume: 52000,
endDate: '2026-07-19T00:00:00.000Z',
new: true,
markets: [
{
ID: 90041,
question: 'Brazil',
outcomes: ['Yes', 'No'],
outcomePrices: [0.22, 0.78],
},
{
ID: 90042,
question: 'Germany',
outcomes: ['Yes', 'No'],
outcomePrices: [0.15, 0.85],
},
],
series: [{ ID: 4, title: 'Sports', ticker: 'SPORT' }],
tags: [{ label: 'World Cup', slug: 'world-cup' }],
},
]

View File

@ -1,9 +1,8 @@
<template>
<v-card class="market-card" elevation="0" :rounded="'lg'" @click="navigateToDetail">
<div class="market-card-content">
<!-- Top Section -->
<!-- Top Section头像 + 标题 + 半圆概率 / 多选项时仅标题 -->
<div class="top-section">
<!-- Market Image and Title -->
<div class="image-title-container">
<v-avatar class="market-image" :size="40" color="#f0f0f0" rounded="sm">
<v-img v-if="props.imageUrl" :src="props.imageUrl" cover />
@ -12,12 +11,10 @@
{{ marketTitle }}
</v-card-title>
</div>
<!-- Chance Container半圆进度条 -->
<div class="chance-container">
<!-- 单一类型右侧半圆进度条 -->
<div v-if="!isMulti" class="chance-container">
<div class="semi-progress-wrap">
<svg class="semi-progress-svg" viewBox="0 0 60 36" preserveAspectRatio="xMidYMin meet">
<!-- 半圆弧开口向下M 左上 A 弧至 右上弧朝下 -->
<path
class="semi-progress-track"
d="M 4 30 A 26 26 0 0 1 56 30"
@ -45,31 +42,117 @@
</div>
</div>
<!-- Options Section点击 Yes/No 弹出交易框阻止冒泡不触发卡片跳转 -->
<div class="options-section">
<v-btn
class="option-yes"
:color="'#e6f9e6'"
:rounded="'sm'"
:text="true"
@click.stop="openTrade('yes')"
<!-- 单一类型中间 Yes/No Up/Down -->
<template v-if="!isMulti">
<div class="options-section">
<v-btn
class="option-yes"
:color="'#e6f9e6'"
:rounded="'sm'"
:text="true"
elevation="0"
@click.stop="openTradeSingle('yes')"
>
<span class="option-text-yes">{{ yesLabel }}</span>
</v-btn>
<v-btn
class="option-no"
:color="'#ffe6e6'"
:rounded="'sm'"
:text="true"
elevation="0"
@click.stop="openTradeSingle('no')"
>
<span class="option-text-no">{{ noLabel }}</span>
</v-btn>
</div>
</template>
<!-- 多选项类型左右滑动轮播每页一项 = 左侧 title+% 右侧 Yes/No -->
<div v-else class="multi-section">
<v-carousel
v-model="currentSlide"
class="outcome-carousel"
:continuous="true"
:show-arrows="false"
hide-delimiters
height="35"
>
<span class="option-text-yes">Yes</span>
</v-btn>
<v-btn
class="option-no"
:color="'#ffe6e6'"
:rounded="'sm'"
:text="true"
@click.stop="openTrade('no')"
>
<span class="option-text-no">No</span>
</v-btn>
<v-carousel-item
v-for="(outcome, idx) in props.outcomes"
:key="idx"
class="outcome-slide"
>
<div class="outcome-slide-inner">
<div class="outcome-row">
<div class="outcome-item">
<span class="outcome-title">{{ outcome.title }}</span>
<span class="outcome-chance-value">{{ outcome.chanceValue }}%</span>
</div>
<div class="outcome-buttons">
<v-btn
class="option-yes option-yes-no-compact"
:color="'#e6f9e6'"
:rounded="'sm'"
:text="true"
elevation="0"
@click.stop="openTradeMulti('yes', outcome)"
>
<span class="option-text-yes">{{ outcome.yesLabel ?? 'Yes' }}</span>
</v-btn>
<v-btn
class="option-no option-yes-no-compact"
:color="'#ffe6e6'"
:rounded="'sm'"
:text="true"
elevation="0"
@click.stop="openTradeMulti('no', outcome)"
>
<span class="option-text-no">{{ outcome.noLabel ?? 'No' }}</span>
</v-btn>
</div>
</div>
</div>
</v-carousel-item>
</v-carousel>
<div class="carousel-controls" @mousedown.stop @touchstart.stop>
<button
type="button"
class="carousel-arrow carousel-arrow--left"
:disabled="outcomeCount <= 1"
aria-label="上一项"
@click.stop="prevSlide"
>
<v-icon size="18">mdi-chevron-left</v-icon>
</button>
<div class="carousel-dots">
<button
v-for="(_, idx) in (props.outcomes ?? [])"
:key="idx"
type="button"
:class="['carousel-dot', { active: currentSlide === idx }]"
:aria-label="`选项 ${idx + 1}`"
@click.stop="currentSlide = idx"
/>
</div>
<button
type="button"
class="carousel-arrow carousel-arrow--right"
:disabled="outcomeCount <= 1"
aria-label="下一项"
@click.stop="nextSlide"
>
<v-icon size="18">mdi-chevron-right</v-icon>
</button>
</div>
</div>
<!-- Bottom Section -->
<div class="bottom-section">
<span class="market-info">{{ marketInfo }}</span>
<span class="market-info">
<span v-if="props.isNew" class="new-badge">+ NEW</span>
{{ marketInfo }}
</span>
<div class="icons-container">
<v-icon class="gift-icon" size="16">mdi-gift</v-icon>
<v-icon class="bookmark-icon" size="16">mdi-bookmark</v-icon>
@ -80,47 +163,69 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { EventCardOutcome } from '../api/event'
const router = useRouter()
const emit = defineEmits<{
openTrade: [side: 'yes' | 'no', market?: { id: string; title: string }]
openTrade: [
side: 'yes' | 'no',
market?: { id: string; title: string; marketId?: string; outcomeTitle?: string }
]
}>()
const props = defineProps({
marketTitle: {
type: String,
default: 'Mamdan opens city-owned grocery store b...',
},
chanceValue: {
type: Number,
default: 17,
},
marketInfo: {
type: String,
default: '$155k Vol.',
},
id: {
type: String,
default: '1',
},
/** 市场图片 URL由卡片传入供详情页展示 */
imageUrl: {
type: String,
default: '',
},
/** 分类标签,如 "Economy · World" */
category: {
type: String,
default: '',
},
/** 结算/到期日期,如 "Mar 31, 2026" */
expiresAt: {
type: String,
default: '',
},
})
const props = withDefaults(
defineProps<{
marketTitle: string
chanceValue: number
marketInfo: string
id: string
imageUrl?: string
category?: string
expiresAt?: string
/** 展示类型single 单一 Yes/Nomulti 多选项左右滑动 */
displayType?: 'single' | 'multi'
/** 多选项时的选项列表(用于滑动) */
outcomes?: EventCardOutcome[]
/** 单一类型时左按钮文案,如 Up */
yesLabel?: string
/** 单一类型时右按钮文案,如 Down */
noLabel?: string
/** 是否显示 + NEW */
isNew?: boolean
}>(),
{
marketTitle: 'Mamdan opens city-owned grocery store b...',
chanceValue: 17,
marketInfo: '$155k Vol.',
id: '1',
imageUrl: '',
category: '',
expiresAt: '',
displayType: 'single',
outcomes: () => [],
yesLabel: 'Yes',
noLabel: 'No',
isNew: false,
}
)
const isMulti = computed(
() => props.displayType === 'multi' && (props.outcomes?.length ?? 0) > 1
)
const currentSlide = ref(0)
const outcomeCount = computed(() => (props.outcomes ?? []).length)
function prevSlide() {
if (outcomeCount.value <= 1) return
currentSlide.value = (currentSlide.value - 1 + outcomeCount.value) % outcomeCount.value
}
function nextSlide() {
if (outcomeCount.value <= 1) return
currentSlide.value = (currentSlide.value + 1) % outcomeCount.value
}
// stroke-dashoffset π*26 0° 180°
const SEMI_CIRCLE_LENGTH = Math.PI * 26
@ -155,7 +260,6 @@ const semiProgressColor = computed(() => {
)
})
// query
const navigateToDetail = () => {
router.push({
path: `/trade-detail/${props.id}`,
@ -170,17 +274,26 @@ const navigateToDetail = () => {
})
}
// Yes/No openTrade
function openTrade(side: 'yes' | 'no') {
function openTradeSingle(side: 'yes' | 'no') {
emit('openTrade', side, { id: props.id, title: props.marketTitle })
}
function openTradeMulti(side: 'yes' | 'no', outcome: EventCardOutcome) {
emit('openTrade', side, {
id: props.id,
title: outcome.title,
marketId: outcome.marketId,
outcomeTitle: outcome.title,
})
}
</script>
<style scoped>
/* 单 market 与多 market 统一高度 */
.market-card {
position: relative;
width: 310px;
height: 160px;
height: 176px;
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #e7e7e7;
@ -190,8 +303,6 @@ function openTrade(side: 'yes' | 'no') {
}
.market-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
border-color: #d0d0d0;
}
@ -199,7 +310,16 @@ function openTrade(side: 'yes' | 'no') {
display: flex;
flex-direction: column;
height: 100%;
gap: 12px;
gap: 10px;
}
/* 扁平化:卡片内所有按钮无阴影 */
.market-card :deep(.v-btn),
.market-card .carousel-arrow {
box-shadow: none !important;
}
.market-card :deep(.v-btn::before) {
box-shadow: none;
}
/* Top Section */
@ -221,18 +341,25 @@ function openTrade(side: 'yes' | 'no') {
flex-shrink: 0;
}
/* 覆盖 Vuetify v-card-title 的 padding、white-space保证两行完整显示且不露第三行 */
.market-title {
flex: 1;
min-width: 0;
padding: 0;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: #000000;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
/* 仅文字区域两行高,无 padding 占用 */
max-height: 2.8em;
}
.chance-container {
@ -287,10 +414,11 @@ function openTrade(side: 'yes' | 'no') {
margin-top: 2px;
}
/* Options Section */
/* Options Section:单 market 时靠底对齐 */
.options-section {
display: flex;
gap: 12px;
margin-top: auto;
}
.option-yes {
@ -319,12 +447,13 @@ function openTrade(side: 'yes' | 'no') {
color: #ff0000;
}
/* Bottom Section */
/* Bottom Section:禁止被挤压,避免与 multi-section 重叠 */
.bottom-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
flex-shrink: 0;
}
.market-info {
@ -346,4 +475,170 @@ function openTrade(side: 'yes' | 'no') {
.bookmark-icon {
color: #808080;
}
/* 多选项:左右滑动区域,整体靠底对齐;预留底部 padding 避免与 bottom-section 重叠 */
.multi-section {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 1 auto;
min-height: 0;
margin-top: auto;
padding-bottom: 14px;
box-sizing: border-box;
overflow: hidden;
}
.outcome-carousel {
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.outcome-carousel :deep(.v-carousel__container) {
touch-action: pan-y pinch-zoom;
}
.outcome-slide {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
box-sizing: border-box;
}
.outcome-slide-inner {
width: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
padding: 6px 12px 6px 10px;
}
.outcome-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.outcome-item {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.outcome-title {
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 500;
color: #000000;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outcome-chance-value {
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 700;
color: #000000;
flex-shrink: 0;
}
.outcome-buttons {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.outcome-buttons .option-yes-no-compact {
min-width: 44px;
height: 28px;
padding: 0 10px;
}
.outcome-buttons .option-text-yes,
.outcome-buttons .option-text-no {
font-size: 13px;
}
.carousel-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0;
margin-bottom: 10px;
flex-shrink: 0;
}
.carousel-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background-color: #f0f0f0;
color: #333;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
box-shadow: none;
}
.carousel-arrow:hover:not(:disabled) {
background-color: #e0e0e0;
color: #000;
}
.carousel-arrow:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.carousel-dots {
display: flex;
justify-content: center;
gap: 6px;
padding: 0;
flex-shrink: 0;
}
.carousel-dot {
width: 6px;
height: 6px;
border-radius: 50%;
border: none;
background-color: #e0e0e0;
cursor: pointer;
transition: background-color 0.2s ease;
padding: 0;
}
.carousel-dot:hover {
background-color: #bdbdbd;
}
.carousel-dot.active {
background-color: rgb(var(--v-theme-primary));
width: 8px;
height: 6px;
border-radius: 3px;
}
.new-badge {
display: inline-block;
margin-right: 6px;
padding: 1px 6px;
font-size: 11px;
font-weight: 600;
color: #000;
background-color: #fef08a;
border-radius: 4px;
}
</style>

View File

@ -34,6 +34,11 @@ const router = createRouter({
component: Wallet
}
],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash }
return { top: 0 }
},
})
export default router

View File

@ -1,6 +1,6 @@
<template>
<div class="home-page">
<v-container class="home-container">
<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>
@ -12,7 +12,7 @@
<div ref="scrollRef" class="home-list-scroll">
<v-pull-to-refresh class="pull-to-refresh" @load="onRefresh">
<div class="pull-to-refresh-inner">
<div class="home-list">
<div ref="listRef" class="home-list" :style="gridListStyle">
<MarketCard
v-for="card in eventList"
:key="card.id"
@ -23,6 +23,11 @@
: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">
@ -155,6 +160,7 @@
</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'
@ -202,9 +208,29 @@ function onCardOpenTrade(side: 'yes' | 'no', market?: { id: string; title: strin
}
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 {
@ -256,12 +282,10 @@ function loadMore() {
}
function checkScrollLoad() {
const el = scrollRef.value
if (!el || loadingMore.value || eventList.value.length === 0 || noMoreEvents.value) return
const { scrollTop, clientHeight, scrollHeight } = el
if (scrollHeight - scrollTop - clientHeight < SCROLL_LOAD_THRESHOLD) {
loadMore()
}
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(() => {
@ -275,20 +299,24 @@ onMounted(() => {
loadEvents(0, false)
}
nextTick(() => {
const scrollEl = scrollRef.value
const sentinel = sentinelRef.value
if (!sentinel || !scrollEl) return
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 })
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)
}
})
})
@ -296,13 +324,21 @@ onUnmounted(() => {
const sentinel = sentinelRef.value
if (observer && sentinel) observer.unobserve(sentinel)
observer = null
scrollRef.value?.removeEventListener('scroll', checkScrollLoad)
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 {
@ -326,22 +362,17 @@ onUnmounted(() => {
min-height: 100%;
}
/* 不设固定高度与 overflow列表随页面窗口滚动便于 Vue Router scrollBehavior 自动恢复位置 */
.home-list-scroll {
width: 100%;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
/* 占满剩余视口高度,内容多时在内部滚动 */
min-height: calc(100vh - 64px - 48px - 32px);
max-height: calc(100vh - 64px - 48px - 32px);
}
/* 卡片最小宽度与 MarketCard 一致 310pxauto-fill 按可用宽度自动 14 列,避免第三列被裁 */
/* 列数由 JS 根据容器宽度与 CARD_MIN_WIDTH 连续计算,避免断点导致 6→4 跳变 */
.home-list {
width: 100%;
display: grid;
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
}
.home-list-empty {
@ -404,12 +435,6 @@ onUnmounted(() => {
padding: 0;
}
/* 大屏最多 4 列,避免过宽时出现 5 列以上 */
@media (min-width: 1320px) {
.home-list {
grid-template-columns: repeat(4, minmax(310px, 1fr));
}
}
.home-subtitle {
margin-bottom: 40px;